Commit c806a0a1 authored by prurite's avatar prurite
Browse files

更新适配 11.09 新校巴时刻

parent cbb4a053
Loading
Loading
Loading
Loading
+38 −10
Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 38 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@
    <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>
    <BusVehicleLegendV2 class="bus-map__legend" :language="language" />
    <BusVehicleLegendV2 class="bus-map__legend" :language="language" text-markers />
    <label class="bus-map__overlap"><input v-model="avoidOverlap" type="checkbox" @change="refreshVehicleMarkers" /> {{ language === 'zh' ? '避免重叠' : 'Avoid overlap' }}</label>
  </section>
</template>
@@ -13,7 +13,7 @@ import { createApp, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BusVehicleDetailV2 from './BusVehicleDetailV2.vue'
import BusVehicleLegendV2 from './BusVehicleLegendV2.vue'
import { parseGeometry } from './bus-v2-helpers.mjs'
import { displayName, displayStopName, vehicleBearingAt } from './core.mjs'
import { displayName, displayStopName, isCampusRoute, vehicleBearingAt, vehicleRouteLabel, vehicleTextColor } from './core.mjs'

const LIGHT_STYLE = 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-light.json'
const DARK_STYLE = 'https://bus.sustcra.com/static/protomaps/pmtiles-style/pmtiles-dark.json'
@@ -49,9 +49,10 @@ let loaded = false
let protocolInUse = false
let vehicleMarkers = new Map()
let mapEventsBound = false
const landmarkIcons = new Map()

const activeRoutes = () => props.routes.filter((route) => !props.routeId || route.id === props.routeId)
const activeVehicles = () => props.vehicles.filter((vehicle) => (!props.routeId || vehicle.route_id === props.routeId) && Number.isFinite(+vehicle.longitude) && Number.isFinite(+vehicle.latitude))
const activeRoutes = () => props.routes.filter((route) => isCampusRoute(route.id) && (!props.routeId || route.id === props.routeId))
const activeVehicles = () => props.vehicles.filter((vehicle) => isCampusRoute(vehicle.route_id) && (!props.routeId || vehicle.route_id === props.routeId) && Number.isFinite(+vehicle.longitude) && Number.isFinite(+vehicle.latitude))
const routeFor = (id) => props.routes.find((route) => route.id === id)
const darkTheme = () => document.documentElement.getAttribute('data-theme') === 'dark'
const neutralRouteColor = () => darkTheme() ? '#aaa' : '#666'
@@ -134,7 +135,11 @@ function updateVehicleMarker(record, vehicle) {
  const route = routeFor(vehicle.route_id)
  record.element.className = `bus-map__vehicle status-${vehicle.data_status || 'offline'}`
  record.element.title = displayName(vehicle, props.language) || vehicle.id
  record.element.setAttribute('aria-label', record.element.title)
  const label = vehicleRouteLabel(vehicle.route_id)
  record.element.setAttribute('aria-label', `${label} · ${record.element.title}`)
  record.label.textContent = label
  record.label.classList.toggle('bus-map__vehicle-label--compact', label.length > 1)
  record.label.style.color = vehicleTextColor(vehicle.vehicle_type)
  record.element.style.setProperty('--route-color', route?.color || '#2878c8')
  const bearing = vehicleBearing(vehicle)
  record.element.style.setProperty('--bearing', `${bearing}deg`)
@@ -159,16 +164,17 @@ function createVehicleMarker(vehicle) {
  const markerElement = document.createElement('div')
  const element = document.createElement('button')
  const arrow = document.createElement('span')
  const image = document.createElement('img')
  const label = document.createElement('span')
  arrow.className = 'bus-map__vehicle-arrow'
  markerElement.className = 'bus-map__vehicle-marker'
  image.src = String(vehicle.vehicle_type).toUpperCase() === 'SHUTTLE' ? '/sev.png' : '/bus.png'
  image.alt = ''
  label.className = 'bus-map__vehicle-label'
  label.setAttribute('aria-hidden', 'true')
  element.type = 'button'
  element.append(arrow, image)
  element.append(arrow, label)
  markerElement.append(element)
  element.addEventListener('click', (event) => { event.stopPropagation(); showVehiclePopup(record.vehicle) })
  record.element = element
  record.label = label
  updateVehicleMarker(record, vehicle)
  record.marker = new maplibregl.Marker({ element: markerElement, anchor: 'center' }).setLngLat([+vehicle.longitude, +vehicle.latitude]).addTo(map)
  updateVehicleMarker(record, vehicle)
@@ -207,6 +213,20 @@ function showStopPopup(event) {

function addLayers() {
  if (!map || !map.isStyleLoaded()) return
  for (const [kind, iconSize, textOffset, textSize] of [['bldg', 0.02, 0.3, 11], ['gate', 0.05, 0.6, 10]]) {
    const id = `bus-v2-${kind}`
    if (!landmarkIcons.has(kind)) continue
    if (!map.hasImage(id)) map.addImage(id, landmarkIcons.get(kind))
    if (!map.getSource(id)) map.addSource(id, { type: 'geojson', data: `https://bus.sustcra.com/geojson/sustech_${kind}.json` })
    if (!map.getLayer(id)) map.addLayer({
      id, type: 'symbol', source: id,
      layout: {
        'icon-image': id, 'icon-size': iconSize, 'icon-allow-overlap': true,
        'text-field': ['get', 'name'], 'text-size': textSize, 'text-offset': [0, textOffset], 'text-anchor': 'top',
      },
      paint: { 'text-color': darkTheme() ? '#ebebf5' : '#333', 'text-halo-color': darkTheme() ? '#202127' : '#fff', 'text-halo-width': 1 },
    }, map.getLayer('bus-v2-routes') ? 'bus-v2-routes' : undefined)
  }
  if (!map.getSource('bus-v2-routes')) map.addSource('bus-v2-routes', { type: 'geojson', data: sourceData(routeFeatures()) })
  if (!map.getLayer('bus-v2-routes')) map.addLayer({
    id: 'bus-v2-routes', type: 'line', source: 'bus-v2-routes',
@@ -311,6 +331,13 @@ async function initialise() {
        loading.value = false
      }
    })
    for (const kind of ['bldg', 'gate']) {
      map.loadImage(`https://bus.sustcra.com/${kind}_icon.png`).then((image) => {
        landmarkIcons.set(kind, image.data)
        if (map?.isStyleLoaded()) restoreOverlays()
        else map?.once('idle', restoreOverlays)
      }).catch((error) => console.error(`Failed to load ${kind} map icon:`, error))
    }
  } catch (error) {
    mapError.value = `${props.language === 'zh' ? '地图不可用:' : 'Map unavailable: '}${error.message || error}`
    loading.value = false
@@ -348,7 +375,8 @@ defineExpose({ refresh, refreshVehicleMarkers })
.bus-map__overlap { position: absolute; z-index: 1; top: .75rem; left: 3rem; padding: .35rem .5rem; border-radius: .35rem; background: color-mix(in srgb, var(--bus-v2-bg, #fff) 90%, transparent); color: var(--bus-v2-text, #333); font-size: .8rem; }
.bus-map :deep(.bus-map__vehicle-marker) { display: block; width: 1.6rem; min-width: 1.6rem; max-width: 1.6rem; height: 1.6rem; min-height: 1.6rem; max-height: 1.6rem; line-height: 0; }
.bus-map :deep(.bus-map__vehicle) { box-sizing: border-box; display: block; position: relative; width: 1.6rem; min-width: 1.6rem; max-width: 1.6rem; height: 1.6rem; min-height: 1.6rem; max-height: 1.6rem; aspect-ratio: 1; border: 2px solid #fff; border-radius: 50%; padding: 1px; background: var(--route-color); cursor: pointer; }
.bus-map :deep(.bus-map__vehicle img) { position: absolute; inset: 0; width: 80%; height: 80%; margin: auto; object-fit: contain; }
.bus-map :deep(.bus-map__vehicle-label) { position: absolute; inset: 2px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: #fff; font: 700 .9rem/1 "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; }
.bus-map :deep(.bus-map__vehicle-label--compact) { font-size: .65rem; }
.bus-map :deep(.bus-map__vehicle-arrow) { position: absolute; z-index: 2; inset: 0; pointer-events: none; transform: rotate(var(--bearing)); }
.bus-map :deep(.bus-map__vehicle-arrow)::before { content: ''; position: absolute; top: -.57rem; left: 50%; transform: translateX(-50%); border-right: .44rem solid transparent; border-bottom: .62rem solid #fff; border-left: .44rem solid transparent; }
.bus-map :deep(.bus-map__vehicle-arrow)::after { content: ''; position: absolute; top: -.45rem; left: 50%; transform: translateX(-50%); border-right: .32rem solid transparent; border-bottom: .5rem solid var(--route-color); border-left: .32rem solid transparent; }
+2 −2
Changes for docs/.vuepress/components/bus-v2/BusRouteV2.vue: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -50,7 +50,7 @@
        <p v-if="arrivalState.loading && !arrivalState.items.length" class="muted">{{ busText('loading') }}</p>
        <p v-else-if="arrivalState.error && !arrivalState.items.length" class="muted">{{ busText('unavailable') }}</p>
        <p v-else-if="!selectedArrivals.length" class="muted">{{ busText('empty') }}</p>
        <ul v-else class="arrival-list"><li v-for="(arrival, index) in visibleArrivals" :key="index"><span>{{ arrivalText(arrival) }}</span><small v-if="arrivalMeta(arrival) || (arrival.service_type && arrivalSource(arrival) !== 'unavailable')">{{ arrivalMeta(arrival) }}<template v-if="arrival.service_type && arrivalSource(arrival) !== 'unavailable'">{{ arrivalMeta(arrival) ? ' · ' : '' }}{{ arrival.service_type }}</template></small></li></ul>
        <ul v-else class="arrival-list"><li v-for="(arrival, index) in visibleArrivals" :key="index"><span>{{ arrivalText(arrival) }}</span><small v-if="arrivalMeta(arrival) || (arrival.service_type && arrivalSource(arrival) !== 'unavailable')">{{ arrivalMeta(arrival) }}<template v-if="arrival.service_type && arrivalSource(arrival) !== 'unavailable'">{{ arrivalMeta(arrival) ? ' · ' : '' }}{{ serviceTypeLabel(arrival.service_type, busLanguage) }}</template></small></li></ul>
      </section>

      <section class="panel route-stops" :style="{ '--route-color': route.color || '#2878c8' }">
@@ -80,7 +80,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { busApi, publicApi } from './api.mjs'
import { displayName, displayStopName, formatLocalDateTime, realtimeArrivalText, unavailableReasonTextKey } from './core.mjs'
import { displayName, displayStopName, formatLocalDateTime, realtimeArrivalText, serviceTypeLabel, unavailableReasonTextKey } from './core.mjs'
import { groupSchedules } from './bus-v2-helpers.mjs'
import { isFavorite, loadFavorites, toggleFavorite } from './favorites.mjs'
import { busLanguage, busText, setBusLanguage } from './i18n.mjs'
+5 −3
Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 5 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,15 +21,17 @@

<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { serviceTypeLabel, vehicleTextColor } from './core.mjs'

const props = defineProps({ groups: { type: Array, default: () => [] }, language: { type: String, default: 'zh' } })
const activeOnly = ref(true)
const collapsedRoutes = ref(new Set())
const portrait = ref(false)
const busColor = vehicleTextColor('BUS'), shuttleColor = vehicleTextColor('SHUTTLE')
let orientationQuery
const text = (zh, en) => props.language === 'zh' ? zh : en
const changeLabel = (item) => ({ EXTRA: text('加班', 'Extra'), CANCELLED: text('停运', 'Cancelled') })[item.change] || ''
const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? group.serviceType : '', group.vehicleTypes?.join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service')
const serviceLabel = (group) => [group.serviceType && group.serviceType !== 'NORMAL' ? serviceTypeLabel(group.serviceType, props.language) : '', group.vehicleTypes?.map((type) => type === 'SHUTTLE' ? text('电瓶车', 'EV Shuttle') : type === 'BUS' ? text('巴士', 'Bus') : type).join(' / ')].filter(Boolean).join(' · ') || text('常规服务', 'Normal service')
const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.status !== 'past') : times
const isCollapsed = (groupKey) => portrait.value && collapsedRoutes.value.has(groupKey)
const displayedTimes = (group) => isCollapsed(group.key) ? visibleTimes(group.times).slice(0, 1) : visibleTimes(group.times)
@@ -40,7 +42,7 @@ onBeforeUnmount(() => { orientationQuery?.removeEventListener?.('change', update
</script>

<style scoped lang="scss">
.schedule-toolbar { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; justify-content: space-between; margin-bottom: .7rem; font-size: .84rem; }.schedule-legend { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; }.schedule-legend > span { display: inline-flex; align-items: center; gap: .3rem; }.schedule-filter { cursor: pointer; white-space: nowrap; }.swatch { width: .72rem; height: .72rem; border-radius: 50%; }.swatch.bus { background: #ed6c00; }.swatch.shuttle { background: #00bcd4; }.schedule-list { overflow: hidden; border: 1px solid #d9e2ec; border-radius: .6rem; container-type: inline-size; }.schedule-row { display: flex; flex-wrap: wrap; border-bottom: 1px solid #e7edf4; }.schedule-row:last-child { border: 0; }.route-info { box-sizing: border-box; display: flex; min-width: 8rem; flex: 1 1 9rem; flex-direction: column; justify-content: center; gap: .2rem; padding: .75rem; border-left: .4rem solid; background: var(--c-bg-soft, #f8fafc); }.route-info small, .muted { color: #667085; }.times { box-sizing: border-box; display: flex; min-width: 8rem; flex: 999 1 8rem; flex-wrap: wrap; align-content: flex-start; gap: .45rem; padding: .75rem; }.time { --vehicle-color: #ed6c00; padding: .13rem .38rem; border-radius: .25rem; color: var(--vehicle-color); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }.time.shuttle { --vehicle-color: #00bcd4; }.time.running { border: 1px solid var(--vehicle-color); background: color-mix(in srgb, var(--vehicle-color) 10%, transparent); }.time.next { background: var(--vehicle-color); color: #fff; font-weight: 700; }.time.past { opacity: .35; }
.schedule-toolbar { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; justify-content: space-between; margin-bottom: .7rem; font-size: .84rem; }.schedule-legend { display: flex; flex-wrap: wrap; gap: .55rem 1rem; align-items: center; }.schedule-legend > span { display: inline-flex; align-items: center; gap: .3rem; }.schedule-filter { cursor: pointer; white-space: nowrap; }.swatch { width: .72rem; height: .72rem; border-radius: 50%; }.swatch.bus { background: v-bind(busColor); }.swatch.shuttle { background: v-bind(shuttleColor); }.schedule-list { overflow: hidden; border: 1px solid #d9e2ec; border-radius: .6rem; container-type: inline-size; }.schedule-row { display: flex; flex-wrap: wrap; border-bottom: 1px solid #e7edf4; }.schedule-row:last-child { border: 0; }.route-info { box-sizing: border-box; display: flex; min-width: 8rem; flex: 1 1 9rem; flex-direction: column; justify-content: center; gap: .2rem; padding: .75rem; border-left: .4rem solid; background: var(--c-bg-soft, #f8fafc); }.route-info small, .muted { color: #667085; }.times { box-sizing: border-box; display: flex; min-width: 8rem; flex: 999 1 8rem; flex-wrap: wrap; align-content: flex-start; gap: .45rem; padding: .75rem; }.time { --vehicle-color: v-bind(busColor); padding: .13rem .38rem; border-radius: .25rem; color: var(--vehicle-color); background: color-mix(in srgb, var(--vehicle-color) 6%, #fff); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }.time.shuttle { --vehicle-color: v-bind(shuttleColor); }.time.running { border: 1px solid var(--vehicle-color); background: color-mix(in srgb, var(--vehicle-color) 10%, #fff); }.time.next { background: var(--vehicle-color); color: #fff; font-weight: 700; }.time.past { opacity: .35; }
@container (max-width: 24rem) { .schedule-row { flex-direction: column; }.route-info { width: 100%; min-width: 0; flex: none; flex-direction: row; flex-wrap: wrap; align-items: baseline; justify-content: flex-start; border-left: 0; border-top: .35rem solid; padding: .5rem; }.times { width: 100%; min-width: 0; flex: none; } }
.schedule-list { border-color: var(--bus-v2-border); background: var(--bus-v2-bg); color: var(--bus-v2-text); }
.schedule-row { border-color: var(--bus-v2-border); }
@@ -49,7 +51,7 @@ onBeforeUnmount(() => { orientationQuery?.removeEventListener?.('change', update
.ellipsis { border: 0; padding: .13rem .38rem; background: transparent; color: var(--bus-v2-muted); font: inherit; letter-spacing: .15em; cursor: pointer; }
.route-info small, .muted { color: var(--bus-v2-muted); }
.time.changed { border: 1px dashed currentColor; }
.time.cancelled { color: var(--bus-v2-muted); }
.time.cancelled { background: transparent; color: var(--bus-v2-muted); }
.time small { font-family: inherit; font-size: .8em; }
.time del { opacity: .7; }
</style>
+2 −2
Changes for docs/.vuepress/components/bus-v2/BusVehicleDetailV2.vue: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@
    <div class="vehicle-detail__head"><strong>{{ name }}</strong><button v-if="closable" type="button" :aria-label="language === 'zh' ? '关闭' : 'Close'" @click="$emit('close')">×</button></div>
    <p><i />{{ routeName }} <span v-if="directionName">· {{ directionName }}</span></p>
    <dl>
      <template v-if="vehicle.service_type"><dt>{{ language === 'zh' ? '服务类型' : 'Service' }}</dt><dd>{{ vehicle.service_type }}</dd></template>
      <template v-if="vehicle.service_type"><dt>{{ language === 'zh' ? '服务类型' : 'Service' }}</dt><dd>{{ serviceTypeLabel(vehicle.service_type, language) }}</dd></template>
      <dt>{{ language === 'zh' ? '车型' : 'Vehicle' }}</dt><dd>{{ vehicle.vehicle_type || '—' }}</dd>
      <template v-if="locationText"><dt>{{ language === 'zh' ? '运行位置' : 'Position' }}</dt><dd>{{ locationText }}</dd></template>
      <dt>{{ language === 'zh' ? '更新时间' : 'Updated' }}</dt><dd>{{ updatedText }}</dd>
@@ -13,7 +13,7 @@

<script setup>
import { computed } from 'vue'
import { displayName, vehicleLocationText } from './core.mjs'
import { displayName, serviceTypeLabel, vehicleLocationText } from './core.mjs'

const props = defineProps({ vehicle: Object, routes: { type: Array, default: () => [] }, stops: { type: Array, default: () => [] }, language: { type: String, default: 'zh' }, closable: Boolean })
defineEmits(['close'])
+7 −4
Changes for docs/.vuepress/components/bus-v2/BusVehicleLegendV2.vue: 7 added lines, 4 removed lines.
Original line number Diff line number Diff line
<template>
  <div class="vehicle-legend" :aria-label="language === 'zh' ? '车型图例' : 'Vehicle legend'">
    <span><img src="/bus.png" alt="">{{ language === 'zh' ? '巴士' : 'Bus' }}</span>
    <span><img src="/sev.png" alt="">{{ language === 'zh' ? '电瓶车' : 'EV Shuttle' }}</span>
    <span><i v-if="textMarkers" class="vehicle-legend__marker" :style="{ color: vehicleTextColor('BUS') }" aria-hidden="true">1</i><img v-else src="/bus.png" alt="">{{ language === 'zh' ? '巴士' : 'Bus' }}</span>
    <span><i v-if="textMarkers" class="vehicle-legend__marker" :style="{ color: vehicleTextColor('SHUTTLE') }" aria-hidden="true">1</i><img v-else src="/sev.png" alt="">{{ language === 'zh' ? '电瓶车' : 'EV Shuttle' }}</span>
  </div>
</template>

<script setup>
defineProps({ language: { type: String, default: 'zh' } })
import { vehicleTextColor } from './core.mjs'
defineProps({ language: { type: String, default: 'zh' }, textMarkers: Boolean })
</script>

<style scoped>
.vehicle-legend { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .5rem; color: var(--bus-v2-muted); font-size: .82rem; }.vehicle-legend span { display: inline-flex; align-items: center; gap: .25rem; white-space: nowrap; }.vehicle-legend img { box-sizing: border-box; width: 1rem; height: 1rem; padding: 1px; border-radius: 50%; background: #fff; }
.vehicle-legend { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .5rem; color: var(--bus-v2-muted); font-size: .82rem; }.vehicle-legend span { display: inline-flex; align-items: center; gap: .25rem; white-space: nowrap; }
.vehicle-legend img { box-sizing: border-box; width: 1rem; height: 1rem; padding: 1px; border-radius: 50%; background: #fff; }
.vehicle-legend__marker { display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; border-radius: 50%; background: #fff; font: 700 .75rem/1 "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; }
</style>
Loading