Loading docs/.vuepress/components/RealtimeMapv2.vue +6 −6 Changes for docs/.vuepress/components/RealtimeMapv2.vue: 6 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -69,7 +69,7 @@ export default { }, // --- Data Placeholders --- geojson_NKDH1: [], geojson_RC61: [], predictionRoutes: {}, sevPredictionHistory: {}, Loading Loading @@ -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); } Loading Loading @@ -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 语义缩放 Loading docs/.vuepress/components/bus-v2/BusHomeV2.vue +43 −23 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 43 added lines, 23 removed lines. Original line number Diff line number Diff line Loading @@ -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"> Loading Loading @@ -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 })), Loading @@ -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}` Loading @@ -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() { Loading @@ -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> docs/.vuepress/components/bus-v2/BusMapV2.vue +8 −6 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 8 added lines, 6 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> <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> Loading @@ -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. Loading @@ -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() { Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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()) } Loading docs/.vuepress/components/bus-v2/BusRouteV2.vue +19 −6 File changed.Preview size limit exceeded, changes collapsed. Show changes docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +4 −0 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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
docs/.vuepress/components/RealtimeMapv2.vue +6 −6 Changes for docs/.vuepress/components/RealtimeMapv2.vue: 6 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -69,7 +69,7 @@ export default { }, // --- Data Placeholders --- geojson_NKDH1: [], geojson_RC61: [], predictionRoutes: {}, sevPredictionHistory: {}, Loading Loading @@ -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); } Loading Loading @@ -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 语义缩放 Loading
docs/.vuepress/components/bus-v2/BusHomeV2.vue +43 −23 Changes for docs/.vuepress/components/bus-v2/BusHomeV2.vue: 43 added lines, 23 removed lines. Original line number Diff line number Diff line Loading @@ -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"> Loading Loading @@ -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 })), Loading @@ -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}` Loading @@ -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() { Loading @@ -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>
docs/.vuepress/components/bus-v2/BusMapV2.vue +8 −6 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 8 added lines, 6 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> <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> Loading @@ -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. Loading @@ -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() { Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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()) } Loading
docs/.vuepress/components/bus-v2/BusRouteV2.vue +19 −6 File changed.Preview size limit exceeded, changes collapsed. Show changes
docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue +4 −0 Changes for docs/.vuepress/components/bus-v2/BusScheduleRowsV2.vue: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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>