Loading docs/.vuepress/components/bus-v2/BusHomeV2.vue +59 −20 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 59 added lines, 20 removed lines. Original line number Diff line number Diff line Loading @@ -5,7 +5,7 @@ <h1>{{ busLanguage === 'zh' ? '校园巴士' : 'Campus bus' }}</h1> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息' : 'Live arrivals and travel information' }}</p> </div> <button class="text-button" type="button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button> <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> <section class="panel search" :aria-label="busText('search')"> Loading Loading @@ -38,9 +38,18 @@ </details> </section> <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.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> </section> <section class="panel nearby"> <div class="section-title"> <h3>{{ busText('nearby') }}</h3> <div class="nearby-title"><h3>{{ busText('nearby') }}</h3><span v-if="locationUpdatedText" class="location-updated">{{ locationUpdatedText }}</span></div> <button type="button" :disabled="locationState === 'loading'" @click="locate">{{ busText(locationState === 'loading' ? 'locating' : 'locate') }}</button> </div> <p v-if="locationState === 'idle'" class="muted">{{ busText('noLocation') }}</p> Loading @@ -65,15 +74,6 @@ <button v-if="nearbyStops.length > 2" type="button" class="nearby-toggle" :aria-expanded="allNearbyStops" @click="allNearbyStops = !allNearbyStops">{{ allNearbyStops ? (busLanguage === 'zh' ? '收起' : 'Show less') : (busLanguage === 'zh' ? '展开' : 'Show all') }}</button> </section> <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.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> </section> <nav class="feature-links" :aria-label="busLanguage === 'zh' ? '巴士功能' : 'Bus features'"> <a :href="vehiclesHref"><span>{{ busText('vehicles') }}</span></a> <a :href="schedulesHref"><span>{{ busText('schedules') }}</span></a> Loading Loading @@ -110,9 +110,12 @@ const locationState = ref('idle') const locationErrorKey = ref('') const nearbyStops = ref([]) const allNearbyStops = ref(false) const refreshRemaining = ref(30) const locationUpdatedAt = ref(0) let nearbyCandidates = [] let refreshTimer let locationRequest = 0 const LOCATION_CACHE_KEY = 'sustech-bus-v2-location' const searchResults = computed(() => [ ...routes.value.filter((route) => matchesSearch(route, query.value, busLanguage.value)).map((item) => ({ kind: 'route', item })), Loading @@ -121,6 +124,15 @@ const searchResults = computed(() => [ 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 visibleNearbyStops = computed(() => nearbyStops.value.slice(0, allNearbyStops.value ? 5 : 2)) const locationUpdatedText = computed(() => { const updated = new Date(locationUpdatedAt.value) if (!Number.isFinite(updated.getTime())) return '' const now = new Date() const time = updated.toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false }) const date = `${String(updated.getMonth() + 1).padStart(2, '0')}-${String(updated.getDate()).padStart(2, '0')}` const value = updated.toDateString() === now.toDateString() ? time : date return busLanguage.value === 'zh' ? `位置更新于 ${value}` : `Location updated ${value}` }) function go(href, id) { if (typeof window !== 'undefined') window.location.assign(`${href}${encodeURIComponent(id)}`) Loading Loading @@ -165,6 +177,34 @@ async function loadArrivals(stopList) { return results } async function refresh() { refreshRemaining.value = 30 await load() if (nearbyCandidates.length) await loadArrivals(nearbyCandidates) } function saveLocation(coords, updatedAt) { const value = { latitude: +coords.latitude, longitude: +coords.longitude, updatedAt } try { localStorage.setItem(LOCATION_CACHE_KEY, JSON.stringify(value)) } catch { /* storage is unavailable */ } } async function useLocation(coords, updatedAt) { nearbyCandidates = stops.value.filter((stop) => 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 locationUpdatedAt.value = updatedAt locationState.value = 'ready' await loadArrivals(nearbyCandidates) } function useCachedLocation() { try { const cached = JSON.parse(localStorage.getItem(LOCATION_CACHE_KEY) || 'null') if (Number.isFinite(cached?.latitude) && Number.isFinite(cached?.longitude) && Number.isFinite(cached?.updatedAt)) useLocation(cached, cached.updatedAt) } catch { /* ignore an invalid cache */ } } function locate() { const geolocation = typeof window === 'undefined' ? null : window.navigator.geolocation if (!geolocation) { locationState.value = 'failed'; locationErrorKey.value = 'locationUnavailable'; return } Loading @@ -173,19 +213,16 @@ function locate() { locationErrorKey.value = '' const request = ++locationRequest let remaining = 2, hasLocation = false, highLocated = false, update = Promise.resolve() const located = ({ coords }, highAccuracy) => { const located = (position, highAccuracy) => { remaining-- if (request !== locationRequest || (!highAccuracy && highLocated)) return hasLocation = true if (highAccuracy) highLocated = true update = update.then(async () => { if (request !== locationRequest) return nearbyCandidates = stops.value.filter((stop) => 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 locationState.value = 'ready' await loadArrivals(nearbyCandidates) const updatedAt = Number(position.timestamp) || Date.now() saveLocation(position.coords, updatedAt) await useLocation(position.coords, updatedAt) }) } const failed = (reason) => { Loading @@ -209,9 +246,9 @@ async function load() { } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } onMounted(() => { loadFavorites(); load(); refreshTimer = setInterval(() => { if (nearbyCandidates.length) loadArrivals(nearbyCandidates) }, 30000) }) onMounted(async () => { loadFavorites(); await load(); useCachedLocation(); refreshTimer = setInterval(() => { if (--refreshRemaining.value < 1) refresh() }, 1000) }) onBeforeUnmount(() => clearInterval(refreshTimer)) defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStopIds }) defineExpose({ load, locate, refresh, openRoute, openStop, favoriteRouteIds, favoriteStopIds }) </script> <style scoped lang="scss"> Loading @@ -223,6 +260,7 @@ defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStop .notices details[open] > summary > span::after { content: ' ▾'; } .bus-home { max-width: 900px; margin: 0 auto; color: var(--bus-v2-text); } .bus-home__header, .section-title, .nearby-stop__head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; } .nearby-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; } .location-updated { color: var(--bus-v2-muted); font-size: .875rem; font-weight: 400; white-space: nowrap; } .bus-home__header { margin: .5rem 0 .75rem; } .bus-home__header h1, .panel h3 { margin: 0; padding-top: 0; } .bus-home__header h1 { font-size: 1.4rem; line-height: 1.25; } .bus-home__header p, .muted { color: var(--bus-v2-muted); } .panel h3 { font-size: 1rem; line-height: 1.3; } .panel { margin: 1rem 0; padding: 1rem; border: 1px solid var(--bus-v2-border); border-radius: .6rem; background: var(--bus-v2-bg); } Loading @@ -233,6 +271,7 @@ defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStop .spinner { width: 1em; height: 1em; border: 2px solid var(--bus-v2-border); border-top-color: var(--bus-v2-link); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } details + details { border-top: 1px solid var(--bus-v2-border); } summary { display: flex; justify-content: space-between; padding: .65rem 0; cursor: pointer; font-weight: 600; } .markdown { margin: 1rem 0 0; padding-bottom: .25rem; } .markdown :deep(p) { margin: 1rem 0 0; } .markdown :deep(:first-child) { margin-top: 0; } .markdown :deep(:last-child) { margin-bottom: 0; } .markdown :deep(code) { padding: .1em .3em; background: var(--bus-v2-bg-alt); border-radius: .2em; } .section-title button, .status button, .text-button { padding: .4rem .65rem; border: 1px solid var(--bus-v2-border); border-radius: .35rem; background: transparent; color: inherit; } .text-button { border: 0; color: var(--bus-v2-link); } .nearby .section-title button { border-color: var(--bus-v2-link-soft); background: var(--bus-v2-link-soft); color: var(--bus-v2-link); font-weight: 600; } .nearby-stop { padding: .75rem 0; border-top: 1px solid var(--bus-v2-border); } .nearby-stop__head { align-items: baseline; } .link-title { padding: 0; border: 0; background: transparent; color: var(--bus-v2-link); font-weight: 700; text-align: left; } .nearby-toggle { padding: .4rem .65rem; border: 1px solid var(--bus-v2-border); border-radius: .35rem; background: transparent; color: inherit; font: inherit; } .arrival-list { margin: .45rem 0 0; padding: 0; list-style: none; } .arrival-list li { display: grid; grid-template-columns: .35rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; padding: .3rem 0; } .arrival-list i { width: .3rem; height: 1.2rem; border-radius: 2px; } .arrival-link { color: var(--bus-v2-link); text-decoration: none; } .arrival-link:hover, .arrival-link:focus-visible { text-decoration: underline; } .arrival-list .muted { display: block; } Loading docs/.vuepress/components/bus-v2/BusMapV2.vue +6 −5 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 6 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -226,7 +226,8 @@ async function initialise() { } } watch(() => [props.routes, props.vehicles, props.routeId, props.language], refresh, { deep: true, flush: 'post' }) watch(() => [props.routes, props.stops, props.routeId, props.language], refresh, { deep: true, flush: 'post' }) watch(() => props.vehicles, refreshVehicleMarkers, { deep: true, flush: 'post' }) watch(() => props.styleUrl, reloadStyle) onMounted(initialise) onBeforeUnmount(() => { Loading @@ -240,7 +241,7 @@ onBeforeUnmount(() => { mapEventsBound = false releaseProtocol() }) defineExpose({ refresh }) defineExpose({ refresh, refreshVehicleMarkers }) </script> <style> Loading @@ -252,9 +253,9 @@ defineExpose({ refresh }) .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 :deep(.bus-map__vehicle) { width: 2rem; height: 2rem; border: 2px solid #fff; 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 1px 4px rgba(0, 0, 0, .35); } .bus-map :deep(.bus-map__vehicle.status-delayed) { background-color: #f7a600; box-shadow: 0 0 0 3px var(--route-color), 0 1px 4px rgba(0, 0, 0, .35); } .bus-map :deep(.bus-map__vehicle.status-offline) { background-color: #9aa4b2; box-shadow: 0 0 0 3px var(--route-color), 0 1px 4px rgba(0, 0, 0, .35); } .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; } .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 +15 −8 File changed.Preview size limit exceeded, changes collapsed. Show changes docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +1 −1 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ import { ref } from 'vue' const props = defineProps({ groups: { type: Array, default: () => [] }, language: { type: String, default: 'zh' } }) const activeOnly = ref(false) const activeOnly = ref(true) const text = (zh, en) => props.language === 'zh' ? zh : en const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? group.serviceType : '', group.vehicleTypes?.join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service') const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.status !== 'past') : times Loading docs/.vuepress/components/bus-v2/BusSchedulesV2.vue +3 −3 Changes for docs/.vuepress/components/bus-v2/BusSchedulesV2.vue: 3 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ </template> <script setup> import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue' import { busApi } from './api.mjs' import { groupSchedules } from './bus-v2-helpers.mjs' import { busLanguage, busText, setBusLanguage } from './i18n.mjs' Loading @@ -19,13 +19,13 @@ import BusScheduleRowsV2 from './BusScheduleRowsV2.vue' let routesCache = null, routesRequest const loadRoutes = () => routesCache ? Promise.resolve(routesCache) : (routesRequest ||= busApi.routes().then((data) => routesCache = Array.isArray(data) ? data : []).finally(() => { routesRequest = undefined })) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const now = ref(new Date()); let timer const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const now = ref(new Date()) const text = (zh, en) => language.value === 'zh' ? zh : en const nowMinutes = computed(() => now.value.getHours() * 60 + now.value.getMinutes()) const nowText = computed(() => now.value.toLocaleTimeString(language.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false })) const groups = computed(() => groupSchedules(schedules.value, nowMinutes.value, routes.value).map((item) => ({ ...item, color: item.route_color, routeName: item[language.value === 'zh' ? 'route_name_zh' : 'route_name_en'] || item.route_name_zh || item.route_name_en || item.route_id, directionName: item[language.value === 'zh' ? 'direction_name_zh' : 'direction_name_en'] || item.direction_name_zh || item.direction_name_en || item.route_direction_id }))) async function load(requestedDayType) { loading.value = true; error.value = ''; try { const [data, routeData] = await Promise.all([busApi.schedules(requestedDayType ? `day_type=${requestedDayType}` : ''), loadRoutes()]); schedules.value = Array.isArray(data) ? data : []; routes.value = routeData; dayType.value = schedules.value[0]?.day_type || requestedDayType || (new Date().getDay() % 6 ? 'WORKDAY' : 'HOLIDAY') } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } onMounted(() => { load(); timer = setInterval(() => { now.value = new Date() }, 30000) }); onBeforeUnmount(() => clearInterval(timer)); defineExpose({ load }) onMounted(load); defineExpose({ load }) </script> <style scoped lang="scss"> Loading Loading
docs/.vuepress/components/bus-v2/BusHomeV2.vue +59 −20 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 59 added lines, 20 removed lines. Original line number Diff line number Diff line Loading @@ -5,7 +5,7 @@ <h1>{{ busLanguage === 'zh' ? '校园巴士' : 'Campus bus' }}</h1> <p>{{ busLanguage === 'zh' ? '实时到站与出行信息' : 'Live arrivals and travel information' }}</p> </div> <button class="text-button" type="button" @click="setBusLanguage(busLanguage === 'zh' ? 'en' : 'zh')">{{ busText('language') }}</button> <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> <section class="panel search" :aria-label="busText('search')"> Loading Loading @@ -38,9 +38,18 @@ </details> </section> <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.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> </section> <section class="panel nearby"> <div class="section-title"> <h3>{{ busText('nearby') }}</h3> <div class="nearby-title"><h3>{{ busText('nearby') }}</h3><span v-if="locationUpdatedText" class="location-updated">{{ locationUpdatedText }}</span></div> <button type="button" :disabled="locationState === 'loading'" @click="locate">{{ busText(locationState === 'loading' ? 'locating' : 'locate') }}</button> </div> <p v-if="locationState === 'idle'" class="muted">{{ busText('noLocation') }}</p> Loading @@ -65,15 +74,6 @@ <button v-if="nearbyStops.length > 2" type="button" class="nearby-toggle" :aria-expanded="allNearbyStops" @click="allNearbyStops = !allNearbyStops">{{ allNearbyStops ? (busLanguage === 'zh' ? '收起' : 'Show less') : (busLanguage === 'zh' ? '展开' : 'Show all') }}</button> </section> <section class="panel favorites"> <h3>{{ busText('favorites') }}</h3> <div v-if="favoriteRoutes.length || favoriteStops.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> </section> <nav class="feature-links" :aria-label="busLanguage === 'zh' ? '巴士功能' : 'Bus features'"> <a :href="vehiclesHref"><span>{{ busText('vehicles') }}</span></a> <a :href="schedulesHref"><span>{{ busText('schedules') }}</span></a> Loading Loading @@ -110,9 +110,12 @@ const locationState = ref('idle') const locationErrorKey = ref('') const nearbyStops = ref([]) const allNearbyStops = ref(false) const refreshRemaining = ref(30) const locationUpdatedAt = ref(0) let nearbyCandidates = [] let refreshTimer let locationRequest = 0 const LOCATION_CACHE_KEY = 'sustech-bus-v2-location' const searchResults = computed(() => [ ...routes.value.filter((route) => matchesSearch(route, query.value, busLanguage.value)).map((item) => ({ kind: 'route', item })), Loading @@ -121,6 +124,15 @@ const searchResults = computed(() => [ 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 visibleNearbyStops = computed(() => nearbyStops.value.slice(0, allNearbyStops.value ? 5 : 2)) const locationUpdatedText = computed(() => { const updated = new Date(locationUpdatedAt.value) if (!Number.isFinite(updated.getTime())) return '' const now = new Date() const time = updated.toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false }) const date = `${String(updated.getMonth() + 1).padStart(2, '0')}-${String(updated.getDate()).padStart(2, '0')}` const value = updated.toDateString() === now.toDateString() ? time : date return busLanguage.value === 'zh' ? `位置更新于 ${value}` : `Location updated ${value}` }) function go(href, id) { if (typeof window !== 'undefined') window.location.assign(`${href}${encodeURIComponent(id)}`) Loading Loading @@ -165,6 +177,34 @@ async function loadArrivals(stopList) { return results } async function refresh() { refreshRemaining.value = 30 await load() if (nearbyCandidates.length) await loadArrivals(nearbyCandidates) } function saveLocation(coords, updatedAt) { const value = { latitude: +coords.latitude, longitude: +coords.longitude, updatedAt } try { localStorage.setItem(LOCATION_CACHE_KEY, JSON.stringify(value)) } catch { /* storage is unavailable */ } } async function useLocation(coords, updatedAt) { nearbyCandidates = stops.value.filter((stop) => 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 locationUpdatedAt.value = updatedAt locationState.value = 'ready' await loadArrivals(nearbyCandidates) } function useCachedLocation() { try { const cached = JSON.parse(localStorage.getItem(LOCATION_CACHE_KEY) || 'null') if (Number.isFinite(cached?.latitude) && Number.isFinite(cached?.longitude) && Number.isFinite(cached?.updatedAt)) useLocation(cached, cached.updatedAt) } catch { /* ignore an invalid cache */ } } function locate() { const geolocation = typeof window === 'undefined' ? null : window.navigator.geolocation if (!geolocation) { locationState.value = 'failed'; locationErrorKey.value = 'locationUnavailable'; return } Loading @@ -173,19 +213,16 @@ function locate() { locationErrorKey.value = '' const request = ++locationRequest let remaining = 2, hasLocation = false, highLocated = false, update = Promise.resolve() const located = ({ coords }, highAccuracy) => { const located = (position, highAccuracy) => { remaining-- if (request !== locationRequest || (!highAccuracy && highLocated)) return hasLocation = true if (highAccuracy) highLocated = true update = update.then(async () => { if (request !== locationRequest) return nearbyCandidates = stops.value.filter((stop) => 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 locationState.value = 'ready' await loadArrivals(nearbyCandidates) const updatedAt = Number(position.timestamp) || Date.now() saveLocation(position.coords, updatedAt) await useLocation(position.coords, updatedAt) }) } const failed = (reason) => { Loading @@ -209,9 +246,9 @@ async function load() { } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } onMounted(() => { loadFavorites(); load(); refreshTimer = setInterval(() => { if (nearbyCandidates.length) loadArrivals(nearbyCandidates) }, 30000) }) onMounted(async () => { loadFavorites(); await load(); useCachedLocation(); refreshTimer = setInterval(() => { if (--refreshRemaining.value < 1) refresh() }, 1000) }) onBeforeUnmount(() => clearInterval(refreshTimer)) defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStopIds }) defineExpose({ load, locate, refresh, openRoute, openStop, favoriteRouteIds, favoriteStopIds }) </script> <style scoped lang="scss"> Loading @@ -223,6 +260,7 @@ defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStop .notices details[open] > summary > span::after { content: ' ▾'; } .bus-home { max-width: 900px; margin: 0 auto; color: var(--bus-v2-text); } .bus-home__header, .section-title, .nearby-stop__head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; } .nearby-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem; } .location-updated { color: var(--bus-v2-muted); font-size: .875rem; font-weight: 400; white-space: nowrap; } .bus-home__header { margin: .5rem 0 .75rem; } .bus-home__header h1, .panel h3 { margin: 0; padding-top: 0; } .bus-home__header h1 { font-size: 1.4rem; line-height: 1.25; } .bus-home__header p, .muted { color: var(--bus-v2-muted); } .panel h3 { font-size: 1rem; line-height: 1.3; } .panel { margin: 1rem 0; padding: 1rem; border: 1px solid var(--bus-v2-border); border-radius: .6rem; background: var(--bus-v2-bg); } Loading @@ -233,6 +271,7 @@ defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStop .spinner { width: 1em; height: 1em; border: 2px solid var(--bus-v2-border); border-top-color: var(--bus-v2-link); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } details + details { border-top: 1px solid var(--bus-v2-border); } summary { display: flex; justify-content: space-between; padding: .65rem 0; cursor: pointer; font-weight: 600; } .markdown { margin: 1rem 0 0; padding-bottom: .25rem; } .markdown :deep(p) { margin: 1rem 0 0; } .markdown :deep(:first-child) { margin-top: 0; } .markdown :deep(:last-child) { margin-bottom: 0; } .markdown :deep(code) { padding: .1em .3em; background: var(--bus-v2-bg-alt); border-radius: .2em; } .section-title button, .status button, .text-button { padding: .4rem .65rem; border: 1px solid var(--bus-v2-border); border-radius: .35rem; background: transparent; color: inherit; } .text-button { border: 0; color: var(--bus-v2-link); } .nearby .section-title button { border-color: var(--bus-v2-link-soft); background: var(--bus-v2-link-soft); color: var(--bus-v2-link); font-weight: 600; } .nearby-stop { padding: .75rem 0; border-top: 1px solid var(--bus-v2-border); } .nearby-stop__head { align-items: baseline; } .link-title { padding: 0; border: 0; background: transparent; color: var(--bus-v2-link); font-weight: 700; text-align: left; } .nearby-toggle { padding: .4rem .65rem; border: 1px solid var(--bus-v2-border); border-radius: .35rem; background: transparent; color: inherit; font: inherit; } .arrival-list { margin: .45rem 0 0; padding: 0; list-style: none; } .arrival-list li { display: grid; grid-template-columns: .35rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; padding: .3rem 0; } .arrival-list i { width: .3rem; height: 1.2rem; border-radius: 2px; } .arrival-link { color: var(--bus-v2-link); text-decoration: none; } .arrival-link:hover, .arrival-link:focus-visible { text-decoration: underline; } .arrival-list .muted { display: block; } Loading
docs/.vuepress/components/bus-v2/BusMapV2.vue +6 −5 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 6 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -226,7 +226,8 @@ async function initialise() { } } watch(() => [props.routes, props.vehicles, props.routeId, props.language], refresh, { deep: true, flush: 'post' }) watch(() => [props.routes, props.stops, props.routeId, props.language], refresh, { deep: true, flush: 'post' }) watch(() => props.vehicles, refreshVehicleMarkers, { deep: true, flush: 'post' }) watch(() => props.styleUrl, reloadStyle) onMounted(initialise) onBeforeUnmount(() => { Loading @@ -240,7 +241,7 @@ onBeforeUnmount(() => { mapEventsBound = false releaseProtocol() }) defineExpose({ refresh }) defineExpose({ refresh, refreshVehicleMarkers }) </script> <style> Loading @@ -252,9 +253,9 @@ defineExpose({ refresh }) .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 :deep(.bus-map__vehicle) { width: 2rem; height: 2rem; border: 2px solid #fff; 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 1px 4px rgba(0, 0, 0, .35); } .bus-map :deep(.bus-map__vehicle.status-delayed) { background-color: #f7a600; box-shadow: 0 0 0 3px var(--route-color), 0 1px 4px rgba(0, 0, 0, .35); } .bus-map :deep(.bus-map__vehicle.status-offline) { background-color: #9aa4b2; box-shadow: 0 0 0 3px var(--route-color), 0 1px 4px rgba(0, 0, 0, .35); } .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; } .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 +15 −8 File changed.Preview size limit exceeded, changes collapsed. Show changes
docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +1 −1 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -22,7 +22,7 @@ import { ref } from 'vue' const props = defineProps({ groups: { type: Array, default: () => [] }, language: { type: String, default: 'zh' } }) const activeOnly = ref(false) const activeOnly = ref(true) const text = (zh, en) => props.language === 'zh' ? zh : en const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? group.serviceType : '', group.vehicleTypes?.join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service') const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.status !== 'past') : times Loading
docs/.vuepress/components/bus-v2/BusSchedulesV2.vue +3 −3 Changes for docs/.vuepress/components/bus-v2/BusSchedulesV2.vue: 3 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -11,7 +11,7 @@ </template> <script setup> import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue' import { busApi } from './api.mjs' import { groupSchedules } from './bus-v2-helpers.mjs' import { busLanguage, busText, setBusLanguage } from './i18n.mjs' Loading @@ -19,13 +19,13 @@ import BusScheduleRowsV2 from './BusScheduleRowsV2.vue' let routesCache = null, routesRequest const loadRoutes = () => routesCache ? Promise.resolve(routesCache) : (routesRequest ||= busApi.routes().then((data) => routesCache = Array.isArray(data) ? data : []).finally(() => { routesRequest = undefined })) const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const now = ref(new Date()); let timer const language = busLanguage; const schedules = ref([]); const routes = ref([]); const loading = ref(true); const error = ref(''); const dayType = ref('WORKDAY'); const now = ref(new Date()) const text = (zh, en) => language.value === 'zh' ? zh : en const nowMinutes = computed(() => now.value.getHours() * 60 + now.value.getMinutes()) const nowText = computed(() => now.value.toLocaleTimeString(language.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false })) const groups = computed(() => groupSchedules(schedules.value, nowMinutes.value, routes.value).map((item) => ({ ...item, color: item.route_color, routeName: item[language.value === 'zh' ? 'route_name_zh' : 'route_name_en'] || item.route_name_zh || item.route_name_en || item.route_id, directionName: item[language.value === 'zh' ? 'direction_name_zh' : 'direction_name_en'] || item.direction_name_zh || item.direction_name_en || item.route_direction_id }))) async function load(requestedDayType) { loading.value = true; error.value = ''; try { const [data, routeData] = await Promise.all([busApi.schedules(requestedDayType ? `day_type=${requestedDayType}` : ''), loadRoutes()]); schedules.value = Array.isArray(data) ? data : []; routes.value = routeData; dayType.value = schedules.value[0]?.day_type || requestedDayType || (new Date().getDay() % 6 ? 'WORKDAY' : 'HOLIDAY') } catch (reason) { error.value = reason.message || String(reason) } finally { loading.value = false } } onMounted(() => { load(); timer = setInterval(() => { now.value = new Date() }, 30000) }); onBeforeUnmount(() => clearInterval(timer)); defineExpose({ load }) onMounted(load); defineExpose({ load }) </script> <style scoped lang="scss"> Loading