mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
fix: refine map controls and garage layout
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
defaultCayoStyle,
|
||||
defaultMainlandStyle,
|
||||
defaultMapPercentToWorld,
|
||||
defaultMapWorldToPercent,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
@@ -13,4 +15,18 @@ describe('default map geometry', () => {
|
||||
expect(restored.x).toBeCloseTo(world.x, 6)
|
||||
expect(restored.y).toBeCloseTo(world.y, 6)
|
||||
})
|
||||
|
||||
it('keeps every rendered map layer inside the composite canvas', () => {
|
||||
for (const style of [defaultMainlandStyle, defaultCayoStyle]) {
|
||||
const left = Number.parseFloat(style.left)
|
||||
const top = Number.parseFloat(style.top)
|
||||
const width = Number.parseFloat(style.width)
|
||||
const height = Number.parseFloat(style.height)
|
||||
|
||||
expect(left).toBeGreaterThanOrEqual(0)
|
||||
expect(top).toBeGreaterThanOrEqual(0)
|
||||
expect(left + width).toBeLessThanOrEqual(100.000001)
|
||||
expect(top + height).toBeLessThanOrEqual(100.000001)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,19 +15,29 @@ const cayoMapCoordinates = {
|
||||
height: 1994.4,
|
||||
}
|
||||
|
||||
const cayoTerritoryBounds = {
|
||||
maxX: 7542.07,
|
||||
minY: -7170.07,
|
||||
const cayoLayerBounds = {
|
||||
minX: cayoMapCoordinates.centerX - cayoMapCoordinates.width / 2,
|
||||
minY:
|
||||
defaultMainlandCoordinates.yFlipOffset -
|
||||
(cayoMapCoordinates.centerY + cayoMapCoordinates.height / 2),
|
||||
width: cayoMapCoordinates.width,
|
||||
height: cayoMapCoordinates.height,
|
||||
}
|
||||
|
||||
const mapMaximumX = Math.max(
|
||||
defaultMainlandCoordinates.minX + defaultMainlandCoordinates.width,
|
||||
cayoLayerBounds.minX + cayoLayerBounds.width,
|
||||
)
|
||||
const mapMaximumY = Math.max(
|
||||
defaultMainlandCoordinates.minY + defaultMainlandCoordinates.height,
|
||||
cayoLayerBounds.minY + cayoLayerBounds.height,
|
||||
)
|
||||
|
||||
export const defaultMapCoordinates = {
|
||||
minX: defaultMainlandCoordinates.minX,
|
||||
minY: defaultMainlandCoordinates.minY,
|
||||
width: cayoTerritoryBounds.maxX - defaultMainlandCoordinates.minX,
|
||||
height:
|
||||
defaultMainlandCoordinates.yFlipOffset -
|
||||
cayoTerritoryBounds.minY -
|
||||
defaultMainlandCoordinates.minY,
|
||||
width: mapMaximumX - defaultMainlandCoordinates.minX,
|
||||
height: mapMaximumY - defaultMainlandCoordinates.minY,
|
||||
yFlipOffset: defaultMainlandCoordinates.yFlipOffset,
|
||||
}
|
||||
|
||||
@@ -47,12 +57,7 @@ export const defaultMainlandStyle = toDefaultMapLayerStyle(
|
||||
defaultMainlandCoordinates,
|
||||
)
|
||||
export const defaultCayoStyle = toDefaultMapLayerStyle({
|
||||
minX: cayoMapCoordinates.centerX - cayoMapCoordinates.width / 2,
|
||||
minY:
|
||||
defaultMapCoordinates.yFlipOffset -
|
||||
(cayoMapCoordinates.centerY + cayoMapCoordinates.height / 2),
|
||||
width: cayoMapCoordinates.width,
|
||||
height: cayoMapCoordinates.height,
|
||||
...cayoLayerBounds,
|
||||
})
|
||||
|
||||
export const clampDefaultMapPoint = (point: MapPoint): MapPoint => ({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clampMapPan,
|
||||
clientPointToMapPercent,
|
||||
minimumCoverZoom,
|
||||
zoomPanAtPoint,
|
||||
} from '@/features/map/mapViewport'
|
||||
|
||||
describe('map viewport geometry', () => {
|
||||
const metrics = {
|
||||
canvasHeight: 900,
|
||||
canvasWidth: 600,
|
||||
viewportHeight: 720,
|
||||
viewportWidth: 360,
|
||||
}
|
||||
|
||||
it('raises the minimum zoom until the canvas covers the viewport', () => {
|
||||
expect(minimumCoverZoom(metrics)).toBe(0.8)
|
||||
})
|
||||
|
||||
it('clamps panning so no empty edge enters the viewport', () => {
|
||||
expect(clampMapPan({ x: 999, y: -999 }, 1, metrics)).toEqual({
|
||||
x: 120,
|
||||
y: -90,
|
||||
})
|
||||
})
|
||||
|
||||
it('converts scaled client positions into bounded map percentages', () => {
|
||||
const canvas = { height: 800, left: 100, top: 50, width: 400 }
|
||||
|
||||
expect(clientPointToMapPercent({ x: 300, y: 250 }, canvas)).toEqual({
|
||||
x: 0.5,
|
||||
y: 0.25,
|
||||
})
|
||||
expect(clientPointToMapPercent({ x: 900, y: -100 }, canvas)).toEqual({
|
||||
x: 1,
|
||||
y: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the focal map point stationary while zooming', () => {
|
||||
expect(
|
||||
zoomPanAtPoint(
|
||||
{ x: 20, y: -10 },
|
||||
1,
|
||||
2,
|
||||
{ x: 270, y: 180 },
|
||||
{ x: 360, y: 720 },
|
||||
),
|
||||
).toEqual({ x: -50, y: 160 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { MapPoint } from './defaultMapGeometry'
|
||||
|
||||
export type MapViewportMetrics = {
|
||||
canvasHeight: number
|
||||
canvasWidth: number
|
||||
viewportHeight: number
|
||||
viewportWidth: number
|
||||
}
|
||||
|
||||
export type MapRect = {
|
||||
height: number
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
Math.min(maximum, Math.max(minimum, value))
|
||||
|
||||
export function minimumCoverZoom(
|
||||
metrics: MapViewportMetrics,
|
||||
minimum = 0.7,
|
||||
): number {
|
||||
if (
|
||||
metrics.canvasWidth <= 0 ||
|
||||
metrics.canvasHeight <= 0 ||
|
||||
metrics.viewportWidth <= 0 ||
|
||||
metrics.viewportHeight <= 0
|
||||
) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
minimum,
|
||||
metrics.viewportWidth / metrics.canvasWidth,
|
||||
metrics.viewportHeight / metrics.canvasHeight,
|
||||
)
|
||||
}
|
||||
|
||||
export function clampMapPan(
|
||||
pan: MapPoint,
|
||||
zoom: number,
|
||||
metrics: MapViewportMetrics,
|
||||
): MapPoint {
|
||||
const maximumX = Math.max(
|
||||
0,
|
||||
(metrics.canvasWidth * zoom - metrics.viewportWidth) / 2,
|
||||
)
|
||||
const maximumY = Math.max(
|
||||
0,
|
||||
(metrics.canvasHeight * zoom - metrics.viewportHeight) / 2,
|
||||
)
|
||||
|
||||
return {
|
||||
x: clamp(pan.x, -maximumX, maximumX),
|
||||
y: clamp(pan.y, -maximumY, maximumY),
|
||||
}
|
||||
}
|
||||
|
||||
export function clientPointToMapPercent(
|
||||
point: MapPoint,
|
||||
canvas: MapRect,
|
||||
): MapPoint {
|
||||
if (canvas.width <= 0 || canvas.height <= 0) return { x: 0.5, y: 0.5 }
|
||||
|
||||
return {
|
||||
x: clamp((point.x - canvas.left) / canvas.width, 0, 1),
|
||||
y: clamp((point.y - canvas.top) / canvas.height, 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function zoomPanAtPoint(
|
||||
pan: MapPoint,
|
||||
currentZoom: number,
|
||||
nextZoom: number,
|
||||
focalPoint: MapPoint,
|
||||
viewportSize: MapPoint,
|
||||
): MapPoint {
|
||||
if (currentZoom <= 0 || nextZoom <= 0) return pan
|
||||
const scale = nextZoom / currentZoom
|
||||
|
||||
return {
|
||||
x: pan.x + (focalPoint.x - viewportSize.x / 2 - pan.x) * (1 - scale),
|
||||
y: pan.y + (focalPoint.y - viewportSize.y / 2 - pan.y) * (1 - scale),
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,19 @@ describe('GarageApp Sky UI contract', () => {
|
||||
expect(source).not.toContain('overview.system')
|
||||
})
|
||||
|
||||
it('raises only the Garage title block', () => {
|
||||
it('raises the Garage title and subtitle when no navbar action exists', () => {
|
||||
expect(source).toContain('class="garage-navbar"')
|
||||
expect(source).toMatch(
|
||||
/\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-20px\)/s,
|
||||
/\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-30px\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('centers filter labels and counters inside their segmented controls', () => {
|
||||
expect(source).toMatch(
|
||||
/\.garage-filters :deep\(button\)\s*\{[^}]*height: 36px;[^}]*align-items: center;[^}]*line-height: 1;/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.garage-filters span,\s*\.garage-filters small\s*\{[^}]*height: 18px;[^}]*align-items: center;[^}]*line-height: 1;/s,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ onBeforeUnmount(() => {
|
||||
--garage-separator: var(--sky-hairline);
|
||||
}
|
||||
.garage-navbar :deep(.sky-navbar__title-container > div) {
|
||||
transform: translateY(-20px);
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
.garage-scroll {
|
||||
padding-top: 0;
|
||||
@@ -679,12 +679,12 @@ onBeforeUnmount(() => {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
margin-bottom: 13px;
|
||||
padding: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
.garage-filters :deep(button) {
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding-right: 4px;
|
||||
padding-left: 4px;
|
||||
align-items: center;
|
||||
@@ -692,14 +692,23 @@ onBeforeUnmount(() => {
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.garage-filters :deep(.sky-segmented__highlight) {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
.garage-filters span,
|
||||
.garage-filters small {
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.garage-filters small {
|
||||
min-width: 17px;
|
||||
padding: 1px 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
background: rgb(36 120 255 / 13%);
|
||||
font-size: 10px;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./MapApp.vue', import.meta.url), 'utf8')
|
||||
|
||||
describe('MapApp interaction contract', () => {
|
||||
it('uses Sky UI controls and the central EasyShare store', () => {
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
expect(source).toContain('<SkyAppPage')
|
||||
expect(source).toContain('<SkyFab')
|
||||
expect(source).toContain('<SkyGlass')
|
||||
expect(source).toContain('easyShare.open({')
|
||||
expect(source).toContain('subtitle: coordinates')
|
||||
expect(source).not.toContain('<EasyShareSheet')
|
||||
})
|
||||
|
||||
it('keeps zoom and panning inside scale-aware map bounds', () => {
|
||||
expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)')
|
||||
expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)')
|
||||
expect(source).toContain('zoomPanAtPoint(')
|
||||
expect(source).not.toContain("width: 'max(120%, 120vh)'")
|
||||
})
|
||||
|
||||
it('allows the marker crosshair to be placed by tapping the map', () => {
|
||||
expect(source).toContain('placementPoint.value = viewportPoint({')
|
||||
expect(source).toContain(':style="placementCrosshairStyle"')
|
||||
expect(source).toContain('clientPointToMapPercent(')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SkyList,
|
||||
SkyField,
|
||||
SkyAppPage,
|
||||
SkyGlass,
|
||||
SkySpinner,
|
||||
SkySheet,
|
||||
SkyToast,
|
||||
@@ -32,6 +33,13 @@ import {
|
||||
defaultMapWorldToPercent,
|
||||
type MapPoint,
|
||||
} from '@/features/map/defaultMapGeometry'
|
||||
import {
|
||||
clampMapPan,
|
||||
clientPointToMapPercent,
|
||||
minimumCoverZoom,
|
||||
zoomPanAtPoint,
|
||||
type MapViewportMetrics,
|
||||
} from '@/features/map/mapViewport'
|
||||
import { useMapStore } from '@/stores/map'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
@@ -47,6 +55,7 @@ const easyShare = useEasyShareStore()
|
||||
const mapStyle = ref<MapStyle>('default')
|
||||
const zoom = ref(1.1)
|
||||
const pan = ref<MapPoint>({ x: 0, y: 0 })
|
||||
const viewportSize = ref<MapPoint>({ x: 390, y: 844 })
|
||||
const currentLocation = ref<MapPoint | null>(null)
|
||||
const mapAspect = ref(
|
||||
defaultMapCoordinates.width / defaultMapCoordinates.height,
|
||||
@@ -55,10 +64,10 @@ 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)
|
||||
const placingMarker = ref(false)
|
||||
const placementPoint = ref<MapPoint | null>(null)
|
||||
const draftCoords = ref<(MapPoint & { z: number }) | null>(null)
|
||||
const markerLabel = ref('')
|
||||
const markerColor = ref<MapMarkerColor>('blue')
|
||||
@@ -68,8 +77,9 @@ const toastText = ref('')
|
||||
let pointerMoveFrame: number | undefined
|
||||
let wheelZoomFrame: number | undefined
|
||||
let toastTimer: number | undefined
|
||||
let pendingWheelDirection: -1 | 0 | 1 = 0
|
||||
let pendingWheelDelta = 0
|
||||
let pendingWheelPoint: MapPoint | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
const pointerStart = {
|
||||
x: 0,
|
||||
@@ -86,8 +96,7 @@ const mapBounds = {
|
||||
}
|
||||
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 baseMinZoom = 0.7
|
||||
const maxZoom = 12.3
|
||||
|
||||
const mapStyles = [
|
||||
@@ -120,10 +129,20 @@ const mapImageUrl = computed(() => {
|
||||
})
|
||||
const cayoMapImageUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
|
||||
|
||||
const canvasWidth = computed(() =>
|
||||
Math.max(
|
||||
viewportSize.value.x * 1.2,
|
||||
viewportSize.value.y * 1.2 * mapAspect.value,
|
||||
),
|
||||
)
|
||||
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)',
|
||||
width: `${canvasWidth.value}px`,
|
||||
}))
|
||||
const placementCrosshairStyle = computed(() => ({
|
||||
left: `${placementPoint.value?.x ?? viewportSize.value.x / 2}px`,
|
||||
top: `${placementPoint.value?.y ?? viewportSize.value.y / 2}px`,
|
||||
}))
|
||||
|
||||
const mapToWorld = (coords: MapPoint): MapPoint => ({
|
||||
@@ -216,6 +235,7 @@ function markerErrorText(error?: string): string {
|
||||
function setMapStyle(style: MapStyle): void {
|
||||
mapStyle.value = style
|
||||
imageError.value = false
|
||||
void nextTick(normalizeViewport)
|
||||
}
|
||||
|
||||
function cycleMapStyle(): void {
|
||||
@@ -229,37 +249,50 @@ function startMarkerPlacement(): void {
|
||||
selectedMarker.value = null
|
||||
draftCoords.value = null
|
||||
markerError.value = ''
|
||||
placementPoint.value = {
|
||||
x: viewportRef.value?.clientWidth
|
||||
? viewportRef.value.clientWidth / 2
|
||||
: viewportSize.value.x / 2,
|
||||
y: viewportRef.value?.clientHeight
|
||||
? viewportRef.value.clientHeight / 2
|
||||
: viewportSize.value.y / 2,
|
||||
}
|
||||
placingMarker.value = true
|
||||
}
|
||||
|
||||
function cancelMarkerPlacement(): void {
|
||||
placingMarker.value = false
|
||||
placementPoint.value = null
|
||||
}
|
||||
|
||||
function openMarkerEditor(): void {
|
||||
const viewport = viewportRef.value?.getBoundingClientRect()
|
||||
const viewportElement = viewportRef.value
|
||||
const viewport = viewportElement?.getBoundingClientRect()
|
||||
const canvas = canvasRef.value?.getBoundingClientRect()
|
||||
if (!viewport || !canvas || canvas.width <= 0 || canvas.height <= 0) {
|
||||
if (
|
||||
!viewportElement ||
|
||||
!viewport ||
|
||||
!canvas ||
|
||||
canvas.width <= 0 ||
|
||||
canvas.height <= 0
|
||||
) {
|
||||
showToast(phone.t('Apps.map.errors.request_failed'))
|
||||
return
|
||||
}
|
||||
|
||||
const percent = {
|
||||
x: Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
|
||||
),
|
||||
),
|
||||
y: Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
|
||||
),
|
||||
),
|
||||
const renderedScaleX = viewport.width / viewportElement.clientWidth
|
||||
const renderedScaleY = viewport.height / viewportElement.clientHeight
|
||||
const target = placementPoint.value ?? {
|
||||
x: viewportElement.clientWidth / 2,
|
||||
y: viewportElement.clientHeight / 2,
|
||||
}
|
||||
const percent = clientPointToMapPercent(
|
||||
{
|
||||
x: viewport.left + target.x * renderedScaleX,
|
||||
y: viewport.top + target.y * renderedScaleY,
|
||||
},
|
||||
canvas,
|
||||
)
|
||||
const coords = percentToWorld(percent)
|
||||
draftCoords.value = {
|
||||
x: Math.round(coords.x * 100) / 100,
|
||||
@@ -270,6 +303,7 @@ function openMarkerEditor(): void {
|
||||
markerColor.value = 'blue'
|
||||
markerError.value = ''
|
||||
placingMarker.value = false
|
||||
placementPoint.value = null
|
||||
}
|
||||
|
||||
function updateMarkerLabel(event: Event): void {
|
||||
@@ -335,30 +369,69 @@ async function setSelectedMarkerWaypoint(): Promise<void> {
|
||||
selectedMarker.value = null
|
||||
}
|
||||
|
||||
function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void {
|
||||
const targetZoom =
|
||||
direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor
|
||||
function viewportMetrics(): MapViewportMetrics | null {
|
||||
const viewport = viewportRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!viewport || !canvas) return null
|
||||
|
||||
return {
|
||||
canvasHeight: canvas.clientHeight,
|
||||
canvasWidth: canvas.clientWidth,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
viewportWidth: viewport.clientWidth,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeViewport(
|
||||
requestedPan: MapPoint = pan.value,
|
||||
requestedZoom: number = zoom.value,
|
||||
): void {
|
||||
const metrics = viewportMetrics()
|
||||
if (!metrics) return
|
||||
const nextZoom = Math.min(
|
||||
Math.max(Math.round(targetZoom * 1000) / 1000, minZoom),
|
||||
maxZoom,
|
||||
Math.max(minimumCoverZoom(metrics, baseMinZoom), requestedZoom),
|
||||
)
|
||||
zoom.value = Math.round(nextZoom * 1000) / 1000
|
||||
pan.value = clampMapPan(requestedPan, zoom.value, metrics)
|
||||
}
|
||||
|
||||
function viewportPoint(point: MapPoint): MapPoint | null {
|
||||
const viewport = viewportRef.value
|
||||
const bounds = viewport?.getBoundingClientRect()
|
||||
if (!viewport || !bounds || bounds.width <= 0 || bounds.height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: (point.x - bounds.left) / (bounds.width / viewport.clientWidth),
|
||||
y: (point.y - bounds.top) / (bounds.height / viewport.clientHeight),
|
||||
}
|
||||
}
|
||||
|
||||
function changeZoom(targetZoom: number, focalClientPoint?: MapPoint): void {
|
||||
const metrics = viewportMetrics()
|
||||
if (!metrics) return
|
||||
const nextZoom = Math.min(
|
||||
Math.max(
|
||||
Math.round(targetZoom * 1000) / 1000,
|
||||
minimumCoverZoom(metrics, baseMinZoom),
|
||||
),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const focalPoint = focalClientPoint
|
||||
? viewportPoint(focalClientPoint)
|
||||
: { x: metrics.viewportWidth / 2, y: metrics.viewportHeight / 2 }
|
||||
const nextPan = focalPoint
|
||||
? zoomPanAtPoint(pan.value, zoom.value, nextZoom, focalPoint, {
|
||||
x: metrics.viewportWidth,
|
||||
y: metrics.viewportHeight,
|
||||
})
|
||||
: pan.value
|
||||
zoom.value = nextZoom
|
||||
pan.value = clampMapPan(nextPan, nextZoom, metrics)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
@@ -388,14 +461,22 @@ function onPointerMove(event: PointerEvent): void {
|
||||
if (!viewportElement || !viewport) return
|
||||
const renderedScaleX = viewport.width / viewportElement.clientWidth
|
||||
const renderedScaleY = viewport.height / viewportElement.clientHeight
|
||||
pan.value = {
|
||||
const metrics = viewportMetrics()
|
||||
const nextPan = {
|
||||
x: pointerStart.panX + deltaX / renderedScaleX,
|
||||
y: pointerStart.panY + deltaY / renderedScaleY,
|
||||
}
|
||||
pan.value = metrics ? clampMapPan(nextPan, zoom.value, metrics) : nextPan
|
||||
})
|
||||
}
|
||||
|
||||
function onPointerUp(): void {
|
||||
function onPointerUp(event: PointerEvent): void {
|
||||
if (placingMarker.value && !isPanning.value) {
|
||||
placementPoint.value = viewportPoint({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
isPointerDown.value = false
|
||||
isPanning.value = false
|
||||
}
|
||||
@@ -403,15 +484,19 @@ function onPointerUp(): void {
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pendingWheelDirection = event.deltaY > 0 ? -1 : 1
|
||||
pendingWheelDelta += event.deltaY
|
||||
pendingWheelPoint = { x: event.clientX, y: event.clientY }
|
||||
if (wheelZoomFrame) return
|
||||
wheelZoomFrame = requestAnimationFrame(() => {
|
||||
wheelZoomFrame = undefined
|
||||
if (pendingWheelDirection !== 0) {
|
||||
changeZoom(pendingWheelDirection, pendingWheelPoint)
|
||||
if (pendingWheelDelta !== 0) {
|
||||
const normalizedDelta = Math.min(240, Math.max(-240, pendingWheelDelta))
|
||||
changeZoom(
|
||||
zoom.value * Math.exp(-normalizedDelta * 0.0024),
|
||||
pendingWheelPoint,
|
||||
)
|
||||
}
|
||||
pendingWheelDirection = 0
|
||||
pendingWheelDelta = 0
|
||||
pendingWheelPoint = undefined
|
||||
})
|
||||
}
|
||||
@@ -423,6 +508,7 @@ function onImageLoad(event: Event): void {
|
||||
? defaultMapCoordinates.width / defaultMapCoordinates.height
|
||||
: image.naturalWidth / image.naturalHeight
|
||||
imageError.value = false
|
||||
void nextTick(normalizeViewport)
|
||||
}
|
||||
|
||||
async function loadCurrentLocation(center: boolean): Promise<void> {
|
||||
@@ -434,40 +520,53 @@ async function loadCurrentLocation(center: boolean): Promise<void> {
|
||||
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,
|
||||
}
|
||||
const metrics = viewportMetrics()
|
||||
if (!metrics) return
|
||||
const nextZoom = Math.max(3, minimumCoverZoom(metrics, baseMinZoom))
|
||||
const percent = worldToPercent(currentLocation.value)
|
||||
normalizeViewport(
|
||||
{
|
||||
x: -(percent.x - 0.5) * metrics.canvasWidth * nextZoom,
|
||||
y: -(percent.y - 0.5) * metrics.canvasHeight * nextZoom,
|
||||
},
|
||||
nextZoom,
|
||||
)
|
||||
}
|
||||
|
||||
function shareCurrentLocation(): void {
|
||||
const location = currentLocation.value
|
||||
if (!location) {
|
||||
showToast(phone.t('Apps.map.locationUnavailable'))
|
||||
return
|
||||
}
|
||||
const coordinates = `${location.x.toFixed(1)}, ${location.y.toFixed(1)}`
|
||||
easyShare.open({
|
||||
appId: 'map',
|
||||
copyText: phone.t('Apps.map.currentLocation'),
|
||||
copyText: `${phone.t('Apps.map.currentLocation')}\n${coordinates}`,
|
||||
id: `${location.x.toFixed(2)}:${location.y.toFixed(2)}`,
|
||||
kind: 'location',
|
||||
link: 'skyphone://location/current',
|
||||
link: `skyphone://location/${location.x.toFixed(2)}/${location.y.toFixed(2)}`,
|
||||
meta: { coords: { ...location } },
|
||||
subtitle: coordinates,
|
||||
title: phone.t('Apps.map.currentLocation'),
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const viewport = viewportRef.value
|
||||
if (viewport) {
|
||||
const updateViewport = (): void => {
|
||||
viewportSize.value = {
|
||||
x: viewport.clientWidth,
|
||||
y: viewport.clientHeight,
|
||||
}
|
||||
void nextTick(normalizeViewport)
|
||||
}
|
||||
resizeObserver = new ResizeObserver(updateViewport)
|
||||
resizeObserver.observe(viewport)
|
||||
updateViewport()
|
||||
}
|
||||
void loadCurrentLocation(false)
|
||||
void mapStore.load()
|
||||
})
|
||||
@@ -476,11 +575,18 @@ onBeforeUnmount(() => {
|
||||
if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame)
|
||||
if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame)
|
||||
if (toastTimer) window.clearTimeout(toastTimer)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<sky-app-page class="map-app">
|
||||
<SkyAppPage
|
||||
class="map-app"
|
||||
:label="phone.t('Apps.map.name')"
|
||||
:dark="phone.isDarkMode"
|
||||
accent="#0a84ff"
|
||||
accent-soft="rgba(10, 132, 255, 0.16)"
|
||||
>
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="map-viewport"
|
||||
@@ -516,7 +622,6 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<div
|
||||
v-if="currentLocation && locationStyle"
|
||||
ref="locationRef"
|
||||
class="current-location"
|
||||
:style="locationStyle"
|
||||
>
|
||||
@@ -546,6 +651,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-if="placingMarker"
|
||||
class="map-placement-crosshair"
|
||||
:style="placementCrosshairStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span></span>
|
||||
@@ -557,19 +663,19 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<nav class="map-controls" :aria-label="phone.t('Apps.map.controls')">
|
||||
<sky-fab
|
||||
<SkyFab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--share"
|
||||
variant="neutral"
|
||||
variant="primary"
|
||||
:aria-label="phone.t('Apps.easyShare.name')"
|
||||
@click="shareCurrentLocation"
|
||||
>
|
||||
<template #icon>
|
||||
<Share2 aria-hidden="true" />
|
||||
</template>
|
||||
</sky-fab>
|
||||
<sky-fab
|
||||
</SkyFab>
|
||||
<SkyFab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control"
|
||||
@@ -580,8 +686,8 @@ onBeforeUnmount(() => {
|
||||
<template #icon>
|
||||
<component :is="activeMapStyle.icon" aria-hidden="true" />
|
||||
</template>
|
||||
</sky-fab>
|
||||
<sky-fab
|
||||
</SkyFab>
|
||||
<SkyFab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--marker"
|
||||
@@ -593,8 +699,8 @@ onBeforeUnmount(() => {
|
||||
<template #icon>
|
||||
<MapPinPlus aria-hidden="true" />
|
||||
</template>
|
||||
</sky-fab>
|
||||
<sky-fab
|
||||
</SkyFab>
|
||||
<SkyFab
|
||||
component="button"
|
||||
type="button"
|
||||
class="map-control map-control--location"
|
||||
@@ -606,23 +712,33 @@ onBeforeUnmount(() => {
|
||||
<template #icon>
|
||||
<LocateFixed aria-hidden="true" />
|
||||
</template>
|
||||
</sky-fab>
|
||||
</SkyFab>
|
||||
</nav>
|
||||
|
||||
<section v-if="placingMarker" class="map-placement-panel">
|
||||
<SkyGlass
|
||||
v-if="placingMarker"
|
||||
component="section"
|
||||
class="map-placement-panel"
|
||||
>
|
||||
<strong>{{ phone.t('Apps.map.placeMarker') }}</strong>
|
||||
<span>{{ phone.t('Apps.map.placeMarkerHint') }}</span>
|
||||
<div>
|
||||
<sky-button small rounded outline @click="cancelMarkerPlacement">
|
||||
<SkyButton
|
||||
block
|
||||
rounded
|
||||
tonal
|
||||
variant="secondary"
|
||||
@click="cancelMarkerPlacement"
|
||||
>
|
||||
<X :size="16" />
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</sky-button>
|
||||
<sky-button small rounded @click="openMarkerEditor">
|
||||
</SkyButton>
|
||||
<SkyButton block rounded @click="openMarkerEditor">
|
||||
<MapPin :size="16" />
|
||||
{{ phone.t('Apps.map.addHere') }}
|
||||
</sky-button>
|
||||
</SkyButton>
|
||||
</div>
|
||||
</section>
|
||||
</SkyGlass>
|
||||
|
||||
<div class="map-marker-sheet">
|
||||
<sky-sheet
|
||||
@@ -737,7 +853,7 @@ onBeforeUnmount(() => {
|
||||
<sky-toast :opened="Boolean(toastText)" position="center">
|
||||
{{ toastText }}
|
||||
</sky-toast>
|
||||
</sky-app-page>
|
||||
</SkyAppPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -904,6 +1020,9 @@ onBeforeUnmount(() => {
|
||||
--sky-glass-solid: rgb(247 247 248 / 92%);
|
||||
color: #151515;
|
||||
}
|
||||
.map-control--share {
|
||||
color: #fff;
|
||||
}
|
||||
.sky-app-page--dark .map-control {
|
||||
--sky-glass-solid: rgb(44 44 46 / 88%);
|
||||
color: #fff;
|
||||
@@ -921,14 +1040,13 @@ onBeforeUnmount(() => {
|
||||
bottom: 28px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
min-height: 84px;
|
||||
min-height: 112px;
|
||||
padding: 12px;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid rgb(255 255 255 / 22%);
|
||||
border-radius: 18px;
|
||||
border-color: rgb(255 255 255 / 22%);
|
||||
border-radius: var(--sky-radius-card);
|
||||
color: #fff;
|
||||
background: rgb(24 24 27 / 86%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 32%);
|
||||
background: rgb(24 24 27 / 92%);
|
||||
backdrop-filter: blur(22px) saturate(145%);
|
||||
}
|
||||
|
||||
@@ -951,8 +1069,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.map-placement-panel :deep(button) {
|
||||
min-height: 32px;
|
||||
font-size: 11px;
|
||||
min-height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.map-marker-sheet__content {
|
||||
|
||||
@@ -1580,6 +1580,7 @@ Locales["en"] = {
|
||||
},
|
||||
map = {
|
||||
name = "Map", controls = "Map controls", currentLocation = "Current Location",
|
||||
locationUnavailable = "Your current location is unavailable.",
|
||||
imageError = "The map image could not be loaded.", switchStyle = "Switch Map Type",
|
||||
addMarker = "Add Marker", placeMarker = "Place Marker", placeMarkerHint = "Move the map until the crosshair is over the destination.",
|
||||
addHere = "Add Here", newMarker = "New Marker", newMarkerDescription = "Give this saved place a name and color.",
|
||||
|
||||
Reference in New Issue
Block a user