120 lines
8.7 KiB
TypeScript
120 lines
8.7 KiB
TypeScript
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,
|
||
menus(win: any) {
|
||
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') },
|
||
{ sep: true },
|
||
{ label: '放大', key: '⌘+', action: () => st?.zoomBy(1) },
|
||
{ label: '缩小', key: '⌘−', action: () => st?.zoomBy(-1) },
|
||
]
|
||
})
|
||
},
|
||
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())
|
||
}
|
||
}
|
||
Apps.register(MapsApp)
|