Loading docs/.vuepress/components/bus-v2/BusMapV2.vue +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 Loading @@ -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> Loading @@ -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' Loading Loading @@ -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' Loading Loading @@ -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`) Loading @@ -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) Loading Loading @@ -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', Loading Loading @@ -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 Loading Loading @@ -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; } Loading docs/.vuepress/components/bus-v2/BusRouteV2.vue +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 Loading @@ -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' }"> Loading Loading @@ -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' Loading docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +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 Loading @@ -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) Loading @@ -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); } Loading @@ -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> docs/.vuepress/components/bus-v2/BusVehicleDetailV2.vue +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 Loading @@ -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> Loading @@ -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']) Loading docs/.vuepress/components/bus-v2/BusVehicleLegendV2.vue +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
docs/.vuepress/components/bus-v2/BusMapV2.vue +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 Loading @@ -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> Loading @@ -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' Loading Loading @@ -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' Loading Loading @@ -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`) Loading @@ -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) Loading Loading @@ -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', Loading Loading @@ -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 Loading Loading @@ -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; } Loading
docs/.vuepress/components/bus-v2/BusRouteV2.vue +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 Loading @@ -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' }"> Loading Loading @@ -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' Loading
docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +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 Loading @@ -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) Loading @@ -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); } Loading @@ -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>
docs/.vuepress/components/bus-v2/BusVehicleDetailV2.vue +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 Loading @@ -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> Loading @@ -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']) Loading
docs/.vuepress/components/bus-v2/BusVehicleLegendV2.vue +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>