Add map zoom slider

This commit is contained in:
Matiss
2026-06-01 18:05:25 +01:00
parent 54721bb744
commit b7a4112911
2 changed files with 123 additions and 10 deletions

View File

@@ -133,7 +133,9 @@ const GLASSES_MAX_LINES = 9
const BRIDGE_TIMEOUT_MS = 3_000 const BRIDGE_TIMEOUT_MS = 3_000
const DEFAULT_MAP_CENTER: GeoPoint = { lat: 53.3498, lon: -6.2603 } const DEFAULT_MAP_CENTER: GeoPoint = { lat: 53.3498, lon: -6.2603 }
const MAP_TILE_SIZE = 256 const MAP_TILE_SIZE = 256
const MAP_ZOOM = 14 const DEFAULT_MAP_ZOOM = 14
const MIN_MAP_ZOOM = 12
const MAX_MAP_ZOOM = 17
const MAP_VIEW_STOP_LIMIT = 80 const MAP_VIEW_STOP_LIMIT = 80
const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000] const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000]
const STOP_GROUP_RADIUS_METERS = 130 const STOP_GROUP_RADIUS_METERS = 130
@@ -861,6 +863,7 @@ function renderPhone(): void {
function PhoneApp(): React.ReactElement { function PhoneApp(): React.ReactElement {
const [mapCenter, setMapCenter] = React.useState<GeoPoint>(state.userLocation ?? state.selectedStop ?? DEFAULT_MAP_CENTER) const [mapCenter, setMapCenter] = React.useState<GeoPoint>(state.userLocation ?? state.selectedStop ?? DEFAULT_MAP_CENTER)
const [mapZoom, setMapZoom] = React.useState(DEFAULT_MAP_ZOOM)
const [modeFilter, setModeFilter] = React.useState<MapModeFilter>('all') const [modeFilter, setModeFilter] = React.useState<MapModeFilter>('all')
const [radiusMeters, setRadiusMeters] = React.useState(1000) const [radiusMeters, setRadiusMeters] = React.useState(1000)
const [configOpen, setConfigOpen] = React.useState(true) const [configOpen, setConfigOpen] = React.useState(true)
@@ -891,10 +894,12 @@ function PhoneApp(): React.ReactElement {
{ className: 'map-stage', 'aria-label': 'Nearby stop map' }, { className: 'map-stage', 'aria-label': 'Nearby stop map' },
h(MapSurface, { h(MapSurface, {
center: mapCenter, center: mapCenter,
zoom: mapZoom,
stops: visibleStops, stops: visibleStops,
selectedStop: state.selectedStop, selectedStop: state.selectedStop,
userLocation: state.userLocation, userLocation: state.userLocation,
onCenterChange: setMapCenter, onCenterChange: setMapCenter,
onZoomChange: setMapZoom,
onStopSelect: (stop: StopSummary) => void selectStop(stop), onStopSelect: (stop: StopSummary) => void selectStop(stop),
}), }),
h( h(
@@ -1014,17 +1019,21 @@ function PhoneApp(): React.ReactElement {
function MapSurface({ function MapSurface({
center, center,
zoom,
stops, stops,
selectedStop, selectedStop,
userLocation, userLocation,
onCenterChange, onCenterChange,
onZoomChange,
onStopSelect, onStopSelect,
}: { }: {
center: GeoPoint center: GeoPoint
zoom: number
stops: StopSummary[] stops: StopSummary[]
selectedStop?: StopSummary selectedStop?: StopSummary
userLocation?: GeoPoint userLocation?: GeoPoint
onCenterChange: (center: GeoPoint) => void onCenterChange: (center: GeoPoint) => void
onZoomChange: (zoom: number) => void
onStopSelect: (stop: StopSummary) => void onStopSelect: (stop: StopSummary) => void
}): React.ReactElement { }): React.ReactElement {
const mapRef = React.useRef<HTMLDivElement | null>(null) const mapRef = React.useRef<HTMLDivElement | null>(null)
@@ -1048,17 +1057,17 @@ function MapSurface({
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
const centerPoint = latLonToWorld(center.lat, center.lon, MAP_ZOOM) const centerPoint = latLonToWorld(center.lat, center.lon, zoom)
const tiles = mapTiles(centerPoint, size) const tiles = mapTiles(centerPoint, size, zoom)
const pinItems = stops.map((stop) => { const pinItems = stops.map((stop) => {
const point = latLonToWorld(stop.lat, stop.lon, MAP_ZOOM) const point = latLonToWorld(stop.lat, stop.lon, zoom)
return { return {
stop, stop,
left: size.width / 2 + point.x - centerPoint.x, left: size.width / 2 + point.x - centerPoint.x,
top: size.height / 2 + point.y - centerPoint.y, top: size.height / 2 + point.y - centerPoint.y,
} }
}) })
const userPoint = userLocation ? latLonToWorld(userLocation.lat, userLocation.lon, MAP_ZOOM) : undefined const userPoint = userLocation ? latLonToWorld(userLocation.lat, userLocation.lon, zoom) : undefined
const userMarker = userPoint const userMarker = userPoint
? { ? {
left: size.width / 2 + userPoint.x - centerPoint.x, left: size.width / 2 + userPoint.x - centerPoint.x,
@@ -1071,12 +1080,12 @@ function MapSurface({
return return
} }
const startPoint = latLonToWorld(dragRef.current.center.lat, dragRef.current.center.lon, MAP_ZOOM) const startPoint = latLonToWorld(dragRef.current.center.lat, dragRef.current.center.lon, zoom)
const nextPoint = { const nextPoint = {
x: startPoint.x - (event.clientX - dragRef.current.x), x: startPoint.x - (event.clientX - dragRef.current.x),
y: startPoint.y - (event.clientY - dragRef.current.y), y: startPoint.y - (event.clientY - dragRef.current.y),
} }
onCenterChange(worldToLatLon(nextPoint.x, nextPoint.y, MAP_ZOOM)) onCenterChange(worldToLatLon(nextPoint.x, nextPoint.y, zoom))
} }
return h( return h(
@@ -1107,6 +1116,26 @@ function MapSurface({
}), }),
), ),
h('div', { className: 'map-vignette', 'aria-hidden': true }), h('div', { className: 'map-vignette', 'aria-hidden': true }),
h(
'div',
{
className: 'map-zoom-control',
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => event.stopPropagation(),
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => event.stopPropagation(),
onClick: (event: React.MouseEvent<HTMLDivElement>) => event.stopPropagation(),
},
h('span', { className: 'map-zoom-label' }, '+'),
h('input', {
type: 'range',
min: MIN_MAP_ZOOM,
max: MAX_MAP_ZOOM,
step: 1,
value: zoom,
'aria-label': 'Map zoom',
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onZoomChange(Number(event.currentTarget.value)),
}),
h('span', { className: 'map-zoom-label' }, '-'),
),
userMarker userMarker
? h('span', { ? h('span', {
className: 'map-user', className: 'map-user',
@@ -1457,7 +1486,7 @@ function worldToLatLon(x: number, y: number, zoom: number): GeoPoint {
return { lat: clamp(lat, -85.0511, 85.0511), lon: wrapLongitude(lon) } return { lat: clamp(lat, -85.0511, 85.0511), lon: wrapLongitude(lon) }
} }
function mapTiles(centerPoint: { x: number; y: number }, size: { width: number; height: number }): MapTile[] { function mapTiles(centerPoint: { x: number; y: number }, size: { width: number; height: number }, zoom: number): MapTile[] {
const topLeft = { const topLeft = {
x: centerPoint.x - size.width / 2, x: centerPoint.x - size.width / 2,
y: centerPoint.y - size.height / 2, y: centerPoint.y - size.height / 2,
@@ -1466,7 +1495,7 @@ function mapTiles(centerPoint: { x: number; y: number }, size: { width: number;
const firstY = Math.floor(topLeft.y / MAP_TILE_SIZE) const firstY = Math.floor(topLeft.y / MAP_TILE_SIZE)
const lastX = Math.floor((topLeft.x + size.width) / MAP_TILE_SIZE) const lastX = Math.floor((topLeft.x + size.width) / MAP_TILE_SIZE)
const lastY = Math.floor((topLeft.y + size.height) / MAP_TILE_SIZE) const lastY = Math.floor((topLeft.y + size.height) / MAP_TILE_SIZE)
const maxTile = 2 ** MAP_ZOOM const maxTile = 2 ** zoom
const tiles: MapTile[] = [] const tiles: MapTile[] = []
for (let x = firstX; x <= lastX; x += 1) { for (let x = firstX; x <= lastX; x += 1) {
@@ -1479,7 +1508,7 @@ function mapTiles(centerPoint: { x: number; y: number }, size: { width: number;
key: `${wrappedX}-${y}`, key: `${wrappedX}-${y}`,
x: x * MAP_TILE_SIZE - topLeft.x, x: x * MAP_TILE_SIZE - topLeft.x,
y: y * MAP_TILE_SIZE - topLeft.y, y: y * MAP_TILE_SIZE - topLeft.y,
url: `https://tile.openstreetmap.org/${MAP_ZOOM}/${wrappedX}/${y}.png`, url: `https://tile.openstreetmap.org/${zoom}/${wrappedX}/${y}.png`,
}) })
} }
} }

View File

@@ -140,6 +140,7 @@ h2 {
.map-surface { .map-surface {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1;
overflow: hidden; overflow: hidden;
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
@@ -241,6 +242,80 @@ h2 {
font-size: 0.72rem; font-size: 0.72rem;
} }
.map-zoom-control {
position: absolute;
right: 16px;
top: 50%;
z-index: 5;
display: grid;
justify-items: center;
gap: 8px;
border: 1px solid rgb(35 35 35 / 0.18);
border-radius: 8px;
padding: 10px 8px;
background: rgb(255 255 255 / 0.92);
box-shadow: 0 14px 36px rgb(0 0 0 / 0.22);
transform: translateY(-50%);
}
.map-zoom-label {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 8px;
color: #232323;
background: #eeeeee;
font-size: 0.9rem;
font-weight: 900;
line-height: 1;
}
.map-zoom-control input {
width: 24px;
height: 140px;
min-height: 24px;
padding: 0;
border: 0;
background: transparent;
accent-color: #fef991;
appearance: none;
writing-mode: vertical-lr;
direction: rtl;
}
.map-zoom-control input::-webkit-slider-runnable-track {
width: 8px;
height: 100%;
border-radius: 999px;
background: #eeeeee;
}
.map-zoom-control input::-webkit-slider-thumb {
width: 24px;
height: 24px;
margin-left: -8px;
border: 3px solid #232323;
border-radius: 999px;
background: #fef991;
appearance: none;
}
.map-zoom-control input::-moz-range-track {
width: 8px;
height: 100%;
border-radius: 999px;
background: #eeeeee;
}
.map-zoom-control input::-moz-range-thumb {
width: 20px;
height: 20px;
border: 3px solid #232323;
border-radius: 999px;
background: #fef991;
}
.config-row, .config-row,
.map-config, .map-config,
.config-group { .config-group {
@@ -472,6 +547,15 @@ h2 {
right: 12px; right: 12px;
} }
.map-zoom-control {
right: 10px;
padding: 8px 6px;
}
.map-zoom-control input {
height: 112px;
}
.map-top { .map-top {
top: 14px; top: 14px;
} }