Make mobile TFI UI map first
This commit is contained in:
434
src/main.ts
434
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<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) {
|
||||
|
||||
318
src/style.css
318
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user