macos-web/src/apps/maps/index.ts
2026-07-21 16:13:16 +08:00

120 lines
8.7 KiB
TypeScript
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.

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)