时空智能赋能城市便民:用 WorkBuddy 构建地铁×早餐可达性可视化

引言:被忽视的"出行---早餐"时空耦合

城市清晨的第一笔消费,往往发生在「从家到地铁、从地铁到工位」的这十几分钟里。对通勤者而言,地铁站 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 博客:时空智能方法论

几个关键交互细节:

  1. 合规先行 。首轮即调用地图合规技能,确认底图采用高德(国内合规),坐标系锁定 GCJ-02,从源头规避"图源不合规 + 坐标漂移"双重风险。

  2. 数据源失败的优雅降级 。公开源(Overpass、Wikidata、Nominatim、高德旧 _dts_ 接口)在当前环境均不可达或返回 502;raw.githubusercontent.commap.amap.com 可达。WorkBuddy 据此放弃"爬公开数据集"路线,改走高德开放接口直连

  3. 接口探针式试探 。对比 _dts_(502 失败)与 _drw_(200 成功)两种端点,最终锁定可用 URL:

    复制代码
    https://map.amap.com/service/subway?_=<时间戳>&srhdata=4301_drw_changsha.json

    必须带 Referer: https://www.amap.com/ 与时间戳参数,否则易被网关拦截。

  4. 构建流水线解耦 。数据生成(gen_data.py)与页面模板(template.html)分离,build.py 仅做"数据 JSON 注入占位符 → 单文件 HTML5",便于迭代不破坏布局。

  5. 验证即交付node --check 做语法门禁;无头 Chrome 的 --dump-dom(统计 DOM 元素数 / 瓦片数 / KPI)与 --screenshot(视觉核对路网对齐)构成"无 GUI 也有真渲染"的自动化质检。

  6. 人在回路纠偏。用户两次反馈(坐标偏差、不要连线)都被接回同一条 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 时空可视化的表达层

系统用图层化方式把"时空智能"翻译为人能一眼读懂的画面:

  • 站点层:圆点大小区分换乘站/普通站,颜色映射所属线路;
  • 热力层:早餐点空间密度(核密度思想的栅格近似),揭示"早餐热区";
  • 半径圈层:步行可达范围的可视化边界;
  • 盲区层:半径内零供给的站点以红圈高亮------这是空间可达性缺口最直观的表达;
  • 时间切片 :按"近一年新开"过滤,观察早餐供给的时序涌现

一个关键产品决策:站点之间不绘制连线 ,只展示站点本身。因为真实地铁走向已由高德底图渲染,自绘直连线反而会误导"可达性"判断------可达性看的是半径覆盖,不是线路串接。


六、价值延伸:从"早餐"到城市生活服务

"地铁 × 早餐"只是时空智能便民场景的切片。同一套方法论可直接迁移到:

  1. 便民服务可达性审计------早餐/药店/菜市场/充电桩的站点覆盖盲区;
  2. 时空演化监测------新店开业、旧店歇业构成的城市活力热力;
  3. 应急与人口估算------结合栅格人口与矢量多边形,估算站点周边早高峰服务人口(可插拔多策略框架);
  4. 商业选址辅决策------以"盲区 + 人流 + 时段"三维打分给出补点建议。

其本质,是把城市看作一个时空场:每一个 POI 是一个时空事件,每一条出行链是一次时空轨迹,而 WorkBuddy 这样的智能体平台,正成为打通"数据---分析---决策"闭环的底层生产力。


七、结语

当 AI 从"聊天"走向"干活",真正的价值不在于生成多少文字,而在于能否把一门学科的方法论(这里是时空智能)封装成可复用的工程能力

本文用 WorkBuddy 完成的"长沙地铁 × 早餐"系统证明:一个专业的时空分析产品,可以在一次对话内完成数据获取、坐标治理、空间分析与交互可视化的完整闭环。而坐标系对齐、半径可达性、覆盖盲区这些"朴素却关键"的概念,恰是时空智能落地的真正门槛。

让城市更懂人的时空节律------这,就是时空智能的意义。


本文案例系统由 WorkBuddy 智能体全程构建:高德开放数据接入、GCJ-02 坐标对齐、单文件 HTML5 时空可视化交付。技术栈可无缝对接 SpringBoot + PostGIS 工程体系。

相关推荐
夜郎king1 个月前
用 WorkBuddy 一键解读统计公报:从图片到 Word 报告与 HTML 看板(湖南省人口数据实战)
workbuddy实践·workbuddy统计·ai 自动统计