diff --git a/src/main.ts b/src/main.ts index 7889e2e..74e7462 100644 --- a/src/main.ts +++ b/src/main.ts @@ -133,7 +133,9 @@ const GLASSES_MAX_LINES = 9 const BRIDGE_TIMEOUT_MS = 3_000 const DEFAULT_MAP_CENTER: GeoPoint = { lat: 53.3498, lon: -6.2603 } 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_RADIUS_OPTIONS = [500, 1000, 2000, 5000] const STOP_GROUP_RADIUS_METERS = 130 @@ -861,6 +863,7 @@ function renderPhone(): void { function PhoneApp(): React.ReactElement { const [mapCenter, setMapCenter] = React.useState(state.userLocation ?? state.selectedStop ?? DEFAULT_MAP_CENTER) + const [mapZoom, setMapZoom] = React.useState(DEFAULT_MAP_ZOOM) const [modeFilter, setModeFilter] = React.useState('all') const [radiusMeters, setRadiusMeters] = React.useState(1000) const [configOpen, setConfigOpen] = React.useState(true) @@ -891,10 +894,12 @@ function PhoneApp(): React.ReactElement { { className: 'map-stage', 'aria-label': 'Nearby stop map' }, h(MapSurface, { center: mapCenter, + zoom: mapZoom, stops: visibleStops, selectedStop: state.selectedStop, userLocation: state.userLocation, onCenterChange: setMapCenter, + onZoomChange: setMapZoom, onStopSelect: (stop: StopSummary) => void selectStop(stop), }), h( @@ -1014,17 +1019,21 @@ function PhoneApp(): React.ReactElement { function MapSurface({ center, + zoom, stops, selectedStop, userLocation, onCenterChange, + onZoomChange, onStopSelect, }: { center: GeoPoint + zoom: number stops: StopSummary[] selectedStop?: StopSummary userLocation?: GeoPoint onCenterChange: (center: GeoPoint) => void + onZoomChange: (zoom: number) => void onStopSelect: (stop: StopSummary) => void }): React.ReactElement { const mapRef = React.useRef(null) @@ -1048,17 +1057,17 @@ function MapSurface({ return () => observer.disconnect() }, []) - const centerPoint = latLonToWorld(center.lat, center.lon, MAP_ZOOM) - const tiles = mapTiles(centerPoint, size) + const centerPoint = latLonToWorld(center.lat, center.lon, zoom) + const tiles = mapTiles(centerPoint, size, zoom) const pinItems = stops.map((stop) => { - const point = latLonToWorld(stop.lat, stop.lon, MAP_ZOOM) + const point = latLonToWorld(stop.lat, stop.lon, zoom) return { stop, left: size.width / 2 + point.x - centerPoint.x, 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 ? { left: size.width / 2 + userPoint.x - centerPoint.x, @@ -1071,12 +1080,12 @@ function MapSurface({ 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 = { x: startPoint.x - (event.clientX - dragRef.current.x), 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( @@ -1107,6 +1116,26 @@ function MapSurface({ }), ), h('div', { className: 'map-vignette', 'aria-hidden': true }), + h( + 'div', + { + className: 'map-zoom-control', + onPointerDown: (event: React.PointerEvent) => event.stopPropagation(), + onPointerMove: (event: React.PointerEvent) => event.stopPropagation(), + onClick: (event: React.MouseEvent) => 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) => onZoomChange(Number(event.currentTarget.value)), + }), + h('span', { className: 'map-zoom-label' }, '-'), + ), userMarker ? h('span', { 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) } } -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 = { x: centerPoint.x - size.width / 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 lastX = Math.floor((topLeft.x + size.width) / 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[] = [] 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}`, x: x * MAP_TILE_SIZE - topLeft.x, 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`, }) } } diff --git a/src/style.css b/src/style.css index ad5070c..6cd2df7 100644 --- a/src/style.css +++ b/src/style.css @@ -140,6 +140,7 @@ h2 { .map-surface { position: absolute; inset: 0; + z-index: 1; overflow: hidden; cursor: grab; touch-action: none; @@ -241,6 +242,80 @@ h2 { 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, .map-config, .config-group { @@ -472,6 +547,15 @@ h2 { right: 12px; } + .map-zoom-control { + right: 10px; + padding: 8px 6px; + } + + .map-zoom-control input { + height: 112px; + } + .map-top { top: 14px; }