Loading docs/.vuepress/components/bus-v2/BusMapV2.vue +2 −2 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 2 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -12,7 +12,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, lineBearingAt } from './core.mjs' import { displayName, displayStopName, vehicleBearingAt } 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 @@ -58,7 +58,7 @@ const sourceData = (features) => ({ type: 'FeatureCollection', features }) function vehicleBearing(vehicle) { const direction = routeFor(vehicle.route_id)?.directions?.find((item) => item.id === vehicle.route_direction_id) return lineBearingAt(parseGeometry(direction?.geometry_json), +vehicle.longitude, +vehicle.latitude) return vehicleBearingAt(parseGeometry(direction?.geometry_json), direction?.stops, vehicle) } function routeFeatures() { Loading docs/.vuepress/components/bus-v2/core.mjs +31 −6 Changes for docs/.vuepress/components/bus-v2/core.mjs: 31 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -81,22 +81,47 @@ export function formatDistance(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km` } export function lineBearingAt(coordinates, longitude, latitude) { if (!Array.isArray(coordinates) || !Number.isFinite(longitude) || !Number.isFinite(latitude) || coordinates.length < 2) return 0 function lineProjection(coordinates, longitude, latitude, startProgress = 0, endProgress = Infinity) { if (!Array.isArray(coordinates) || !Number.isFinite(longitude) || !Number.isFinite(latitude) || coordinates.length < 2) return null const scale = Math.cos(latitude * Math.PI / 180) let nearest let routeProgress = 0, nearest for (let index = 1; index < coordinates.length; index++) { const start = coordinates[index - 1], end = coordinates[index] if (![start, end].every((point) => Number.isFinite(+point?.[0]) && Number.isFinite(+point?.[1]))) continue const dx = (+end[0] - +start[0]) * scale, dy = +end[1] - +start[1], length = dx ** 2 + dy ** 2 if (!length) continue const progress = Math.max(0, Math.min(1, (((longitude - +start[0]) * scale) * dx + (latitude - +start[1]) * dy) / length)) const distance = ((longitude - (+start[0] + (+end[0] - +start[0]) * progress)) * scale) ** 2 + (latitude - (+start[1] + (+end[1] - +start[1]) * progress)) ** 2 if (!nearest || distance < nearest.distance) nearest = { distance, dx, dy } const fraction = Math.max(0, Math.min(1, (((longitude - +start[0]) * scale) * dx + (latitude - +start[1]) * dy) / length)) const segmentLength = haversineMeters(+start[1], +start[0], +end[1], +end[0]) const candidateProgress = routeProgress + segmentLength * fraction if (candidateProgress >= startProgress && candidateProgress <= endProgress && routeProgress + segmentLength > startProgress && routeProgress < endProgress) { const distance = ((longitude - (+start[0] + (+end[0] - +start[0]) * fraction)) * scale) ** 2 + (latitude - (+start[1] + (+end[1] - +start[1]) * fraction)) ** 2 if (!nearest || distance < nearest.distance) nearest = { distance, dx, dy, progress: candidateProgress } } routeProgress += segmentLength } return nearest } export function lineBearingAt(coordinates, longitude, latitude, startProgress = 0, endProgress = Infinity) { const nearest = lineProjection(coordinates, longitude, latitude, startProgress, endProgress) return nearest ? Math.atan2(nearest.dx, nearest.dy) * 180 / Math.PI : 0 } export function vehicleBearingAt(coordinates, stops, vehicle) { const position = vehicle?.current_position const fallback = () => lineBearingAt(coordinates, +vehicle?.longitude, +vehicle?.latitude) if (!position || !Array.isArray(stops) || stops.length < 2) return fallback() const nextNumber = Number(position.next_stop_num) const nextIndex = stops.findIndex((stop) => stop.id === position.next_stop_id || Number.isFinite(nextNumber) && Number(stop.sequence) === nextNumber) if (nextIndex < 0) return fallback() const leftIndex = position.type === 'between_stops' ? nextIndex - 1 : Math.min(nextIndex, stops.length - 2) if (leftIndex < 0) return fallback() const progress = stops.map((stop) => lineProjection(coordinates, +stop.longitude, +stop.latitude)?.progress ?? 0) const total = coordinates.slice(1).reduce((sum, point, index) => sum + haversineMeters(+coordinates[index][1], +coordinates[index][0], +point[1], +point[0]), 0) for (let index = 1; index < progress.length; index++) if (progress[index] < progress[index - 1]) progress[index] = index === progress.length - 1 ? total : progress[index - 1] return lineBearingAt(coordinates, +vehicle.longitude, +vehicle.latitude, progress[leftIndex], progress[leftIndex + 1]) } export function vehicleLocationText(vehicle, route, stops = [], language = 'zh') { const direction = route?.directions?.find((item) => item.id === vehicle?.route_direction_id) const position = vehicle?.current_position || {} Loading docs/.vuepress/components/bus-v2/core.test.mjs +11 −1 Changes for docs/.vuepress/components/bus-v2/core.test.mjs: 11 added lines, 1 removed line. Original line number Diff line number Diff line import assert from 'node:assert/strict' import test from 'node:test' import { closestArrivalsByRoute, displayName, displayStopName, haversineMeters, isTerminalArrival, lineBearingAt, matchesSearch, realtimeArrivalText, resolveBusApiBase, sortArrivalsByEstimatedTime, unavailableReasonTextKey, vehicleLocationText } from './core.mjs' import { closestArrivalsByRoute, displayName, displayStopName, haversineMeters, isTerminalArrival, lineBearingAt, matchesSearch, realtimeArrivalText, resolveBusApiBase, sortArrivalsByEstimatedTime, unavailableReasonTextKey, vehicleBearingAt, vehicleLocationText } from './core.mjs' import { renderNoticeMarkdown } from './markdown.mjs' test('API base uses fixed development and production addresses', () => { Loading Loading @@ -64,6 +64,16 @@ test('line bearing follows the nearest route segment', () => { assert.ok(Math.abs(lineBearingAt([[0, 0], [1, 0]], .5, 0) - 90) < .001) }) test('vehicle bearing stays inside the backend-reported stop interval', () => { const coordinates = [[0, 0], [2, 0], [2, .001], [0, .001]] const stops = [{ id: 'a', sequence: 1, longitude: 0, latitude: 0 }, { id: 'b', sequence: 2, longitude: 2, latitude: .001 }, { id: 'c', sequence: 3, longitude: 0, latitude: .001 }] const between = { longitude: 1, latitude: .001, current_position: { type: 'between_stops', next_stop_id: 'b', next_stop_num: 2 } } const atStop = { longitude: 2, latitude: .001, current_position: { type: 'at_stop', next_stop_id: 'b', next_stop_num: 2 } } assert.ok(Math.abs(lineBearingAt(coordinates, 1, .001) + 90) < .001) assert.ok(Math.abs(vehicleBearingAt(coordinates, stops, between) - 90) < .001) assert.ok(Math.abs(vehicleBearingAt(coordinates, stops, atStop) + 90) < .001) }) test('vehicle locations show route intervals and stops', () => { const route = { directions: [{ id: 'outbound', stops: [{ id: 'a', sequence: 1, name_zh: '1' }, { id: 'b', sequence: 2, name_zh: '1' }] }] } const stops = [{ id: 'a', group_name_zh: '欣园' }, { id: 'b', group_name_zh: '慧园' }] Loading Loading
docs/.vuepress/components/bus-v2/BusMapV2.vue +2 −2 Changes for docs/.vuepress/components/bus-v2/BusMapV2.vue: 2 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -12,7 +12,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, lineBearingAt } from './core.mjs' import { displayName, displayStopName, vehicleBearingAt } 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 @@ -58,7 +58,7 @@ const sourceData = (features) => ({ type: 'FeatureCollection', features }) function vehicleBearing(vehicle) { const direction = routeFor(vehicle.route_id)?.directions?.find((item) => item.id === vehicle.route_direction_id) return lineBearingAt(parseGeometry(direction?.geometry_json), +vehicle.longitude, +vehicle.latitude) return vehicleBearingAt(parseGeometry(direction?.geometry_json), direction?.stops, vehicle) } function routeFeatures() { Loading
docs/.vuepress/components/bus-v2/core.mjs +31 −6 Changes for docs/.vuepress/components/bus-v2/core.mjs: 31 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -81,22 +81,47 @@ export function formatDistance(meters) { return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km` } export function lineBearingAt(coordinates, longitude, latitude) { if (!Array.isArray(coordinates) || !Number.isFinite(longitude) || !Number.isFinite(latitude) || coordinates.length < 2) return 0 function lineProjection(coordinates, longitude, latitude, startProgress = 0, endProgress = Infinity) { if (!Array.isArray(coordinates) || !Number.isFinite(longitude) || !Number.isFinite(latitude) || coordinates.length < 2) return null const scale = Math.cos(latitude * Math.PI / 180) let nearest let routeProgress = 0, nearest for (let index = 1; index < coordinates.length; index++) { const start = coordinates[index - 1], end = coordinates[index] if (![start, end].every((point) => Number.isFinite(+point?.[0]) && Number.isFinite(+point?.[1]))) continue const dx = (+end[0] - +start[0]) * scale, dy = +end[1] - +start[1], length = dx ** 2 + dy ** 2 if (!length) continue const progress = Math.max(0, Math.min(1, (((longitude - +start[0]) * scale) * dx + (latitude - +start[1]) * dy) / length)) const distance = ((longitude - (+start[0] + (+end[0] - +start[0]) * progress)) * scale) ** 2 + (latitude - (+start[1] + (+end[1] - +start[1]) * progress)) ** 2 if (!nearest || distance < nearest.distance) nearest = { distance, dx, dy } const fraction = Math.max(0, Math.min(1, (((longitude - +start[0]) * scale) * dx + (latitude - +start[1]) * dy) / length)) const segmentLength = haversineMeters(+start[1], +start[0], +end[1], +end[0]) const candidateProgress = routeProgress + segmentLength * fraction if (candidateProgress >= startProgress && candidateProgress <= endProgress && routeProgress + segmentLength > startProgress && routeProgress < endProgress) { const distance = ((longitude - (+start[0] + (+end[0] - +start[0]) * fraction)) * scale) ** 2 + (latitude - (+start[1] + (+end[1] - +start[1]) * fraction)) ** 2 if (!nearest || distance < nearest.distance) nearest = { distance, dx, dy, progress: candidateProgress } } routeProgress += segmentLength } return nearest } export function lineBearingAt(coordinates, longitude, latitude, startProgress = 0, endProgress = Infinity) { const nearest = lineProjection(coordinates, longitude, latitude, startProgress, endProgress) return nearest ? Math.atan2(nearest.dx, nearest.dy) * 180 / Math.PI : 0 } export function vehicleBearingAt(coordinates, stops, vehicle) { const position = vehicle?.current_position const fallback = () => lineBearingAt(coordinates, +vehicle?.longitude, +vehicle?.latitude) if (!position || !Array.isArray(stops) || stops.length < 2) return fallback() const nextNumber = Number(position.next_stop_num) const nextIndex = stops.findIndex((stop) => stop.id === position.next_stop_id || Number.isFinite(nextNumber) && Number(stop.sequence) === nextNumber) if (nextIndex < 0) return fallback() const leftIndex = position.type === 'between_stops' ? nextIndex - 1 : Math.min(nextIndex, stops.length - 2) if (leftIndex < 0) return fallback() const progress = stops.map((stop) => lineProjection(coordinates, +stop.longitude, +stop.latitude)?.progress ?? 0) const total = coordinates.slice(1).reduce((sum, point, index) => sum + haversineMeters(+coordinates[index][1], +coordinates[index][0], +point[1], +point[0]), 0) for (let index = 1; index < progress.length; index++) if (progress[index] < progress[index - 1]) progress[index] = index === progress.length - 1 ? total : progress[index - 1] return lineBearingAt(coordinates, +vehicle.longitude, +vehicle.latitude, progress[leftIndex], progress[leftIndex + 1]) } export function vehicleLocationText(vehicle, route, stops = [], language = 'zh') { const direction = route?.directions?.find((item) => item.id === vehicle?.route_direction_id) const position = vehicle?.current_position || {} Loading
docs/.vuepress/components/bus-v2/core.test.mjs +11 −1 Changes for docs/.vuepress/components/bus-v2/core.test.mjs: 11 added lines, 1 removed line. Original line number Diff line number Diff line import assert from 'node:assert/strict' import test from 'node:test' import { closestArrivalsByRoute, displayName, displayStopName, haversineMeters, isTerminalArrival, lineBearingAt, matchesSearch, realtimeArrivalText, resolveBusApiBase, sortArrivalsByEstimatedTime, unavailableReasonTextKey, vehicleLocationText } from './core.mjs' import { closestArrivalsByRoute, displayName, displayStopName, haversineMeters, isTerminalArrival, lineBearingAt, matchesSearch, realtimeArrivalText, resolveBusApiBase, sortArrivalsByEstimatedTime, unavailableReasonTextKey, vehicleBearingAt, vehicleLocationText } from './core.mjs' import { renderNoticeMarkdown } from './markdown.mjs' test('API base uses fixed development and production addresses', () => { Loading Loading @@ -64,6 +64,16 @@ test('line bearing follows the nearest route segment', () => { assert.ok(Math.abs(lineBearingAt([[0, 0], [1, 0]], .5, 0) - 90) < .001) }) test('vehicle bearing stays inside the backend-reported stop interval', () => { const coordinates = [[0, 0], [2, 0], [2, .001], [0, .001]] const stops = [{ id: 'a', sequence: 1, longitude: 0, latitude: 0 }, { id: 'b', sequence: 2, longitude: 2, latitude: .001 }, { id: 'c', sequence: 3, longitude: 0, latitude: .001 }] const between = { longitude: 1, latitude: .001, current_position: { type: 'between_stops', next_stop_id: 'b', next_stop_num: 2 } } const atStop = { longitude: 2, latitude: .001, current_position: { type: 'at_stop', next_stop_id: 'b', next_stop_num: 2 } } assert.ok(Math.abs(lineBearingAt(coordinates, 1, .001) + 90) < .001) assert.ok(Math.abs(vehicleBearingAt(coordinates, stops, between) - 90) < .001) assert.ok(Math.abs(vehicleBearingAt(coordinates, stops, atStop) + 90) < .001) }) test('vehicle locations show route intervals and stops', () => { const route = { directions: [{ id: 'outbound', stops: [{ id: 'a', sequence: 1, name_zh: '1' }, { id: 'b', sequence: 2, name_zh: '1' }] }] } const stops = [{ id: 'a', group_name_zh: '欣园' }, { id: 'b', group_name_zh: '慧园' }] Loading