Loading docs/.vuepress/components/bus-v2/BusHomeV2.vue +20 −10 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 20 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ <header class="bus-home__header"> <div> <h1>{{ busLanguage === 'zh' ? '校园巴士' : 'Campus bus' }}</h1> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息' : 'Live arrivals and travel information' }}</p> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息。所有到站时间均为预测,仅供参考。' : 'Live arrivals and travel information. All arrival times are estimates and for reference only.' }}</p> </div> <div><button class="text-button" type="button" :aria-label="busLanguage === 'zh' ? '立即刷新' : 'Refresh now'" @click="refresh">🔄{{ refreshRemaining }}s</button><button class="text-button" type="button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button></div> </header> Loading Loading @@ -40,11 +40,16 @@ <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.length" class="quick-links"> <div v-if="favoriteRoutes.length" class="quick-links"> <button v-for="route in favoriteRoutes" :key="route.id" type="button" @click="openRoute(route.id)">🚌 {{ displayName(route, busLanguage) }}</button> <button v-for="stop in favoriteStops" :key="stop.id" type="button" @click="openStop(stop.id)">⌖ {{ displayStopName(stop, busLanguage) }}</button> </div> <p v-else class="muted">{{ busText('noFavorites') }}</p> <article v-for="stop in favoriteStopCards" :key="stop.id" class="nearby-stop"> <div class="nearby-stop__head"><button type="button" class="link-title" @click="openStop(stop.id)">{{ displayStopName(stop, busLanguage) }}</button><span v-if="stop.distance != null">{{ formatDistance(stop.distance) }}</span></div> <p v-if="favoriteArrivals[stop.id]?.loading" class="muted">{{ busText('loading') }}</p> <p v-else-if="favoriteArrivals[stop.id]?.error" class="muted">{{ busText('unavailable') }}</p> <ul v-else class="arrival-list"><li v-for="arrival in favoriteArrivals[stop.id]?.items" :key="`${arrival.route_direction_id}-${arrival.source}-${arrival.trip_id || arrival.planned_arrival_at || ''}`"><i :style="{ background: arrival.route_color || '#2878c8' }" /><a class="arrival-link" :href="routeDirectionHref(arrival)">{{ arrivalName(arrival) }}</a><strong>{{ arrivalText(arrival) }}</strong></li><li v-if="!favoriteArrivals[stop.id]?.items?.length" class="muted">{{ busText('empty') }}</li></ul> </article> <p v-if="!favoriteRoutes.length && !favoriteStops.length" class="muted">{{ busText('noFavorites') }}</p> </section> <section class="panel nearby"> Loading Loading @@ -106,9 +111,11 @@ const stops = ref([]) const notices = ref([]) const query = ref('') const arrivals = ref({}) const favoriteArrivals = ref({}) const locationState = ref('idle') const locationErrorKey = ref('') const nearbyStops = ref([]) const location = ref(null) const allNearbyStops = ref(false) const refreshRemaining = ref(30) const locationUpdatedAt = ref(0) Loading @@ -123,6 +130,7 @@ const searchResults = computed(() => [ ].slice(0, 8)) const favoriteRoutes = computed(() => routes.value.filter((route) => favoriteRouteIds.value.includes(route.id))) const favoriteStops = computed(() => stops.value.filter((stop) => favoriteStopIds.value.includes(stop.id))) const favoriteStopCards = computed(() => favoriteStops.value.map((stop) => location.value ? { ...stop, distance: haversineMeters(location.value.latitude, location.value.longitude, stop.latitude, stop.longitude) } : stop)) const visibleNearbyStops = computed(() => nearbyStops.value.slice(0, allNearbyStops.value ? 5 : 2)) const locationUpdatedText = computed(() => { const updated = new Date(locationUpdatedAt.value) Loading Loading @@ -159,21 +167,21 @@ function arrivalText(arrival) { return busText(unavailableReasonTextKey(arrival.unavailable_reason)) } async function loadArrivals(stopList) { async function loadArrivals(stopList, target = arrivals) { const results = await Promise.all(stopList.map(async (stop) => { arrivals.value = { ...arrivals.value, [stop.id]: { loading: true, items: [] } } target.value = { ...target.value, [stop.id]: { loading: true, items: [] } } try { const result = await busApi.arrivals(stop.id) const rawItems = result.arrivals || [] const items = sortArrivalsByEstimatedTime(rawItems.filter((arrival) => !isTerminalArrival(arrival, stop.id, routes.value))) arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, items } } target.value = { ...target.value, [stop.id]: { loading: false, items } } return { stop, hidden: rawItems.length > 0 && !items.length } } catch { arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, error: true, items: [] } } target.value = { ...target.value, [stop.id]: { loading: false, error: true, items: [] } } return { stop, hidden: false } } })) nearbyStops.value = results.filter(({ hidden }) => !hidden).map(({ stop }) => stop) if (target === arrivals) nearbyStops.value = results.filter(({ hidden }) => !hidden).map(({ stop }) => stop) return results } Loading @@ -189,7 +197,8 @@ function saveLocation(coords, updatedAt) { } async function useLocation(coords, updatedAt) { nearbyCandidates = stops.value.filter((stop) => Number.isFinite(stop.latitude) && Number.isFinite(stop.longitude)).map((stop) => ({ location.value = { latitude: +coords.latitude, longitude: +coords.longitude } nearbyCandidates = stops.value.filter((stop) => !favoriteStopIds.value.includes(stop.id) && Number.isFinite(stop.latitude) && Number.isFinite(stop.longitude)).map((stop) => ({ ...stop, distance: haversineMeters(coords.latitude, coords.longitude, stop.latitude, stop.longitude), })).sort((left, right) => left.distance - right.distance).slice(0, 6) nearbyStops.value = nearbyCandidates Loading Loading @@ -243,6 +252,7 @@ async function load() { routes.value = Array.isArray(routeData) ? routeData : [] stops.value = Array.isArray(stopData) ? stopData : [] notices.value = (Array.isArray(noticeData) ? noticeData : []).sort((left, right) => right.priority - left.priority) await loadArrivals(favoriteStops.value, favoriteArrivals) } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } Loading docs/.vuepress/components/bus-v2/BusMapV2.vue +39 −7 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 39 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -3,12 +3,12 @@ <div ref="mapElement" class="bus-map__canvas" :aria-label="language === 'zh' ? '车辆地图' : 'Vehicle map'" /> <p v-if="mapError" class="bus-map__message" role="alert">{{ mapError }}</p> <p v-else-if="loading" class="bus-map__message">{{ language === 'zh' ? '正在加载地图…' : 'Loading map…' }}</p> <BusVehicleDetailV2 v-if="selectedVehicle" class="bus-map__detail" :vehicle="selectedVehicle" :routes="routes" :stops="stops" :language="language" closable @close="selectedVehicle = null" /> <div class="bus-map__legend"><span><i class="bus" />{{ language === 'zh' ? '巴士' : 'Bus' }}</span><span><i class="shuttle" />{{ language === 'zh' ? '电瓶车' : 'EV Shuttle' }}</span></div> </section> </template> <script setup> import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { createApp, onBeforeUnmount, onMounted, ref, watch } from 'vue' import BusVehicleDetailV2 from './BusVehicleDetailV2.vue' import { parseGeometry } from './bus-v2-helpers.mjs' import { displayName, displayStopName } from './core.mjs' Loading Loading @@ -39,6 +39,8 @@ let mediaQuery let themeChangeHandler let themeObserver let activePopup let vehicleDetailMarker let vehicleDetailApp let loaded = false let protocolInUse = false let vehicleMarkers = [] Loading Loading @@ -81,15 +83,42 @@ function stopFeatures() { return [...stops.values()].map((feature) => ({ ...feature, properties: { ...feature.properties, name: feature.properties.names.join(' / ') } })) } function fitToStops() { const coordinates = stopFeatures().map((feature) => feature.geometry.coordinates) if (!coordinates.length) return const bounds = coordinates.reduce((current, coordinate) => current.extend(coordinate), new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) map.fitBounds(bounds, { padding: 48, maxZoom: 14.5, duration: 0 }) } function clearVehicleMarkers() { vehicleMarkers.forEach((marker) => marker.remove()) vehicleMarkers = [] } function closeVehiclePopup() { vehicleDetailApp?.unmount() vehicleDetailApp = null vehicleDetailMarker?.remove() vehicleDetailMarker = null selectedVehicle.value = null } function showVehiclePopup(vehicle) { activePopup?.remove() closeVehiclePopup() selectedVehicle.value = vehicle const content = document.createElement('div') content.addEventListener('click', (event) => event.stopPropagation()) vehicleDetailApp = createApp(BusVehicleDetailV2, { vehicle, routes: props.routes, stops: props.stops, language: props.language, closable: true, onClose: closeVehiclePopup }) vehicleDetailApp.mount(content) vehicleDetailMarker = new maplibregl.Marker({ element: content, anchor: 'bottom', offset: [0, -18] }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map) } function refreshVehicleMarkers() { clearVehicleMarkers() const visibleVehicles = activeVehicles() if (selectedVehicle.value) selectedVehicle.value = visibleVehicles.find((vehicle) => vehicle.id === selectedVehicle.value.id) || null 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') const route = routeFor(vehicle.route_id) Loading @@ -99,9 +128,10 @@ function refreshVehicleMarkers() { 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', () => { selectedVehicle.value = vehicle }) element.addEventListener('click', (event) => { event.stopPropagation(); showVehiclePopup(vehicle) }) return new maplibregl.Marker({ element, anchor: 'center' }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map) }) if (selected) showVehiclePopup(selected) } function refresh() { Loading @@ -114,6 +144,7 @@ function refresh() { function showStopPopup(event) { const feature = event.features?.[0] if (!feature) return closeVehiclePopup() activePopup?.remove() const content = document.createElement('strong') content.textContent = feature.properties.name || (props.language === 'zh' ? '站点' : 'Stop') Loading Loading @@ -203,7 +234,7 @@ async function initialise() { 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.5, minZoom: 13, attributionControl: true }) 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') map.addControl(new maplibregl.FullscreenControl(), 'top-left') map.addControl(createInteractionLockControl(), 'top-left') Loading @@ -212,7 +243,7 @@ async function initialise() { mediaQuery.addEventListener('change', themeChangeHandler) themeObserver = new MutationObserver(themeChangeHandler) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) map.on('load', () => { loaded = true; addLayers(); refresh(); loading.value = false }) map.on('load', () => { loaded = true; addLayers(); fitToStops(); refresh(); loading.value = false }) map.on('error', (event) => { if (!loaded && event.error) { mapError.value = `${props.language === 'zh' ? '地图不可用:' : 'Map unavailable: '}${event.error.message}` Loading @@ -232,6 +263,7 @@ watch(() => props.styleUrl, reloadStyle) onMounted(initialise) onBeforeUnmount(() => { clearVehicleMarkers() closeVehiclePopup() activePopup?.remove() mediaQuery?.removeEventListener('change', themeChangeHandler) themeObserver?.disconnect() Loading @@ -252,7 +284,7 @@ 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__detail { position: absolute; z-index: 2; right: .75rem; bottom: 1.75rem; max-width: calc(100% - 1.5rem); } .bus-map__legend { position: absolute; z-index: 1; bottom: 1.75rem; left: .75rem; display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .55rem; border-radius: .35rem; background: color-mix(in srgb, var(--bus-v2-bg, #fff) 90%, transparent); color: var(--bus-v2-text, #243043); font-size: .82rem; }.bus-map__legend span { display: inline-flex; align-items: center; gap: .25rem; white-space: nowrap; }.bus-map__legend i { width: 1rem; height: 1rem; border: 1px solid var(--bus-v2-link, #2878c8); border-radius: 50%; background: var(--bus-v2-link, #2878c8) url('/bus.png') center / contain no-repeat; }.bus-map__legend i.shuttle { background-image: url('/sev.png'); } .bus-map :deep(.bus-map__vehicle) { width: 2rem; height: 2rem; 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; } Loading docs/.vuepress/components/bus-v2/BusVehiclesV2.vue +2 −3 Changes for docs/.vuepress/components/bus-v2/BusVehiclesV2.vue: 2 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -5,7 +5,7 @@ <section v-else-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section> <template v-else> <section class="notices"><h3>{{ text('运营公告', 'Service notices') }}</h3><p v-if="!notices.length" class="muted">{{ busText('empty') }}</p><details v-for="notice in notices" :key="notice.id" :open="!notice.route_id"><summary><span>{{ noticeTitle(notice) }}</span><small>{{ notice.route_id ? routeName(notice.route_id) : text('全局', 'Global') }}<template v-if="noticeTime(notice)"> · <time :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></template></small></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details></section> <div class="map-head"><div class="legend"><span><i class="normal" />{{ text('正常(60 秒内)', 'Normal (within 60 sec)') }}</span><span><i class="delayed" />{{ text('延迟(60–120 秒)', 'Delayed (60–120 sec)') }}</span><span><i class="offline" />{{ text('离线(超过 120 秒)', 'Offline (over 120 sec)') }}</span></div><time v-if="lastUpdated">{{ text('更新于', 'Updated') }} {{ lastUpdated }}</time></div> <div class="map-head"><time v-if="lastUpdated">{{ text('更新于', 'Updated') }} {{ lastUpdated }}</time></div> <BusMapV2 :routes="routes" :vehicles="vehicles" :stops="stops" :language="language" /> <p v-if="!vehicles.length" class="state muted">{{ text('当前没有运营中的车辆。', 'No vehicles are currently in service.') }}</p> </template> Loading Loading @@ -45,11 +45,10 @@ onMounted(() => { load(); refreshTimer = setInterval(() => { if (--refreshRemain .notices summary::-webkit-details-marker { display: none; } .notices summary > span::after { content: ' ▸'; color: #667085; } .notices details[open] > summary > span::after { content: ' ▾'; } .bus-vehicles { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.bus-vehicles header, .map-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }.bus-vehicles header h1, .notices h3 { margin: 0; padding-top: 0; }.bus-vehicles header h1 { font-size: 1.4rem; line-height: 1.25; }.notices h3 { font-size: 1rem; line-height: 1.3; }.bus-vehicles p { margin-top: 0; }.bus-vehicles header p, .muted { color: #667085; }.bus-vehicles button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; font: inherit; cursor: pointer; }.state, .notices { margin: 1rem 0; padding: .85rem 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; background: var(--c-bg-soft, #fff); }.state { display: flex; gap: .5rem; align-items: center; }.error { color: #b42318; }.notices details + details { border-top: 1px solid #e6ebf0; }.notices summary { padding: .6rem 0; cursor: pointer; font-weight: 600; }.notices small { color: #667085; font-weight: 400; }.notices .markdown { margin: 1rem 0 0; padding-bottom: .6rem; }.notices .markdown :deep(p) { margin: 1rem 0 0; }.notices .markdown :deep(:first-child) { margin-top: 0; }.notices .markdown :deep(:last-child) { margin-bottom: 0; }.map-head { margin: 1rem 0 .5rem; }.map-head time { color: #667085; font-size: .85rem; }.legend { display: flex; flex-wrap: wrap; gap: .75rem; font-size: .85rem; }.legend span { display: inline-flex; align-items: center; gap: .3rem; }.legend i { width: .65rem; height: .65rem; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px #9ba7b5; }.legend .normal { background: #2878c8; }.legend .delayed { background: #f7a600; }.legend .offline { background: #9aa4b2; } @media (max-width: 600px) { .bus-vehicles header, .map-head { align-items: flex-start; flex-direction: column; } } .bus-vehicles { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.bus-vehicles header, .map-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }.bus-vehicles header h1, .notices h3 { margin: 0; padding-top: 0; }.bus-vehicles header h1 { font-size: 1.4rem; line-height: 1.25; }.notices h3 { font-size: 1rem; line-height: 1.3; }.bus-vehicles p { margin-top: 0; }.bus-vehicles header p, .muted { color: #667085; }.bus-vehicles button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; font: inherit; cursor: pointer; }.state, .notices { margin: 1rem 0; padding: .85rem 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; background: var(--c-bg-soft, #fff); }.state { display: flex; gap: .5rem; align-items: center; }.error { color: #b42318; }.notices details + details { border-top: 1px solid #e6ebf0; }.notices summary { padding: .6rem 0; cursor: pointer; font-weight: 600; }.notices small { color: #667085; font-weight: 400; }.notices .markdown { margin: 1rem 0 0; padding-bottom: .6rem; }.notices .markdown :deep(p) { margin: 1rem 0 0; }.notices .markdown :deep(:first-child) { margin-top: 0; }.notices .markdown :deep(:last-child) { margin-bottom: 0; }.map-head { margin: 1rem 0 .5rem; }.map-head time { color: #667085; font-size: .85rem; } @media (max-width: 600px) { .bus-vehicles header, .map-head { align-items: flex-start; flex-direction: column; } } .bus-vehicles { color: var(--bus-v2-text); } .bus-vehicles header p, .bus-vehicles .muted, .bus-vehicles .notices small, .bus-vehicles .map-head time, .bus-vehicles .notices summary > span::after { color: var(--bus-v2-muted); } .bus-vehicles button, .bus-vehicles .state, .bus-vehicles .notices { border-color: var(--bus-v2-border); } .bus-vehicles .state, .bus-vehicles .notices { background: var(--bus-v2-bg); } .bus-vehicles .notices details + details { border-color: var(--bus-v2-border); } .bus-vehicles .legend i { border-color: var(--bus-v2-bg); box-shadow: 0 0 0 1px var(--bus-v2-border); } </style> Loading
docs/.vuepress/components/bus-v2/BusHomeV2.vue +20 −10 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 20 added lines, 10 removed lines. Original line number Diff line number Diff line Loading @@ -3,7 +3,7 @@ <header class="bus-home__header"> <div> <h1>{{ busLanguage === 'zh' ? '校园巴士' : 'Campus bus' }}</h1> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息' : 'Live arrivals and travel information' }}</p> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息。所有到站时间均为预测,仅供参考。' : 'Live arrivals and travel information. All arrival times are estimates and for reference only.' }}</p> </div> <div><button class="text-button" type="button" :aria-label="busLanguage === 'zh' ? '立即刷新' : 'Refresh now'" @click="refresh">🔄{{ refreshRemaining }}s</button><button class="text-button" type="button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button></div> </header> Loading Loading @@ -40,11 +40,16 @@ <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.length" class="quick-links"> <div v-if="favoriteRoutes.length" class="quick-links"> <button v-for="route in favoriteRoutes" :key="route.id" type="button" @click="openRoute(route.id)">🚌 {{ displayName(route, busLanguage) }}</button> <button v-for="stop in favoriteStops" :key="stop.id" type="button" @click="openStop(stop.id)">⌖ {{ displayStopName(stop, busLanguage) }}</button> </div> <p v-else class="muted">{{ busText('noFavorites') }}</p> <article v-for="stop in favoriteStopCards" :key="stop.id" class="nearby-stop"> <div class="nearby-stop__head"><button type="button" class="link-title" @click="openStop(stop.id)">{{ displayStopName(stop, busLanguage) }}</button><span v-if="stop.distance != null">{{ formatDistance(stop.distance) }}</span></div> <p v-if="favoriteArrivals[stop.id]?.loading" class="muted">{{ busText('loading') }}</p> <p v-else-if="favoriteArrivals[stop.id]?.error" class="muted">{{ busText('unavailable') }}</p> <ul v-else class="arrival-list"><li v-for="arrival in favoriteArrivals[stop.id]?.items" :key="`${arrival.route_direction_id}-${arrival.source}-${arrival.trip_id || arrival.planned_arrival_at || ''}`"><i :style="{ background: arrival.route_color || '#2878c8' }" /><a class="arrival-link" :href="routeDirectionHref(arrival)">{{ arrivalName(arrival) }}</a><strong>{{ arrivalText(arrival) }}</strong></li><li v-if="!favoriteArrivals[stop.id]?.items?.length" class="muted">{{ busText('empty') }}</li></ul> </article> <p v-if="!favoriteRoutes.length && !favoriteStops.length" class="muted">{{ busText('noFavorites') }}</p> </section> <section class="panel nearby"> Loading Loading @@ -106,9 +111,11 @@ const stops = ref([]) const notices = ref([]) const query = ref('') const arrivals = ref({}) const favoriteArrivals = ref({}) const locationState = ref('idle') const locationErrorKey = ref('') const nearbyStops = ref([]) const location = ref(null) const allNearbyStops = ref(false) const refreshRemaining = ref(30) const locationUpdatedAt = ref(0) Loading @@ -123,6 +130,7 @@ const searchResults = computed(() => [ ].slice(0, 8)) const favoriteRoutes = computed(() => routes.value.filter((route) => favoriteRouteIds.value.includes(route.id))) const favoriteStops = computed(() => stops.value.filter((stop) => favoriteStopIds.value.includes(stop.id))) const favoriteStopCards = computed(() => favoriteStops.value.map((stop) => location.value ? { ...stop, distance: haversineMeters(location.value.latitude, location.value.longitude, stop.latitude, stop.longitude) } : stop)) const visibleNearbyStops = computed(() => nearbyStops.value.slice(0, allNearbyStops.value ? 5 : 2)) const locationUpdatedText = computed(() => { const updated = new Date(locationUpdatedAt.value) Loading Loading @@ -159,21 +167,21 @@ function arrivalText(arrival) { return busText(unavailableReasonTextKey(arrival.unavailable_reason)) } async function loadArrivals(stopList) { async function loadArrivals(stopList, target = arrivals) { const results = await Promise.all(stopList.map(async (stop) => { arrivals.value = { ...arrivals.value, [stop.id]: { loading: true, items: [] } } target.value = { ...target.value, [stop.id]: { loading: true, items: [] } } try { const result = await busApi.arrivals(stop.id) const rawItems = result.arrivals || [] const items = sortArrivalsByEstimatedTime(rawItems.filter((arrival) => !isTerminalArrival(arrival, stop.id, routes.value))) arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, items } } target.value = { ...target.value, [stop.id]: { loading: false, items } } return { stop, hidden: rawItems.length > 0 && !items.length } } catch { arrivals.value = { ...arrivals.value, [stop.id]: { loading: false, error: true, items: [] } } target.value = { ...target.value, [stop.id]: { loading: false, error: true, items: [] } } return { stop, hidden: false } } })) nearbyStops.value = results.filter(({ hidden }) => !hidden).map(({ stop }) => stop) if (target === arrivals) nearbyStops.value = results.filter(({ hidden }) => !hidden).map(({ stop }) => stop) return results } Loading @@ -189,7 +197,8 @@ function saveLocation(coords, updatedAt) { } async function useLocation(coords, updatedAt) { nearbyCandidates = stops.value.filter((stop) => Number.isFinite(stop.latitude) && Number.isFinite(stop.longitude)).map((stop) => ({ location.value = { latitude: +coords.latitude, longitude: +coords.longitude } nearbyCandidates = stops.value.filter((stop) => !favoriteStopIds.value.includes(stop.id) && Number.isFinite(stop.latitude) && Number.isFinite(stop.longitude)).map((stop) => ({ ...stop, distance: haversineMeters(coords.latitude, coords.longitude, stop.latitude, stop.longitude), })).sort((left, right) => left.distance - right.distance).slice(0, 6) nearbyStops.value = nearbyCandidates Loading Loading @@ -243,6 +252,7 @@ async function load() { routes.value = Array.isArray(routeData) ? routeData : [] stops.value = Array.isArray(stopData) ? stopData : [] notices.value = (Array.isArray(noticeData) ? noticeData : []).sort((left, right) => right.priority - left.priority) await loadArrivals(favoriteStops.value, favoriteArrivals) } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } Loading
docs/.vuepress/components/bus-v2/BusMapV2.vue +39 −7 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 39 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -3,12 +3,12 @@ <div ref="mapElement" class="bus-map__canvas" :aria-label="language === 'zh' ? '车辆地图' : 'Vehicle map'" /> <p v-if="mapError" class="bus-map__message" role="alert">{{ mapError }}</p> <p v-else-if="loading" class="bus-map__message">{{ language === 'zh' ? '正在加载地图…' : 'Loading map…' }}</p> <BusVehicleDetailV2 v-if="selectedVehicle" class="bus-map__detail" :vehicle="selectedVehicle" :routes="routes" :stops="stops" :language="language" closable @close="selectedVehicle = null" /> <div class="bus-map__legend"><span><i class="bus" />{{ language === 'zh' ? '巴士' : 'Bus' }}</span><span><i class="shuttle" />{{ language === 'zh' ? '电瓶车' : 'EV Shuttle' }}</span></div> </section> </template> <script setup> import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { createApp, onBeforeUnmount, onMounted, ref, watch } from 'vue' import BusVehicleDetailV2 from './BusVehicleDetailV2.vue' import { parseGeometry } from './bus-v2-helpers.mjs' import { displayName, displayStopName } from './core.mjs' Loading Loading @@ -39,6 +39,8 @@ let mediaQuery let themeChangeHandler let themeObserver let activePopup let vehicleDetailMarker let vehicleDetailApp let loaded = false let protocolInUse = false let vehicleMarkers = [] Loading Loading @@ -81,15 +83,42 @@ function stopFeatures() { return [...stops.values()].map((feature) => ({ ...feature, properties: { ...feature.properties, name: feature.properties.names.join(' / ') } })) } function fitToStops() { const coordinates = stopFeatures().map((feature) => feature.geometry.coordinates) if (!coordinates.length) return const bounds = coordinates.reduce((current, coordinate) => current.extend(coordinate), new maplibregl.LngLatBounds(coordinates[0], coordinates[0])) map.fitBounds(bounds, { padding: 48, maxZoom: 14.5, duration: 0 }) } function clearVehicleMarkers() { vehicleMarkers.forEach((marker) => marker.remove()) vehicleMarkers = [] } function closeVehiclePopup() { vehicleDetailApp?.unmount() vehicleDetailApp = null vehicleDetailMarker?.remove() vehicleDetailMarker = null selectedVehicle.value = null } function showVehiclePopup(vehicle) { activePopup?.remove() closeVehiclePopup() selectedVehicle.value = vehicle const content = document.createElement('div') content.addEventListener('click', (event) => event.stopPropagation()) vehicleDetailApp = createApp(BusVehicleDetailV2, { vehicle, routes: props.routes, stops: props.stops, language: props.language, closable: true, onClose: closeVehiclePopup }) vehicleDetailApp.mount(content) vehicleDetailMarker = new maplibregl.Marker({ element: content, anchor: 'bottom', offset: [0, -18] }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map) } function refreshVehicleMarkers() { clearVehicleMarkers() const visibleVehicles = activeVehicles() if (selectedVehicle.value) selectedVehicle.value = visibleVehicles.find((vehicle) => vehicle.id === selectedVehicle.value.id) || null 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') const route = routeFor(vehicle.route_id) Loading @@ -99,9 +128,10 @@ function refreshVehicleMarkers() { 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', () => { selectedVehicle.value = vehicle }) element.addEventListener('click', (event) => { event.stopPropagation(); showVehiclePopup(vehicle) }) return new maplibregl.Marker({ element, anchor: 'center' }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map) }) if (selected) showVehiclePopup(selected) } function refresh() { Loading @@ -114,6 +144,7 @@ function refresh() { function showStopPopup(event) { const feature = event.features?.[0] if (!feature) return closeVehiclePopup() activePopup?.remove() const content = document.createElement('strong') content.textContent = feature.properties.name || (props.language === 'zh' ? '站点' : 'Stop') Loading Loading @@ -203,7 +234,7 @@ async function initialise() { 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.5, minZoom: 13, attributionControl: true }) 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') map.addControl(new maplibregl.FullscreenControl(), 'top-left') map.addControl(createInteractionLockControl(), 'top-left') Loading @@ -212,7 +243,7 @@ async function initialise() { mediaQuery.addEventListener('change', themeChangeHandler) themeObserver = new MutationObserver(themeChangeHandler) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) map.on('load', () => { loaded = true; addLayers(); refresh(); loading.value = false }) map.on('load', () => { loaded = true; addLayers(); fitToStops(); refresh(); loading.value = false }) map.on('error', (event) => { if (!loaded && event.error) { mapError.value = `${props.language === 'zh' ? '地图不可用:' : 'Map unavailable: '}${event.error.message}` Loading @@ -232,6 +263,7 @@ watch(() => props.styleUrl, reloadStyle) onMounted(initialise) onBeforeUnmount(() => { clearVehicleMarkers() closeVehiclePopup() activePopup?.remove() mediaQuery?.removeEventListener('change', themeChangeHandler) themeObserver?.disconnect() Loading @@ -252,7 +284,7 @@ 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__detail { position: absolute; z-index: 2; right: .75rem; bottom: 1.75rem; max-width: calc(100% - 1.5rem); } .bus-map__legend { position: absolute; z-index: 1; bottom: 1.75rem; left: .75rem; display: flex; flex-wrap: wrap; gap: .6rem; padding: .4rem .55rem; border-radius: .35rem; background: color-mix(in srgb, var(--bus-v2-bg, #fff) 90%, transparent); color: var(--bus-v2-text, #243043); font-size: .82rem; }.bus-map__legend span { display: inline-flex; align-items: center; gap: .25rem; white-space: nowrap; }.bus-map__legend i { width: 1rem; height: 1rem; border: 1px solid var(--bus-v2-link, #2878c8); border-radius: 50%; background: var(--bus-v2-link, #2878c8) url('/bus.png') center / contain no-repeat; }.bus-map__legend i.shuttle { background-image: url('/sev.png'); } .bus-map :deep(.bus-map__vehicle) { width: 2rem; height: 2rem; 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; } Loading
docs/.vuepress/components/bus-v2/BusVehiclesV2.vue +2 −3 Changes for docs/.vuepress/components/bus-v2/BusVehiclesV2.vue: 2 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -5,7 +5,7 @@ <section v-else-if="error" class="state error" role="alert"><strong>{{ busText('loadFailed') }}</strong><span>{{ error }}</span><button type="button" @click="load">{{ busText('retry') }}</button></section> <template v-else> <section class="notices"><h3>{{ text('运营公告', 'Service notices') }}</h3><p v-if="!notices.length" class="muted">{{ busText('empty') }}</p><details v-for="notice in notices" :key="notice.id" :open="!notice.route_id"><summary><span>{{ noticeTitle(notice) }}</span><small>{{ notice.route_id ? routeName(notice.route_id) : text('全局', 'Global') }}<template v-if="noticeTime(notice)"> · <time :datetime="notice.starts_at">{{ noticeTime(notice) }}</time></template></small></summary><div class="markdown" v-html="renderNoticeMarkdown(notice.body_markdown)" /></details></section> <div class="map-head"><div class="legend"><span><i class="normal" />{{ text('正常(60 秒内)', 'Normal (within 60 sec)') }}</span><span><i class="delayed" />{{ text('延迟(60–120 秒)', 'Delayed (60–120 sec)') }}</span><span><i class="offline" />{{ text('离线(超过 120 秒)', 'Offline (over 120 sec)') }}</span></div><time v-if="lastUpdated">{{ text('更新于', 'Updated') }} {{ lastUpdated }}</time></div> <div class="map-head"><time v-if="lastUpdated">{{ text('更新于', 'Updated') }} {{ lastUpdated }}</time></div> <BusMapV2 :routes="routes" :vehicles="vehicles" :stops="stops" :language="language" /> <p v-if="!vehicles.length" class="state muted">{{ text('当前没有运营中的车辆。', 'No vehicles are currently in service.') }}</p> </template> Loading Loading @@ -45,11 +45,10 @@ onMounted(() => { load(); refreshTimer = setInterval(() => { if (--refreshRemain .notices summary::-webkit-details-marker { display: none; } .notices summary > span::after { content: ' ▸'; color: #667085; } .notices details[open] > summary > span::after { content: ' ▾'; } .bus-vehicles { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.bus-vehicles header, .map-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }.bus-vehicles header h1, .notices h3 { margin: 0; padding-top: 0; }.bus-vehicles header h1 { font-size: 1.4rem; line-height: 1.25; }.notices h3 { font-size: 1rem; line-height: 1.3; }.bus-vehicles p { margin-top: 0; }.bus-vehicles header p, .muted { color: #667085; }.bus-vehicles button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; font: inherit; cursor: pointer; }.state, .notices { margin: 1rem 0; padding: .85rem 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; background: var(--c-bg-soft, #fff); }.state { display: flex; gap: .5rem; align-items: center; }.error { color: #b42318; }.notices details + details { border-top: 1px solid #e6ebf0; }.notices summary { padding: .6rem 0; cursor: pointer; font-weight: 600; }.notices small { color: #667085; font-weight: 400; }.notices .markdown { margin: 1rem 0 0; padding-bottom: .6rem; }.notices .markdown :deep(p) { margin: 1rem 0 0; }.notices .markdown :deep(:first-child) { margin-top: 0; }.notices .markdown :deep(:last-child) { margin-bottom: 0; }.map-head { margin: 1rem 0 .5rem; }.map-head time { color: #667085; font-size: .85rem; }.legend { display: flex; flex-wrap: wrap; gap: .75rem; font-size: .85rem; }.legend span { display: inline-flex; align-items: center; gap: .3rem; }.legend i { width: .65rem; height: .65rem; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px #9ba7b5; }.legend .normal { background: #2878c8; }.legend .delayed { background: #f7a600; }.legend .offline { background: #9aa4b2; } @media (max-width: 600px) { .bus-vehicles header, .map-head { align-items: flex-start; flex-direction: column; } } .bus-vehicles { max-width: 1100px; margin: 0 auto; color: var(--c-text, #243043); }.bus-vehicles header, .map-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }.bus-vehicles header h1, .notices h3 { margin: 0; padding-top: 0; }.bus-vehicles header h1 { font-size: 1.4rem; line-height: 1.25; }.notices h3 { font-size: 1rem; line-height: 1.3; }.bus-vehicles p { margin-top: 0; }.bus-vehicles header p, .muted { color: #667085; }.bus-vehicles button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; font: inherit; cursor: pointer; }.state, .notices { margin: 1rem 0; padding: .85rem 1rem; border: 1px solid #d9e2ec; border-radius: .55rem; background: var(--c-bg-soft, #fff); }.state { display: flex; gap: .5rem; align-items: center; }.error { color: #b42318; }.notices details + details { border-top: 1px solid #e6ebf0; }.notices summary { padding: .6rem 0; cursor: pointer; font-weight: 600; }.notices small { color: #667085; font-weight: 400; }.notices .markdown { margin: 1rem 0 0; padding-bottom: .6rem; }.notices .markdown :deep(p) { margin: 1rem 0 0; }.notices .markdown :deep(:first-child) { margin-top: 0; }.notices .markdown :deep(:last-child) { margin-bottom: 0; }.map-head { margin: 1rem 0 .5rem; }.map-head time { color: #667085; font-size: .85rem; } @media (max-width: 600px) { .bus-vehicles header, .map-head { align-items: flex-start; flex-direction: column; } } .bus-vehicles { color: var(--bus-v2-text); } .bus-vehicles header p, .bus-vehicles .muted, .bus-vehicles .notices small, .bus-vehicles .map-head time, .bus-vehicles .notices summary > span::after { color: var(--bus-v2-muted); } .bus-vehicles button, .bus-vehicles .state, .bus-vehicles .notices { border-color: var(--bus-v2-border); } .bus-vehicles .state, .bus-vehicles .notices { background: var(--bus-v2-bg); } .bus-vehicles .notices details + details { border-color: var(--bus-v2-border); } .bus-vehicles .legend i { border-color: var(--bus-v2-bg); box-shadow: 0 0 0 1px var(--bus-v2-border); } </style>