198 lines
7.5 KiB
Vue
198 lines
7.5 KiB
Vue
<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>
|
||
|