Loading docs/.vuepress/components/bus-v2/BusMapV2.vue +73 −28 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 73 added lines, 28 removed lines. Original line number Diff line number Diff line Loading @@ -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' Loading @@ -37,7 +37,6 @@ const loading = ref(true) const mapError = ref('') let map let maplibregl let mediaQuery let themeChangeHandler let themeObserver let activePopup Loading @@ -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', Loading Loading @@ -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() { Loading @@ -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) } Loading Loading @@ -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 Loading Loading @@ -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') Loading @@ -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}` Loading @@ -276,7 +319,6 @@ onBeforeUnmount(() => { clearVehicleMarkers() closeVehiclePopup() activePopup?.remove() mediaQuery?.removeEventListener('change', themeChangeHandler) themeObserver?.disconnect() if (map) map.remove() map = null Loading @@ -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: '🖐'; } Loading docs/.vuepress/components/bus-v2/BusRouteV2.vue +3 −1 Changes for docs/.vuepress/components/bus-v2/BusRouteV2.vue: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" /> Loading Loading @@ -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'); } Loading docs/.vuepress/components/bus-v2/BusStopArrivalsV2.vue +6 −2 Changes for docs/.vuepress/components/bus-v2/BusStopArrivalsV2.vue: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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> Loading @@ -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` : '' } Loading @@ -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> docs/.vuepress/components/bus-v2/BusStopV2.vue +2 −1 Changes for docs/.vuepress/components/bus-v2/BusStopV2.vue: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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> Loading @@ -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) Loading docs/.vuepress/components/bus-v2/BusVehicleDetailV2.vue +2 −4 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
docs/.vuepress/components/bus-v2/BusMapV2.vue +73 −28 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 73 added lines, 28 removed lines. Original line number Diff line number Diff line Loading @@ -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' Loading @@ -37,7 +37,6 @@ const loading = ref(true) const mapError = ref('') let map let maplibregl let mediaQuery let themeChangeHandler let themeObserver let activePopup Loading @@ -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', Loading Loading @@ -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() { Loading @@ -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) } Loading Loading @@ -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 Loading Loading @@ -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') Loading @@ -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}` Loading @@ -276,7 +319,6 @@ onBeforeUnmount(() => { clearVehicleMarkers() closeVehiclePopup() activePopup?.remove() mediaQuery?.removeEventListener('change', themeChangeHandler) themeObserver?.disconnect() if (map) map.remove() map = null Loading @@ -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: '🖐'; } Loading
docs/.vuepress/components/bus-v2/BusRouteV2.vue +3 −1 Changes for docs/.vuepress/components/bus-v2/BusRouteV2.vue: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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" /> Loading Loading @@ -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'); } Loading
docs/.vuepress/components/bus-v2/BusStopArrivalsV2.vue +6 −2 Changes for docs/.vuepress/components/bus-v2/BusStopArrivalsV2.vue: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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> Loading @@ -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` : '' } Loading @@ -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>
docs/.vuepress/components/bus-v2/BusStopV2.vue +2 −1 Changes for docs/.vuepress/components/bus-v2/BusStopV2.vue: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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> Loading @@ -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) Loading
docs/.vuepress/components/bus-v2/BusVehicleDetailV2.vue +2 −4 File changed.Preview size limit exceeded, changes collapsed. Show changes