package aeroapi
import (
"errors"
"log"
"time"
"github.com/noodnik2/flightvisualizer/internal/kml"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
type TracksConverter struct {
Verbose bool
CutoffTime time.Time
FlightCount int
}
func (tc *TracksConverter) ConvertForTailNumber(aeroApi aeroapi.Api, tracker kml.TrackGenerator, tailNumber string) ([]*kml.Track, error) {
flightIds, getIdsErr := aeroApi.GetFlightIds(tailNumber, tc.CutoffTime)
if getIdsErr != nil {
return nil, getIdsErr
}
var kmlTracks []*kml.Track
nFlights := len(flightIds)
var errorList []error
for i := 0; i < nFlights; i++ {
kmlTrack, convertErr := ConvertForFlightId(aeroApi, tracker, flightIds[i])
if convertErr != nil {
errorList = append(errorList, convertErr)
continue
}
kmlTracks = append(kmlTracks, kmlTrack)
if tc.FlightCount != 0 && len(kmlTracks) == tc.FlightCount {
// presumes the user's preferred flights are first in the list
break
}
}
if errorList != nil {
verboseMessagePrinter := func(mt string) {
if tc.Verbose {
for _, err := range errorList {
log.Printf("%s: %s\n", mt, err)
}
} else {
log.Printf("NOTE: not all tracks were generated; use 'verbose' for more detail\n")
}
}
nGenerated := len(kmlTracks)
if nGenerated == 0 {
verboseMessagePrinter("ERROR")
return nil, errors.New("error(s) encountered generating KML visualization(s)")
}
verboseMessagePrinter("INFO")
}
return kmlTracks, nil
}
func ConvertForFlightId(aeroApi aeroapi.Api, tracker kml.TrackGenerator, flightId string) (*kml.Track, error) {
track, getTrackErr := aeroApi.GetTrackForFlightId(flightId)
if getTrackErr != nil {
return nil, getTrackErr
}
kmlTrack, kmlTrackErr := tracker.Generate(track)
if kmlTrackErr != nil {
return nil, kmlTrackErr
}
return kmlTrack, nil
}
package internal
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime/debug"
)
type Config struct {
AeroApiUrl string `env:"AEROAPI_API_URL,default=https://aeroapi.flightaware.com/aeroapi"`
ArtifactsDir string `env:"ARTIFACTS_DIR,default=."`
Verbose bool `env:"VERBOSE,default=false"`
// "required" fields should come at the end; otherwise, the defaults (above) won't be applied when
// the required values aren't found (that error isn't fatal so we want the defaults to be applied)
AeroApiKey string `env:"AEROAPI_API_KEY,required" secret:"mask"`
}
const (
userConfigFilenameEnvVar = "FVIZ_CONFIG_FILE"
configFile = ".config/fviz"
)
func GetConfigFilename(verbose bool) string {
if userConfigFilename := os.Getenv(userConfigFilenameEnvVar); userConfigFilename != "" {
return userConfigFilename
}
const homeDirEnvVarName = "HOME"
_, ok := os.LookupEnv(homeDirEnvVarName)
if !ok {
log.Printf("NOTE: '%s' environment variable not found\n", homeDirEnvVarName)
}
homeDir := os.Getenv(homeDirEnvVarName)
configFilename := filepath.Join(homeDir, configFile)
if verbose {
log.Printf("INFO: config file location is '%s'\n", configFilename)
}
return configFilename
}
func GetBuildVcsVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
var vcsRevision, vcsTime, vcsModified string
for _, kv := range info.Settings {
switch kv.Key {
case "vcs.revision":
vcsRevision = kv.Value
case "vcs.time":
vcsTime = kv.Value
case "vcs.modified":
if kv.Value == "true" {
vcsModified = " (modified)"
}
}
}
return fmt.Sprintf("%s %s%s", vcsTime, vcsRevision[:7], vcsModified)
}
package internal
import (
"errors"
"fmt"
"image/color"
"log"
"path/filepath"
"sort"
"strings"
"time"
iaeroapi "github.com/noodnik2/flightvisualizer/internal/aeroapi"
"github.com/noodnik2/flightvisualizer/internal/kml"
"github.com/noodnik2/flightvisualizer/internal/kml/builders"
ios "github.com/noodnik2/flightvisualizer/internal/os"
"github.com/noodnik2/flightvisualizer/internal/persistence"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
persistence2 "github.com/noodnik2/flightvisualizer/pkg/persistence"
)
const (
TracksLayerCamera = "camera"
TracksLayerPath = "path"
TracksLayerPlacemark = "placemark"
TracksLayerVector = "vector"
kmlArtifactsFilenamePrefix = "fvk_"
)
type sourceType int
const (
sourceTypeUnrecognized sourceType = iota
sourceTypeMultiTrackRemote // pull a remote "flight ids" document (e.g., from AeroAPI server)
sourceTypeSingleTrackArtifact // use a recorded "track" artifact as the source document
sourceTypeMultiTrackArtifact // use a recorded "flight ids" artifact as the source document
sourceTypeSingleTrackRemote // pull a remote "flight id" document (e.g., from AeroAPI server)
)
var TracksLayersSupported = []string{TracksLayerCamera, TracksLayerPath, TracksLayerPlacemark, TracksLayerVector}
type TracksCommandArgs struct {
Config Config
LaunchFirstKml bool
NoBanking bool
SaveResponses bool
VerboseOperation bool
DebugOperation bool
FromArtifacts string
ArtifactsDir string
KmlLayers string
TailNumber string
FlightNumber string
FlightCount int
CutoffTime time.Time
}
func (tca TracksCommandArgs) GenerateTracks() error {
kmlGenerator, getKmlGeneratorErr := tca.newKmlTrackGenerator(strings.Split(tca.KmlLayers, ","))
if getKmlGeneratorErr != nil {
return getKmlGeneratorErr
}
trackFactory, trackFactoryErr := tca.newTrackFactory()
if trackFactoryErr != nil {
return fmt.Errorf("no KML track factory could be created: %v", trackFactoryErr)
}
kmlTracks, generateKmlTracksErr := trackFactory(kmlGenerator)
if generateKmlTracksErr != nil {
return generateKmlTracksErr
}
firstKmlFilename, saveKmlErr := tca.saveKmlTracks(kmlTracks, kmlGenerator.Name)
if saveKmlErr != nil {
return saveKmlErr
}
// if indicated, "launch" the (first of the) generated KML visualization(s)
if tca.LaunchFirstKml && firstKmlFilename != "" {
log.Printf("INFO: Launching '%s'\n", firstKmlFilename)
if openErr := ios.LaunchFile(firstKmlFilename); openErr != nil {
return fmt.Errorf("error returned from launching(%s): %v", firstKmlFilename, openErr)
}
}
return nil
}
func (tca TracksCommandArgs) saveKmlTracks(kmlTracks []*kml.Track, kmlLayersUi string) (string, error) {
nKmlDocs := len(kmlTracks)
if tca.IsVerbose() || nKmlDocs > 1 {
log.Printf("INFO: writing %d %s KML document(s)\n", nKmlDocs, kmlLayersUi)
}
// save the KML document(s) produced along with their asset(s) as `.kmz` file(s)
var firstKmlFilename string
for _, aeroKml := range kmlTracks {
kmzSaver := &persistence.KmzSaver{
Saver: &persistence2.FileSaver{},
Assets: aeroKml.KmlAssets,
}
flightTimeRange := getTsFromTo(*aeroKml.StartTime, *aeroKml.EndTime)
kmlFilename := filepath.Join(
tca.getArtifactsDir(),
fmt.Sprintf("%s%s_%s_%s.kmz", kmlArtifactsFilenamePrefix, tca.TailNumber, flightTimeRange, kmlLayersUi),
)
if writeErr := kmzSaver.Save(kmlFilename, aeroKml.KmlDoc); writeErr != nil {
return "", fmt.Errorf("couldn't write output artifact(%s): %v", kmlFilename, writeErr)
}
if firstKmlFilename == "" {
firstKmlFilename = kmlFilename
}
}
return firstKmlFilename, nil
}
func (tca TracksCommandArgs) newKmlTrackGenerator(kmlLayers []string) (*kml.TrackBuilderEnsemble, error) {
// order layer builder(s) for deterministic output
sort.Strings(kmlLayers)
builtLayers := make([]string, 0, len(kmlLayers))
var kmlBuilders []builders.KmlTrackBuilder
for _, kmlLayer := range kmlLayers {
if len(builtLayers) > 0 && kmlLayer == builtLayers[len(builtLayers)-1] {
// ignore duplicates
continue
}
var kmlBuilder builders.KmlTrackBuilder
switch kmlLayer {
case TracksLayerCamera:
kmlBuilder = &builders.CameraBuilder{
AddBankAngle: !tca.NoBanking,
DebugFlag: tca.DebugOperation,
}
case TracksLayerPath:
kmlBuilder = &builders.PathBuilder{
Extrude: true,
Color: color.RGBA{R: 217, G: 51, B: 255},
}
case TracksLayerPlacemark:
kmlBuilder = &builders.PlacemarkBuilder{}
case TracksLayerVector:
kmlBuilder = &builders.VectorBuilder{}
default:
return nil, fmt.Errorf("unrecognized kmlLayer(%s); supported: %v", kmlLayer,
strings.Join(TracksLayersSupported, ","))
}
builtLayers = append(builtLayers, kmlLayer)
kmlBuilders = append(kmlBuilders, kmlBuilder)
}
ensemble := &kml.TrackBuilderEnsemble{
Name: strings.Join(builtLayers, "-"),
Builders: kmlBuilders,
}
return ensemble, nil
}
type kmlTrackFactory func(kml.TrackGenerator) ([]*kml.Track, error)
func (tca TracksCommandArgs) newTrackFactory() (kmlTrackFactory, error) {
st, stErr := tca.getSourceType()
if stErr != nil {
return nil, stErr
}
switch st {
case sourceTypeSingleTrackRemote:
// pull single track from remote source (e.g., based upon flight number)
return singleTrackRemoteFactory(tca), nil
case sourceTypeSingleTrackArtifact:
// pull single track from a recorded artifact (e.g., using either / both tail number and/or flight id)
return singleTrackArtifactFactory(tca), nil
case sourceTypeMultiTrackRemote:
// pull potentially multiple tracks from remote source (e.g., based upon tail number, cutoff time and max flight count)
return multiTrackRemoteFactory(tca), nil
case sourceTypeMultiTrackArtifact:
// pull potentially multiple tracks from a recorded artifact (e.g., for tail number potentially having multiple flights)
return multiTrackArtifactFactory(tca), nil
}
return nil, errors.New("can't determine source type")
}
func multiTrackRemoteFactory(tca TracksCommandArgs) kmlTrackFactory {
return func(tracker kml.TrackGenerator) ([]*kml.Track, error) {
tc := iaeroapi.TracksConverter{
Verbose: tca.IsVerbose(),
FlightCount: tca.FlightCount,
CutoffTime: tca.CutoffTime,
}
return tc.ConvertForTailNumber(newRemoteAeroApi(tca), tracker, tca.TailNumber)
}
}
func singleTrackRemoteFactory(tca TracksCommandArgs) kmlTrackFactory {
return func(tracker kml.TrackGenerator) ([]*kml.Track, error) {
if tca.FlightNumber == "" {
return nil, errors.New("no flight number was provided")
}
kmlTrack, err := iaeroapi.ConvertForFlightId(newRemoteAeroApi(tca), tracker, tca.FlightNumber)
if err != nil {
return nil, err
}
return []*kml.Track{kmlTrack}, nil
}
}
func multiTrackArtifactFactory(tca TracksCommandArgs) kmlTrackFactory {
return func(tracker kml.TrackGenerator) ([]*kml.Track, error) {
if tca.SaveResponses {
// there's no good reason to save data already coming from local files
log.Printf("NOTE: inappropriate 'save responses' option ignored\n")
}
aeroApi := &aeroapi.RetrieverSaverApiImpl{
// reading AeroAPI data from saved artifact files
Retriever: &aeroapi.FileAeroApi{
ArtifactsDir: tca.getArtifactsDir(),
FlightIdsFileName: tca.FromArtifacts,
},
}
tc := iaeroapi.TracksConverter{
Verbose: tca.IsVerbose(),
FlightCount: tca.FlightCount,
CutoffTime: tca.CutoffTime,
}
return tc.ConvertForTailNumber(aeroApi, tracker, tca.TailNumber)
}
}
func singleTrackArtifactFactory(tca TracksCommandArgs) kmlTrackFactory {
return func(tracker kml.TrackGenerator) ([]*kml.Track, error) {
track, getTfaErr := tca.getTrackFromArtifact()
if getTfaErr != nil {
return nil, getTfaErr
}
kmlTrack, err := tracker.Generate(track)
if err != nil {
return nil, err
}
return []*kml.Track{kmlTrack}, nil
}
}
func newRemoteAeroApi(tca TracksCommandArgs) *aeroapi.RetrieverSaverApiImpl {
var artifactSaver aeroapi.ArtifactSaver
if tca.SaveResponses {
artifactSaver = &aeroapi.FileAeroApi{ArtifactsDir: tca.getArtifactsDir()}
}
aeroApi := &aeroapi.RetrieverSaverApiImpl{
// reading AeroAPI data from live AeroAPI REST API calls
Retriever: &aeroapi.HttpAeroApi{
Verbose: tca.IsVerbose(),
ApiKey: tca.Config.AeroApiKey,
ApiUrl: tca.Config.AeroApiUrl,
},
Saver: artifactSaver,
}
return aeroApi
}
func (tca TracksCommandArgs) getSourceType() (sourceType, error) {
if tca.FromArtifacts == "" {
if tca.FlightNumber != "" {
return sourceTypeSingleTrackRemote, nil
}
return sourceTypeMultiTrackRemote, nil
}
if aeroapi.IsTrackArtifactFilename(tca.FromArtifacts) {
return sourceTypeSingleTrackArtifact, nil
}
if aeroapi.IsFlightIdsArtifactFilename(tca.FromArtifacts) {
return sourceTypeMultiTrackArtifact, nil
}
return sourceTypeUnrecognized, fmt.Errorf("unrecognized artifact(%s)", tca.FromArtifacts)
}
func (tca TracksCommandArgs) getTrackFromArtifact() (*aeroapi.Track, error) {
contents, loadErr := (&persistence2.FileLoader{}).Load(tca.FromArtifacts)
if loadErr != nil {
return nil, loadErr
}
return aeroapi.TrackFromJson(contents)
}
func (tca TracksCommandArgs) getArtifactsDir() string {
if tca.ArtifactsDir != "" {
return tca.ArtifactsDir
}
return tca.Config.ArtifactsDir
}
func (tca TracksCommandArgs) IsVerbose() bool {
return tca.VerboseOperation || tca.Config.Verbose
}
const fnPrefixTimestampFormat = "20060102150405Z"
// GetTsFromTo returns a string representation of a time range using fnPrefixTimestampFormat
// to format the "from" time, and a subsequence of that for the "to" time, with leading common
// prefix removed. Example:
//
// { 2023010203040506Z, 2023010203050506Z } => "23010203040506Z-50506Z" ('5' differs with '4' in tsBase)
func getTsFromTo(from, to time.Time) string {
fromFmt := from.Format(fnPrefixTimestampFormat)[2:]
toFmt := to.Format(fnPrefixTimestampFormat)[2:]
i := 0
for i < len(fromFmt) && i < len(toFmt) && fromFmt[i] == toFmt[i] {
i++
}
return fmt.Sprintf("%s-%s", fromFmt, toFmt[i:])
}
package builders
import (
"time"
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
type CameraBuilder struct {
AddBankAngle bool
DebugFlag bool
}
func (ctb *CameraBuilder) Name() string {
return "Camera"
}
func (ctb *CameraBuilder) Build(positions []aeroapi.Position) (*KmlProduct, error) {
var frames []gokml.Element
nPositions := len(positions)
aeroApiMathUtil := &aeroapi.Math{
Debug: ctb.DebugFlag,
}
flyToMode := gokml.GxFlyToModeBounce // initial "bounce" into tour
var startTime time.Time
for i := 0; i < nPositions-1; i++ {
thisPosition := positions[i]
nextPosition := positions[i+1]
if startTime.IsZero() {
startTime = thisPosition.Timestamp
}
var bankAngle float64
if ctb.AddBankAngle {
bankAngle = float64(aeroApiMathUtil.GetBankAngle(thisPosition, nextPosition))
}
deltaT := nextPosition.Timestamp.Sub(thisPosition.Timestamp)
const cameraHeightFromWheels = 2
frames = append(frames, gokml.GxFlyTo(
gokml.GxDuration(deltaT),
gokml.GxFlyToMode(flyToMode),
gokml.Camera(
gokml.TimeSpan(
gokml.Begin(startTime),
gokml.End(nextPosition.Timestamp),
),
gokml.Longitude(thisPosition.Longitude),
gokml.Latitude(thisPosition.Latitude),
gokml.Altitude(aeroAlt2Meters(thisPosition.AltMslD100)+cameraHeightFromWheels),
gokml.Heading(thisPosition.Heading),
gokml.Tilt(80),
gokml.Roll(-bankAngle),
gokml.AltitudeMode(gokml.AltitudeModeAbsolute),
)))
flyToMode = gokml.GxFlyToModeSmooth
}
root := gokml.GxTour(
gokml.Name("Camera View"),
gokml.Description("First-person view of the flight"),
gokml.GxPlaylist(frames...),
)
return &KmlProduct{Root: root}, nil
}
package builders
import (
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
// KmlProduct contains the top-level KML model element and the assets it references
type KmlProduct struct {
Root gokml.Element
Assets map[string]any
}
// KmlTrackBuilder can build a KmlProduct from a list of (location) coordinates
type KmlTrackBuilder interface {
Name() string
Build(positions []aeroapi.Position) (*KmlProduct, error)
}
const feetPerMeter = 3.28084
// AeroAlt2Meters converts altitude values emitted by AeroAPI,
// which are expressed in units of 100 feet, into meters
func aeroAlt2Meters(altD100ft float64) float64 {
feetAgl := altD100ft * 100
return feetAgl / feetPerMeter
}
package builders
import (
"image/color"
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
// PathBuilder builds the visible "path" track, and optionally its extrusion to the ground
type PathBuilder struct {
Color color.Color
Extrude bool
}
func (pb *PathBuilder) Name() string {
return "Path"
}
func (pb *PathBuilder) Build(aeroTrackPositions []aeroapi.Position) (*KmlProduct, error) {
var coordinates []gokml.Coordinate
for _, position := range aeroTrackPositions {
coordinates = append(coordinates, gokml.Coordinate{
Lon: position.Longitude,
Lat: position.Latitude,
Alt: aeroAlt2Meters(position.AltMslD100),
})
}
lc := func(a uint8) color.RGBA {
r, g, b, _ := pb.Color.RGBA()
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: a}
}
flightStyle := gokml.Style(
gokml.LineStyle(
gokml.Color(lc(127)),
gokml.Width(3),
),
gokml.PolyStyle(gokml.Color(lc(63))),
).WithID("FlightStyle")
lineString := gokml.LineString(
gokml.AltitudeMode(gokml.AltitudeModeAbsolute),
gokml.Extrude(pb.Extrude),
gokml.Coordinates(coordinates...),
)
flightLine := gokml.Placemark(
gokml.StyleURL("#FlightStyle"),
lineString,
)
mainFolder := gokml.Folder(
gokml.Name("Path Track"),
gokml.Description("Visible flight path, optionally extruded to the ground"),
flightStyle,
flightLine,
)
return &KmlProduct{Root: mainFolder}, nil
}
package builders
import (
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
type PlacemarkBuilder struct{}
func (*PlacemarkBuilder) Name() string {
return "Placemark"
}
func (*PlacemarkBuilder) Build(aeroTrackPositions []aeroapi.Position) (*KmlProduct, error) {
// - https://github.com/twpayne/go-kml/blob/a1a42dcf7ccb20a4b7b88b5bd61178cc14e050fc/kml_test.go#L870
var positions []gokml.Element
// - https://developers.google.com/kml/documentation/kmlreference#gxtrack
for _, position := range aeroTrackPositions {
positions = append(positions, gokml.When(position.Timestamp))
}
// - https://developers.google.com/kml/documentation/kmlreference#gxcoord
for _, position := range aeroTrackPositions {
positions = append(positions, gokml.GxCoord(gokml.Coordinate{
Lon: position.Longitude,
Lat: position.Latitude,
Alt: aeroAlt2Meters(position.AltMslD100),
}))
}
track := gokml.GxTrack(positions...)
placemark := gokml.Placemark(track)
root := gokml.Folder(
gokml.Name("Placemark Track"),
gokml.Description("Flight path track across the ground in a single Placemark"),
placemark,
)
return &KmlProduct{Root: root}, nil
}
package builders
import (
"bytes"
"embed"
"fmt"
"image/color"
"io/fs"
"path/filepath"
"time"
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
// VectorBuilder - builds a KML folder of Placemarks revealing significant
// details of the track coordinates received from AeroAPI, including:
//
// => Location - Placemark's location
// => Altitude - Placemark's altitude
// => Heading - direction of an arrow representing the Placemark
// => Groundspeed - reflected by magnitude / size of the arrow
//
// Additional sets of Placemarks are used to reveal secondary information
// calculated from the track data (e.g., "imputed" values).
type VectorBuilder struct{}
const vectorArrowRelPath = "images/blue_fast_arrow.png"
func (vb *VectorBuilder) Name() string {
return "Vector"
}
//go:embed images
var embeddedImages embed.FS
func (vb *VectorBuilder) Build(aeroTrackPositions []aeroapi.Position) (*KmlProduct, error) {
vectorArrowPngBytes, getErr := getEmbeddedFileContents(embeddedImages, vectorArrowRelPath)
if getErr != nil {
return nil, fmt.Errorf("can't get embedded file: %v", getErr)
}
vectorArrowHref := filepath.Base(vectorArrowRelPath)
thing := &KmlProduct{
Assets: map[string]any{
vectorArrowHref: vectorArrowPngBytes,
},
}
aeroapiMathUtil := &aeroapi.Math{}
var positionReferences []gokml.Element
for i := 0; i < len(aeroTrackPositions)-1; i++ {
thisPosition := aeroTrackPositions[i]
nextPosition := aeroTrackPositions[i+1]
// the "reported" placemarks plot info received directly from AeroAPI
reportedDescription := getReportedDescription(thisPosition, nextPosition)
reportedPlacemark := styledPlacemark{
styleName: fmt.Sprintf("heading-icon%d", i),
balloonText: fmt.Sprintf("<h1>Reported</h1>%s", reportedDescription),
styleColor: color.RGBA{R: 255, G: 255, B: 0, A: 255},
heading: thisPosition.Heading,
gs: thisPosition.GsKnots,
iconImageUrl: vectorArrowHref,
position: thisPosition,
}
positionReferences = append(positionReferences, reportedPlacemark.getElements()...)
// the "imputed" placemarks plot info calculated indirectly from AeroAPI data
geoHeading := aeroapiMathUtil.GetGeoBearing(thisPosition, nextPosition)
geoGsKnots := aeroapiMathUtil.GetGeoGsKnots(thisPosition, nextPosition)
imputedPlacemark := styledPlacemark{
styleName: fmt.Sprintf("bearing-icon%d", i),
balloonText: fmt.Sprintf("<h1>Imputed</h1>%s%s", getImputedDescription(geoHeading, geoGsKnots), reportedDescription),
styleColor: color.RGBA{R: 0, G: 255, B: 255, A: 255},
heading: float64(geoHeading),
gs: geoGsKnots,
iconImageUrl: vectorArrowHref,
position: thisPosition,
}
positionReferences = append(positionReferences, imputedPlacemark.getElements()...)
}
thing.Root = gokml.Folder(
gokml.Name("Vector Track"),
gokml.Description("Vectors along flight path reflecting performance data"),
).
Append(positionReferences...)
return thing, nil
}
func getEmbeddedFileContents(fs fs.FS, fileName string) ([]byte, error) {
arrowFile, openErr := fs.Open(fileName)
if openErr != nil {
return nil, openErr
}
defer func() { _ = arrowFile.Close() }()
buffer := &bytes.Buffer{}
_, readErr := buffer.ReadFrom(arrowFile)
if readErr != nil {
return nil, readErr
}
return buffer.Bytes(), nil
}
type styledPlacemark struct {
styleName string
balloonText string
styleColor color.Color
heading float64
gs float64
iconImageUrl string
position aeroapi.Position
}
func (sp *styledPlacemark) getElements() []gokml.Element {
return []gokml.Element{
gokml.Style(
gokml.IconStyle(
gokml.Color(sp.styleColor),
gokml.Icon(gokml.Href(sp.iconImageUrl)),
gokml.Heading(sp.heading-90),
gokml.Scale(sp.gs/100),
),
gokml.BalloonStyle(gokml.Text(sp.balloonText)),
).WithID(sp.styleName),
gokml.Placemark(
gokml.StyleURL("#"+sp.styleName),
gokml.Point(
gokml.AltitudeMode(gokml.AltitudeModeAbsolute),
gokml.Coordinates(
gokml.Coordinate{
Lon: sp.position.Longitude,
Lat: sp.position.Latitude,
Alt: aeroAlt2Meters(sp.position.AltMslD100),
},
),
),
),
}
}
func getImputedDescription(geoHeading aeroapi.Degrees, geoGsKnots float64) string {
return fmt.Sprintf(`<h2>Imputed From Location Change</h2>
<ul>
<li>Heading: %.1fº</li>
<li>Groundspeed: %.1fkt</li>
</ul>`,
geoHeading,
geoGsKnots,
)
}
func getReportedDescription(thisPosition aeroapi.Position, nextPosition aeroapi.Position) string {
return fmt.Sprintf(`<h2>Reported by AeroAPI</h2>
<h3>This Location</h3>
<ul>
<li>Time: %v</li>
<li>Location: %v</li>
<li>Altitude: %.0f'</li>
<li>Heading: %.1fº</li>
<li>Groundspeed: %.1fkt</li>
</ul>
<h3>Next Location</h3>
<ul>
<li>Time: %v</li>
<li>Location: %v</li>
<li>Altitude: %.0f'</li>
<li>Heading: %.1fº</li>
<li>Groundspeed: %.1fkt</li>
</ul>`,
thisPosition.Timestamp.Format(time.RFC3339),
[]float64{thisPosition.Latitude, thisPosition.Longitude},
thisPosition.AltMslD100*100,
thisPosition.Heading,
thisPosition.GsKnots,
thisPosition.Timestamp.Format(time.RFC3339),
[]float64{nextPosition.Latitude, nextPosition.Longitude},
nextPosition.AltMslD100*100,
thisPosition.Heading,
thisPosition.GsKnots,
)
}
package kml
import (
"bytes"
"fmt"
"strings"
"time"
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/internal/kml/builders"
"github.com/noodnik2/flightvisualizer/pkg/aeroapi"
)
// Track contains the fully-rendered KML document representing a flight,
// assets referenced by that KML document, and some relevant metadata
type Track struct {
KmlDoc []byte
KmlAssets map[string]any
StartTime *time.Time
EndTime *time.Time
}
// TrackGenerator can generate a Track from raw flight position data
type TrackGenerator interface {
Generate(*aeroapi.Track) (*Track, error)
}
// TrackBuilderEnsemble is a named set of KmlTrackBuilder instances
type TrackBuilderEnsemble struct {
Name string
Builders []builders.KmlTrackBuilder
}
func (gxt *TrackBuilderEnsemble) Generate(aeroTrack *aeroapi.Track) (*Track, error) {
const cantGenerateTrackForFlightError = "can't generate KML track for flightId(%s)"
if len(gxt.Builders) == 0 {
return nil, fmt.Errorf(cantGenerateTrackForFlightError+"; no builders", aeroTrack.FlightId)
}
positions := aeroTrack.Positions
nPositions := len(positions)
var fromTime, toTime *time.Time
if nPositions == 0 {
return nil, fmt.Errorf(cantGenerateTrackForFlightError+"; has no positions", aeroTrack.FlightId)
}
fromTime = &positions[0].Timestamp
toTime = &positions[nPositions-1].Timestamp
var layerNames []string
for _, kmlBuilder := range gxt.Builders {
layerNames = append(layerNames, kmlBuilder.Name())
}
mainDocument := gokml.Document(
gokml.Name(fmt.Sprintf("AeroAPI Flight %s", aeroTrack.FlightId)),
gokml.Description(fmt.Sprintf("Layers: %s", strings.Join(layerNames, ", "))),
)
kmlAssets := make(map[string]any)
for _, kb := range gxt.Builders {
kmlThing, buildErr := kb.Build(positions)
if buildErr != nil {
fmt.Printf("NOTE: %s\n", buildErr)
continue
}
mainDocument.Append(kmlThing.Root)
for k, v := range kmlThing.Assets {
kmlAssets[k] = v
}
}
gxKMLElement := gokml.GxKML(mainDocument)
var kmlBuilder bytes.Buffer
if err := gxKMLElement.Write(&kmlBuilder); err != nil {
return nil, err
}
kmlTrack := Track{
KmlDoc: kmlBuilder.Bytes(),
KmlAssets: kmlAssets,
StartTime: fromTime,
EndTime: toTime,
}
return &kmlTrack, nil
}
package os
import (
"os/exec"
"runtime"
)
func LaunchFile(filename string) error {
switch runtime.GOOS {
case "windows":
return exec.Command("cmd", "/C", filename).Run()
case "darwin":
return exec.Command("open", filename).Run()
case "linux":
return exec.Command("sh", "-c", filename).Run()
}
return exec.Command(filename).Run()
}
package persistence
import (
"bytes"
gokml "github.com/twpayne/go-kml/v3"
"github.com/noodnik2/flightvisualizer/pkg/persistence"
)
type KmzSaver struct {
persistence.Saver
Assets map[string]any
}
func (rs *KmzSaver) Save(fnFragment string, contents []byte) error {
files := make(map[string]any)
for assetKey, assetValue := range rs.Assets {
files[assetKey] = assetValue
}
files["doc.kml"] = contents
memoryWriter := &bytes.Buffer{}
if writeErr := gokml.WriteKMZ(memoryWriter, files); writeErr != nil {
return writeErr
}
return rs.Saver.Save(fnFragment, memoryWriter.Bytes())
}
package aeroapi
import (
"encoding/json"
"fmt"
"time"
"github.com/noodnik2/flightvisualizer/pkg/persistence"
)
type ResponseSaver func(string, []byte) (string, error)
type FlightsResponse struct {
Flights []Flight `json:"flights"`
}
type Flight struct {
FlightId string `json:"fa_flight_id"`
}
type Track struct {
FlightId string
Positions []Position `json:"positions"`
}
type Position struct {
// 230811 Changed "AltAglD100" to "AltMslD100" per updated understanding
// based upon post: "https://discussions.flightaware.com/t/aero-api-altitude/82883/4"
// which reads: "None of our aircraft altitudes will ever be reported in AGL, if that helps."
// and also based upon observed results for Virgin Galactic flight
AltMslD100 float64 `json:"altitude"` // feet / 100 (MSL)
GsKnots float64 `json:"groundspeed"` // knots
Heading float64 `json:"heading"` // 0..359
Latitude float64 `json:"latitude"` // -90..90
Longitude float64 `json:"longitude"` // -180..180
Timestamp time.Time `json:"timestamp"`
}
type Api interface {
GetFlightIds(tailNumber string, cutoffTime time.Time) ([]string, error)
GetTrackForFlightId(flightId string) (*Track, error)
}
type ArtifactLocator interface {
// GetFlightIdsRef returns a reference used to obtain the flight identifier(s) for the desired track(s).
// The return value is an address (such as a URL or file name) used within context to obtain the desired list.
GetFlightIdsRef(tailNumber string, cutoffTime time.Time) (string, error)
// GetTrackForFlightRef returns a reference (such as a URL or file name) used to obtain the desired track data.
GetTrackForFlightRef(flightId string) string
}
type ArtifactRetriever interface {
ArtifactLocator
persistence.Loader
}
type ArtifactSaver interface {
ArtifactLocator
persistence.Saver
}
type RetrieverSaverApiImpl struct {
Retriever ArtifactRetriever
Saver ArtifactSaver
}
// GetFlightIds returns the AeroAPI identifier(s) of the flight(s) specified by the parameters
// cutoffTime (optional) - most recent time for a flight to be considered
func (a *RetrieverSaverApiImpl) GetFlightIds(tailNumber string, cutoffTime time.Time) ([]string, error) {
endpoint, getFidsErr := a.Retriever.GetFlightIdsRef(tailNumber, cutoffTime)
if getFidsErr != nil {
return nil, newFlightApiError("get endpoint", "retrieving flight IDs", getFidsErr)
}
responseBytes, getErr := a.Retriever.Load(endpoint)
if getErr != nil {
return nil, newFlightApiError("get", endpoint, getErr)
}
if a.Saver != nil {
saveUri, getSaveFidsErr := a.Saver.GetFlightIdsRef(tailNumber, cutoffTime)
if getSaveFidsErr != nil {
return nil, newFlightApiError("get URI", "saving flight IDs", getSaveFidsErr)
}
if getSaveErr := a.Saver.Save(saveUri, responseBytes); getSaveErr != nil {
return nil, newFlightApiError("save get flight ids response", endpoint, getSaveErr)
}
}
flights, flightsErr := FlightsFromJson(responseBytes)
if flightsErr != nil {
return nil, newFlightApiError("unmarshal", endpoint, flightsErr)
}
var flightIds []string
for _, flight := range flights.Flights {
flightIds = append(flightIds, flight.FlightId)
}
return flightIds, nil
}
// GetTrackForFlightId retrieves the track for the given flight given its AeroAPI identifier
func (a *RetrieverSaverApiImpl) GetTrackForFlightId(flightId string) (*Track, error) {
endpoint := a.Retriever.GetTrackForFlightRef(flightId)
responseBytes, getErr := a.Retriever.Load(endpoint)
if getErr != nil {
return nil, newFlightApiError("get", endpoint, getErr)
}
if a.Saver != nil {
saveUri := a.Saver.GetTrackForFlightRef(flightId)
if getSaveErr := a.Saver.Save(saveUri, responseBytes); getSaveErr != nil {
return nil, newFlightApiError("save get track response", endpoint, getSaveErr)
}
}
track, unmarshallErr := TrackFromJson(responseBytes)
if unmarshallErr != nil {
return nil, newFlightApiError("unmarshal", endpoint, unmarshallErr)
}
track.FlightId = flightId
return track, nil
}
func FlightsFromJson(flightsBytes []byte) (*FlightsResponse, error) {
var flights FlightsResponse
if unmarshallErr := json.Unmarshal(flightsBytes, &flights); unmarshallErr != nil {
return nil, unmarshallErr
}
return &flights, nil
}
func TrackFromJson(aeroApiTrackJson []byte) (*Track, error) {
var track Track
if unmarshallErr := json.Unmarshal(aeroApiTrackJson, &track); unmarshallErr != nil {
return nil, unmarshallErr
}
return &track, nil
}
func newFlightApiError(what, where string, err error) error {
return fmt.Errorf("couldn't %s for %s: %w", what, where, err)
}
package aeroapi
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/noodnik2/flightvisualizer/pkg/persistence"
)
type FileAeroApi struct {
ArtifactsDir string
FlightIdsFileName string
persistence.FileLoader
persistence.FileSaver
}
const trackArtifactFilenamePrefix = "fvt_"
const trackArtifactFilenameSuffix = ".json"
const flightIdsArtifactFilenamePrefix = "fvf_"
const flightIdsArtifactFilenameSuffix = ".json"
func MakeTrackArtifactFilename(flightId string) string {
return trackArtifactFilenamePrefix + flightId + trackArtifactFilenameSuffix
}
func IsTrackArtifactFilename(fn string) bool {
base := filepath.Base(fn)
return strings.HasPrefix(base, trackArtifactFilenamePrefix) && strings.HasSuffix(base, trackArtifactFilenameSuffix)
}
func MakeFlightIdsArtifactFilename(queryId string) string {
return flightIdsArtifactFilenamePrefix + queryId + flightIdsArtifactFilenameSuffix
}
func IsFlightIdsArtifactFilename(fn string) bool {
base := filepath.Base(fn)
return strings.HasPrefix(base, flightIdsArtifactFilenamePrefix) && strings.HasSuffix(base, flightIdsArtifactFilenameSuffix)
}
func (c *FileAeroApi) GetFlightIdsRef(tailNumber string, cutoffTime time.Time) (string, error) {
var fileName string
if c.FlightIdsFileName != "" {
fileName = c.FlightIdsFileName
} else {
var queryId string
if cutoffTime.IsZero() {
queryId = tailNumber
} else {
queryId = fmt.Sprintf("%s_cutoff-%s", tailNumber, cutoffTime.Format("20060102T150405Z0700"))
}
fileName = MakeFlightIdsArtifactFilename(queryId)
}
if !IsFlightIdsArtifactFilename(filepath.Base(fileName)) {
return "", fmt.Errorf("unrecognized flight ids filename(%s)", fileName)
}
if filepath.Dir(fileName) == "." {
// use the artifacts directory if not specified
return filepath.Join(c.ArtifactsDir, fileName), nil
}
return fileName, nil
}
func (c *FileAeroApi) GetTrackForFlightRef(flightId string) string {
artifactDir := filepath.Dir(c.FlightIdsFileName)
if artifactDir == "." {
// use the artifacts directory if not specified
artifactDir = c.ArtifactsDir
}
return filepath.Join(artifactDir, MakeTrackArtifactFilename(flightId))
}
package aeroapi
import (
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
type HttpAeroApi struct {
Verbose bool
ApiKey string
ApiUrl string
}
func (c *HttpAeroApi) GetFlightIdsRef(tailNumber string, cutoffTime time.Time) (string, error) {
endpoint := fmt.Sprintf("/flights/%s", tailNumber)
if !cutoffTime.IsZero() {
endpoint += fmt.Sprintf("?&end=%s", cutoffTime.Format(time.RFC3339))
}
return endpoint, nil
}
func (c *HttpAeroApi) GetTrackForFlightRef(flightId string) string {
return fmt.Sprintf("/flights/%s/track", flightId)
}
func (c *HttpAeroApi) Load(endpoint string) ([]byte, error) {
const pathSep = "/"
requestUrl := fmt.Sprintf("%s%s%s", strings.TrimRight(c.ApiUrl, pathSep), pathSep, strings.TrimLeft(endpoint, pathSep))
if c.Verbose {
log.Printf("INFO: requesting from endpoint(%s)\n", endpoint)
}
req, buildReqErr := http.NewRequest("GET", requestUrl, nil)
if buildReqErr != nil {
return nil, newApiError("create request", requestUrl, buildReqErr)
}
client := &http.Client{}
req.Header.Set("Accept", "application/json")
req.Header.Set("x-apikey", c.ApiKey)
resp, issueReqErr := client.Do(req)
if issueReqErr != nil {
return nil, newApiError("issue request", requestUrl, issueReqErr)
}
defer func(body io.ReadCloser) {
if err := body.Close(); err != nil {
log.Printf("WARNING: error closing request body: %v\n", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
responsePayload, _ := io.ReadAll(resp.Body)
responseErr := fmt.Errorf("statusCode(%d), status(%s), body(%s)", resp.StatusCode, resp.Status, string(responsePayload))
return nil, newApiError("get successful response", requestUrl, responseErr)
}
responsePayload, readResponseBodyErr := io.ReadAll(resp.Body)
if readResponseBodyErr != nil {
return nil, newApiError("read response body", requestUrl, readResponseBodyErr)
}
return responsePayload, nil
}
func newApiError(what, where string, err error) error {
return fmt.Errorf("couldn't %s for %s: %w", what, where, err)
}
package aeroapi
import (
"log"
"math"
"time"
"github.com/twpayne/go-kml/v3"
"github.com/twpayne/go-kml/v3/sphere"
)
type Math struct {
Debug bool
}
type f = float64
type Degrees float64
type Radians float64
// GetBankAngle calculates and reports, using a loose heuristic, a reasonable "bank angle"
// that could be used by a general aviation aircraft to achieve the observed change in heading
// between one reported position and another.
func (u *Math) GetBankAngle(fromPosition, toPosition Position) Degrees {
isLeftHandTurn := !isRightHandTurn(fromPosition.Heading, toPosition.Heading)
deltaH := toPosition.Heading - fromPosition.Heading
if isLeftHandTurn {
// turning left so magnitude of heading change is
// current heading (larger) less next heading
deltaH = -deltaH
}
if deltaH < 0 {
// rationalize negative values
deltaH += 360
}
if isLeftHandTurn {
// express left hand turns as a negative offset from current heading
deltaH = -deltaH
}
deltaT := toPosition.Timestamp.Sub(fromPosition.Timestamp)
turnRate := deltaH * f(time.Second) / f(deltaT)
// use prior heuristic:
// see https://github.com/noodnik2/MSFS2020-PilotPathRecorder/blob/8bda00905b8566d103e32d0e76b01941ce066c92/FS2020PlanePath/FlightDataGenerator.cs#L74
newRawPlaneBankAngle := ((fromPosition.GsKnots / 10) + 7) * turnRate / 3
bankAngle := rationalizeBankAngle(Degrees(newRawPlaneBankAngle))
if u.Debug {
log.Printf("heading(%f), bank angle(%f), deltaT(%v) deltaH(%f), turnRate(%v), groundspeed(%f)\n",
fromPosition.Heading, bankAngle, deltaT, deltaH, turnRate, fromPosition.GsKnots)
}
return bankAngle
}
// GetGeoGsKnots calculates and reports the apparent average ground speed used
// navigate the straight line distance between two geolocations
func (u *Math) GetGeoGsKnots(fromPosition, toPosition Position) float64 {
// get distance
const earthRadiusKm = 6371
earth := sphere.T{R: earthRadiusKm}
kilometers := earth.HaversineDistance(
kml.Coordinate{Lon: fromPosition.Longitude, Lat: fromPosition.Latitude},
kml.Coordinate{Lon: toPosition.Longitude, Lat: toPosition.Latitude})
const kilometersPerNauticalMile = 1.852
nauticalMiles := kilometers / kilometersPerNauticalMile
// get time
deltaT := toPosition.Timestamp.Sub(fromPosition.Timestamp)
deltaTHours := f(deltaT) / f(time.Hour)
// get ground speed
return nauticalMiles / deltaTHours
}
// GetGeoBearing calculates and reports the apparent compass bearing
// (0 <= bearing < 360) needed to arrive at a new geolocation
func (u *Math) GetGeoBearing(fromPosition, toPosition Position) Degrees {
var earth sphere.T
initialBearing := earth.InitialBearingTo(
kml.Coordinate{Lon: fromPosition.Longitude, Lat: fromPosition.Latitude},
kml.Coordinate{Lon: toPosition.Longitude, Lat: toPosition.Latitude})
if initialBearing < 0 {
initialBearing += 360
}
return Degrees(math.Mod(initialBearing, 360))
}
func isRightHandTurn(origHdg, destHdg float64) bool {
switchDir := math.Abs(destHdg-origHdg) > 180
destGreaterThanOrig := destHdg > origHdg
return destGreaterThanOrig != switchDir
}
func rationalizeBankAngle(bankAngle Degrees) Degrees {
if bankAngle < -60 {
return -60
}
if bankAngle > 60 {
return 60
}
return bankAngle
}
package aeroapi
import (
"fmt"
"time"
)
type MockArtifactRetriever struct {
Err error
Contents []byte
RequestedEndpoints []string
}
func (*MockArtifactRetriever) GetFlightIdsRef(tailNumber string, _ time.Time) (string, error) {
return "/fl/" + tailNumber, nil
}
func (*MockArtifactRetriever) GetTrackForFlightRef(flightId string) string {
return fmt.Sprintf("/fli/%s/track", flightId)
}
func (r *MockArtifactRetriever) Load(requestEndpoint string) ([]byte, error) {
r.RequestedEndpoints = append(r.RequestedEndpoints, requestEndpoint)
return r.Contents, r.Err
}
package persistence
import (
"log"
"os"
)
type Saver interface {
Save(string, []byte) error
}
type Loader interface {
Load(string) ([]byte, error)
}
type FileSaverWriter func(filePath string, contents []byte) error
type FileLoaderReader func(filePath string) ([]byte, error)
type FileSaver struct {
Writer FileSaverWriter
}
type FileLoader struct {
Reader FileLoaderReader
}
func (rs *FileSaver) Save(fnRef string, contents []byte) error {
return rs.save(os.WriteFile, fnRef, contents)
}
func (fl *FileLoader) Load(fnRef string) (contents []byte, err error) {
return fl.load(os.ReadFile, fnRef)
}
type underWriter func(name string, data []byte, perm os.FileMode) error
type underReader func(name string) ([]byte, error)
func (rs *FileSaver) save(uw underWriter, fnRef string, contents []byte) error {
log.Printf("INFO: saving to file(%s)\n", fnRef)
if rs.Writer != nil {
return rs.Writer(fnRef, contents)
}
return uw(fnRef, contents, 0644)
}
func (fl *FileLoader) load(ur underReader, fnRef string) (contents []byte, err error) {
log.Printf("INFO: reading from file(%s)\n", fnRef)
if fl.Reader != nil {
return fl.Reader(fnRef)
}
return ur(fnRef)
}
package testfixtures
func NewMockTestAeroApiTrackResponse() string {
return `
{
"positions": [
{
"fa_flight_id": null,
"altitude": 3,
"altitude_change": "C",
"groundspeed": 65,
"heading": 270,
"latitude": 33.82281,
"longitude": -118.1579,
"timestamp": "2023-05-10T03:45:35Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 13,
"altitude_change": "C",
"groundspeed": 62,
"heading": 271,
"latitude": 33.82324,
"longitude": -118.19361,
"timestamp": "2023-05-10T03:47:11Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 20,
"altitude_change": "C",
"groundspeed": 103,
"heading": 177,
"latitude": 33.78622,
"longitude": -118.19458,
"timestamp": "2023-05-10T03:48:47Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 26,
"altitude_change": "C",
"groundspeed": 112,
"heading": 137,
"latitude": 33.74135,
"longitude": -118.18599,
"timestamp": "2023-05-10T03:50:26Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 29,
"altitude_change": "D",
"groundspeed": 102,
"heading": 199,
"latitude": 33.70266,
"longitude": -118.1488,
"timestamp": "2023-05-10T03:52:18Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 31,
"altitude_change": "-",
"groundspeed": 86,
"heading": 220,
"latitude": 33.70372,
"longitude": -118.17808,
"timestamp": "2023-05-10T03:54:06Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 32,
"altitude_change": "-",
"groundspeed": 120,
"heading": 107,
"latitude": 33.67398,
"longitude": -118.11178,
"timestamp": "2023-05-10T03:56:10Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 32,
"altitude_change": "D",
"groundspeed": 88,
"heading": 262,
"latitude": 33.69589,
"longitude": -118.10303,
"timestamp": "2023-05-10T03:57:52Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 32,
"altitude_change": "-",
"groundspeed": 118,
"heading": 130,
"latitude": 33.67067,
"longitude": -118.12287,
"timestamp": "2023-05-10T03:59:30Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "-",
"groundspeed": 123,
"heading": 66,
"latitude": 33.63543,
"longitude": -118.04724,
"timestamp": "2023-05-10T04:01:38Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "-",
"groundspeed": 111,
"heading": 39,
"latitude": 33.69791,
"longitude": -117.97714,
"timestamp": "2023-05-10T04:04:25Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "-",
"groundspeed": 110,
"heading": 38,
"latitude": 33.76854,
"longitude": -117.91082,
"timestamp": "2023-05-10T04:07:18Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "C",
"groundspeed": 118,
"heading": 27,
"latitude": 33.83324,
"longitude": -117.86173,
"timestamp": "2023-05-10T04:09:46Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "C",
"groundspeed": 125,
"heading": 51,
"latitude": 33.89743,
"longitude": -117.83438,
"timestamp": "2023-05-10T04:11:58Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "-",
"groundspeed": 117,
"heading": 164,
"latitude": 33.8776,
"longitude": -117.75576,
"timestamp": "2023-05-10T04:14:03Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 30,
"altitude_change": "D",
"groundspeed": 107,
"heading": 208,
"latitude": 33.83077,
"longitude": -117.77149,
"timestamp": "2023-05-10T04:15:44Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 24,
"altitude_change": "D",
"groundspeed": 101,
"heading": 207,
"latitude": 33.78575,
"longitude": -117.7993,
"timestamp": "2023-05-10T04:17:34Z",
"update_type": "A"
},
{
"fa_flight_id": null,
"altitude": 14,
"altitude_change": "D",
"groundspeed": 98,
"heading": 208,
"latitude": 33.73999,
"longitude": -117.82883,
"timestamp": "2023-05-10T04:19:24Z",
"update_type": "X"
},
{
"fa_flight_id": null,
"altitude": 2,
"altitude_change": "D",
"groundspeed": 75,
"heading": 207,
"latitude": 33.68587,
"longitude": -117.86251,
"timestamp": "2023-05-10T04:21:53Z",
"update_type": "A"
}
]
}
`
}