mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 17:59:02 +00:00
465 lines
12 KiB
Vue
465 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { kFab, kPage } from 'konsta/vue'
|
|
import { LocateFixed, Map, MapPinned, Route, Satellite } from 'lucide-vue-next'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
import {
|
|
clampDefaultMapPoint,
|
|
defaultCayoStyle,
|
|
defaultMainlandStyle,
|
|
defaultMapCoordinates,
|
|
defaultMapWorldToPercent,
|
|
type MapPoint,
|
|
} from '@/features/map/defaultMapGeometry'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import { nuiCall } from '@/utils/nui'
|
|
|
|
type MapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
|
|
|
|
const phone = usePhoneStore()
|
|
const mapStyle = ref<MapStyle>('default')
|
|
const zoom = ref(1.1)
|
|
const pan = ref<MapPoint>({ x: 0, y: 0 })
|
|
const currentLocation = ref<MapPoint | null>(null)
|
|
const mapAspect = ref(
|
|
defaultMapCoordinates.width / defaultMapCoordinates.height,
|
|
)
|
|
const imageError = ref(false)
|
|
const locating = ref(false)
|
|
const viewportRef = ref<HTMLElement | null>(null)
|
|
const canvasRef = ref<HTMLElement | null>(null)
|
|
const locationRef = ref<HTMLElement | null>(null)
|
|
const isPointerDown = ref(false)
|
|
const isPanning = ref(false)
|
|
let pointerMoveFrame: number | undefined
|
|
let wheelZoomFrame: number | undefined
|
|
let pendingWheelDirection: -1 | 0 | 1 = 0
|
|
let pendingWheelPoint: MapPoint | undefined
|
|
|
|
const pointerStart = {
|
|
x: 0,
|
|
y: 0,
|
|
panX: 0,
|
|
panY: 0,
|
|
}
|
|
|
|
const mapBounds = {
|
|
minX: -4096,
|
|
maxX: 4096,
|
|
minY: -4096,
|
|
maxY: 4096,
|
|
}
|
|
const mapOrigin = { x: -336.8, y: -1412.2 }
|
|
const mapScale = { x: -2340 / -1548.1, y: 291 / 189.3 }
|
|
const zoomFactor = 1.35
|
|
const minZoom = 0.7
|
|
const maxZoom = 12.3
|
|
|
|
const mapStyles = [
|
|
{ id: 'default' as const, icon: MapPinned },
|
|
{ id: 'satellite' as const, icon: Satellite },
|
|
{ id: 'atlas' as const, icon: Map },
|
|
{ id: 'roads' as const, icon: Route },
|
|
]
|
|
const mapControlColors = {
|
|
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
|
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
|
textIos: 'text-black dark:text-white',
|
|
}
|
|
const locationControlColors = {
|
|
...mapControlColors,
|
|
textIos: 'text-white',
|
|
}
|
|
const activeMapStyle = computed(
|
|
() => mapStyles.find((style) => style.id === mapStyle.value) ?? mapStyles[0],
|
|
)
|
|
|
|
const mapImageUrl = computed(() => {
|
|
const filename =
|
|
mapStyle.value === 'default'
|
|
? 'gtav-map.svg'
|
|
: mapStyle.value === 'roads'
|
|
? 'map_roads_4096.webp'
|
|
: mapStyle.value === 'atlas'
|
|
? 'map_atlas_4096.webp'
|
|
: 'map_satellite_4096.webp'
|
|
return `${import.meta.env.BASE_URL}img/maps/${filename}`
|
|
})
|
|
const cayoMapImageUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
|
|
|
|
const canvasStyle = computed(() => ({
|
|
aspectRatio: String(mapAspect.value),
|
|
transform: `translate(-50%, -50%) translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`,
|
|
width: 'max(120%, 120vh)',
|
|
}))
|
|
|
|
const mapToWorld = (coords: MapPoint): MapPoint => ({
|
|
x: coords.x / mapScale.x + mapOrigin.x,
|
|
y: coords.y / mapScale.y + mapOrigin.y,
|
|
})
|
|
|
|
const worldToPercent = (coords: MapPoint): MapPoint => {
|
|
if (mapStyle.value === 'default') {
|
|
return defaultMapWorldToPercent(coords)
|
|
}
|
|
|
|
const world = mapToWorld(coords)
|
|
return {
|
|
x: Math.min(
|
|
Math.max(
|
|
(world.x - mapBounds.minX) / (mapBounds.maxX - mapBounds.minX),
|
|
0,
|
|
),
|
|
1,
|
|
),
|
|
y: Math.min(
|
|
Math.max(
|
|
(mapBounds.maxY - world.y) / (mapBounds.maxY - mapBounds.minY),
|
|
0,
|
|
),
|
|
1,
|
|
),
|
|
}
|
|
}
|
|
|
|
const locationStyle = computed(() => {
|
|
if (!currentLocation.value) return undefined
|
|
const percent = worldToPercent(currentLocation.value)
|
|
return {
|
|
left: `${percent.x * 100}%`,
|
|
top: `${percent.y * 100}%`,
|
|
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`,
|
|
}
|
|
})
|
|
|
|
function setMapStyle(style: MapStyle): void {
|
|
mapStyle.value = style
|
|
imageError.value = false
|
|
}
|
|
|
|
function cycleMapStyle(): void {
|
|
const currentIndex = mapStyles.findIndex(
|
|
(style) => style.id === mapStyle.value,
|
|
)
|
|
setMapStyle(mapStyles[(currentIndex + 1) % mapStyles.length].id)
|
|
}
|
|
|
|
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
|
|
const targetZoom =
|
|
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
|
|
const nextZoom = Math.min(
|
|
Math.max(Math.round(targetZoom * 1000) / 1000, minZoom),
|
|
maxZoom,
|
|
)
|
|
if (nextZoom === zoom.value) return
|
|
|
|
if (focalPoint && canvasRef.value && viewportRef.value) {
|
|
const rect = canvasRef.value.getBoundingClientRect()
|
|
const viewport = viewportRef.value.getBoundingClientRect()
|
|
const renderedScaleX = viewport.width / viewportRef.value.clientWidth
|
|
const renderedScaleY = viewport.height / viewportRef.value.clientHeight
|
|
const offsetX = focalPoint.x - rect.left - rect.width / 2
|
|
const offsetY = focalPoint.y - rect.top - rect.height / 2
|
|
const scale = nextZoom / zoom.value
|
|
pan.value = {
|
|
x: pan.value.x - (offsetX * (scale - 1)) / renderedScaleX,
|
|
y: pan.value.y - (offsetY * (scale - 1)) / renderedScaleY,
|
|
}
|
|
}
|
|
|
|
zoom.value = nextZoom
|
|
}
|
|
|
|
function onPointerDown(event: PointerEvent): void {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) return
|
|
isPointerDown.value = true
|
|
isPanning.value = false
|
|
pointerStart.x = event.clientX
|
|
pointerStart.y = event.clientY
|
|
pointerStart.panX = pan.value.x
|
|
pointerStart.panY = pan.value.y
|
|
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent): void {
|
|
if (!isPointerDown.value) return
|
|
const deltaX = event.clientX - pointerStart.x
|
|
const deltaY = event.clientY - pointerStart.y
|
|
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
|
|
isPanning.value = true
|
|
}
|
|
if (!isPanning.value) return
|
|
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
|
pointerMoveFrame = requestAnimationFrame(() => {
|
|
pointerMoveFrame = undefined
|
|
const viewportElement = viewportRef.value
|
|
const viewport = viewportElement?.getBoundingClientRect()
|
|
if (!viewportElement || !viewport) return
|
|
const renderedScaleX = viewport.width / viewportElement.clientWidth
|
|
const renderedScaleY = viewport.height / viewportElement.clientHeight
|
|
pan.value = {
|
|
x: pointerStart.panX + deltaX / renderedScaleX,
|
|
y: pointerStart.panY + deltaY / renderedScaleY,
|
|
}
|
|
})
|
|
}
|
|
|
|
function onPointerUp(): void {
|
|
isPointerDown.value = false
|
|
isPanning.value = false
|
|
}
|
|
|
|
function onWheel(event: WheelEvent): void {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
pendingWheelDirection = event.deltaY > 0 ? -1 : 1
|
|
pendingWheelPoint = { x: event.clientX, y: event.clientY }
|
|
if (wheelZoomFrame) return
|
|
wheelZoomFrame = requestAnimationFrame(() => {
|
|
wheelZoomFrame = undefined
|
|
if (pendingWheelDirection !== 0) {
|
|
changeZoom(pendingWheelDirection, pendingWheelPoint)
|
|
}
|
|
pendingWheelDirection = 0
|
|
pendingWheelPoint = undefined
|
|
})
|
|
}
|
|
|
|
function onImageLoad(event: Event): void {
|
|
const image = event.target as HTMLImageElement
|
|
mapAspect.value =
|
|
mapStyle.value === 'default'
|
|
? defaultMapCoordinates.width / defaultMapCoordinates.height
|
|
: image.naturalWidth / image.naturalHeight
|
|
imageError.value = false
|
|
}
|
|
|
|
async function loadCurrentLocation(center: boolean): Promise<void> {
|
|
locating.value = true
|
|
const response = await nuiCall<{ coords?: MapPoint }>('map:getPlayerCoords')
|
|
locating.value = false
|
|
if (!response.success || !response.data?.coords) return
|
|
|
|
currentLocation.value = clampDefaultMapPoint(response.data.coords)
|
|
if (!center) return
|
|
|
|
zoom.value = 3
|
|
pan.value = { x: 0, y: 0 }
|
|
await nextTick()
|
|
const viewportElement = viewportRef.value
|
|
const viewport = viewportElement?.getBoundingClientRect()
|
|
const location = locationRef.value?.getBoundingClientRect()
|
|
if (!viewportElement || !viewport || !location) return
|
|
const renderedScaleX = viewport.width / viewportElement.clientWidth
|
|
const renderedScaleY = viewport.height / viewportElement.clientHeight
|
|
pan.value = {
|
|
x:
|
|
(viewport.left +
|
|
viewport.width / 2 -
|
|
(location.left + location.width / 2)) /
|
|
renderedScaleX,
|
|
y:
|
|
(viewport.top +
|
|
viewport.height / 2 -
|
|
(location.top + location.height / 2)) /
|
|
renderedScaleY,
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadCurrentLocation(false)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
|
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<k-page class="map-app">
|
|
<div
|
|
ref="viewportRef"
|
|
class="map-viewport"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
@pointercancel="onPointerUp"
|
|
@wheel="onWheel"
|
|
>
|
|
<div ref="canvasRef" class="map-canvas" :style="canvasStyle">
|
|
<img
|
|
:src="mapImageUrl"
|
|
alt=""
|
|
class="map-image"
|
|
:class="{ 'map-image-default': mapStyle === 'default' }"
|
|
:style="mapStyle === 'default' ? defaultMainlandStyle : undefined"
|
|
decoding="async"
|
|
draggable="false"
|
|
@dragstart.prevent
|
|
@load="onImageLoad"
|
|
@error="imageError = true"
|
|
/>
|
|
<img
|
|
v-if="mapStyle === 'default'"
|
|
:src="cayoMapImageUrl"
|
|
alt=""
|
|
class="map-default-layer"
|
|
:style="defaultCayoStyle"
|
|
decoding="async"
|
|
draggable="false"
|
|
@dragstart.prevent
|
|
@error="imageError = true"
|
|
/>
|
|
<div
|
|
v-if="currentLocation && locationStyle"
|
|
ref="locationRef"
|
|
class="current-location"
|
|
:style="locationStyle"
|
|
>
|
|
<span class="current-location__pulse"></span>
|
|
<span class="current-location__dot"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="imageError" class="map-error">
|
|
{{ phone.t('Apps.map.imageError') }}
|
|
</p>
|
|
</div>
|
|
|
|
<nav class="map-controls" :aria-label="phone.t('Apps.map.controls')">
|
|
<k-fab
|
|
component="button"
|
|
type="button"
|
|
class="map-control"
|
|
:colors="mapControlColors"
|
|
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
|
|
@click="cycleMapStyle"
|
|
>
|
|
<template #icon>
|
|
<component :is="activeMapStyle.icon" aria-hidden="true" />
|
|
</template>
|
|
</k-fab>
|
|
<k-fab
|
|
component="button"
|
|
type="button"
|
|
class="map-control map-control--location"
|
|
:colors="locationControlColors"
|
|
:disabled="locating"
|
|
:aria-label="phone.t('Apps.map.currentLocation')"
|
|
@click="loadCurrentLocation(true)"
|
|
>
|
|
<template #icon>
|
|
<LocateFixed aria-hidden="true" />
|
|
</template>
|
|
</k-fab>
|
|
</nav>
|
|
</k-page>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.map-app {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: #111827;
|
|
}
|
|
|
|
.map-viewport {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
touch-action: none;
|
|
user-select: none;
|
|
background: #111827;
|
|
}
|
|
|
|
.map-canvas {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
height: auto;
|
|
transform-origin: center;
|
|
user-select: none;
|
|
}
|
|
|
|
.map-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
filter: saturate(1.05) contrast(1.02);
|
|
user-select: none;
|
|
}
|
|
|
|
.map-image-default,
|
|
.map-default-layer {
|
|
position: absolute;
|
|
display: block;
|
|
object-fit: fill;
|
|
}
|
|
|
|
.map-default-layer {
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.current-location {
|
|
position: absolute;
|
|
width: 18px;
|
|
height: 18px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.current-location__pulse,
|
|
.current-location__dot {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.current-location__pulse {
|
|
background: rgb(0 122 255 / 25%);
|
|
border: 1px solid rgb(255 255 255 / 80%);
|
|
}
|
|
|
|
.current-location__dot {
|
|
inset: 5px;
|
|
background: #007aff;
|
|
border: 1.5px solid #fff;
|
|
box-shadow: 0 1px 4px rgb(0 0 0 / 35%);
|
|
}
|
|
|
|
.map-controls {
|
|
position: absolute;
|
|
z-index: 3;
|
|
right: 12px;
|
|
bottom: 28px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.map-control {
|
|
--color-primary: #8e8e93;
|
|
}
|
|
|
|
.map-control svg {
|
|
width: 21px;
|
|
height: 21px;
|
|
}
|
|
|
|
.map-error {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 70%;
|
|
padding: 12px;
|
|
margin: 0;
|
|
color: #fff;
|
|
text-align: center;
|
|
transform: translate(-50%, -50%);
|
|
border-radius: 12px;
|
|
background: rgb(0 0 0 / 70%);
|
|
}
|
|
</style>
|