Commit 538ca863 authored by prurite's avatar prurite
Browse files

BusTimerV2: vehicle map bug fixes

parent 6c64578e
Loading
Loading
Loading
Loading
+73 −28
Original line number Diff line number Diff line
@@ -12,7 +12,7 @@ import { createApp, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BusVehicleDetailV2 from './BusVehicleDetailV2.vue'
import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'
import { parseGeometry } from './bus-v2-helpers.mjs'
import { displayName, displayStopName } from './core.mjs'
import { displayName, displayStopName, lineBearingAt } from './core.mjs'

const LIGHT_STYLE = 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-light.json'
const DARK_STYLE = 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-dark.json'
@@ -37,7 +37,6 @@ const loading = ref(true)
const mapError = ref('')
let map
let maplibregl
let mediaQuery
let themeChangeHandler
let themeObserver
let activePopup
@@ -45,18 +44,23 @@ let vehicleDetailMarker
let vehicleDetailApp
let loaded = false
let protocolInUse = false
let vehicleMarkers = []
let vehicleMarkers = new Map()
let mapEventsBound = false

const activeRoutes = () => props.routes.filter((route) => !props.routeId || route.id === props.routeId)
const activeVehicles = () => props.vehicles.filter((vehicle) => (!props.routeId || vehicle.route_id === props.routeId) && Number.isFinite(+vehicle.longitude) && Number.isFinite(+vehicle.latitude))
const routeFor = (id) => props.routes.find((route) => route.id === id)
const darkTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' || mediaQuery?.matches
const darkTheme = () => document.documentElement.getAttribute('data-theme') === 'dark'
const neutralRouteColor = () => darkTheme() ? '#aaa' : '#666'
const neutralStopColor = () => darkTheme() ? '#ccc' : '#444'
const styleUrl = () => props.styleUrl || (darkTheme() ? DARK_STYLE : LIGHT_STYLE)
const sourceData = (features) => ({ type: 'FeatureCollection', features })

function vehicleBearing(vehicle) {
  const direction = routeFor(vehicle.route_id)?.directions?.find((item) => item.id === vehicle.route_direction_id)
  return lineBearingAt(parseGeometry(direction?.geometry_json), +vehicle.longitude, +vehicle.latitude)
}

function routeFeatures() {
  return activeRoutes().flatMap((route) => (route.directions || []).map((direction) => ({
    type: 'Feature',
@@ -95,8 +99,12 @@ function fitToStops() {
}

function clearVehicleMarkers() {
  vehicleMarkers.forEach((marker) => marker.remove())
  vehicleMarkers = []
  vehicleMarkers.forEach((record) => { cancelAnimationFrame(record.frame); record.marker.remove() })
  vehicleMarkers = new Map()
}

function settleVehicleMarkers() {
  vehicleMarkers.forEach((record) => { cancelAnimationFrame(record.frame); record.element.style.transition = 'none'; record.element.style.transform = '' })
}

function closeVehiclePopup() {
@@ -118,23 +126,58 @@ function showVehiclePopup(vehicle) {
  vehicleDetailMarker = new maplibregl.Marker({ element: content, anchor: 'bottom', offset: [0, -18] }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map)
}

function refreshVehicleMarkers() {
  clearVehicleMarkers()
  const visibleVehicles = activeVehicles()
  const selected = selectedVehicle.value && visibleVehicles.find((vehicle) => vehicle.id === selectedVehicle.value.id)
  if (!selected && selectedVehicle.value) closeVehiclePopup()
  vehicleMarkers = visibleVehicles.map((vehicle) => {
    const element = document.createElement('button')
function updateVehicleMarker(record, vehicle) {
  record.vehicle = vehicle
  const route = routeFor(vehicle.route_id)
  record.element.className = `bus-map__vehicle status-${vehicle.data_status || 'offline'}`
  record.element.title = displayName(vehicle, props.language) || vehicle.id
  record.element.setAttribute('aria-label', record.element.title)
  record.element.style.setProperty('--route-color', route?.color || '#2878c8')
  record.element.style.setProperty('--bearing', `${vehicleBearing(vehicle)}deg`)
}

function moveVehicleMarker(record, longitude, latitude) {
  cancelAnimationFrame(record.frame)
  const start = record.marker.getLngLat()
  const target = [+longitude, +latitude]
  if (start.lng === target[0] && start.lat === target[1]) return
  const startPoint = map.project(start), targetPoint = map.project(target)
  record.marker.setLngLat(target)
  record.element.style.transition = 'none'
  record.element.style.transform = `translate(${startPoint.x - targetPoint.x}px, ${startPoint.y - targetPoint.y}px)`
  record.frame = requestAnimationFrame(() => { record.element.style.transition = 'transform 1s linear'; record.element.style.transform = '' })
}

function createVehicleMarker(vehicle) {
  const record = { vehicle, marker: null, frame: 0, element: null }
  const markerElement = document.createElement('div')
  const element = document.createElement('button')
  const arrow = document.createElement('span')
  const image = document.createElement('img')
  arrow.className = 'bus-map__vehicle-arrow'
  markerElement.className = 'bus-map__vehicle-marker'
  image.src = String(vehicle.vehicle_type).toUpperCase() === 'SHUTTLE' ? '/sev.png' : '/bus.png'
  image.alt = ''
  element.type = 'button'
    element.className = `bus-map__vehicle status-${vehicle.data_status || 'offline'}`
    element.style.backgroundImage = `url(${String(vehicle.vehicle_type).toUpperCase() === 'SHUTTLE' ? '/sev.png' : '/bus.png'})`
    element.title = displayName(vehicle, props.language) || vehicle.id
    element.setAttribute('aria-label', element.title)
    element.style.setProperty('--route-color', route?.color || '#2878c8')
    element.addEventListener('click', (event) => { event.stopPropagation(); showVehiclePopup(vehicle) })
    return new maplibregl.Marker({ element, anchor: 'center' }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map)
  element.append(arrow, image)
  markerElement.append(element)
  element.addEventListener('click', (event) => { event.stopPropagation(); showVehiclePopup(record.vehicle) })
  record.element = element
  updateVehicleMarker(record, vehicle)
  record.marker = new maplibregl.Marker({ element: markerElement, anchor: 'center' }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map)
  return record
}

function refreshVehicleMarkers() {
  const visibleVehicles = activeVehicles(), visibleIds = new Set(visibleVehicles.map((vehicle) => vehicle.id))
  vehicleMarkers.forEach((record, id) => { if (!visibleIds.has(id)) { cancelAnimationFrame(record.frame); record.marker.remove(); vehicleMarkers.delete(id) } })
  visibleVehicles.forEach((vehicle) => {
    const record = vehicleMarkers.get(vehicle.id)
    if (!record) vehicleMarkers.set(vehicle.id, createVehicleMarker(vehicle))
    else { updateVehicleMarker(record, vehicle); moveVehicleMarker(record, vehicle.longitude, vehicle.latitude) }
  })
  const selected = selectedVehicle.value && visibleVehicles.find((vehicle) => vehicle.id === selectedVehicle.value.id)
  if (!selected && selectedVehicle.value) closeVehiclePopup()
  if (selected) showVehiclePopup(selected)
}

@@ -175,6 +218,7 @@ function addLayers() {
  })
  if (!mapEventsBound) {
    map.on('click', 'bus-v2-stops', showStopPopup)
    map.on('click', (event) => { if (!map.queryRenderedFeatures(event.point, { layers: ['bus-v2-stops'] }).length) closeVehiclePopup() })
    map.on('mouseenter', 'bus-v2-stops', () => { map.getCanvas().style.cursor = 'pointer' })
    map.on('mouseleave', 'bus-v2-stops', () => { map.getCanvas().style.cursor = '' })
    mapEventsBound = true
@@ -242,7 +286,6 @@ async function initialise() {
  if (typeof window === 'undefined' || !mapElement.value) return
  try {
    maplibregl = (await import('maplibre-gl')).default
    mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    await acquireProtocol()
    map = new maplibregl.Map({ container: mapElement.value, style: styleUrl(), center: CAMPUS_CENTER, zoom: 14, minZoom: 12, attributionControl: true })
    map.addControl(new maplibregl.NavigationControl(), 'top-left')
@@ -251,10 +294,10 @@ async function initialise() {
    map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showUserHeading: true }), 'top-right')
    map.on('style.load', restoreOverlays)
    themeChangeHandler = () => { if (!props.styleUrl) reloadStyle() }
    mediaQuery.addEventListener('change', themeChangeHandler)
    themeObserver = new MutationObserver(themeChangeHandler)
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
    map.on('load', () => { loaded = true; addLayers(); fitToStops(); refresh(); loading.value = false })
    map.on('movestart', settleVehicleMarkers)
    map.on('load', () => { loaded = true; addLayers(); fitToStops(); refresh(); requestAnimationFrame(() => mapElement.value?.querySelector('.maplibregl-ctrl-attrib')?.classList.remove('maplibregl-compact-show')); loading.value = false })
    map.on('error', (event) => {
      if (!loaded && event.error) {
        mapError.value = `${props.language === 'zh' ? '地图不可用:' : 'Map unavailable: '}${event.error.message}`
@@ -276,7 +319,6 @@ onBeforeUnmount(() => {
  clearVehicleMarkers()
  closeVehiclePopup()
  activePopup?.remove()
  mediaQuery?.removeEventListener('change', themeChangeHandler)
  themeObserver?.disconnect()
  if (map) map.remove()
  map = null
@@ -295,10 +337,13 @@ defineExpose({ refresh, refreshVehicleMarkers })
.bus-map { position: relative; min-height: 22rem; overflow: hidden; border: 1px solid #d9e2ec; border-radius: .6rem; background: #eef4f8; }
.bus-map__canvas { width: 100%; height: 28rem; }
.bus-map__message { position: absolute; top: .75rem; left: .75rem; z-index: 1; margin: 0; padding: .45rem .65rem; border-radius: .35rem; background: rgba(255, 255, 255, .9); color: #526172; }
.bus-map__legend { position: absolute; z-index: 1; bottom: 1.75rem; left: .75rem; margin: 0; padding: .4rem .55rem; border-radius: .35rem; background: color-mix(in srgb, var(--bus-v2-bg, #fff) 90%, transparent); }
.bus-map :deep(.bus-map__vehicle) { width: 1.4rem; height: 1.4rem; border: 2px solid var(--route-color); border-radius: 50%; padding: 0; background-color: var(--route-color); background-position: center; background-repeat: no-repeat; background-size: contain; cursor: pointer; box-shadow: 0 0 0 2px var(--route-color), 0 1px 4px rgba(0, 0, 0, .35); }
.bus-map :deep(.bus-map__vehicle.status-delayed) { background-color: #f7a600; }
.bus-map :deep(.bus-map__vehicle.status-offline) { background-color: #9aa4b2; }
.bus-map__legend { position: absolute; z-index: 1; bottom: .75rem; left: .75rem; margin: 0; padding: .4rem .55rem; border-radius: .35rem; background: color-mix(in srgb, var(--bus-v2-bg, #fff) 90%, transparent); }
.bus-map :deep(.bus-map__vehicle-marker) { display: block; width: 1.6rem; min-width: 1.6rem; max-width: 1.6rem; height: 1.6rem; min-height: 1.6rem; max-height: 1.6rem; line-height: 0; }
.bus-map :deep(.bus-map__vehicle) { box-sizing: border-box; display: block; position: relative; width: 1.6rem; min-width: 1.6rem; max-width: 1.6rem; height: 1.6rem; min-height: 1.6rem; max-height: 1.6rem; aspect-ratio: 1; border: 2px solid #fff; border-radius: 50%; padding: 1px; background: var(--route-color); cursor: pointer; }
.bus-map :deep(.bus-map__vehicle img) { position: absolute; inset: 0; width: 80%; height: 80%; margin: auto; object-fit: contain; }
.bus-map :deep(.bus-map__vehicle-arrow) { position: absolute; z-index: 2; inset: 0; pointer-events: none; transform: rotate(var(--bearing)); }
.bus-map :deep(.bus-map__vehicle-arrow)::before { content: ''; position: absolute; top: -.57rem; left: 50%; transform: translateX(-50%); border-right: .44rem solid transparent; border-bottom: .62rem solid #fff; border-left: .44rem solid transparent; }
.bus-map :deep(.bus-map__vehicle-arrow)::after { content: ''; position: absolute; top: -.45rem; left: 50%; transform: translateX(-50%); border-right: .32rem solid transparent; border-bottom: .5rem solid var(--route-color); border-left: .32rem solid transparent; }
.bus-map :deep(.bus-map__interaction-lock), .bus-map :deep(.bus-map__interaction-allow) { background-image: none; font-size: 1rem; }
.bus-map :deep(.bus-map__interaction-lock)::before { content: '🔒'; }
.bus-map :deep(.bus-map__interaction-allow)::before { content: '🖐'; }
+3 −1
Original line number Diff line number Diff line
@@ -59,7 +59,7 @@
        <p v-if="!currentDirection?.stops?.length" class="muted">{{ busText('empty') }}</p>
        <ol v-else>
          <li v-for="stop in currentDirection.stops" :key="stop.id" :class="{ selected: stop.id === selectedStopId }">
            <div class="stop-line"><i aria-hidden="true" /><div v-if="atStop(stop).length || betweenStop(stop).length" class="vehicle-markers"><button v-if="atStop(stop).length" type="button" class="vehicle-marker at" :aria-expanded="expandedVehicleKey === `at-${stop.id}`" @click="toggleVehicles(`at-${stop.id}`)"><i aria-hidden="true" class="vehicle-icon" :class="{ shuttle: String(atStop(stop)[0]?.vehicle_type).toUpperCase() === 'SHUTTLE' }" /><em v-if="hasSpecialService(atStop(stop))">S</em><b v-if="atStop(stop).length > 1">{{ atStop(stop).length }}</b></button><button v-if="betweenStop(stop).length" type="button" class="vehicle-marker between" :aria-expanded="expandedVehicleKey === `between-${stop.id}`" @click="toggleVehicles(`between-${stop.id}`)"><i aria-hidden="true" class="vehicle-icon" :class="{ shuttle: String(betweenStop(stop)[0]?.vehicle_type).toUpperCase() === 'SHUTTLE' }" /><em v-if="hasSpecialService(betweenStop(stop))">S</em><b v-if="betweenStop(stop).length > 1">{{ betweenStop(stop).length }}</b></button></div></div>
            <div class="stop-line"><i aria-hidden="true" /><div v-if="atStop(stop).length || betweenStop(stop).length" class="vehicle-markers"><button v-if="atStop(stop).length" type="button" class="vehicle-marker at" :aria-expanded="expandedVehicleKey === `at-${stop.id}`" @click="toggleVehicles(`at-${stop.id}`)"><b v-if="atStop(stop).length > 1">{{ atStop(stop).length }}</b><i aria-hidden="true" class="vehicle-icon" :class="{ shuttle: String(atStop(stop)[0]?.vehicle_type).toUpperCase() === 'SHUTTLE' }" /><em v-if="hasSpecialService(atStop(stop))">S</em></button><button v-if="betweenStop(stop).length" type="button" class="vehicle-marker between" :aria-expanded="expandedVehicleKey === `between-${stop.id}`" @click="toggleVehicles(`between-${stop.id}`)"><b v-if="betweenStop(stop).length > 1">{{ betweenStop(stop).length }}</b><i aria-hidden="true" class="vehicle-icon" :class="{ shuttle: String(betweenStop(stop)[0]?.vehicle_type).toUpperCase() === 'SHUTTLE' }" /><em v-if="hasSpecialService(betweenStop(stop))">S</em></button></div></div>
            <div class="stop-main"><a :href="`${stopHref}${encodeURIComponent(stop.id)}`" @click.prevent="selectStop(stop.id)"><strong>{{ routeStopName(stop) }}</strong></a></div>
            <div v-if="expandedVehicleKey === `at-${stop.id}` || expandedVehicleKey === `between-${stop.id}`" class="vehicle-details">
              <BusVehicleDetailV2 v-for="vehicle in expandedVehicleKey === `at-${stop.id}` ? atStop(stop) : betweenStop(stop)" :key="vehicle.id" :vehicle="vehicle" :routes="[route]" :stops="stops" :language="busLanguage" />
@@ -153,6 +153,8 @@ defineExpose({ load, refresh, selectDirection, selectStop })
.route-stops .vehicle-marker.at { top: 1rem; }
.route-stops .vehicle-marker.between { top: -0.1rem; background: color-mix(in srgb, #f7a600 18%, var(--bus-v2-bg)); }
.route-stops .vehicle-marker { gap: .1rem; padding: 0; background: transparent; }
.route-stops .vehicle-marker { display: flex; align-items: center; }
.route-stops .vehicle-marker b { margin: 0 .15rem 0 0; }
.route-stops .vehicle-marker.between { background: transparent; }
.route-stops .vehicle-icon { z-index: auto; display: block; width: 1.5rem; height: 1.5rem; margin: 0; border-radius: 50%; background: #fff url('/bus.png') center / calc(100% - 2px) no-repeat; }
.route-stops .vehicle-icon.shuttle { background-image: url('/sev.png'); }
+6 −2
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@
  <p v-if="props.state?.loading" class="muted">{{ busText('loading') }}</p>
  <p v-else-if="props.state?.error" class="muted">{{ busText('unavailable') }}</p>
  <p v-else-if="!items.length" class="muted">{{ busText('empty') }}</p>
  <ul v-else class="arrival-list"><li v-for="arrival in items" :key="arrivalKey(arrival)"><i :style="{ background: arrival.route_color || '#2878c8' }" /><a :href="routeDirectionHref(arrival)"><strong>{{ routeName(arrival) }}</strong><small>{{ directionName(arrival) }}</small></a><span>{{ arrivalText(arrival) }}<small v-if="arrivalMeta(arrival)">{{ arrivalMeta(arrival) }}</small></span></li></ul>
  <ul v-else class="arrival-list"><li v-for="arrival in items" :key="arrivalKey(arrival)"><i :style="{ background: arrival.route_color || '#2878c8' }" /><a :href="routeDirectionHref(arrival)"><strong>{{ routeName(arrival) }}</strong><small>{{ directionName(arrival) }}</small></a><span>{{ arrivalText(arrival) }}<small v-if="arrivalMeta(arrival)">{{ arrivalMeta(arrival) }}</small></span><img v-if="showVehicleIcon(arrival)" :src="vehicleIcon(arrival)" :alt="vehicleLabel(arrival)"></li></ul>
</template>

<script setup>
@@ -16,6 +16,9 @@ const routeName = (item) => item[busLanguage.value === 'en' ? 'route_name_en' :
const directionName = (item) => item[busLanguage.value === 'en' ? 'direction_name_en' : 'direction_name_zh'] || item.direction_name_zh || item.direction_name_en || ''
const arrivalKey = (item) => `${item.route_direction_id}-${item.trip_id || item.planned_arrival_at || item.updated_at || item.eta_minutes}`
const routeDirectionHref = (item) => `${props.routeHref}${encodeURIComponent(item.route_id)}&direction=${encodeURIComponent(item.route_direction_id)}`
const vehicleIcon = (item) => String(item.vehicle_type).toUpperCase() === 'SHUTTLE' ? '/sev.png' : '/bus.png'
const vehicleLabel = (item) => String(item.vehicle_type).toUpperCase() === 'SHUTTLE' ? (busLanguage.value === 'zh' ? '电瓶车' : 'EV Shuttle') : (busLanguage.value === 'zh' ? '巴士' : 'Bus')
const showVehicleIcon = (item) => !['LAST_SERVICE_PASSED', 'NOT_OPERATING'].includes(String(item.unavailable_reason).toUpperCase())
function time(value) { return value ? new Date(value).toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false }) : '' }
function arrivalText(item) { return item.source === 'real_time' ? [realtimeArrivalText(item, busLanguage.value), arrivalDistance(item)].filter(Boolean).join(' ') : item.source === 'planned' ? busText('planAt', { time: time(item.planned_arrival_at) }) : busText(unavailableReasonTextKey(item.unavailable_reason)) }
function arrivalDistance(item) { const meters = Number(item.distance ?? item.distance_to_stop ?? item.distance_to_next_stop ?? item.distance_meters); return Number.isFinite(meters) ? `${Math.round(meters)}m` : '' }
@@ -25,10 +28,11 @@ function arrivalMeta(item) { return item.source === 'real_time' ? `${busLanguage
<style scoped>
.muted { color: var(--bus-v2-muted); }
.arrival-list { margin: .5rem 0 0; padding: 0; list-style: none; }
.arrival-list li { display: grid; grid-template-columns: .35rem minmax(0, 1fr) auto; gap: .5rem; align-items: center; padding: .6rem 0; border-top: 1px solid var(--bus-v2-border); }
.arrival-list li { display: grid; grid-template-columns: .35rem minmax(0, 1fr) auto 1rem; gap: .5rem; align-items: center; padding: .6rem 0; border-top: 1px solid var(--bus-v2-border); }
.arrival-list i { width: .3rem; align-self: stretch; border-radius: 2px; }
.arrival-list a { display: grid; gap: .15rem; color: var(--bus-v2-link); text-decoration: none; }
.arrival-list a:hover, .arrival-list a:focus-visible { text-decoration: underline; }
.arrival-list small { color: var(--bus-v2-muted); }
.arrival-list span { display: grid; justify-items: end; gap: .15rem; text-align: right; }
.arrival-list img { box-sizing: border-box; width: 1rem; height: 1rem; padding: 1px; border-radius: 50%; background: #fff; }
</style>
+2 −1
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@
    <template v-else-if="stop">
      <section v-if="platforms.length > 1" class="panel platforms"><h3>{{ label('platforms') }}</h3><div><button v-for="item in platforms" :key="item.id" type="button" :class="{ active: item.id === stop.id }" @click="openPlatform(item.id)">{{ displayName(item, busLanguage) || item.id }}</button></div></section>
      <section class="panel notices"><h3>{{ busText('announcements') }}</h3><p v-if="!stopNotices.length" class="muted">{{ busText('empty') }}</p><details v-for="notice in stopNotices" :key="notice.id"><summary><span>{{ noticeTitle(notice) }}</span><time v-if="noticeTime(notice)" :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details></section>
      <section class="panel arrivals" aria-live="polite"><div class="section-head"><h3>{{ label('arrivals') }}</h3><button v-if="hasMoreArrivals(arrivalState)" type="button" class="plain-button" :aria-expanded="allArrivals" @click="allArrivals = !allArrivals">{{ allArrivals ? label('collapse') : label('allArrivals') }}</button></div><BusStopArrivalsV2 :state="arrivalState" :collapsed="!allArrivals" :route-href="routeHref" /></section>
      <section class="panel arrivals" aria-live="polite"><div class="section-head"><h3>{{ label('arrivals') }}</h3><button v-if="hasMoreArrivals(arrivalState)" type="button" class="plain-button" :aria-expanded="allArrivals" @click="allArrivals = !allArrivals">{{ allArrivals ? label('collapse') : label('allArrivals') }}</button></div><BusVehicleLegendV2 :language="busLanguage" /><BusStopArrivalsV2 :state="arrivalState" :collapsed="!allArrivals" :route-href="routeHref" /></section>
      <section v-if="otherPlatforms.length" class="platform-services"><article v-for="item in otherPlatforms" :key="item.id" class="panel"><div class="section-head"><h3>{{ label('platform', { name: displayName(item, busLanguage) || item.id }) }}</h3><div class="platform-actions"><button v-if="hasMoreArrivals(platformArrivals[item.id])" type="button" class="plain-button" :aria-expanded="expandedPlatforms[item.id]" @click="togglePlatformArrivals(item.id)">{{ expandedPlatforms[item.id] ? label('collapse') : label('allArrivals') }}</button><button type="button" class="plain-button" @click="openPlatform(item.id)">{{ label('open') }}</button></div></div><BusStopArrivalsV2 :state="platformArrivals[item.id]" :collapsed="!expandedPlatforms[item.id]" :route-href="routeHref" /></article></section>
    </template>
  </main>
@@ -23,6 +23,7 @@ import { isFavorite, loadFavorites, toggleFavorite } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
import { renderNoticeMarkdown } from './markdown.mjs'
import BusStopArrivalsV2 from './BusStopArrivalsV2.vue'
import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'

const props = defineProps({ id: { type: String, default: '' }, routeHref: { type: String, default: '/transport/bustimer_v2_route.html?id=' }, stopHref: { type: String, default: '/transport/bustimer_v2_stop.html?id=' } })
const stop = ref(null), platforms = ref([]), notices = ref([]), loading = ref(true), error = ref(''), allArrivals = ref(false), arrivalState = ref({ loading: false, error: false, items: [] }), platformArrivals = ref({}), expandedPlatforms = ref({}), refreshRemaining = ref(30)
+2 −4

File changed.

Preview size limit exceeded, changes collapsed.

Loading