Commit cda284f1 authored by prurite's avatar prurite
Browse files

BusTimerV2: Add refresh button and location memory; bug fixes

parent 172eadc2
Loading
Loading
Loading
Loading
+59 −20
Original line number Diff line number Diff line
@@ -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')">
@@ -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>
@@ -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>
@@ -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 })),
@@ -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)}`)
@@ -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 }
@@ -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) => {
@@ -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">
@@ -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); }
@@ -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; }
+6 −5
Original line number Diff line number Diff line
@@ -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(() => {
@@ -240,7 +241,7 @@ onBeforeUnmount(() => {
  mapEventsBound = false
  releaseProtocol()
})
defineExpose({ refresh })
defineExpose({ refresh, refreshVehicleMarkers })
</script>

<style>
@@ -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: '🖐'; }
+15 −8

File changed.

Preview size limit exceeded, changes collapsed.

+1 −1
Original line number Diff line number Diff line
@@ -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
+3 −3
Original line number Diff line number Diff line
@@ -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'
@@ -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