macos-web/src/apps/maps/Maps.vue

198 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>