Add map zoom slider
This commit is contained in:
49
src/main.ts
49
src/main.ts
@@ -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`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user