fix: refine map controls and garage layout

This commit is contained in:
Leon.Schmidt
2026-08-16 22:08:00 +02:00
parent 1bcf04d021
commit c5c9c33c2e
9 changed files with 443 additions and 116 deletions
@@ -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)
}
})
})
+19 -14
View File
@@ -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 })
})
})
+86
View File
@@ -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),
}
}