diff --git a/src/main.ts b/src/main.ts index 3d4083d..3a12a71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,6 +106,21 @@ type AppState = PersistedState & { voiceState: string updatedAt?: number selectedMenuIndex: number + userLocation?: GeoPoint +} + +type GeoPoint = { + lat: number + lon: number +} + +type MapModeFilter = 'all' | Mode + +type MapTile = { + key: string + x: number + y: number + url: string } const STORAGE_KEY = 'tfi-live-g2-state' @@ -114,6 +129,11 @@ const REQUEST_COOLDOWN_MS = 60_000 const GLASSES_LINE_WIDTH = 552 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 MAP_VIEW_STOP_LIMIT = 80 +const MAP_RADIUS_OPTIONS = [500, 1000, 2000, 5000] const MAIN_MENU: Array<{ label: string description: string @@ -424,6 +444,7 @@ async function loadNearbyStops(): Promise { try { const position = await getCurrentPosition() + state.userLocation = { lat: position.coords.latitude, lon: position.coords.longitude } state.view = 'nearby' state.returnView = 'nearby' state.selectedStopIndex = 0 @@ -813,6 +834,10 @@ function renderPhone(): void { } function PhoneApp(): React.ReactElement { + const [mapCenter, setMapCenter] = React.useState(state.userLocation ?? state.selectedStop ?? DEFAULT_MAP_CENTER) + const [modeFilter, setModeFilter] = React.useState('all') + const [radiusMeters, setRadiusMeters] = React.useState(1000) + const [configOpen, setConfigOpen] = React.useState(true) const selectedIndex = state.view === 'menu' ? `${state.selectedMenuIndex + 1}/${MAIN_MENU.length}` @@ -822,55 +847,109 @@ function PhoneApp(): React.ReactElement { const selectedStopMeta = state.selectedStop ? `${state.selectedStop.id} • ${state.selectedStop.modes.join(', ')} • ${state.selectedStop.routes.slice(0, 8).join(', ')}` : 'Favourites, nearby stops, and search results appear on the left.' + const visibleStops = getMapStops(mapCenter, modeFilter, radiusMeters) + + React.useEffect(() => { + if (state.userLocation) { + setMapCenter(state.userLocation) + } else if (state.selectedStop) { + setMapCenter({ lat: state.selectedStop.lat, lon: state.selectedStop.lon }) + } + }, [state.userLocation?.lat, state.userLocation?.lon, state.selectedStop?.id]) return h( - React.Fragment, - null, - h( - 'header', - { className: 'topbar' }, - h('div', null, h('p', { className: 'eyebrow' }, 'Glance-first transit'), h('h1', null, 'TFI Live G2')), - h(Badge, { variant: state.connection === 'G2 connected' ? 'positive' : 'neutral', className: 'connection-badge' }, state.connection), - ), + 'main', + { className: 'mobile-shell' }, h( 'section', - { className: 'searchbar', 'aria-label': 'Stop search' }, - h(Input, { - type: 'search', - placeholder: 'Search stops, routes, places', - autoComplete: 'off', - value: state.searchQuery, - onChange: (event: React.ChangeEvent) => scheduleTextSearch(event.currentTarget.value), + { className: 'map-stage', 'aria-label': 'Nearby stop map' }, + h(MapSurface, { + center: mapCenter, + stops: visibleStops, + selectedStop: state.selectedStop, + userLocation: state.userLocation, + onCenterChange: setMapCenter, + onStopSelect: (stop: StopSummary) => void selectStop(stop), }), - h(Button, { type: 'button', variant: 'secondary', onClick: () => void startVoiceSearch() }, 'Mic'), - h(Button, { type: 'button', variant: 'secondary', onClick: () => void loadNearbyStops() }, 'Nearby'), + h( + 'div', + { className: 'map-top' }, + h( + 'header', + { className: 'topbar' }, + h('div', null, h('p', { className: 'eyebrow' }, 'Glance-first transit'), h('h1', null, 'TFI Live G2')), + h(Badge, { variant: state.connection === 'G2 connected' ? 'positive' : 'neutral', className: 'connection-badge' }, state.connection), + ), + h( + 'section', + { className: 'searchbar', 'aria-label': 'Stop search' }, + h(Input, { + type: 'search', + placeholder: 'Search stops, routes, places', + autoComplete: 'off', + value: state.searchQuery, + onChange: (event: React.ChangeEvent) => scheduleTextSearch(event.currentTarget.value), + }), + h(Button, { type: 'button', variant: 'secondary', onClick: () => void startVoiceSearch() }, 'Mic'), + h(Button, { type: 'button', variant: 'secondary', onClick: () => void loadNearbyStops() }, 'Nearby'), + ), + ), + h( + 'section', + { className: 'map-bottom' }, + h(NavBar, { + className: 'tabs', + items: [ + { id: 'favourites', label: 'Favourites' }, + { id: 'nearby', label: 'Nearby' }, + { id: 'recent', label: 'Recent' }, + { id: 'search', label: 'Search' }, + { id: 'departures', label: 'Departures' }, + ], + activeId: state.view === 'menu' ? 'nearby' : state.view, + onNavigate: (id: string) => void setView(id as View), + }), + h( + 'div', + { className: 'config-row' }, + h(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: () => setConfigOpen(!configOpen) }, configOpen ? 'Hide options' : 'Configure'), + h(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: () => void refresh() }, 'Refresh'), + h(Button, { + type: 'button', + variant: 'ghost', + size: 'sm', + onClick: () => { + if (state.selectedStop) { + setMapCenter({ lat: state.selectedStop.lat, lon: state.selectedStop.lon }) + } else if (state.userLocation) { + setMapCenter(state.userLocation) + } + }, + }, 'Recenter'), + ), + configOpen + ? h(MapConfig, { + modeFilter, + radiusMeters, + onModeFilter: setModeFilter, + onRadius: setRadiusMeters, + }) + : null, + ), ), - h(NavBar, { - className: 'tabs', - items: [ - { id: 'menu', label: 'Menu' }, - { id: 'favourites', label: 'Favourites' }, - { id: 'nearby', label: 'Nearby' }, - { id: 'recent', label: 'Recent' }, - { id: 'search', label: 'Search' }, - { id: 'departures', label: 'Departures' }, - ], - activeId: state.view, - onNavigate: (id: string) => void setView(id as View), - }), h( 'section', - { className: 'layout' }, + { className: 'content-panel' }, h( Card, - { className: 'panel', padding: 'lg' }, + { className: 'panel stop-panel', padding: 'lg' }, h( 'div', { className: 'panel-head' }, - h('h2', null, listTitle()), - h(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: () => void refresh() }, 'Refresh'), + h('h2', null, state.view === 'menu' ? 'Nearby stops' : listTitle()), + h('span', { className: 'panel-count' }, `${state.view === 'menu' ? visibleStops.length : state.filteredStops.length} stops`), ), - h(StopList), + h(StopList, { stops: state.view === 'menu' ? visibleStops.slice(0, 24) : undefined }), ), h( Card, @@ -888,26 +967,210 @@ function PhoneApp(): React.ReactElement { h(DepartureList), ), ), - h(StatGrid, { - className: 'status-grid', - columns: 4, - stats: [ - { label: 'updated', value: state.updatedAt ? ageLabel(state.updatedAt) : 'never' }, - { label: 'selected', value: selectedIndex }, - { label: 'voice', value: state.voiceState }, - { label: 'api', value: state.apiState }, - ], - }), - h('p', { className: 'message' }, state.message), - h('p', { className: 'hint' }, 'G2: swipe moves selection, press selects or refreshes, double press goes back.'), + h( + 'footer', + { className: 'app-footer' }, + h(StatGrid, { + className: 'status-grid', + columns: 4, + stats: [ + { label: 'updated', value: state.updatedAt ? ageLabel(state.updatedAt) : 'never' }, + { label: 'selected', value: selectedIndex }, + { label: 'voice', value: state.voiceState }, + { label: 'api', value: state.apiState }, + ], + }), + h('p', { className: 'message' }, state.message), + h('p', { className: 'hint' }, 'G2: swipe moves selection, press selects or refreshes, double press goes back.'), + ), ) } -function StopList(): React.ReactElement { +function MapSurface({ + center, + stops, + selectedStop, + userLocation, + onCenterChange, + onStopSelect, +}: { + center: GeoPoint + stops: StopSummary[] + selectedStop?: StopSummary + userLocation?: GeoPoint + onCenterChange: (center: GeoPoint) => void + onStopSelect: (stop: StopSummary) => void +}): React.ReactElement { + const mapRef = React.useRef(null) + const dragRef = React.useRef<{ x: number; y: number; center: GeoPoint } | null>(null) + const [size, setSize] = React.useState({ width: 390, height: 520 }) + + React.useEffect(() => { + const node = mapRef.current + if (!node) { + return undefined + } + + const update = () => { + const rect = node.getBoundingClientRect() + setSize({ width: Math.max(1, rect.width), height: Math.max(1, rect.height) }) + } + update() + + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + const centerPoint = latLonToWorld(center.lat, center.lon, MAP_ZOOM) + const tiles = mapTiles(centerPoint, size) + const pinItems = stops.map((stop) => { + const point = latLonToWorld(stop.lat, stop.lon, MAP_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 userMarker = userPoint + ? { + left: size.width / 2 + userPoint.x - centerPoint.x, + top: size.height / 2 + userPoint.y - centerPoint.y, + } + : undefined + + const handlePointerMove = (event: React.PointerEvent) => { + if (!dragRef.current) { + return + } + + const startPoint = latLonToWorld(dragRef.current.center.lat, dragRef.current.center.lon, MAP_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)) + } + + return h( + 'div', + { + ref: mapRef, + className: 'map-surface', + onPointerDown: (event: React.PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { x: event.clientX, y: event.clientY, center } + }, + onPointerMove: handlePointerMove, + onPointerUp: () => { + dragRef.current = null + }, + onPointerCancel: () => { + dragRef.current = null + }, + }, + ...tiles.map((tile) => + h('img', { + key: tile.key, + className: 'map-tile', + src: tile.url, + alt: '', + draggable: false, + style: { transform: `translate(${tile.x}px, ${tile.y}px)` }, + }), + ), + h('div', { className: 'map-vignette', 'aria-hidden': true }), + userMarker + ? h('span', { + className: 'map-user', + title: 'Current location', + style: { transform: `translate(${userMarker.left}px, ${userMarker.top}px)` }, + }) + : null, + ...pinItems.map(({ stop, left, top }) => + h( + 'button', + { + key: stop.id, + type: 'button', + className: `map-pin${selectedStop?.id === stop.id ? ' selected' : ''}`, + title: stop.name, + style: { transform: `translate(${left}px, ${top}px)` }, + onClick: (event: React.MouseEvent) => { + event.stopPropagation() + onStopSelect(stop) + }, + }, + modeLabel(stop.modes[0] ?? 'unknown').slice(0, 1), + ), + ), + h( + 'div', + { className: 'map-attribution' }, + `${stops.length} stops shown`, + h('span', null, 'OSM'), + ), + ) +} + +function MapConfig({ + modeFilter, + radiusMeters, + onModeFilter, + onRadius, +}: { + modeFilter: MapModeFilter + radiusMeters: number + onModeFilter: (mode: MapModeFilter) => void + onRadius: (radius: number) => void +}): React.ReactElement { + const modes: MapModeFilter[] = ['all', 'bus', 'tram', 'rail', 'train', 'dart'] + return h( + 'div', + { className: 'map-config' }, + h( + 'div', + { className: 'config-group' }, + h('span', null, 'Mode'), + ...modes.map((mode) => + h( + 'button', + { + key: mode, + type: 'button', + className: modeFilter === mode ? 'active' : undefined, + onClick: () => onModeFilter(mode), + }, + mode === 'all' ? 'All' : modeLabel(mode), + ), + ), + ), + h( + 'div', + { className: 'config-group' }, + h('span', null, 'Radius'), + ...MAP_RADIUS_OPTIONS.map((radius) => + h( + 'button', + { + key: radius, + type: 'button', + className: radiusMeters === radius ? 'active' : undefined, + onClick: () => onRadius(radius), + }, + radius >= 1000 ? `${radius / 1000}km` : `${radius}m`, + ), + ), + ), + ) +} + +function StopList({ stops = state.filteredStops }: { stops?: StopSummary[] }): React.ReactElement { return h( 'ul', { className: 'stop-list' }, - ...state.filteredStops.map((stop, index) => + ...stops.map((stop, index) => h( 'li', { key: stop.id, className: index === state.selectedStopIndex && state.view !== 'departures' ? 'selected' : undefined }, @@ -1129,6 +1392,79 @@ function getStopsForView(view: View): StopSummary[] { return state.filteredStops } +function getMapStops(center: GeoPoint, modeFilter: MapModeFilter, radiusMeters: number): StopSummary[] { + const source = + (state.view === 'favourites' || state.view === 'recent' || state.view === 'search') && state.filteredStops.length + ? state.filteredStops + : state.stops + + return source + .filter((stop) => modeFilter === 'all' || stop.modes.includes(modeFilter)) + .map((stop) => ({ + ...stop, + distanceMeters: distanceMeters(center.lat, center.lon, stop.lat, stop.lon), + })) + .filter((stop) => { + if (state.view === 'search' && state.searchQuery.trim()) { + return true + } + return (stop.distanceMeters ?? 0) <= radiusMeters + }) + .sort((a, b) => (a.distanceMeters ?? 0) - (b.distanceMeters ?? 0)) + .slice(0, MAP_VIEW_STOP_LIMIT) +} + +function latLonToWorld(lat: number, lon: number, zoom: number): { x: number; y: number } { + const sinLat = Math.sin((clamp(lat, -85.0511, 85.0511) * Math.PI) / 180) + const scale = MAP_TILE_SIZE * 2 ** zoom + return { + x: ((lon + 180) / 360) * scale, + y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale, + } +} + +function worldToLatLon(x: number, y: number, zoom: number): GeoPoint { + const scale = MAP_TILE_SIZE * 2 ** zoom + const lon = (x / scale) * 360 - 180 + const n = Math.PI - (2 * Math.PI * y) / scale + const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))) + return { lat: clamp(lat, -85.0511, 85.0511), lon: wrapLongitude(lon) } +} + +function mapTiles(centerPoint: { x: number; y: number }, size: { width: number; height: number }): MapTile[] { + const topLeft = { + x: centerPoint.x - size.width / 2, + y: centerPoint.y - size.height / 2, + } + const firstX = Math.floor(topLeft.x / MAP_TILE_SIZE) + 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 tiles: MapTile[] = [] + + for (let x = firstX; x <= lastX; x += 1) { + for (let y = firstY; y <= lastY; y += 1) { + if (y < 0 || y >= maxTile) { + continue + } + const wrappedX = ((x % maxTile) + maxTile) % maxTile + tiles.push({ + 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`, + }) + } + } + + return tiles +} + +function wrapLongitude(lon: number): number { + return ((((lon + 180) % 360) + 360) % 360) - 180 +} + function searchStops(query: string): StopSummary[] { const cleanQuery = query.trim().toLowerCase() if (!cleanQuery) { diff --git a/src/style.css b/src/style.css index f62d6b1..ad5070c 100644 --- a/src/style.css +++ b/src/style.css @@ -1,6 +1,6 @@ :root { color: var(--color-text); - background: var(--color-bg); + background: #eeeeee; font-family: var(--font-body), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -19,7 +19,7 @@ body { min-width: 320px; min-height: 100vh; margin: 0; - background: var(--color-bg); + background: #eeeeee; } button, @@ -53,10 +53,10 @@ input { } #app { - width: min(100%, 1120px); + width: 100%; min-height: 100vh; - margin: 0 auto; - padding: 24px; + margin: 0; + padding: 0; } .topbar, @@ -68,7 +68,8 @@ input { } .topbar { - margin-bottom: 16px; + align-items: center; + margin-bottom: 12px; } .eyebrow, @@ -79,15 +80,15 @@ p { } .eyebrow { - margin-bottom: 8px; - color: var(--color-accent-warning); - font-size: 0.78rem; - font-weight: 800; + margin-bottom: 4px; + color: var(--color-text-dim); + font-size: 0.7rem; + font-weight: 700; text-transform: uppercase; } h1 { - font-size: clamp(1.8rem, 5vw, 3.4rem); + font-size: 1.45rem; line-height: 1; } @@ -112,35 +113,204 @@ h2 { } .searchbar { - grid-template-columns: 1fr 92px 92px; + grid-template-columns: minmax(0, 1fr) 72px 86px; } .tabs { - grid-template-columns: repeat(6, minmax(0, 1fr)); - margin-top: 12px; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; } -.layout { +.mobile-shell { + width: 100%; + min-height: 100vh; + color: #232323; + background: #eeeeee; +} + +.map-stage { + position: relative; + height: min(72vh, 760px); + min-height: 520px; + overflow: hidden; + background: #d9d9d9; + touch-action: none; +} + +.map-surface { + position: absolute; + inset: 0; + overflow: hidden; + cursor: grab; + touch-action: none; + user-select: none; +} + +.map-surface:active { + cursor: grabbing; +} + +.map-tile { + position: absolute; + left: 0; + top: 0; + width: 256px; + height: 256px; + max-width: none; + pointer-events: none; + user-select: none; +} + +.map-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgb(238 238 238 / 0.88) 0%, rgb(238 238 238 / 0.18) 28%, rgb(238 238 238 / 0) 48%), + linear-gradient(0deg, rgb(238 238 238 / 0.95) 0%, rgb(238 238 238 / 0.18) 38%, rgb(238 238 238 / 0) 58%); +} + +.map-top, +.map-bottom { + position: absolute; + left: 20px; + right: 20px; + z-index: 4; +} + +.map-top { + top: 20px; +} + +.map-bottom { + bottom: 16px; + display: grid; + gap: 8px; +} + +.map-user, +.map-pin { + position: absolute; + z-index: 2; + left: 0; + top: 0; + translate: -50% -50%; +} + +.map-user { + width: 22px; + height: 22px; + border: 4px solid #ffffff; + border-radius: 999px; + background: #232323; + box-shadow: 0 5px 18px rgb(0 0 0 / 0.26); +} + +.map-pin { + width: 34px; + min-height: 34px; + height: 34px; + border: 2px solid #ffffff; + border-radius: 999px; + color: #232323; + background: #ffffff; + box-shadow: 0 8px 22px rgb(0 0 0 / 0.18); + font-size: 0.76rem; + font-weight: 900; + line-height: 1; +} + +.map-pin.selected, +.map-pin:hover { + border-color: #232323; + color: #232323; + background: #fef991; +} + +.map-attribution { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 3; + display: flex; + gap: 8px; + border-radius: 999px; + padding: 6px 10px; + color: #7b7b7b; + background: rgb(255 255 255 / 0.86); + font-size: 0.72rem; +} + +.config-row, +.map-config, +.config-group { + display: flex; + align-items: center; + gap: 8px; +} + +.config-row { + justify-content: space-between; +} + +.map-config { + justify-content: space-between; + overflow-x: auto; + border-radius: 8px; + padding: 10px; + background: rgb(255 255 255 / 0.92); + box-shadow: 0 12px 35px rgb(0 0 0 / 0.08); +} + +.config-group { + flex: 0 0 auto; +} + +.config-group span { + color: #7b7b7b; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; +} + +.config-group button, +.config-row button { + min-height: 32px; + border: 0; + border-radius: 8px; + padding: 0 10px; + color: #232323; + background: #eeeeee; + font-size: 0.78rem; +} + +.config-group button.active { + background: #fef991; +} + +.content-panel { display: grid; grid-template-columns: minmax(280px, 0.95fr) minmax(360px, 1.35fr); gap: 16px; - margin-top: 16px; + width: min(100%, 1120px); + margin: 16px auto 0; + padding: 0 20px; min-height: 0; } .panel { min-height: 0; - max-height: calc(100vh - 286px); + max-height: 420px; overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-default); - background: var(--color-surface); + border: 0; + border-radius: 8px; + background: #ffffff; padding: 16px; } .stop-list, .departure-list { - max-height: calc(100vh - 370px); + max-height: 330px; overflow-y: auto; overscroll-behavior: contain; margin: 14px 0 0; @@ -150,7 +320,7 @@ h2 { .stop-list li, .departure-list li { - border-top: 1px solid var(--color-border); + border-top: 1px solid rgb(35 35 35 / 0.08); padding: 12px 0; } @@ -166,8 +336,8 @@ h2 { .stop-list li.selected, .departure-list li.selected { - border-left: 4px solid var(--color-accent); - background: var(--color-accent-alpha); + border-left: 4px solid #fef991; + background: rgb(254 249 145 / 0.34); padding-left: 10px; } @@ -182,7 +352,7 @@ h2 { .dep-meta, #stopMeta, .message { - color: var(--color-text-dim); + color: #7b7b7b; line-height: 1.45; } @@ -194,10 +364,10 @@ h2 { } .mode-chip { - border: 1px solid var(--color-border-light); - border-radius: 999px; + border: 1px solid rgb(35 35 35 / 0.08); + border-radius: 8px; padding: 2px 7px; - color: var(--color-accent-warning); + color: #232323; font-size: 0.78rem; font-weight: 800; } @@ -210,7 +380,7 @@ h2 { } .dep-time { - color: var(--color-accent-warning); + color: #232323; font-size: 1.2rem; font-variant-numeric: tabular-nums; font-weight: 900; @@ -222,21 +392,21 @@ h2 { } .status-grid div { - border: 1px solid var(--color-border); - border-radius: var(--radius-default); - background: var(--color-surface); + border: 0; + border-radius: 8px; + background: #ffffff; padding: 12px; } .status-grid span { display: block; - color: var(--color-accent-warning); + color: #232323; font-weight: 900; overflow-wrap: anywhere; } .status-grid small { - color: var(--color-text-dim); + color: #7b7b7b; text-transform: uppercase; } @@ -246,13 +416,29 @@ h2 { .hint { margin-top: 8px; - color: var(--color-text-muted); + color: #7b7b7b; font-size: 0.9rem; } +.connection-badge { + white-space: nowrap; +} + +.panel-count { + color: #7b7b7b; + font-size: 0.82rem; +} + +.app-footer { + width: min(100%, 1120px); + margin: 0 auto; + padding: 0 20px 24px; +} + @media (max-width: 820px) { - #app { - padding: 16px; + .map-stage { + height: 72vh; + min-height: 520px; } .topbar, @@ -261,24 +447,66 @@ h2 { flex-direction: column; } - .layout, + .map-top .topbar { + align-items: start; + flex-direction: row; + } + + .map-top .connection-badge { + display: none; + } + .searchbar, - .tabs, .status-grid { grid-template-columns: 1fr; } + .searchbar { + grid-template-columns: 1fr 64px 76px; + gap: 8px; + } + + .map-top, + .map-bottom { + left: 12px; + right: 12px; + } + + .map-top { + top: 14px; + } + + .tabs { + overflow-x: auto; + grid-template-columns: repeat(5, minmax(92px, 1fr)); + } + + .map-config { + align-items: stretch; + flex-direction: column; + } + + .config-group { + overflow-x: auto; + padding-bottom: 2px; + } + + .content-panel { + grid-template-columns: 1fr; + margin-top: 12px; + padding: 0 12px; + } + .panel { max-height: none; } - .layout { - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - min-height: 0; - } - .stop-list, .departure-list { - max-height: calc((100vh - 410px) / 2); + max-height: 260px; + } + + .app-footer { + padding: 0 12px 18px; } }