PHP GeoJSON转PNG地图渲染程序开发笔记、源码解读、问题复盘与整改方案

整体业务流程

  1. 加密行政区参数请求后端GeoJSON矢量接口;
  2. GD解析地理边界绘制地图,生成PNG图片并缓存输出。

一、完整源码

php 复制代码
<?php
ob_end_clean();
require_once "common.php";
//宁波区县列表
$areaList = array(
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '慈溪市'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '奉化区'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '海曙区'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '江北区'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '宁海县'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '象山县'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '鄞州区'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '余姚市'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '镇海区'),
    array('province' => '浙江省', 'city' => '宁波市', 'district' => '北仑区'),
);
//随机选取一个区县
$randItem = $areaList[array_rand($areaList)];
//AuthCode加密行政区名称
$Key = "LOCKDATAV";
$expiry = 0;
$province = AuthCode($randItem['province'], 'ENCODE', $Key, $expiry);
$city = AuthCode($randItem['city'], 'ENCODE', $Key, $expiry);
$district = AuthCode($randItem['district'], 'ENCODE', $Key, $expiry);
//全局常量配置
define('GEO_API_URL', 'http://geojson.lockdata.cn/proxy.php?province=' . $province . '&city=' . $city . '&district=' . $district);
define('FONT_PATH', __DIR__ . DIRECTORY_SEPARATOR . 'simsun.ttf');
define('MAP_CACHE_FILE', __DIR__ . '/district_map_cache.png');
define('MAP_CACHE_TTL', 0);
define('MAP_WIDTH', 800);
define('MAP_HEIGHT', 600);
define('MAP_PADDING', 25);

//缓存判断逻辑
if (file_exists(MAP_CACHE_FILE)) {
    $cacheMtime = filemtime(MAP_CACHE_FILE);
    if ((time() - $cacheMtime) < MAP_CACHE_TTL) {
        header('Content-Type: image/png');
        header('Cache-Control: public, max-age=300');
        header('X-Content-Type-Options: nosniff');
        readfile(MAP_CACHE_FILE);
        exit;
    }
    @unlink(MAP_CACHE_FILE);
}

/**
 * CURL请求封装
 */
function safeCurlGet($url)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, array(
        CURLOPT_TIMEOUT => 10,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_USERAGENT => 'MapRenderServer',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => false,
        CURLOPT_MAXREDIRS => 2,
        CURLOPT_BINARYTRANSFER => true,
    ));
    $response = curl_exec($ch);
    $error = curl_error($ch);
    curl_close($ch);
    if (!empty($error) || empty($response)) {
        return false;
    }
    if (strlen($response) > 5 * 1024 * 1024) {
        return false;
    }
    return $response;
}

//拉取GeoJSON数据
$jsonRaw = safeCurlGet(GEO_API_URL);
if (!$jsonRaw) {
    http_response_code(503);
    exit;
}
$geoData = json_decode($jsonRaw, true);
unset($jsonRaw);
if (json_last_error() !== JSON_ERROR_NONE || empty($geoData['features'])) {
    http_response_code(503);
    exit;
}
$features = $geoData['features'];
unset($geoData);

//计算经纬度最大最小值
$lngMin = 180.0;
$lngMax = -180.0;
$latMin = 90.0;
$latMax = -90.0;
foreach ($features as $feat) {
    $coords = $feat['geometry']['coordinates'];
    $stack = array($coords);
    while (!empty($stack)) {
        $item = array_pop($stack);
        if (isset($item[0]) && is_numeric($item[0]) && isset($item[1]) && is_numeric($item[1])) {
            $lng = (float)$item[0];
            $lat = (float)$item[1];
            $lngMin = min($lngMin, $lng);
            $lngMax = max($lngMax, $lng);
            $latMin = min($latMin, $lat);
            $latMax = max($latMax, $lat);
        } elseif (is_array($item)) {
            foreach ($item as $sub) {
                $stack[] = $sub;
            }
        }
    }
}

//经纬度转像素闭包
$lng2x = function ($lng) {
    return (int)(MAP_PADDING + ($lng - $GLOBALS['lngMin']) / ($GLOBALS['lngMax'] - $GLOBALS['lngMin']) * (MAP_WIDTH - MAP_PADDING * 2));
};
$lat2y = function ($lat) {
    return (int)(MAP_HEIGHT - MAP_PADDING - ($lat - $GLOBALS['latMin']) / ($GLOBALS['latMax'] - $GLOBALS['latMin']) * (MAP_HEIGHT - MAP_PADDING * 2));
};

//初始化GD画布
$img = imagecreatetruecolor(MAP_WIDTH, MAP_HEIGHT);
$colorWhite = imagecolorallocate($img, 255, 255, 255);
$colorLine = imagecolorallocate($img, 20, 70, 160);
$colorBlack = imagecolorallocate($img, 0, 0, 0);
$colorTextBg = imagecolorallocatealpha($img, 255, 255, 255, 65);
imagefill($img, 0, 0, $colorWhite);
imagesetthickness($img, 2);

//绘制多边形轮廓函数
function drawPolygonOutline($ring, $img, $lng2x, $lat2y, $lineColor)
{
    $pointCount = count($ring);
    if ($pointCount < 3) return;
    $xStart = $lng2x($ring[0][0]);
    $yStart = $lat2y($ring[0][1]);
    $prevX = $xStart;
    $prevY = $yStart;
    for ($i = 1; $i < $pointCount; $i++) {
        $curX = $lng2x($ring[$i][0]);
        $curY = $lat2y($ring[$i][1]);
        imageline($img, $prevX, $prevY, $curX, $curY, $lineColor);
        $prevX = $curX;
        $prevY = $curY;
    }
    imageline($img, $prevX, $prevY, $xStart, $yStart, $lineColor);
}

$fontValid = file_exists(FONT_PATH) && is_readable(FONT_PATH) && function_exists('imagettftext');
//循环绘制各个区县边界+文字标注
foreach ($features as $feature) {
    $geo = $feature['geometry'];
    $name = empty($feature['properties']['name']) ? '' : trim((string)$feature['properties']['name']);
    $polygonRings = array();
    if ($geo['type'] === 'Polygon') {
        $polygonRings = $geo['coordinates'];
        foreach ($polygonRings as $ring) {
            drawPolygonOutline($ring, $img, $lng2x, $lat2y, $colorLine);
        }
    } elseif ($geo['type'] === 'MultiPolygon') {
        foreach ($geo['coordinates'] as $singlePoly) {
            foreach ($singlePoly as $ring) {
                $polygonRings[] = $ring;
                drawPolygonOutline($ring, $img, $lng2x, $lat2y, $colorLine);
            }
        }
    }
    //文字标注
    if ($fontValid && $name !== '' && !empty($polygonRings)) {
        $sumLon = 0.0;
        $sumLat = 0.0;
        $pointNum = 0;
        foreach ($polygonRings as $ring) {
            foreach ($ring as $pt) {
                $sumLon += $pt[0];
                $sumLat += $pt[1];
                $pointNum++;
            }
        }
        if ($pointNum === 0) continue;
        $centerLon = $sumLon / $pointNum;
        $centerLat = $sumLat / $pointNum;
        $cx = $lng2x($centerLon);
        $cy = $lat2y($centerLat);
        try {
            $bbox = imagettfbbox(16, 0, FONT_PATH, $name);
            if ($bbox) {
                $textW = abs($bbox[4] - $bbox[0]);
                $textH = abs($bbox[5] - $bbox[1]);
                imagefilledrectangle($img, $cx - 5, $cy - $textH, $cx + $textW + 5, $cy + 3, $colorTextBg);
                imagettftext($img, 10, 0, $cx, $cy, $colorBlack, FONT_PATH, $name);
            }
        } catch (Exception $e) {
            continue;
        }
    }
    unset($polygonRings);
}
unset($features);

//居中水印
$waterText = 'LOCKDATAV GEOJSON';
$waterFontSize = 48;
$waterColor = imagecolorallocatealpha($img, 80, 80, 80, 70);
$textBox = imagettfbbox($waterFontSize, 0, FONT_PATH, $waterText);
$textWidth = abs($textBox[2] - $textBox[0]);
$textHeight = abs($textBox[1] - $textBox[7]);
$waterX = (MAP_WIDTH - $textWidth) / 2;
$waterY = (MAP_HEIGHT + $textHeight) / 2;
imagettftext($img, $waterFontSize, 0, $waterX, $waterY, $waterColor, FONT_PATH, $waterText);

//底部版权栏
$footerHeight = 40;
$footerBg = imagecolorallocate($img, 35, 45, 60);
$footerTextColor = imagecolorallocate($img, 255, 255, 255);
imagefilledrectangle($img, 0, MAP_HEIGHT - $footerHeight, MAP_WIDTH, MAP_HEIGHT, $footerBg);
$footerText = '© 2026 漏刻有时 LOCKDATAV';
$footerFontSize = 16;
$footerBox = imagettfbbox($footerFontSize, 0, FONT_PATH, $footerText);
$footerTextW = abs($footerBox[4] - $footerBox[0]);
$textX = (MAP_WIDTH - $footerTextW) / 2;
$textY = MAP_HEIGHT - ($footerHeight / 2) + ($footerFontSize / 3);
imagettftext($img, $footerFontSize, 0, $textX, $textY, $footerTextColor, FONT_PATH, $footerText);

//写入缓存文件
imagepng($img, MAP_CACHE_FILE, 6);
//输出图片
header('Content-Type: image/png');
header('Cache-Control: public, max-age=300');
header('X-Content-Type-Options: nosniff');
imagepng($img);
imagedestroy($img);
exit;
?>

二、分段源码解读

1. 初始化与行政区随机+参数加密

php 复制代码
$areaList = 固定宁波区县数组;
$randItem = $areaList[array_rand($areaList)];
//AuthCode加密行政区名称拼接接口地址

作用:固定宁波行政区池,每次随机选取一个区县,使用AuthCode加密中文行政区名称,避免接口明文传参被爬虫遍历抓取。后续用define统一管理图片尺寸、字体、缓存路径等配置。

2. 缓存判断逻辑

php 复制代码
define('MAP_CACHE_TTL', 0);
if (文件存在 && 当前时间-文件修改时间 < TTL) {直接输出缓存图片退出}

逻辑:命中有效缓存直接返回图片提升速度,过期删除旧缓存重绘。

3. safeCurlGet 请求函数

封装CURL,设置超时、最大返回5MB限制、UA标识,关闭SSL证书校验,拉取远程GeoJSON矢量地理数据。

4. GeoJSON解析+边界极值计算

递归遍历Polygon/MultiPolygon多层坐标,拿到整张地图经度、纬度最大最小值,作为画布缩放基准。

5. 经纬度转像素映射闭包

把地理经纬度换算成画布XY像素坐标,适配图片留白,保证地图完整显示在画布内。

6. GD画布初始化与轮廓绘制

创建画布、配色,drawPolygonOutline实现闭合行政区边界画线;遍历GeoJSON要素绘制全部边界,通过所有坐标平均值计算行政区中心,绘制带白底的区县名称标签。

7. 水印、底部版权、缓存落地输出

画布添加居中半透明水印、底部深色版权栏,图片落地为PNG缓存文件,同时输出图片流,销毁画布释放内存。

三、代码现存问题(对应代码位置)

  1. 缓存逻辑致命BUG

    define('MAP_CACHE_TTL', 0),TTL为0,time()-mtime<0条件永远不成立,首次生成缓存文件后不会刷新,随机区县功能直接失效。缓存文件长期堆积无清理。

  2. CURL安全漏洞

php 复制代码
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,

关闭SSL校验,接口链路可被中间人劫持篡改GeoJSON数据;缓存图片存网站根目录,可直接URL访问盗图。

  1. 配置硬编码

    密钥$Key、字体路径、接口地址、配色、尺寸全部写死在代码,修改配置必须改源码。

  2. 异常处理薄弱

    接口失败、JSON解析失败直接exit(503)空白报错,无日志;字体simsun.ttf缺失直接关闭文字标注,无备用字体;文字渲染异常只catch跳过,无记录。

  3. 性能缺陷

    行政区中心点使用全部坐标求和取平均,点位量大时计算缓慢;高并发同时读写district_map_cache.png,文件极易损坏;大数组$features销毁不及时,常驻内存。

  4. 扩展性差

    区县数组硬编码固定宁波,无法通过传参渲染其他城市地图。

四、针对性解决方案

  1. 缓存修复
    修改MAP_CACHE_TTL = 300(5分钟过期),恢复随机刷新逻辑;定时清理老旧缓存文件。
  2. 安全加固
    开启CURL SSL证书校验;缓存文件移动到站点外部不可访问目录;密钥、路径、配置抽离config.php独立配置文件。
  3. 异常健壮化
    增加错误日志写入;检测宋体失效后使用GD内置文字兜底;接口异常返回占位图而非空白503。
  4. 性能优化
    使用多边形重心算法替代全点平均计算中心;缓存写入增加文件排他锁;循环结束及时unset释放大数组内存。
  5. 业务拓展
    支持GET参数传入省市县,替换固定$areaList,实现任意行政区地图渲染。

五、开发笔记总结

该脚本实现GeoJSON矢量地图静态PNG化,适合地图缩略图生成场景。线上部署首要修复缓存TTL配置,其次加固CURL SSL、文件并发写入、硬编码配置三大问题,整改后可稳定作为静态地图图片服务使用。

相关推荐
暗影凋落2 小时前
CMake构建学习笔记-SQLite库的构建
笔记·学习·sqlite
-SOLO-2 小时前
解决VMware 显示比例被重置的问题
android
always_TT3 小时前
【Python 日志记录:logging 模块入门】
开发语言·python·php
alexhilton3 小时前
探究Android Views、Flutter和Compose如何渲染你的UI
android·kotlin·android jetpack
zzm6284 小时前
精读KDD 2017最佳论文:通过类比挖掘加速创新 | 阅读笔记
笔记
你有哈莫吗4 小时前
CMake构建学习笔记-iconv库的构建
笔记·学习
Lesile6 小时前
Android:Hilt框架入门 · 在ViewUI和ComposeUI下的应用
android·android jetpack
Immortal__y6 小时前
Upload-Labs 关卡防御方式
php
用户423816229076 小时前
Android 16 WebView 页面顶部挖孔/通知栏不能显示UI问题排查
android