From 90909e41f2c0f6f4e2a826f3ea6f4d0eb90d15ab Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:06:03 +0200 Subject: [PATCH] ENH - improve CrewLink map coordination --- frontend/src/stores/phone.ts | 9 +- frontend/src/views/apps/CrewLinkApp.vue | 514 +++++++++++++++++++----- sky_phone/config/locales/en.lua | 2 +- 3 files changed, 418 insertions(+), 107 deletions(-) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index b7e4fd3..4f22fba 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -653,12 +653,13 @@ const defaultLocales: LocaleTree = { routeSet: 'GPS route set.', pingCreated: 'Ping shared with your crew.', pingRemoved: 'Ping removed.', - newPingBody: - 'Share your current position or place the ping at the center of the map.', + newPingBody: 'Choose a point on the map, then add a label and ping type.', pingLabel: 'Ping label', pingLabelPlaceholder: 'e.g. Meet at the garage', - placeOnMap: 'Use map center', - placeOnMapBody: 'Otherwise your current position is used.', + placeOnMap: 'Place ping on map', + placeOnMapBody: 'Move the map until the crosshair marks the exact spot.', + positionSelected: 'Position selected · tap to change', + addHere: 'Place Here', sharePing: 'Share Ping', pingTypes: { meeting: 'Meet', diff --git a/frontend/src/views/apps/CrewLinkApp.vue b/frontend/src/views/apps/CrewLinkApp.vue index 8c7e347..7dd0964 100644 --- a/frontend/src/views/apps/CrewLinkApp.vue +++ b/frontend/src/views/apps/CrewLinkApp.vue @@ -37,12 +37,14 @@ import { LocateFixed, LogOut, Map as MapIcon, + MapPinned, MapPin, Navigation, Plus, Radio, RefreshCw, Route, + Satellite, Settings2, Share2, Shield, @@ -90,6 +92,7 @@ import { nuiCall } from '@/utils/nui' import { isTrustedRootMessageSource } from '@/utils/windowMessages' type CrewLinkTab = 'map' | 'group' | 'pings' | 'profile' +type CrewLinkMapStyle = 'default' | 'satellite' | 'atlas' | 'roads' type CrewLinkSheet = | 'create-group' | 'join-group' @@ -116,9 +119,49 @@ const crew = useCrewLinkStore() const messageMedia = useMessageMediaStore() const route = useRoute() const router = useRouter() -const mainlandMapUrl = `${import.meta.env.BASE_URL}img/maps/gtav-map.svg` +const mapStyle = ref('default') +const mapAspect = ref( + defaultMapCoordinates.width / defaultMapCoordinates.height, +) +const mapMaxZoom = ref(8) const cayoMapUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg` +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 mapStyles = [ + { id: 'default' as const, icon: MapPinned }, + { id: 'satellite' as const, icon: Satellite }, + { id: 'atlas' as const, icon: MapIcon }, + { id: 'roads' as const, icon: Route }, +] +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 activeTab = ref('map') +const headerTitle = computed(() => + activeTab.value === 'map' + ? t('name') + : t(activeTab.value === 'group' ? 'crew' : activeTab.value), +) +const headerSubtitle = computed(() => + activeTab.value === 'map' ? t('liveCoordination') : t('name'), +) const sheet = ref(null) const username = ref('') const authMode = ref<'login' | 'register'>('login') @@ -140,7 +183,8 @@ const sharedInviteCode = ref( ) const pingType = ref('meeting') const pingLabel = ref('') -const pingAtMapCenter = ref(false) +const placingPing = ref(false) +const pingCoords = ref(null) const nearbyPlayers = ref([]) const selectedMember = ref(null) const selectedPing = ref(null) @@ -160,6 +204,7 @@ const viewportRef = ref(null) const canvasRef = ref(null) const isPointerDown = ref(false) const pointerStart = { x: 0, y: 0, panX: 0, panY: 0 } +const pointerLatest = { x: 0, y: 0 } let liveTimer: number | undefined let toastTimer: number | undefined let pointerFrame: number | undefined @@ -231,10 +276,8 @@ const authUsernameValid = computed(() => /^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(authUsername.value.trim()), ) const canvasStyle = computed(() => ({ - aspectRatio: String( - defaultMapCoordinates.width / defaultMapCoordinates.height, - ), - transform: `translate(-50%, -50%) translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`, + aspectRatio: String(mapAspect.value), + transform: `translate3d(-50%, -50%, 0) translate3d(${pan.value.x}px, ${pan.value.y}px, 0) scale(${zoom.value})`, width: 'max(128%, 128vh)', })) const activeColour = computed( @@ -252,7 +295,7 @@ const mapCenterCoords = computed(() => { const viewport = viewportRef.value?.getBoundingClientRect() const canvas = canvasRef.value?.getBoundingClientRect() if (!viewport || !canvas) return null - return defaultMapPercentToWorld({ + return mapPercentToWorld({ x: Math.min( 1, Math.max( @@ -270,6 +313,78 @@ const mapCenterCoords = computed(() => { }) }) +function mapToWorld(coords: MapPoint): MapPoint { + return { + x: coords.x / mapScale.x + mapOrigin.x, + y: coords.y / mapScale.y + mapOrigin.y, + } +} + +function mapWorldToPercent(coords: MapPoint): MapPoint { + if (mapStyle.value === 'default') return defaultMapWorldToPercent(coords) + const world = mapToWorld(coords) + return { + x: Math.min( + 1, + Math.max( + 0, + (world.x - mapBounds.minX) / (mapBounds.maxX - mapBounds.minX), + ), + ), + y: Math.min( + 1, + Math.max( + 0, + (mapBounds.maxY - world.y) / (mapBounds.maxY - mapBounds.minY), + ), + ), + } +} + +function mapPercentToWorld(point: MapPoint): MapPoint { + if (mapStyle.value === 'default') return defaultMapPercentToWorld(point) + const projected = { + x: mapBounds.minX + point.x * (mapBounds.maxX - mapBounds.minX), + y: mapBounds.maxY - point.y * (mapBounds.maxY - mapBounds.minY), + } + return { + x: (projected.x - mapOrigin.x) * mapScale.x, + y: (projected.y - mapOrigin.y) * mapScale.y, + } +} + +function cycleMapStyle(): void { + const currentIndex = mapStyles.findIndex( + (style) => style.id === mapStyle.value, + ) + mapStyle.value = mapStyles[(currentIndex + 1) % mapStyles.length].id +} + +async function onMapImageLoad(event: Event): Promise { + const image = event.target as HTMLImageElement + const loadedStyle = mapStyle.value + mapAspect.value = + loadedStyle === 'default' + ? defaultMapCoordinates.width / defaultMapCoordinates.height + : image.naturalWidth / image.naturalHeight + + await nextTick() + if (!image.isConnected || mapStyle.value !== loadedStyle) return + + mapMaxZoom.value = + loadedStyle === 'default' + ? 8 + : Math.max( + 0.85, + Math.min( + 8, + image.naturalWidth / Math.max(1, image.offsetWidth), + image.naturalHeight / Math.max(1, image.offsetHeight), + ), + ) + if (zoom.value > mapMaxZoom.value) setZoomAt(mapMaxZoom.value) +} + function t(path: string, replacements: Record = {}): string { return phone.t(`Apps.crewlink.${path}`, replacements) } @@ -460,11 +575,13 @@ function markerStyle( coords: MapPoint, offset = '-50%', ): Record { - const point = defaultMapWorldToPercent(coords) + const point = mapWorldToPercent(coords) + const canvasWidth = canvasRef.value?.offsetWidth ?? 0 + const canvasHeight = canvasRef.value?.offsetHeight ?? 0 return { - left: `${point.x * 100}%`, - top: `${point.y * 100}%`, - transform: `translate(-50%, ${offset}) scale(${1 / zoom.value})`, + left: `calc(50% + ${pan.value.x + (point.x - 0.5) * canvasWidth * zoom.value}px)`, + top: `calc(50% + ${pan.value.y + (point.y - 0.5) * canvasHeight * zoom.value}px)`, + transform: `translate(-50%, ${offset})`, } } @@ -492,7 +609,7 @@ function openSheet(next: CrewLinkSheet): void { } else if (next === 'ping') { pingType.value = 'meeting' pingLabel.value = '' - pingAtMapCenter.value = false + pingCoords.value = null } else if (next === 'edit-group' && activeGroup.value) { groupName.value = activeGroup.value.name groupColour.value = activeGroup.value.colour @@ -501,6 +618,7 @@ function openSheet(next: CrewLinkSheet): void { function closeSheet(): void { if (crew.isLoading) return + if (sheet.value === 'ping') pingCoords.value = null sheet.value = null selectedMember.value = null selectedProfilePhoto.value = null @@ -661,8 +779,37 @@ async function toggleGroupSetting( } } -function togglePingAtMapCenter(): void { - pingAtMapCenter.value = !pingAtMapCenter.value +function startPingPlacement(): void { + sheet.value = null + formError.value = '' + pingType.value = 'meeting' + pingLabel.value = '' + pingCoords.value = null + placingPing.value = true + activeTab.value = 'map' +} + +function resumePingPlacement(): void { + sheet.value = null + formError.value = '' + placingPing.value = true + activeTab.value = 'map' +} + +function cancelPingPlacement(): void { + placingPing.value = false + pingCoords.value = null +} + +function confirmPingPlacement(): void { + const center = mapCenterCoords.value + if (!center) { + showToast(errorText()) + return + } + pingCoords.value = { x: center.x, y: center.y } + placingPing.value = false + sheet.value = 'ping' } function copyInviteCode(): void { @@ -768,11 +915,14 @@ function cancelConfirmation(): void { } async function createPing(): Promise { - const center = pingAtMapCenter.value ? mapCenterCoords.value : null + if (!pingCoords.value) { + formError.value = errorText() + return + } const response = await crew.createPing( pingType.value, pingLabel.value.trim(), - center ? { x: center.x, y: center.y, z: 0 } : undefined, + { x: pingCoords.value.x, y: pingCoords.value.y, z: 0 }, ) if (!response.success) { formError.value = errorText(response.error) @@ -819,8 +969,8 @@ function centerOn(coords: MapPoint): void { const viewport = viewportRef.value?.getBoundingClientRect() const canvas = canvasRef.value if (!viewport || !canvas) return - const point = defaultMapWorldToPercent(coords) - const nextZoom = 3.8 + const point = mapWorldToPercent(coords) + const nextZoom = Math.min(3.8, mapMaxZoom.value) zoom.value = nextZoom pan.value = { x: (0.5 - point.x) * canvas.offsetWidth * nextZoom, @@ -832,7 +982,7 @@ function fitOnlineMembers(): void { const viewport = viewportRef.value const canvas = canvasRef.value const points = visibleMapMembers.value.map((member) => - defaultMapWorldToPercent(member.coords!), + mapWorldToPercent(member.coords!), ) if (!viewport || !canvas || !points.length) return @@ -845,7 +995,7 @@ function fitOnlineMembers(): void { const nextZoom = Math.max( 1.25, Math.min( - 5, + mapMaxZoom.value, (viewport.clientWidth - 90) / horizontalSpan, (viewport.clientHeight - 120) / verticalSpan, ), @@ -868,11 +1018,27 @@ function centerOwnLocation(): void { else showToast(t('locationUnavailable')) } +function setZoomAt(nextZoom: number, clientX?: number, clientY?: number): void { + const viewport = viewportRef.value?.getBoundingClientRect() + const currentZoom = zoom.value + const clampedZoom = Math.max(0.85, Math.min(mapMaxZoom.value, nextZoom)) + if (!viewport || clampedZoom === currentZoom) return + + const anchorX = clientX ?? viewport.left + viewport.width / 2 + const anchorY = clientY ?? viewport.top + viewport.height / 2 + const offsetX = anchorX - (viewport.left + viewport.width / 2) + const offsetY = anchorY - (viewport.top + viewport.height / 2) + const scale = clampedZoom / currentZoom + + pan.value = { + x: offsetX - (offsetX - pan.value.x) * scale, + y: offsetY - (offsetY - pan.value.y) * scale, + } + zoom.value = clampedZoom +} + function changeZoom(direction: -1 | 1): void { - zoom.value = Math.max( - 0.85, - Math.min(8, zoom.value * (direction > 0 ? 1.32 : 0.76)), - ) + setZoomAt(zoom.value * (direction > 0 ? 1.25 : 0.8)) } function onPointerDown(event: PointerEvent): void { @@ -882,30 +1048,48 @@ function onPointerDown(event: PointerEvent): void { pointerStart.y = event.clientY pointerStart.panX = pan.value.x pointerStart.panY = pan.value.y + pointerLatest.x = event.clientX + pointerLatest.y = event.clientY ;(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 (pointerFrame) cancelAnimationFrame(pointerFrame) + pointerLatest.x = event.clientX + pointerLatest.y = event.clientY + if (pointerFrame) return pointerFrame = requestAnimationFrame(() => { pointerFrame = undefined pan.value = { - x: pointerStart.panX + deltaX, - y: pointerStart.panY + deltaY, + x: pointerStart.panX + pointerLatest.x - pointerStart.x, + y: pointerStart.panY + pointerLatest.y - pointerStart.y, } }) } -function onPointerUp(): void { +function onPointerUp(event: PointerEvent): void { isPointerDown.value = false + const target = event.currentTarget as HTMLElement + if (target.hasPointerCapture(event.pointerId)) { + target.releasePointerCapture(event.pointerId) + } } function onWheel(event: WheelEvent): void { event.preventDefault() - changeZoom(event.deltaY > 0 ? -1 : 1) + const viewportHeight = viewportRef.value?.clientHeight ?? 1 + const pixelDelta = + event.deltaMode === 1 + ? event.deltaY * 16 + : event.deltaMode === 2 + ? event.deltaY * viewportHeight + : event.deltaY + const limitedDelta = Math.max(-120, Math.min(120, pixelDelta)) + setZoomAt( + zoom.value * Math.exp(-limitedDelta * 0.0018), + event.clientX, + event.clientY, + ) } function onCrewLinkMessage(event: MessageEvent): void { @@ -918,7 +1102,8 @@ function onKeydown(event: KeyboardEvent): void { !confirmAction.value && !sheet.value && !selectedMember.value && - !selectedPing.value + !selectedPing.value && + !placingPing.value ) { return } @@ -930,8 +1115,10 @@ function onKeydown(event: KeyboardEvent): void { closeSheet() } else if (selectedMember.value) { selectedMember.value = null - } else { + } else if (selectedPing.value) { selectedPing.value = null + } else { + cancelPingPlacement() } } @@ -1076,11 +1263,19 @@ onBeforeUnmount(() => {