Commit 2865aa9b authored by prurite's avatar prurite
Browse files

BusTimerV2: Fix incorrect vehicle direction display

parent 3c1f1eb3
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -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'
@@ -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() {
+31 −6
Original line number Diff line number Diff line
@@ -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 || {}
+11 −1
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', () => {
@@ -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: '慧园' }]