Commit cdacf9ee authored by prurite's avatar prurite
Browse files

Add saved stop info display in home page; map vehicle info fix

parent cda284f1
Loading
Loading
Loading
Loading
+20 −10
Original line number Diff line number Diff line
@@ -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>
@@ -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">
@@ -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)
@@ -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)
@@ -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
}

@@ -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
@@ -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 }
}

+39 −7
Original line number Diff line number Diff line
@@ -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'
@@ -39,6 +39,8 @@ let mediaQuery
let themeChangeHandler
let themeObserver
let activePopup
let vehicleDetailMarker
let vehicleDetailApp
let loaded = false
let protocolInUse = false
let vehicleMarkers = []
@@ -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)
@@ -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() {
@@ -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')
@@ -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')
@@ -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}`
@@ -232,6 +263,7 @@ watch(() => props.styleUrl, reloadStyle)
onMounted(initialise)
onBeforeUnmount(() => {
  clearVehicleMarkers()
  closeVehiclePopup()
  activePopup?.remove()
  mediaQuery?.removeEventListener('change', themeChangeHandler)
  themeObserver?.disconnect()
@@ -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; }
+2 −3
Original line number Diff line number Diff line
@@ -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>
@@ -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>