Commit 172eadc2 authored by prurite's avatar prurite
Browse files

BustimerV2 bug fix

parent 894b2ccd
Loading
Loading
Loading
Loading
+6 −6
Original line number Diff line number Diff line
@@ -69,7 +69,7 @@ export default {
      },

      // --- Data Placeholders ---
      geojson_NKDH1: [],
      geojson_RC61: [],
      predictionRoutes: {},
      sevPredictionHistory: {},

@@ -215,11 +215,11 @@ export default {

    async loadGeoJSONLines() {
      try {
        const [NKDH1Res] = await Promise.all([
        const [RC61Res] = await Promise.all([
          axios.get('https://bus.sustcra.com/static/lines/NKDH1_clockwise.json')
        ]);
        // console.log('NKDH1:', NKDH1Res.data);
        this.geojson_NKDH1 = NKDH1Res.data;
        // console.log('RC61:', RC61Res.data);
        this.geojson_RC61 = RC61Res.data;
      } catch (error) {
        console.error("Failed to fetch GeoJSON lines:", error);
      }
@@ -357,8 +357,8 @@ export default {

    setupMapLayers() {
      // 添加线路图层
      this.addRouteLayer('line1', this.geojson_NKDH1, '#747474');
      console.log("NKDH1 route layer added.");
      this.addRouteLayer('line1', this.geojson_RC61, '#747474');
      console.log("RC61 route layer added.");

      // 添加站点、建筑和校门图层
      // station 语义缩放
+43 −23
Original line number Diff line number Diff line
@@ -44,7 +44,7 @@
          <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>
        <p v-else-if="locationState === 'denied' || locationState === 'failed'" class="muted">{{ locationError || busText('locationDenied') }}</p>
        <p v-else-if="locationState === 'denied' || locationState === 'failed'" class="muted">{{ busText(locationErrorKey || 'locationFailed') }}</p>
        <p v-else-if="locationState === 'ready' && !nearbyStops.length" class="muted">{{ busText('empty') }}</p>
        <article v-for="stop in visibleNearbyStops" :key="stop.id" class="nearby-stop">
          <div class="nearby-stop__head">
@@ -107,11 +107,12 @@ const notices = ref([])
const query = ref('')
const arrivals = ref({})
const locationState = ref('idle')
const locationError = ref('')
const locationErrorKey = ref('')
const nearbyStops = ref([])
const allNearbyStops = ref(false)
let nearbyCandidates = []
let refreshTimer
let locationRequest = 0

const searchResults = computed(() => [
  ...routes.value.filter((route) => matchesSearch(route, query.value, busLanguage.value)).map((item) => ({ kind: 'route', item })),
@@ -135,7 +136,10 @@ const noticeTime = (notice) => formatLocalDateTime(notice.starts_at)
const arrivalName = (arrival) => `${arrival[busLanguage.value === 'en' ? 'route_name_en' : 'route_name_zh'] || arrival.route_name_zh || arrival.route_name_en || ''} · ${arrival[busLanguage.value === 'en' ? 'direction_name_en' : 'direction_name_zh'] || arrival.direction_name_zh || arrival.direction_name_en || ''}`

function arrivalText(arrival) {
  if (arrival.source === 'real_time') return busLanguage.value === 'zh' ? `${arrival.eta_minutes} 分钟` : `${arrival.eta_minutes} min`
  if (arrival.source === 'real_time') {
    const meters = Number(arrival.distance ?? arrival.distance_to_stop ?? arrival.distance_to_next_stop ?? arrival.distance_meters)
    return [busLanguage.value === 'zh' ? `${arrival.eta_minutes} 分钟` : `${arrival.eta_minutes} min`, Number.isFinite(meters) && `${Math.round(meters)}m`].filter(Boolean).join(' ')
  }
  if (arrival.source === 'planned') {
    const time = new Date(arrival.planned_arrival_at).toLocaleTimeString(busLanguage.value === 'zh' ? 'zh-CN' : 'en', { hour: '2-digit', minute: '2-digit', hour12: false })
    return busLanguage.value === 'zh' ? `预计 ${time}` : `Scheduled ${time}`
@@ -162,20 +166,36 @@ async function loadArrivals(stopList) {
}

function locate() {
  if (!navigator.geolocation) { locationState.value = 'failed'; locationError.value = busText('locationUnavailable'); return }
  const geolocation = typeof window === 'undefined' ? null : window.navigator.geolocation
  if (!geolocation) { locationState.value = 'failed'; locationErrorKey.value = 'locationUnavailable'; return }
  allNearbyStops.value = false
  locationState.value = 'loading'
  navigator.geolocation.getCurrentPosition(async ({ coords }) => {
  locationErrorKey.value = ''
  const request = ++locationRequest
  let remaining = 2, hasLocation = false, highLocated = false, update = Promise.resolve()
  const located = ({ coords }, 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)
  }, (reason) => {
    })
  }
  const failed = (reason) => {
    remaining--
    if (request !== locationRequest || hasLocation || remaining) return
    locationState.value = reason.code === 1 ? 'denied' : 'failed'
    locationError.value = reason.message || busText('locationDenied')
  }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 })
    locationErrorKey.value = reason.code === 1 ? 'locationDenied' : reason.code === 3 ? 'locationTimeout' : 'locationFailed'
  }
  geolocation.getCurrentPosition((position) => located(position, true), failed, { enableHighAccuracy: true, timeout: 20000, maximumAge: 0 })
  geolocation.getCurrentPosition((position) => located(position, false), failed, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 })
}

async function load() {
@@ -199,25 +219,25 @@ defineExpose({ load, locate, openRoute, openStop, favoriteRouteIds, favoriteStop
.notices > details > .markdown { margin-top: 0; }
.notices summary { list-style: none; }
.notices summary::-webkit-details-marker { display: none; }
.notices summary > span::after { content: ' ▸'; color: #687386; }
.notices summary > span::after { content: ' ▸'; color: var(--bus-v2-muted); }
.notices details[open] > summary > span::after { content: ' ▾'; }
.bus-home { max-width: 900px; margin: 0 auto; color: var(--c-text, #243043); }
.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; }
.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: #687386; }
.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(--c-border, #dce2ea); border-radius: .6rem; background: var(--c-bg-soft, #fff); }
.panel { margin: 1rem 0; padding: 1rem; border: 1px solid var(--bus-v2-border); border-radius: .6rem; background: var(--bus-v2-bg); }
.search { position: relative; padding: 0; } .search input { box-sizing: border-box; width: 100%; padding: .85rem 1rem; border: 0; border-radius: .6rem; font: inherit; background: transparent; color: inherit; }
.search-results { position: absolute; z-index: 2; top: calc(100% + .25rem); width: 100%; overflow: hidden; border: 1px solid #dce2ea; border-radius: .5rem; background: #fff; box-shadow: 0 .5rem 1rem rgba(0,0,0,.12); }
.search-results button, .quick-links button { display: block; width: 100%; padding: .65rem 1rem; border: 0; text-align: left; background: transparent; color: inherit; cursor: pointer; } .search-results button:hover, .quick-links button:hover { background: #f3f7fc; }
.search-results small, summary small { margin-right: .5rem; color: #64748b; } .status { display: flex; align-items: center; gap: .5rem; } .error { color: #a32727; } button { font: inherit; cursor: pointer; } button:disabled { cursor: wait; opacity: .6; }
.spinner { width: 1em; height: 1em; border: 2px solid #b7c9dd; border-top-color: #2672bc; border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
details + details { border-top: 1px solid #e7edf4; } 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: #edf2f7; border-radius: .2em; }
.section-title button, .status button, .text-button { padding: .4rem .65rem; border: 1px solid #a9c2dc; border-radius: .35rem; background: transparent; color: inherit; } .text-button { border: 0; color: #2166a8; }
.nearby-stop { padding: .75rem 0; border-top: 1px solid #e7edf4; } .nearby-stop__head { align-items: baseline; } .link-title { padding: 0; border: 0; background: transparent; color: #1765ac; font-weight: 700; text-align: left; }
.nearby-toggle { padding: .4rem .65rem; border: 1px solid #a9c2dc; 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: #1765ac; text-decoration: none; } .arrival-link:hover, .arrival-link:focus-visible { text-decoration: underline; } .arrival-list .muted { display: block; }
.quick-links { display: flex; flex-wrap: wrap; gap: .5rem; } .quick-links button { width: auto; border: 1px solid #d6e1ec; border-radius: .35rem; }
.feature-links { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } .feature-links a { display: flex; min-height: 4rem; align-items: center; justify-content: center; padding: 0 .75rem; border-radius: .6rem; background: #eaf3fc; color: #165c9d; text-align: center; text-decoration: none; font-weight: 600; }
.search-results { position: absolute; z-index: 2; top: calc(100% + .25rem); width: 100%; overflow: hidden; border: 1px solid var(--bus-v2-border); border-radius: .5rem; background: var(--bus-v2-bg); box-shadow: 0 .5rem 1rem var(--vp-c-shadow); }
.search-results button, .quick-links button { display: block; width: 100%; padding: .65rem 1rem; border: 0; text-align: left; background: transparent; color: inherit; cursor: pointer; } .search-results button:hover, .quick-links button:hover { background: var(--bus-v2-control); }
.search-results small, summary small { margin-right: .5rem; color: var(--bus-v2-muted); } .status { display: flex; align-items: center; gap: .5rem; } .error { color: #b42318; } button { font: inherit; cursor: pointer; } button:disabled { cursor: wait; opacity: .6; }
.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-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; }
.quick-links { display: flex; flex-wrap: wrap; gap: .5rem; } .quick-links button { width: auto; border: 1px solid var(--bus-v2-border); border-radius: .35rem; }
.feature-links { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } .feature-links a { display: flex; min-height: 4rem; align-items: center; justify-content: center; padding: 0 .75rem; border-radius: .6rem; background: var(--bus-v2-link-soft); color: var(--bus-v2-link); text-align: center; text-decoration: none; font-weight: 600; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
@media (max-width: 560px) { .panel { margin: .75rem 0; } .feature-links { grid-template-columns: 1fr; } }
</style>
+8 −6
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>
    <BusVehicleDetailV2 v-if="selectedVehicle" class="bus-map__detail" :vehicle="selectedVehicle" :routes="routes" :language="language" closable @close="selectedVehicle = null" />
    <BusVehicleDetailV2 v-if="selectedVehicle" class="bus-map__detail" :vehicle="selectedVehicle" :routes="routes" :stops="stops" :language="language" closable @close="selectedVehicle = null" />
  </section>
</template>

@@ -22,6 +22,7 @@ let protocol
const props = defineProps({
  routes: { type: Array, default: () => [] },
  vehicles: { type: Array, default: () => [] },
  stops: { type: Array, default: () => [] },
  routeId: { type: String, default: '' },
  language: { type: String, default: 'zh' },
  // An explicit style remains supported for deployments that host their own PMTiles style.
@@ -46,7 +47,8 @@ let mapEventsBound = false
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 routeFor = (id) => props.routes.find((route) => route.id === id)
const styleUrl = () => props.styleUrl || ((document.documentElement.getAttribute('data-theme') === 'dark' || mediaQuery?.matches) ? DARK_STYLE : LIGHT_STYLE)
const darkTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' || mediaQuery?.matches
const styleUrl = () => props.styleUrl || (darkTheme() ? DARK_STYLE : LIGHT_STYLE)
const sourceData = (features) => ({ type: 'FeatureCollection', features })

function routeFeatures() {
@@ -64,7 +66,7 @@ function stopFeatures() {
    const latitude = +stop.latitude
    if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return
    const key = `${longitude.toFixed(6)},${latitude.toFixed(6)}`
    const name = displayStopName(stop, props.language) || stop.id
    const name = displayStopName({ ...props.stops.find((item) => item.id === stop.id), ...stop }, props.language) || stop.id
    const existing = stops.get(key)
    if (existing) {
      if (name && !existing.properties.names.includes(name)) existing.properties.names.push(name)
@@ -129,12 +131,12 @@ function addLayers() {
  if (!map.getSource('bus-v2-stops')) map.addSource('bus-v2-stops', { type: 'geojson', data: sourceData(stopFeatures()) })
  if (!map.getLayer('bus-v2-stops')) map.addLayer({
    id: 'bus-v2-stops', type: 'circle', source: 'bus-v2-stops',
    paint: { 'circle-radius': 4, 'circle-color': ['get', 'color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' },
    paint: { 'circle-radius': 4, 'circle-color': ['get', 'color'], 'circle-stroke-width': 1.5, 'circle-stroke-color': darkTheme() ? '#202127' : '#fff' },
  })
  if (!map.getLayer('bus-v2-stop-labels')) map.addLayer({
    id: 'bus-v2-stop-labels', type: 'symbol', source: 'bus-v2-stops', minzoom: 16.5,
    layout: { 'text-field': ['get', 'name'], 'text-size': 12, 'text-offset': [0, 1], 'text-anchor': 'top' },
    paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 2 },
    paint: { 'text-color': darkTheme() ? '#ebebf5' : '#333', 'text-halo-color': darkTheme() ? '#202127' : '#fff', 'text-halo-width': 2 },
  })
  if (!mapEventsBound) {
    map.on('click', 'bus-v2-stops', showStopPopup)
@@ -191,7 +193,7 @@ function releaseProtocol() {
function reloadStyle() {
  if (!map || !loaded) return
  activePopup?.remove()
  map.once('style.load', () => { if (map) { addLayers(); refresh() } })
  map.once('style.load', () => requestAnimationFrame(() => { if (map?.isStyleLoaded()) { addLayers(); refresh() } }))
  map.setStyle(styleUrl())
}

+19 −6

File changed.

Preview size limit exceeded, changes collapsed.

+4 −0
Original line number Diff line number Diff line
@@ -31,4 +31,8 @@ const visibleTimes = (times) => activeOnly.value ? times.filter((item) => item.s
<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; }
@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); }
.route-info { background: var(--bus-v2-bg-alt); }
.route-info small, .muted { color: var(--bus-v2-muted); }
</style>
Loading