Make mobile TFI UI map first

This commit is contained in:
Matiss
2026-06-01 17:34:14 +01:00
parent 60be45bb85
commit 651c89c58a
2 changed files with 658 additions and 94 deletions

View File

@@ -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<void> {
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<GeoPoint>(state.userLocation ?? state.selectedStop ?? DEFAULT_MAP_CENTER)
const [modeFilter, setModeFilter] = React.useState<MapModeFilter>('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<HTMLInputElement>) => 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<HTMLInputElement>) => 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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLButtonElement>) => {
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) {

View File

@@ -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;
}
}