引言:被忽视的"出行---早餐"时空耦合
城市清晨的第一笔消费,往往发生在「从家到地铁、从地铁到工位」的这十几分钟里。对通勤者而言,地铁站 500 米步行圈内的早餐供给是否充足、品类是否合理、新店是否在最近半年密集涌现 ,直接决定了"能不能吃上一口热乎饭"。

这看似是一个生活琐事,背后却是典型的时空智能(Spatiotemporal Intelligence, STI)问题:它同时具备
- 空间维度------POI 与站点的地理邻近性、步行可达性、空间分布密度;
- 时间维度------营业时段与早高峰的错配、新店开业的时序动态、供给随城市的演化;
- 智能维度------从原始坐标到知识洞察的自动抽取、分析与可视化。
本文以 WorkBuddy(智能体式 AI 工作平台)为工具链,落地一个"长沙地铁 × 早餐"可视化系统,并拆解其背后可被复用的时空智能方法论------它不止服务于早餐,更是一套面向城市便民服务的通用分析范式。
一、什么是时空智能?一个三要素框架
在 urban computing(城市计算)语境下,时空智能可以理解为 "空间 + 时间 + 模型" 的三元耦合:
#mermaid-svg-hU4hahM3kmiUByA3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hU4hahM3kmiUByA3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hU4hahM3kmiUByA3 .error-icon{fill:#552222;}#mermaid-svg-hU4hahM3kmiUByA3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hU4hahM3kmiUByA3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hU4hahM3kmiUByA3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hU4hahM3kmiUByA3 .marker.cross{stroke:#333333;}#mermaid-svg-hU4hahM3kmiUByA3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hU4hahM3kmiUByA3 p{margin:0;}#mermaid-svg-hU4hahM3kmiUByA3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hU4hahM3kmiUByA3 .cluster-label text{fill:#333;}#mermaid-svg-hU4hahM3kmiUByA3 .cluster-label span{color:#333;}#mermaid-svg-hU4hahM3kmiUByA3 .cluster-label span p{background-color:transparent;}#mermaid-svg-hU4hahM3kmiUByA3 .label text,#mermaid-svg-hU4hahM3kmiUByA3 span{fill:#333;color:#333;}#mermaid-svg-hU4hahM3kmiUByA3 .node rect,#mermaid-svg-hU4hahM3kmiUByA3 .node circle,#mermaid-svg-hU4hahM3kmiUByA3 .node ellipse,#mermaid-svg-hU4hahM3kmiUByA3 .node polygon,#mermaid-svg-hU4hahM3kmiUByA3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hU4hahM3kmiUByA3 .rough-node .label text,#mermaid-svg-hU4hahM3kmiUByA3 .node .label text,#mermaid-svg-hU4hahM3kmiUByA3 .image-shape .label,#mermaid-svg-hU4hahM3kmiUByA3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-hU4hahM3kmiUByA3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hU4hahM3kmiUByA3 .rough-node .label,#mermaid-svg-hU4hahM3kmiUByA3 .node .label,#mermaid-svg-hU4hahM3kmiUByA3 .image-shape .label,#mermaid-svg-hU4hahM3kmiUByA3 .icon-shape .label{text-align:center;}#mermaid-svg-hU4hahM3kmiUByA3 .node.clickable{cursor:pointer;}#mermaid-svg-hU4hahM3kmiUByA3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hU4hahM3kmiUByA3 .arrowheadPath{fill:#333333;}#mermaid-svg-hU4hahM3kmiUByA3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hU4hahM3kmiUByA3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hU4hahM3kmiUByA3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hU4hahM3kmiUByA3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hU4hahM3kmiUByA3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hU4hahM3kmiUByA3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hU4hahM3kmiUByA3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hU4hahM3kmiUByA3 .cluster text{fill:#333;}#mermaid-svg-hU4hahM3kmiUByA3 .cluster span{color:#333;}#mermaid-svg-hU4hahM3kmiUByA3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hU4hahM3kmiUByA3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hU4hahM3kmiUByA3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-hU4hahM3kmiUByA3 .icon-shape,#mermaid-svg-hU4hahM3kmiUByA3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hU4hahM3kmiUByA3 .icon-shape p,#mermaid-svg-hU4hahM3kmiUByA3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hU4hahM3kmiUByA3 .icon-shape .label rect,#mermaid-svg-hU4hahM3kmiUByA3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hU4hahM3kmiUByA3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hU4hahM3kmiUByA3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hU4hahM3kmiUByA3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 时空智能 STI
空间 Spatial
时间 Temporal
智能 Intelligence
坐标与坐标系 GCJ-02/WGS84/CGCS2000
可达性 / 邻近度 / 空间密度 KDE
POI · 路网 · 行政区划
营业时段 / 早高峰
新店开业的时序动态
供给演化与趋势
自动数据获取与清洗
覆盖盲区识别
交互式时空可视化
WorkBuddy Agent
传统数据分析往往把"地点"和"时间"割裂处理:要么做一张静态热力图,要么拉一张时间序列表。时空智能的跃迁在于------让空间分析与时间分析在同一个坐标系下联合求解,并交由智能体自动完成从数据到洞察的闭环。
二、WorkBuddy 如何扮演"时空智能副驾驶"
在本项目中,WorkBuddy 不是被动的"问答机",而是主动承担了一套时空数据工程流水线:
| 环节 | 传统做法 | WorkBuddy 做法 |
|---|---|---|
| 数据获取 | 手动爬取、字段对齐 | 调用高德开放接口 map.amap.com/service/subway 自动拉取全量站点(含 6 号线、磁浮) |
| 坐标治理 | 人工纠偏、易错 | 识别源数据为 GCJ-02,与高德电子底图同体系,零偏移对齐 |
| 空间分析 | 依赖 GIS 桌面软件 | 内置 haversine + 半径判定,实时计算"站点 500m 圈内早餐数" |
| 盲区识别 | 人工逐站核对 | 自动标注"半径内 0 供给"的覆盖盲区站点 |
| 可视化 | 外包前端 | 自研轻量瓦片引擎 + 高德在线底图,单文件 HTML5 交付 |
坐标系一致性是时空智能的"地基" 。本项目所有站点采用高德 GCJ-02(火星坐标系),与高德矢量电子底图天然对齐;若要接入 WGS84/CGCS2000 数据,须先做坐标转换,否则会出现约 300~600 m 的整体偏移------这正是许多时空可视化"看着不对劲"的根因。
三、WorkBuddy 交互细节:一次对话跑通时空数据闭环
WorkBuddy 在本项目中不是"问答机",而是以 Agent Loop(智能体循环) 自主驱动「探测 → 获取 → 构建 → 验证 → 纠偏」的完整闭环。下面是真实发生的交互链路:
#mermaid-svg-0H4SYUDhssnuaYJv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0H4SYUDhssnuaYJv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0H4SYUDhssnuaYJv .error-icon{fill:#552222;}#mermaid-svg-0H4SYUDhssnuaYJv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0H4SYUDhssnuaYJv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0H4SYUDhssnuaYJv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0H4SYUDhssnuaYJv .marker.cross{stroke:#333333;}#mermaid-svg-0H4SYUDhssnuaYJv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0H4SYUDhssnuaYJv p{margin:0;}#mermaid-svg-0H4SYUDhssnuaYJv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0H4SYUDhssnuaYJv .cluster-label text{fill:#333;}#mermaid-svg-0H4SYUDhssnuaYJv .cluster-label span{color:#333;}#mermaid-svg-0H4SYUDhssnuaYJv .cluster-label span p{background-color:transparent;}#mermaid-svg-0H4SYUDhssnuaYJv .label text,#mermaid-svg-0H4SYUDhssnuaYJv span{fill:#333;color:#333;}#mermaid-svg-0H4SYUDhssnuaYJv .node rect,#mermaid-svg-0H4SYUDhssnuaYJv .node circle,#mermaid-svg-0H4SYUDhssnuaYJv .node ellipse,#mermaid-svg-0H4SYUDhssnuaYJv .node polygon,#mermaid-svg-0H4SYUDhssnuaYJv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0H4SYUDhssnuaYJv .rough-node .label text,#mermaid-svg-0H4SYUDhssnuaYJv .node .label text,#mermaid-svg-0H4SYUDhssnuaYJv .image-shape .label,#mermaid-svg-0H4SYUDhssnuaYJv .icon-shape .label{text-anchor:middle;}#mermaid-svg-0H4SYUDhssnuaYJv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0H4SYUDhssnuaYJv .rough-node .label,#mermaid-svg-0H4SYUDhssnuaYJv .node .label,#mermaid-svg-0H4SYUDhssnuaYJv .image-shape .label,#mermaid-svg-0H4SYUDhssnuaYJv .icon-shape .label{text-align:center;}#mermaid-svg-0H4SYUDhssnuaYJv .node.clickable{cursor:pointer;}#mermaid-svg-0H4SYUDhssnuaYJv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0H4SYUDhssnuaYJv .arrowheadPath{fill:#333333;}#mermaid-svg-0H4SYUDhssnuaYJv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0H4SYUDhssnuaYJv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0H4SYUDhssnuaYJv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0H4SYUDhssnuaYJv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0H4SYUDhssnuaYJv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0H4SYUDhssnuaYJv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0H4SYUDhssnuaYJv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0H4SYUDhssnuaYJv .cluster text{fill:#333;}#mermaid-svg-0H4SYUDhssnuaYJv .cluster span{color:#333;}#mermaid-svg-0H4SYUDhssnuaYJv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0H4SYUDhssnuaYJv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0H4SYUDhssnuaYJv rect.text{fill:none;stroke-width:0;}#mermaid-svg-0H4SYUDhssnuaYJv .icon-shape,#mermaid-svg-0H4SYUDhssnuaYJv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0H4SYUDhssnuaYJv .icon-shape p,#mermaid-svg-0H4SYUDhssnuaYJv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0H4SYUDhssnuaYJv .icon-shape .label rect,#mermaid-svg-0H4SYUDhssnuaYJv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0H4SYUDhssnuaYJv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0H4SYUDhssnuaYJv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0H4SYUDhssnuaYJv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户需求:地铁×早餐时空可视化
合规优先:加载 geo-map-compliance-guard
数据源探测:Overpass / Wikidata / Nominatim / 高德_dts_ 均不可达或 502
关键突破:高德 drw 接口返回 200,拿到 GCJ-02 全量站点
gen_data.py 解析 → data/changsha_data.json
build.py 注入 template.html → 单文件 HTML5
验证闭环:node --check 语法 + 无头 Chrome --dump-dom/--screenshot
坐标合理性校验 + z=16 路网对齐
用户反馈①:站点坐标偏差大
用户反馈②:站点不要连线
替换高德真实坐标,补全 6 号线/磁浮
移除 polyline,仅保留站点圆点
产出 CSDN 博客:时空智能方法论
几个关键交互细节:
-
合规先行 。首轮即调用地图合规技能,确认底图采用高德(国内合规),坐标系锁定 GCJ-02,从源头规避"图源不合规 + 坐标漂移"双重风险。
-
数据源失败的优雅降级 。公开源(Overpass、Wikidata、Nominatim、高德旧
_dts_接口)在当前环境均不可达或返回 502;raw.githubusercontent.com与map.amap.com可达。WorkBuddy 据此放弃"爬公开数据集"路线,改走高德开放接口直连。 -
接口探针式试探 。对比
_dts_(502 失败)与_drw_(200 成功)两种端点,最终锁定可用 URL:https://map.amap.com/service/subway?_=<时间戳>&srhdata=4301_drw_changsha.json必须带
Referer: https://www.amap.com/与时间戳参数,否则易被网关拦截。 -
构建流水线解耦 。数据生成(
gen_data.py)与页面模板(template.html)分离,build.py仅做"数据 JSON 注入占位符 → 单文件 HTML5",便于迭代不破坏布局。 -
验证即交付 。
node --check做语法门禁;无头 Chrome 的--dump-dom(统计 DOM 元素数 / 瓦片数 / KPI)与--screenshot(视觉核对路网对齐)构成"无 GUI 也有真渲染"的自动化质检。 -
人在回路纠偏。用户两次反馈(坐标偏差、不要连线)都被接回同一条 Agent Loop,原地修改重建,形成"需求---验证---纠偏"的紧闭环。
四、程序实现细节:单文件 HTML5 时空引擎
整个可视化是一个 零框架依赖 的单文件 HTML5 应用,核心由四部分组成:瓦片引擎(Web Mercator)、图层合成、时空热力图、视图预设。
4.1 瓦片引擎与 Web Mercator 投影
高德矢量底图采用 Web Mercator 瓦片。先把经纬度投影到"世界像素",再换算到屏幕坐标:
javascript
const TILE = 256;
// 经纬度 → 世界瓦片坐标(缩放级 z)
function lng2x(lng, z){ return (lng + 180) / 360 * Math.pow(2, z); }
function lat2y(lat, z){
const s = Math.sin(lat * Math.PI / 180);
return (1 - Math.log((1 + s) / (1 - s)) / Math.PI) / 2 * Math.pow(2, z);
}
// 屏幕坐标:以视口左上角世界像素 (viewX,viewY) 为原点,亚瓦片级 scale 缩放
function worldPx(lat, lng, z){ return { x: lng2x(lng, z) * TILE, y: lat2y(lat, z) * TILE }; }
function project(lat, lng){
const z0 = Math.floor(state.zoom), scale = Math.pow(2, state.zoom - z0);
const w = worldPx(lat, lng, z0);
return { x: (w.x - viewX) * scale, y: (w.y - viewY) * scale };
}
瓦片 URL 采用高德 style=8 矢量样式(含中文标注),四子域负载均衡 + 离屏瓦片缓存回收:
javascript
const SUB = [1, 2, 3, 4];
function tileUrl(x, y, z){
const s = SUB[(x * 3 + y) % SUB.length];
return `https://webrd0${s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${x}&y=${y}&z=${z}`;
}
const tileNode = new Map();
function renderTiles(){
const z0 = Math.floor(state.zoom), scale = Math.pow(2, state.zoom - z0);
const seen = new Set();
const x0 = Math.floor(viewX / TILE), x1 = Math.ceil((viewX + W) / TILE);
const y0 = Math.floor(viewY / TILE), y1 = Math.ceil((viewY + H) / TILE);
for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++){
const key = `${z0}/${x}/${y}`; seen.add(key);
if (tileNode.has(key)) continue;
const img = new Image();
img.src = tileUrl(x, y, z0);
img.style.transform = `translate(${(x*TILE - viewX)*scale}px, ${(y*TILE - viewY)*scale}px) scale(${scale})`;
img.style.width = (TILE * scale) + "px";
tileWrap.appendChild(img); tileNode.set(key, img);
}
tileNode.forEach((n, k) => { if (!seen.has(k)) { n.remove(); tileNode.delete(k); } }); // 回收不可见瓦片
}
4.2 图层合成:DOM 标记 + SVG 矢量
站点与早餐点用 DOM 元素 (便于 hover/click 交互与文字标签),服务半径圈 / 盲区圈用 SVG(矢量清晰、可批量):
javascript
// 站点圆点:换乘站放大并描边,颜色取所属线路色
function buildMarkers(){
STATIONS.forEach(st => {
const el = document.createElement("div");
el.className = "mk mk-st" + (st.transfer ? " tf" : "");
el.innerHTML = `<span class="c" style="background:${st.lineColor}"></span><span class="lb">${st.name}</span>`;
el.onclick = e => { e.stopPropagation(); selectStation(st); };
mkBox.appendChild(el); stNodes.push(el);
});
}
// 矢量层:仅渲染半径圈 / 盲区圈(不再绘制线路连线)
function drawVec(){ svg.innerHTML = ringHtml; }
4.3 时空热力图:Canvas 密度场 + 调色板
热力图本质是"点密度 → 颜色场"。先把每个早餐点画成高斯笔刷(累积 alpha),再用调色板把 alpha 映射为温度色,并做 alpha 阈值截断(否则低透明度下只剩一个个蓝圈):
javascript
let pal = null;
function buildPalette(){ // 蓝→青→绿→黄→红 256 级
pal = new Uint8ClampedArray(256 * 4);
for (let i = 0; i < 256; i++){ const [r,g,b] = ramp(i/255); pal[i*4]=r; pal[i*4+1]=g; pal[i*4+2]=b; }
}
// 温度色阶(归一化 t∈[0,1] → RGB)
function ramp(t){
const stops = [[0,0,255],[0,255,255],[0,255,0],[255,255,0],[255,0,0]];
const x = t * (stops.length - 1), i = Math.floor(x), f = x - i;
const a = stops[i], b = stops[Math.min(i+1, stops.length-1)];
return [a[0]+(b[0]-a[0])*f, a[1]+(b[1]-a[1])*f, a[2]+(b[2]-a[2])*f].map(Math.round);
}
// 高斯笔刷:中心不透明、边缘透明的径向渐变,累积形成密度场
function heatBrush(r){
const c = document.createElement("canvas"); c.width = c.height = r*2;
const g = c.getContext("2d").createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, "rgba(0,0,0,1)"); g.addColorStop(1, "rgba(0,0,0,0)");
const ctx = c.getContext("2d"); ctx.fillStyle = g; ctx.fillRect(0, 0, r*2, r*2);
return c;
}
function drawHeat(ctx, list){
ctx.clearRect(0, 0, W, H);
if (!list.length) return;
const r = Math.max(12, 14 * Math.pow(2, state.zoom - 11.5)); // 随缩放调整笔刷半径
const brush = heatBrush(r);
ctx.globalAlpha = 0.16; // 累积透明度,密度越高越红
for (const b of list){ const p = project(b.lat, b.lng); ctx.drawImage(brush, p.x - r, p.y - r); }
ctx.globalAlpha = 1;
const id = ctx.getImageData(0, 0, W, H), px = id.data;
for (let i = 3; i < px.length; i += 4){
const a = px[i]; if (!a) continue;
const o = a * 4; px[i-3]=pal[o]; px[i-2]=pal[o+1]; px[i-1]=pal[o+2];
px[i] = a > 200 ? 210 : a + 40; // alpha 阈值截断
}
ctx.putImageData(id, 0, 0);
}

4.4 视图预设与分享:URL Hash
把视图状态编码进 location.hash,实现"一键复现 / 分享某视角":
javascript
function readHash(){
const h = new URLSearchParams(location.hash.replace(/^#/, ""));
if (h.has("lat")) state.center.lat = +h.get("lat");
if (h.has("lng")) state.center.lng = +h.get("lng");
if (h.has("z")) state.zoom = +h.get("z");
if (h.has("r")) state.radius = +h.get("r");
["heat","ring","gap","station","bf"].forEach(k => { if (h.has(k)) state.layers[k] = h.get(k) !== "0"; });
}
// 分享:location.hash = "#lat=28.19&lng=112.99&z=14&heat=1&r=800"
4.5 数据获取与坐标一致性(Python 侧)
站点坐标来自高德开放接口,返回即为 GCJ-02,与高德底图零偏移,无需二次转换:
python
import urllib.request, json, time
ts = int(time.time() * 1000)
url = f"https://map.amap.com/service/subway?_={ts}&srhdata=4301_drw_changsha.json"
req = urllib.request.Request(url, headers={
"Referer": "https://www.amap.com/",
"User-Agent": "Mozilla/5.0"
})
raw = json.loads(urllib.request.urlopen(req, timeout=40).read())
lines = []
for g in raw["l"]: # 每条线路组
color = "#" + (g.get("cl") or "1f6fe0")[-6:]
stations = []
for st in g.get("st", []):
lng, lat = map(float, st["sl"].split(",")) # GCJ-02:经度,纬度
stations.append({"name": st["n"], "lng": lng, "lat": lat})
lines.append({"id": g["kn"], "name": g["ln"], "color": color, "stations": stations})
# 注:_drw_ 端点返回 200;若改用 _dts_ 会 502。坐标直接为 GCJ-02,无需火星转换。
五、实战拆解:长沙地铁 × 早餐系统的技术内核
系统最终落地为单文件 HTML5 页面:高德在线电子底图(矢量路网 + 中文标注)+ 151 座真实车站(1--6 号线 + 磁浮快线,19 座换乘站)+ 390 个早餐点(米粉面馆 / 包点蒸饺 / 烘焙咖啡 / 连锁快餐 / 便利店 / 早餐工程点 6 类,带开业日期、人均、营业时段、最近站点与距离)。
5.1 时空分析的"原子操作":半径可达性判定
一切洞察都建立在最朴素的几何关系上------以站点为圆心、步行半径 r 为阈值的邻近查询。核心算法即 haversine 球面距离:
javascript
// 站点 st 半径 r(m) 内早餐点数量 = 该站"步行可达供给"
function nearByCount(st, bfs, r) {
return bfs.filter(b =>
haversine(st.lat, st.lng, b.lat, b.lng) <= r
).length;
}
function haversine(lat1, lng1, lat2, lng2) {
const R = 6371000, p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = p2 - p1, dl = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a)); // 米
}

把 r 在 200--1500 m 间滑动,就能得到一条覆盖率---步行半径曲线,这本身就是一条时空可达性的"需求---供给"匹配曲线。
5.2 从 Demo 到工程:PostGIS 版"覆盖盲区"SQL
当数据规模从演示走向城市级台账,空间运算应交由数据库。下面的 PostGIS 语句直接回答了"哪些地铁站是早餐服务盲区":
sql
-- 统计每个地铁站 500m 步行半径(球面)内的早餐 POI 数
SELECT s.name,
COUNT(p.id) AS breakfast_cnt,
CASE WHEN COUNT(p.id) = 0 THEN '盲区' ELSE '覆盖' END AS status
FROM metro_station s
LEFT JOIN breakfast_poi p
ON ST_DWithin(
ST_Transform(s.geom, 4326)::geography,
ST_Transform(p.geom, 4326)::geography,
500) -- 500 米步行阈值
GROUP BY s.name
ORDER BY breakfast_cnt ASC; -- 倒数即是优先补点清单
这与我们后端 SpringBoot + MyBatis-Plus + PostGIS + GeoTools 的技术栈完全一致------Demo 验证思路,工程沉淀数据。

5.3 时空可视化的表达层
系统用图层化方式把"时空智能"翻译为人能一眼读懂的画面:
- 站点层:圆点大小区分换乘站/普通站,颜色映射所属线路;
- 热力层:早餐点空间密度(核密度思想的栅格近似),揭示"早餐热区";
- 半径圈层:步行可达范围的可视化边界;
- 盲区层:半径内零供给的站点以红圈高亮------这是空间可达性缺口最直观的表达;
- 时间切片 :按"近一年新开"过滤,观察早餐供给的时序涌现。
一个关键产品决策:站点之间不绘制连线 ,只展示站点本身。因为真实地铁走向已由高德底图渲染,自绘直连线反而会误导"可达性"判断------可达性看的是半径覆盖,不是线路串接。

六、价值延伸:从"早餐"到城市生活服务
"地铁 × 早餐"只是时空智能便民场景的切片。同一套方法论可直接迁移到:
- 便民服务可达性审计------早餐/药店/菜市场/充电桩的站点覆盖盲区;
- 时空演化监测------新店开业、旧店歇业构成的城市活力热力;
- 应急与人口估算------结合栅格人口与矢量多边形,估算站点周边早高峰服务人口(可插拔多策略框架);
- 商业选址辅决策------以"盲区 + 人流 + 时段"三维打分给出补点建议。
其本质,是把城市看作一个时空场:每一个 POI 是一个时空事件,每一条出行链是一次时空轨迹,而 WorkBuddy 这样的智能体平台,正成为打通"数据---分析---决策"闭环的底层生产力。
七、结语
当 AI 从"聊天"走向"干活",真正的价值不在于生成多少文字,而在于能否把一门学科的方法论(这里是时空智能)封装成可复用的工程能力。
本文用 WorkBuddy 完成的"长沙地铁 × 早餐"系统证明:一个专业的时空分析产品,可以在一次对话内完成数据获取、坐标治理、空间分析与交互可视化的完整闭环。而坐标系对齐、半径可达性、覆盖盲区这些"朴素却关键"的概念,恰是时空智能落地的真正门槛。
让城市更懂人的时空节律------这,就是时空智能的意义。
本文案例系统由 WorkBuddy 智能体全程构建:高德开放数据接入、GCJ-02 坐标对齐、单文件 HTML5 时空可视化交付。技术栈可无缝对接 SpringBoot + PostGIS 工程体系。