每一座城市都在低语——老街的梧桐树下,藏着只有下午三点才透进来的光;巷尾的咖啡馆里,有最适合发呆的角落和旧时光的味道。但这些信息,地图听不见。
打开任何一个地图 App,你能搜到"附近的咖啡厅",却搜不到"一个适合发呆的地方";你能看到 4.8 分的评分,却看不到"这里的空气里飘着慵懒的气息"。
地图变得越来越精确,却离人的感受越来越远。
如果地图能听懂"情绪",城市会变成什么样?这就是 City Whisperer(城市低语)诞生的原因——一个让 AI 听懂你的感觉、让地图为你写诗的小程序。
这个项目的灵感,来自一个再普通不过的周末午后。
那天阳光很好,我想出门走走,但不想去"打卡",也不想"逛街",只想找一个有感觉的地方坐坐。打开地图,搜索框光标闪烁——我该搜什么?
输入"咖啡厅"?出来一堆连锁品牌。输入"景点"?全是人挤人的打卡地。我真正想要的那种"光线好、人不多、能发呆"的地方,根本不是一个品类关键词能描述的。
我最终在朋友圈翻到了朋友的推荐,找到了一家藏在居民楼下的小书店。推门进去的那一刻,阳光刚好从木窗格洒进来,空气里有旧书的味道——这就是我想找的感觉。
那一刻我意识到:用户心里装的是"感觉",但地图搜索框要的是"品类"。 这中间有一道巨大的鸿沟,而 AI 可以成为连接两端的桥梁。
如果我对手机说一句"找个适合发呆的老建筑",AI 能把"发呆"翻译成"咖啡馆、图书馆、书店",把"老建筑"翻译成"古迹、历史建筑",然后地图自动帮我搜索、标记、甚至用诗意的语言告诉我这个地方给人什么感觉——这不就是城市在对你低语吗?
City Whisperer,由此诞生。
在深入技术实现之前,让我们先拆解一下现有地图产品在"情感化探索"场景下的三个核心痛点。

"我想找个适合发呆的地方"——这是人类最自然的表达方式。但当前所有地图 App 都要求你输入一个明确的品类词:咖啡厅、书店、公园……用户被迫把自己的"感觉"翻译成"品类",而这个翻译过程本身就会丢失大量信息。"适合发呆"可能是咖啡馆,也可能是公园的长椅,甚至是一间安静的博物馆——只有 AI 能理解这种模糊性。
当你终于搜到了一堆结果,地图给你的信息是:名称、地址、评分、距离。这些信息是"有用"的,但不是"有感"的。一个 4.5 分的咖啡馆和一个"阳光从木窗格洒进来"的咖啡馆,哪个更让你想推门进去? 用户需要的不仅是事实,更是一种预期感、一种"我想去那里"的情绪驱动。
传统地图的交互链路是线性的:搜关键词 → 看列表 → 比较评分 → 选一个 → 导航。每一步都需要用户主动思考和决策,没有"被引领"的体验感。而"探索城市"本质上应该是一种放松的、被启发的体验——你说一句话,地图就能帮你完成后续所有步骤。
痛点的本质:从"你告诉地图找什么"到"你告诉地图你想感受什么"——这就是 City Whisperer 要解决的命题。
City Whisperer(城市低语) 是一款基于腾讯位置服务的智能地图探索微信小程序。核心交互极其简单:

与传统地图搜索的对比:
维度 | 传统地图 | City Whisperer |
|---|---|---|
输入方式 | 品类关键词 | 自然语言 / 语音 |
搜索逻辑 | 精确匹配 | AI 意图理解 |
信息呈现 | 名称 + 评分 + 距离 | 名称 + 情感文案 + 导航 |
交互体验 | 线性决策 | 沉浸式引导 |
情感连接 | 无 | "场所印象"AI 文案 |

整个系统分为五层,从用户输入到最终交互反馈形成完整闭环:
模块 | 技术方案 | 说明 |
|---|---|---|
地图组件 | 微信小程序原生 <map> | 无需额外组件,性能最优 |
位置服务 | QQMapWX SDK(JavaScript SDK) | 搜索、定位、距离计算 |
坐标系统 | GCJ-02(国测局坐标) | 与微信地图一致,wx.getLocation 直接返回 |
AI 能力 | Mock 实现 + 预留混元 API 接口 | 意图解析、文案生成 |
UI 样式 | 原子化 CSS + Glassmorphism | 毛玻璃风格,原子化工具类 |
用户输入 "找个适合发呆的老建筑"
↓
parseIntent() → { keyword: "古迹", orderby: "_distance" }
↓
qqmapsdk.search({ keyword: "古迹", location: "39.9,116.3" })
↓
processSearchResults() → 生成 markers[] + AI 文案
↓
<map> 渲染 markers + scale: 16 放大
↓
bindmarkertap → 底部抽屉 → 场所印象 → 导航/分享一切探索的起点,是用户当前的位置。我们使用 wx.getLocation 获取 GCJ-02 坐标,并创建地图上下文:
initLocation() {
wx.showLoading({ title: '正在获取位置...' })
wx.getLocation({
type: 'gcj02', // 使用国测局坐标,与地图组件一致
success: (res) => {
this.setData({
latitude: res.latitude,
longitude: res.longitude,
userLat: res.latitude,
userLng: res.longitude
})
// 保存到全局,供搜索时使用
getApp().globalData.userLocation = {
latitude: res.latitude,
longitude: res.longitude
}
this.mapContext = wx.createMapContext('cityMap', this)
wx.hideLoading()
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '定位失败,使用默认位置', icon: 'none' })
}
})
}关键点:type: 'gcj02' 必须显式指定,否则默认返回 WGS-84 坐标,与地图组件的 GCJ-02 坐标系不匹配,会导致定位点偏移。
这是 City Whisperer 最核心的创新点。当前实现采用关键词映射方案,未来将接入混元大模型 API:
parseIntent(userInput) {
const input = userInput.toLowerCase()
// 关键词映射表:将自然语言映射为 POI 搜索关键词
const keywordMap = {
'咖啡': '咖啡厅', '喝咖啡': '咖啡厅',
'发呆': '咖啡馆', '安静': '图书馆',
'老建筑': '古迹', '古建筑': '古迹',
'露台': '露台餐厅', '拍照': '景点',
'夜景': '观景台', '猫': '猫咖',
// ... 50+ 关键词映射
}
// 排序方式推断
const orderbyMap = {
'近': '_distance', '附近': '_distance',
'好评': '_score', '推荐': '_score'
}
// 匹配关键词和排序
let keyword = '景点' // 默认
let orderby = '_distance'
for (const [key, value] of Object.entries(keywordMap)) {
if (input.includes(key)) { keyword = value; break }
}
for (const [key, value] of Object.entries(orderbyMap)) {
if (input.includes(key)) { orderby = value; break }
}
return { keyword, orderby }
}⚠️ 此处未来接入混元大模型 API——将 userInput 发送至混元 API,获取结构化意图,包括关键词、排序方式、情感标签、文案风格等维度。
意图解析后,调用腾讯位置服务的 search 接口进行周边搜索:
handleUserQuery(userInput) {
this.setData({ aiThinking: true })
const intent = this.parseIntent(userInput)
const { userLat, userLng } = this.data
const locationStr = userLat && userLng
? `${userLat},${userLng}`
: `${this.data.latitude},${this.data.longitude}`
qqmapsdk.search({
keyword: intent.keyword,
location: locationStr,
orderby: intent.orderby,
page_size: 20,
page_index: 1,
success: (res) => {
if (res.status === 0 && res.data && res.data.length > 0) {
this.processSearchResults(res.data, intent)
} else {
this.setData({ aiThinking: false })
wx.showToast({ title: '没有找到相关地点', icon: 'none' })
}
}
})
}搜索成功后,将结果处理为地图 markers 数组,并将地图从初始的 scale: 12(大视野)放大到 scale: 16(街道级别),让用户清晰看到搜索点位:
processSearchResults(poiList, intent) {
const markers = poiList.map((poi, index) => {
// 距离格式化
let distanceText = '未知'
if (poi._distance !== undefined) {
distanceText = poi._distance < 1000
? `${Math.round(poi._distance)}m`
: `${(poi._distance / 1000).toFixed(1)}km`
}
return {
id: index,
latitude: poi.location.lat,
longitude: poi.location.lng,
title: poi.title,
iconPath: '/images/marker.svg',
width: 40, height: 40,
anchor: { x: 0.5, y: 1 },
callout: {
content: poi.title,
color: '#1E1B4B', fontSize: 12,
borderRadius: 12, bgColor: 'rgba(255,255,255,0.92)',
display: 'BYCLICK', textAlign: 'center'
},
_poiData: {
...poi,
_distanceText: distanceText,
_aiDescription: this.generateAIDescription(poi, intent)
}
}
})
this.setData({
markers,
aiThinking: false,
scale: 16 // 搜索成功后放大地图展示点位
})
// 缩放视野包含所有标记点
if (markers.length > 0 && this.mapContext) {
this.mapContext.includePoints({
points: markers.map(m => ({
latitude: m.latitude, longitude: m.longitude
})),
padding: [120, 80, 200, 80]
})
}
}传统地图默认的"红色大头针"千篇一律。City Whisperer 使用自定义 SVG 图标 + 毛玻璃风格气泡:
// Marker 配置
{
iconPath: '/images/marker.svg', // 自定义圆形图标
width: 40, height: 40,
anchor: { x: 0.5, y: 1 }, // 锚点在底部中心
callout: {
content: poi.title,
color: '#1E1B4B',
borderRadius: 12,
bgColor: 'rgba(255,255,255,0.92)', // 半透明背景
borderWidth: 1,
borderColor: 'rgba(99,102,241,0.15)',
display: 'BYCLICK'
}
}自定义 Marker 图标采用圆形设计 + Indigo 主色调,与整体毛玻璃风格统一。气泡使用半透明白色背景 + 细微边框,点击时才显示,避免视觉干扰。
点击 Marker 后,底部弹出一个半模态抽屉面板,展示地点详情和 AI 生成的"场所印象"文案:
onMarkerTap(e) {
const markerId = e.detail.markerId || e.markerId
const marker = this.data.markers[markerId]
if (!marker || !marker._poiData) return
this.setData({
selectedPOI: marker._poiData,
showDrawer: true
})
}AI 文案生成当前使用模板方案,8 种风格随机选择:
generateAIDescription(poi, intent) {
// ⚠️ 此处未来接入混元大模型 API
const templates = [
`${poi.title},一个藏在城市褶皱里的温柔角落。在这里,时间慢下来,只有风和光影在低语。`,
`${poi.title}像一首未完的诗——走进去,你会发现,城市的低语原来一直在这里。`,
// ... 更多模板
]
return templates[Math.floor(Math.random() * templates.length)]
}⚠️ 此处未来接入混元大模型 API——将 POI 信息和用户意图发送至混元 API,生成更精准、更有情感张力的场所描述。
导航使用微信内置的 wx.openLocation,无需额外集成:
onNavigate() {
const poi = this.data.selectedPOI
wx.openLocation({
latitude: poi.location.lat,
longitude: poi.location.lng,
name: poi.title,
address: poi.address || '',
scale: 16
})
}分享使用微信小程序原生的 button open-type="share",确保触发微信标准分享流程。在 onShareAppMessage 中携带 POI 坐标和名称,让接收者打开后自动定位到该地点:
<button class="share-btn" open-type="share">
<image class="nav-icon share-icon" src="/images/share.svg" />
<text class="nav-text share-text">分享给朋友</text>
</button>onShareAppMessage() {
const poi = this.data.selectedPOI
if (poi) {
const lat = poi.location ? poi.location.lat : poi.latitude
const lng = poi.location ? poi.location.lng : poi.longitude
return {
title: `我在 City Whisperer 发现了一个好去处:${poi.title}`,
path: `/pages/index/index?lat=${lat}&lng=${lng}&name=${encodeURIComponent(poi.title)}`
}
}
return {
title: 'City Whisperer - 城市低语,发现身边的美好',
path: '/pages/index/index'
}
}在 onLoad 中解析分享参数,实现从分享卡片直接定位到地点:
onLoad(options) {
if (options && options.lat && options.lng) {
const lat = parseFloat(options.lat)
const lng = parseFloat(options.lng)
if (!isNaN(lat) && !isNaN(lng)) {
this.setData({ latitude: lat, longitude: lng, scale: 16 })
}
}
this.initLocation()
}City Whisperer 的 UI 采用毛玻璃(Glassmorphism)风格,核心实现依赖 CSS 的 backdrop-filter: blur() 属性。这种风格的精髓在于:让 UI 元素与底层的地图融为一体,而不是盖在地图上面。
/* 搜索栏 — 浮在地图上方的毛玻璃层 */
.search-bar {
position: absolute;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1rpx solid rgba(99, 102, 241, 0.1);
}
/* 底部抽屉 — 更强模糊的毛玻璃面板 */
.bottom-sheet {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border-radius: 48rpx 48rpx 0 0;
}
/* AI 印象卡片 — 紫色调毛玻璃 */
.ai-impression {
background: rgba(99, 102, 241, 0.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1rpx solid rgba(99, 102, 241, 0.1);
}设计要点:不同层级的 UI 元素使用不同的模糊强度和透明度——搜索栏(72% 透明 + 24px 模糊)→ 抽屉面板(92% 透明 + 32px 模糊)→ AI 卡片(8% 紫色 + 16px 模糊),形成层次分明的视觉纵深。
在 app.wxss 中定义了一套原子化工具类,确保样式一致性的同时提高开发效率:
/* 全局工具类 */
.flex { display: flex; }
.items-center { align-items: center; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border: 1rpx solid rgba(255, 255, 255, 0.3);
}
.shadow-md { box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.12); }这种方式虽然不如 Tailwind CSS 完整,但在小程序环境下足够灵活,且避免了引入第三方库的体积开销。
中国地图开发绕不开的坑就是坐标系。微信小程序的 <map> 组件使用 GCJ-02(国测局坐标,俗称"火星坐标"),而 GPS 设备返回的是 WGS-84 坐标。如果你忘记在 wx.getLocation 中指定 type: 'gcj02',定位点会在地图上偏移几百米——这种 bug 在开发阶段不容易发现,到了真实环境就会暴露。
经验:始终显式指定 type: 'gcj02',不要依赖默认值。
MapContext.includePoints() 的 padding 参数格式为 [top, right, bottom, left],单位是 px。初期我设置的 padding: [80, 80, 80, 80] 四边等距,但实际场景中顶部有搜索栏遮挡、底部有"搜索结果计数"浮层,导致部分 Marker 被遮挡。
经验:根据实际 UI 布局调整 padding。当前使用 [120, 80, 200, 80],顶部多留空间给搜索栏,底部多留空间给浮层和交互区域。
另外,搜索成功后将 scale 从 12 调到 16 再调用 includePoints,这样 includePoints 在计算视野范围时会在缩放后的比例尺下进行,确保所有点位都清晰可见。
最初我使用 <view bindtap="onSharePOI"> 来触发分享,然后在 onSharePOI 中调用 wx.showShareMenu。这个方案无法真正触发微信分享面板——showShareMenu 只是声明了分享能力,并不会弹出分享 UI。
正确做法:使用 <button open-type="share">,这是微信小程序唯一能主动触发分享消息的方式。点击后微信会自动调用页面的 onShareAppMessage 方法,获取分享内容。
同时,别忘了重置 <button> 的默认样式:
.share-btn {
padding: 0;
margin: 0;
line-height: normal;
border: 1rpx solid rgba(99, 102, 241, 0.15) !important;
}
.share-btn::after { border: none; } /* 去除默认边框 */City Whisperer 当前的 AI 能力是 Mock 实现,但架构上已预留了三个明确的接入点。一旦接入腾讯混元大模型 API,将实现三重升级:
第一重:语音识别升级。当前使用 wx.startRecord 录音后模拟识别结果,接入混元后可实现真正的语音转文字,用户只需对着手机说出需求,无需手动输入。
第二重:意图理解升级。当前的关键词映射方案覆盖了 50+ 场景,但终究是有限的。接入混元后,用户可以说任何自然语言——"我想找个适合写稿的地方,最好有插座,不要太吵"——AI 能理解多维度约束,生成精准的搜索策略。
第三重:情感文案升级。当前的 8 种文案模板虽然风格统一,但缺乏针对性。接入混元后,AI 可以根据 POI 的真实信息(名称、类型、位置、评价等)生成独一无二的场所描述——"这家藏在老街拐角的咖啡馆,窗外的梧桐树已经种了三十年,下午三点的阳光刚好落在靠窗的第二个座位"。
最终愿景:City Whisperer 不仅是一个地图工具,更是一个"城市感知器"——它理解你的情绪,感知城市的性格,在人与城之间架起一座由 AI 编织的桥梁。
从一句"找个适合发呆的老建筑"到地图上亮起的标记点,从冰冷的 POI 数据到温暖的"场所印象"——City Whisperer 证明了,当地图服务与 AI 相遇,城市不再只是一张坐标图,而是一本等待翻阅的故事书。
腾讯位置服务提供了稳定、精准的底层能力(定位、搜索、距离计算),让开发者可以把精力集中在"如何让地图更有温度"这件事上。而微信小程序的原生 <map> 组件与 QQMapWX SDK 的无缝衔接,让整个开发过程流畅高效。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。