feat: 重构 maps — Vue 组件化(canvas 地图/搜索/POI/缩放)

This commit is contained in:
李岩岩 2026-07-23 10:24:30 +08:00
parent 0a2ca982bd
commit af2d1341e5
2 changed files with 203 additions and 100 deletions

View File

@ -1 +1,197 @@
<template><div></div></template>
<template>
<div class="maps-body">
<canvas ref="canvasEl" class="maps-canvas" @wheel.prevent="onWheel" @pointerdown="onPointerDown"></canvas>
<div class="maps-top">
<input class="text-input maps-search" type="search" placeholder="搜索地点或地址" v-model="query" @input="onSearch">
</div>
<div ref="resultsEl" :class="'maps-results' + (results.length ? '' : ' hidden')">
<div v-for="r in results" :key="r.name" class="maps-result" @click="goToPOI(r)">
<b>{{ r.name }}</b>
<small>{{ kindLabel(r.kind) }}</small>
</div>
</div>
<div class="segmented maps-seg">
<button :class="{ on: mode === 'std' }" @click="setMode('std')">标准</button>
<button :class="{ on: mode === 'sat' }" @click="setMode('sat')">卫星</button>
</div>
<div class="maps-zoom-col">
<button class="maps-zoom" title="放大" @click="zoomBy(1)"></button>
<button class="maps-zoom" title="缩小" @click="zoomBy(-1)"></button>
</div>
<button class="maps-locate" title="我的位置" @click="goHome"></button>
<div class="maps-offline-tag">离线地图 · 北京市示意图</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { clamp } from '../../utils'
const props = defineProps<{ win: any }>()
// POI
const POIS = [
{ name: '天安门广场', x: 0.50, y: 0.52, kind: 'landmark' },
{ name: '故宫博物院', x: 0.50, y: 0.45, kind: 'landmark' },
{ name: '王府井大街', x: 0.56, y: 0.50, kind: 'shopping' },
{ name: '北海公园', x: 0.44, y: 0.42, kind: 'park' },
{ name: '颐和园', x: 0.18, y: 0.18, kind: 'park' },
{ name: '北京站', x: 0.60, y: 0.62, kind: 'transit' },
{ name: '中关村', x: 0.30, y: 0.25, kind: 'shopping' },
{ name: '三里屯', x: 0.68, y: 0.42, kind: 'shopping' },
{ name: '奥林匹克公园', x: 0.52, y: 0.20, kind: 'park' },
{ name: '北京西站', x: 0.32, y: 0.66, kind: 'transit' },
{ name: '国贸 CBD', x: 0.66, y: 0.56, kind: 'landmark' },
{ name: '南锣鼓巷', x: 0.50, y: 0.40, kind: 'landmark' },
]
const kindLabels: Record<string, string> = { park: '公园', transit: '车站', shopping: '商圈', landmark: '地标' }
function kindLabel(k: string) { return kindLabels[k] || k }
//
const mode = ref<'std' | 'sat'>('std')
const zoom = ref(3)
const cx = ref(0.5)
const cy = ref(0.48)
const pin = ref<any>(null)
const query = ref('')
const results = ref<any[]>([])
const canvasEl = ref<HTMLCanvasElement>()
const resultsEl = ref<HTMLElement>()
let timer: any = null
// canvas
let ctx: CanvasRenderingContext2D | null = null
let dpr = 2
function setMode(m: string) { mode.value = m as any; draw() }
function zoomBy(d: number) { zoom.value = clamp(zoom.value + d, 1, 6); draw() }
function goHome() {
cx.value = 0.5; cy.value = 0.52
pin.value = { name: '我的位置', x: 0.5, y: 0.52 }
draw()
}
function goToPOI(poi: any) {
cx.value = poi.x; cy.value = poi.y
zoom.value = Math.max(zoom.value, 4)
pin.value = poi
query.value = poi.name
results.value = []
draw()
}
function onSearch() {
const q = query.value.trim()
if (!q) { results.value = []; return }
results.value = POIS.filter(p => p.name.includes(q))
}
function onWheel(e: WheelEvent) { zoomBy(e.deltaY < 0 ? 1 : -1) }
function onPointerDown(e: PointerEvent) {
const sx = e.clientX, sy = e.clientY, ox = cx.value, oy = cy.value
const move = (ev: PointerEvent) => {
const scale = Math.pow(1.7, zoom.value - 1)
cx.value = clamp(ox - (ev.clientX - sx) / (canvasEl.value!.width * scale / 2), 0, 1)
cy.value = clamp(oy - (ev.clientY - sy) / (canvasEl.value!.height * scale / 2), 0, 1)
draw()
}
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up) }
document.addEventListener('pointermove', move)
document.addEventListener('pointerup', up)
}
function toScreen(px: number, py: number): [number, number] {
const W = canvasEl.value!.width, H = canvasEl.value!.height
const scale = Math.pow(1.7, zoom.value - 1)
return [W / 2 + (px - cx.value) * W * scale / 2, H / 2 + (py - cy.value) * H * scale / 2]
}
function draw() {
if (!canvasEl.value) return
const c = canvasEl.value
const W = c.width = c.clientWidth * dpr
const H = c.height = c.clientHeight * dpr
ctx = c.getContext('2d')!
const sat = mode.value === 'sat'
ctx.fillStyle = sat ? '#1c2418' : '#e8e4da'; ctx.fillRect(0, 0, W, H)
const scale = Math.pow(1.7, zoom.value - 1)
//
ctx.fillStyle = sat ? '#16202e' : '#a8c8e8'
;[[0.44, 0.42, 0.05, 0.035], [0.17, 0.17, 0.045, 0.05], [0.52, 0.21, 0.035, 0.03]].forEach(([lx, ly, rx, ry]) => {
const [x, y] = toScreen(lx!, ly!)
ctx!.beginPath(); ctx!.ellipse(x, y, rx! * W * scale / 2, ry! * W * scale / 2, 0, 0, Math.PI * 2); ctx!.fill()
})
//
ctx.fillStyle = sat ? '#22331f' : '#b8d9a8'
;[[0.44, 0.42, 0.045], [0.52, 0.20, 0.05], [0.18, 0.18, 0.05]].forEach(([px, py, r]) => {
const [x, y] = toScreen(px!, py!)
ctx!.beginPath(); ctx!.arc(x, y, r! * W * scale / 2, 0, Math.PI * 2); ctx!.fill()
})
//
const main = sat ? '#4a4640' : '#ffffff', ring = sat ? '#57534b' : '#f8d67c'
for (let i = 1; i < 9; i++) { road(i / 9, 0.05, i / 9, 0.95, 3, main) }
for (let j = 1; j < 8; j++) { road(0.05, j / 8, 0.95, j / 8, 3, main) }
road(0.1, 0.52, 0.9, 0.52, 6, ring); road(0.5, 0.08, 0.5, 0.92, 5, ring)
//
ctx.strokeStyle = ring; ctx.lineWidth = 4 * dpr
const [ccx, ccy] = toScreen(0.5, 0.5)
ctx.beginPath(); ctx.arc(ccx, ccy, 0.16 * W * scale / 2, 0, Math.PI * 2); ctx.stroke()
//
if (zoom.value >= 3) {
ctx.fillStyle = sat ? '#b8b2a4' : '#8a8578'; ctx.font = `${11 * dpr}px sans-serif`
ctx.fillText('长安街', ...toScreen(0.42, 0.505))
ctx.fillText('中轴路', ...toScreen(0.505, 0.3))
ctx.fillText('二环路', ...toScreen(0.63, 0.36))
}
// POI
POIS.forEach((p: any) => {
const [x, y] = toScreen(p.x, p.y)
if (x < -60 || y < -60 || x > W + 60 || y > H + 60) return
ctx!.fillStyle = ({ park: '#3f9d4e', transit: '#3a7bd5', shopping: '#c86dd7', landmark: '#d5663a' } as any)[p.kind]
ctx!.beginPath(); ctx!.arc(x, y, 5 * dpr, 0, Math.PI * 2); ctx!.fill()
if (zoom.value >= 2) { ctx!.fillStyle = sat ? '#e8e4da' : '#3a372f'; ctx!.font = `${10.5 * dpr}px sans-serif`; ctx!.fillText(p.name, x + 8 * dpr, y + 4 * dpr) }
})
//
if (pin.value) {
const [x, y] = toScreen(pin.value.x, pin.value.y)
ctx.fillStyle = '#ff453a'
ctx.beginPath(); ctx.arc(x, y - 14 * dpr, 8 * dpr, 0, Math.PI * 2); ctx.fill()
ctx.beginPath(); ctx.moveTo(x - 6 * dpr, y - 9 * dpr); ctx.lineTo(x, y + 2 * dpr); ctx.lineTo(x + 6 * dpr, y - 9 * dpr); ctx.fill()
ctx.fillStyle = '#fff'; ctx.font = `bold ${11 * dpr}px sans-serif`
ctx.fillText(pin.value.name, x + 12 * dpr, y - 10 * dpr)
}
}
function road(x1: number, y1: number, x2: number, y2: number, w: number, color: string) {
if (!ctx) return
const [ax, ay] = toScreen(x1, y1), [bx, by] = toScreen(x2, y2)
ctx.strokeStyle = color; ctx.lineWidth = w * dpr * (0.6 + zoom.value * 0.3); ctx.lineCap = 'round'
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke()
}
// menus
props.win.appState = { mode, setMode, zoomBy }
//
onMounted(() => {
dpr = window.devicePixelRatio || 1
draw()
timer = setInterval(() => {
if (canvasEl.value && canvasEl.value.width !== canvasEl.value.clientWidth * dpr) draw()
}, 500)
;(props.win as any)._mapsTimer = timer
})
onUnmounted(() => { clearInterval(timer) })
</script>

View File

@ -1,24 +1,8 @@
// 地图应用 — Vue 组件化
import { h, render } from 'vue'
// 地图应用 — 从 js/apps3.js 手动转写
import { el, clamp, debounce } from '../../utils'
import { Apps, stdMenus } from '../../composables/useApps'
import MapsComponent from './Maps.vue'
const MAP_POIS = [
{ name: '天安门广场', x: 0.50, y: 0.52, kind: 'landmark' },
{ name: '故宫博物院', x: 0.50, y: 0.45, kind: 'landmark' },
{ name: '王府井大街', x: 0.56, y: 0.50, kind: 'shopping' },
{ name: '北海公园', x: 0.44, y: 0.42, kind: 'park' },
{ name: '颐和园', x: 0.18, y: 0.18, kind: 'park' },
{ name: '北京站', x: 0.60, y: 0.62, kind: 'transit' },
{ name: '中关村', x: 0.30, y: 0.25, kind: 'shopping' },
{ name: '三里屯', x: 0.68, y: 0.42, kind: 'shopping' },
{ name: '奥林匹克公园', x: 0.52, y: 0.20, kind: 'park' },
{ name: '北京西站', x: 0.32, y: 0.66, kind: 'transit' },
{ name: '国贸 CBD', x: 0.66, y: 0.56, kind: 'landmark' },
{ name: '南锣鼓巷', x: 0.50, y: 0.40, kind: 'landmark' },
]
export const MapsApp = {
id: 'maps', name: '地图', icon: '/assets/icons/maps.png',
w: 860, h: 580, minW: 520, minH: 360,
@ -26,8 +10,8 @@ export const MapsApp = {
const st = win?.appState
return stdMenus(this, {
view: [
{ label: '标准', checked: st?.mode === 'std', action: () => st?.setMode('std') },
{ label: '卫星', checked: st?.mode === 'sat', action: () => st?.setMode('sat') },
{ label: '标准', checked: st?.mode?.value === 'std', action: () => st?.setMode('std') },
{ label: '卫星', checked: st?.mode?.value === 'sat', action: () => st?.setMode('sat') },
{ sep: true },
{ label: '放大', key: '⌘+', action: () => st?.zoomBy(1) },
{ label: '缩小', key: '⌘−', action: () => st?.zoomBy(-1) },
@ -35,85 +19,8 @@ export const MapsApp = {
})
},
render(win: any) {
const st = win.appState = { mode: 'std', zoom: 3, cx: 0.5, cy: 0.48, pin: null as any }
win.body.classList.add('maps-body')
const search = el('input', { class: 'text-input maps-search', type: 'search', placeholder: '搜索地点或地址' }) as HTMLInputElement
const results = el('div', { class: 'maps-results hidden' })
const seg = el('div', { class: 'segmented maps-seg' },
el('button', { text: '标准', class: 'on', onclick: () => { st.setMode('std'); (seg.children[0] as HTMLElement).classList.add('on'); (seg.children[1] as HTMLElement).classList.remove('on') } }),
el('button', { text: '卫星', onclick: () => { st.setMode('sat'); (seg.children[1] as HTMLElement).classList.add('on'); (seg.children[0] as HTMLElement).classList.remove('on') } }))
const zIn = el('button', { class: 'maps-zoom', text: '', title: '放大' })
const zOut = el('button', { class: 'maps-zoom', text: '', title: '缩小' })
const locate = el('button', { class: 'maps-locate', html: '◎', title: '我的位置' })
const canvas = el('canvas', { class: 'maps-canvas' }) as HTMLCanvasElement
const offlineTag = el('div', { class: 'maps-offline-tag', text: '离线地图 · 北京市(示意图)' })
win.body.append(canvas, el('div', { class: 'maps-top' }, search), results, seg,
el('div', { class: 'maps-zoom-col' }, zIn, zOut), locate, offlineTag)
const ctx = canvas.getContext('2d')!
const devicePixelRatio = window.devicePixelRatio || 1
st.setMode = (m: string) => { st.mode = m; draw() }
st.zoomBy = (d: number) => { st.zoom = clamp(st.zoom + d, 1, 6); draw() }
zIn.addEventListener('click', () => st.zoomBy(1))
zOut.addEventListener('click', () => st.zoomBy(-1))
locate.addEventListener('click', () => { st.cx = 0.5; st.cy = 0.52; st.pin = { name: '我的位置', x: 0.5, y: 0.52 }; draw() })
canvas.addEventListener('wheel', (e: WheelEvent) => { e.preventDefault(); st.zoomBy(e.deltaY < 0 ? 1 : -1) }, { passive: false })
canvas.addEventListener('pointerdown', (e: PointerEvent) => {
const sx = e.clientX, sy = e.clientY, ox = st.cx, oy = st.cy
const scale = Math.pow(1.7, st.zoom - 1)
const move = (ev: PointerEvent) => { st.cx = clamp(ox - (ev.clientX - sx) / (canvas.width * scale / 2), 0, 1); st.cy = clamp(oy - (ev.clientY - sy) / (canvas.height * scale / 2), 0, 1); draw() }
const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up) }
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up)
})
const toScreen = (px: number, py: number) => {
const scale = Math.pow(1.7, st.zoom - 1)
return [canvas.width / 2 + (px - st.cx) * canvas.width * scale / 2, canvas.height / 2 + (py - st.cy) * canvas.height * scale / 2]
}
const draw = () => {
const W = canvas.width = canvas.clientWidth * devicePixelRatio
const H = canvas.height = canvas.clientHeight * devicePixelRatio
const sat = st.mode === 'sat'
ctx.fillStyle = sat ? '#1c2418' : '#e8e4da'; ctx.fillRect(0, 0, W, H)
const scale = Math.pow(1.7, st.zoom - 1)
ctx.fillStyle = sat ? '#16202e' : '#a8c8e8'
const lake = (cx: number, cy: number, rx: number, ry: number) => { const [x, y] = toScreen(cx, cy); ctx.beginPath(); ctx.ellipse(x, y, rx * W * scale / 2, ry * W * scale / 2, 0, 0, Math.PI * 2); ctx.fill() }
lake(0.44, 0.42, 0.05, 0.035); lake(0.17, 0.17, 0.045, 0.05); lake(0.52, 0.21, 0.035, 0.03)
ctx.fillStyle = sat ? '#22331f' : '#b8d9a8'
const park = (cx: number, cy: number, r: number) => { const [x, y] = toScreen(cx, cy); ctx.beginPath(); ctx.arc(x, y, r * W * scale / 2, 0, Math.PI * 2); ctx.fill() }
park(0.44, 0.42, 0.045); park(0.52, 0.20, 0.05); park(0.18, 0.18, 0.05)
const road = (x1: number, y1: number, x2: number, y2: number, w: number, color: string) => {
const [ax, ay] = toScreen(x1, y1), [bx, by] = toScreen(x2, y2)
ctx.strokeStyle = color; ctx.lineWidth = w * devicePixelRatio * (0.6 + st.zoom * 0.3); ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke()
}
const main = sat ? '#4a4640' : '#ffffff', ring = sat ? '#57534b' : '#f8d67c'
for (let i = 1; i < 9; i++) road(i / 9, 0.05, i / 9, 0.95, 3, main)
for (let j = 1; j < 8; j++) road(0.05, j / 8, 0.95, j / 8, 3, main)
road(0.1, 0.52, 0.9, 0.52, 6, ring); road(0.5, 0.08, 0.5, 0.92, 5, ring)
ctx.strokeStyle = ring; ctx.lineWidth = 4 * devicePixelRatio
const [ccx, ccy] = toScreen(0.5, 0.5); ctx.beginPath(); ctx.arc(ccx, ccy, 0.16 * W * scale / 2, 0, Math.PI * 2); ctx.stroke()
if (st.zoom >= 3) { ctx.fillStyle = sat ? '#b8b2a4' : '#8a8578'; ctx.font = `${11 * devicePixelRatio}px sans-serif`; ctx.fillText('长安街', toScreen(0.42, 0.505)[0], toScreen(0.42, 0.505)[1]); ctx.fillText('中轴路', toScreen(0.505, 0.3)[0], toScreen(0.505, 0.3)[1]); ctx.fillText('二环路', toScreen(0.63, 0.36)[0], toScreen(0.63, 0.36)[1]) }
MAP_POIS.forEach((p: any) => {
const [x, y] = toScreen(p.x, p.y); if (x < -60 || y < -60 || x > W + 60 || y > H + 60) return
ctx.fillStyle = ({ park: '#3f9d4e', transit: '#3a7bd5', shopping: '#c86dd7', landmark: '#d5663a' } as any)[p.kind]
ctx.beginPath(); ctx.arc(x, y, 5 * devicePixelRatio, 0, Math.PI * 2); ctx.fill()
if (st.zoom >= 2) { ctx.fillStyle = sat ? '#e8e4da' : '#3a372f'; ctx.font = `${10.5 * devicePixelRatio}px sans-serif`; ctx.fillText(p.name, x + 8 * devicePixelRatio, y + 4 * devicePixelRatio) }
})
if (st.pin) { const [x, y] = toScreen(st.pin.x, st.pin.y); ctx.fillStyle = '#ff453a'; ctx.beginPath(); ctx.arc(x, y - 14 * devicePixelRatio, 8 * devicePixelRatio, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.moveTo(x - 6 * devicePixelRatio, y - 9 * devicePixelRatio); ctx.lineTo(x, y + 2 * devicePixelRatio); ctx.lineTo(x + 6 * devicePixelRatio, y - 9 * devicePixelRatio); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = `bold ${11 * devicePixelRatio}px sans-serif`; ctx.fillText(st.pin.name, x + 12 * devicePixelRatio, y - 10 * devicePixelRatio) }
}
win.timers.push(setInterval(() => { if (canvas.width !== canvas.clientWidth * devicePixelRatio) draw() }, 500))
setTimeout(draw, 30)
search.addEventListener('input', debounce(() => {
const q = search.value.trim(); results.innerHTML = ''
if (!q) { results.classList.add('hidden'); return }
const hits = MAP_POIS.filter((p: any) => p.name.includes(q))
results.classList.remove('hidden')
if (!hits.length) results.append(el('div', { class: 'maps-result', text: '本地结果中没有"' + q + '"(离线地图)' }))
hits.forEach((p: any) => {
const r = el('div', { class: 'maps-result' }, el('b', { text: p.name }), el('small', { text: ({ park: '公园', transit: '车站', shopping: '商圈', landmark: '地标' } as any)[p.kind] }))
r.addEventListener('click', () => { st.cx = p.x; st.cy = p.y; st.zoom = Math.max(st.zoom, 4); st.pin = p; results.classList.add('hidden'); search.value = p.name; draw() })
results.append(r)
})
}, 200))
search.addEventListener('keydown', (e: KeyboardEvent) => e.stopPropagation())
}
const vnode = h(MapsComponent, { win })
render(vnode, win.body)
},
}
Apps.register(MapsApp)