Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/WayfarerMobile.Core/Helpers/TripSegmentGeometryParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Text.Json;

namespace WayfarerMobile.Core.Helpers;

public enum SegmentGeometryFailure
{
Empty,
MalformedGeoJson,
UnsupportedGeoJsonType,
MalformedEncodedPolyline,
InvalidCoordinate,
InsufficientPoints
}

public sealed record SegmentGeometryParseResult(
IReadOnlyList<(double Latitude, double Longitude)> Coordinates,
SegmentGeometryFailure? Failure)
{
public bool IsSuccess => Failure is null;
}

/// <summary>
/// Parses Segment transport geometry into validated geographic coordinates.
/// </summary>
public static class TripSegmentGeometryParser
{
private static readonly IReadOnlyList<(double Latitude, double Longitude)> NoCoordinates =
Array.Empty<(double Latitude, double Longitude)>();

public static SegmentGeometryParseResult Parse(string? geometry)
{
if (string.IsNullOrWhiteSpace(geometry))
return Failure(SegmentGeometryFailure.Empty);

var detected = geometry.AsSpan().TrimStart();
return detected[0] == '{'
? ParseGeoJson(detected)
: ParseEncodedPolyline(geometry);
}

private static SegmentGeometryParseResult ParseGeoJson(ReadOnlySpan<char> geometry)
{
JsonDocument document;
try
{
document = JsonDocument.Parse(geometry.ToString());
}
catch (JsonException)
{
return Failure(SegmentGeometryFailure.MalformedGeoJson);
}

using (document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("type", out var type) ||
type.ValueKind != JsonValueKind.String)
{
return Failure(SegmentGeometryFailure.MalformedGeoJson);
}

if (type.GetString() != "LineString")
return Failure(SegmentGeometryFailure.UnsupportedGeoJsonType);

if (!root.TryGetProperty("coordinates", out var coordinates) ||
coordinates.ValueKind != JsonValueKind.Array)
{
return Failure(SegmentGeometryFailure.MalformedGeoJson);
}

var result = new List<(double Latitude, double Longitude)>();
foreach (var position in coordinates.EnumerateArray())
{
if (position.ValueKind != JsonValueKind.Array || position.GetArrayLength() < 2)
return Failure(SegmentGeometryFailure.InvalidCoordinate);

var ordinates = position.EnumerateArray();
ordinates.MoveNext();
var longitudeValue = ordinates.Current;
ordinates.MoveNext();
var latitudeValue = ordinates.Current;
if (longitudeValue.ValueKind != JsonValueKind.Number ||
latitudeValue.ValueKind != JsonValueKind.Number ||
!longitudeValue.TryGetDouble(out var longitude) ||
!latitudeValue.TryGetDouble(out var latitude) ||
!IsValidCoordinate(latitude, longitude))
{
return Failure(SegmentGeometryFailure.InvalidCoordinate);
}

result.Add((latitude, longitude));
}

return result.Count < 2
? Failure(SegmentGeometryFailure.InsufficientPoints)
: Success(result);
}
}

private static SegmentGeometryParseResult ParseEncodedPolyline(string geometry)
{
if (!IsStructurallyValidEncodedPolyline(geometry))
return Failure(SegmentGeometryFailure.MalformedEncodedPolyline);

var coordinates = PolylineDecoder.DecodeToTuples(geometry);
if (coordinates.Any(point => !IsValidCoordinate(point.Latitude, point.Longitude)))
return Failure(SegmentGeometryFailure.InvalidCoordinate);

return coordinates.Count < 2
? Failure(SegmentGeometryFailure.InsufficientPoints)
: Success(coordinates);
}

private static bool IsStructurallyValidEncodedPolyline(string encoded)
{
var index = 0;
long latitude = 0;
long longitude = 0;

while (index < encoded.Length)
{
if (!TryReadComponent(encoded, ref index, out var latitudeDelta) ||
!TryReadComponent(encoded, ref index, out var longitudeDelta))
{
return false;
}

try
{
latitude = checked(latitude + latitudeDelta);
longitude = checked(longitude + longitudeDelta);
}
catch (OverflowException)
{
return false;
}

if (latitude is < -9_000_000 or > 9_000_000 ||
longitude is < -18_000_000 or > 18_000_000)
{
return false;
}
}

return true;
}

private static bool TryReadComponent(string encoded, ref int index, out long delta)
{
ulong result = 0;
var shift = 0;

while (index < encoded.Length)
{
var character = encoded[index++];
if (character is < '?' or > '~')
{
delta = 0;
return false;
}

var value = character - 63;
if (shift > 30 || (shift == 30 && (value & 0x1f) > 1))
{
delta = 0;
return false;
}

result |= (ulong)(value & 0x1f) << shift;
if (value < 0x20)
{
delta = (result & 1) == 0
? (long)(result >> 1)
: -(long)(result >> 1) - 1;
return true;
}

shift += 5;
}

delta = 0;
return false;
}

private static bool IsValidCoordinate(double latitude, double longitude) =>
double.IsFinite(latitude) &&
double.IsFinite(longitude) &&
latitude is >= -90 and <= 90 &&
longitude is >= -180 and <= 180;

private static SegmentGeometryParseResult Success(
IReadOnlyList<(double Latitude, double Longitude)> coordinates) => new(coordinates, null);

private static SegmentGeometryParseResult Failure(SegmentGeometryFailure failure) =>
new(NoCoordinates, failure);
}
68 changes: 68 additions & 0 deletions src/WayfarerMobile.Core/Navigation/TripNavigationGraphBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using WayfarerMobile.Core.Helpers;
using WayfarerMobile.Core.Models;

namespace WayfarerMobile.Core.Navigation;

/// <summary>
/// Builds the local navigation graph from transport trip data.
/// </summary>
public static class TripNavigationGraphBuilder
{
public static TripNavigationGraph Build(
TripDetails trip,
Action<Guid, SegmentGeometryFailure>? geometryFailure = null)
{
var graph = new TripNavigationGraph { TripId = trip.Id };

foreach (var region in trip.Regions)
{
foreach (var place in region.Places)
{
graph.AddNode(new NavigationNode
{
Id = place.Id.ToString(),
Name = place.Name,
Latitude = place.Latitude,
Longitude = place.Longitude,
Type = NavigationNodeType.Place,
SortOrder = place.SortOrder,
Notes = place.Notes,
IconName = place.Icon
});
}
}

foreach (var segment in trip.Segments)
{
var edge = new NavigationEdge
{
FromNodeId = (segment.OriginId ?? Guid.Empty).ToString(),
ToNodeId = (segment.DestinationId ?? Guid.Empty).ToString(),
TransportMode = segment.TransportMode ?? "walking",
DistanceKm = segment.DistanceKm ?? 0,
DurationMinutes = (int)(segment.DurationMinutes ?? 0),
EdgeType = NavigationEdgeType.UserSegment
};

var parseResult = TripSegmentGeometryParser.Parse(segment.Geometry);
if (parseResult.IsSuccess)
{
edge.RouteGeometry = parseResult.Coordinates
.Select(point => new RoutePoint
{
Latitude = point.Latitude,
Longitude = point.Longitude
})
.ToList();
}
else if (parseResult.Failure != SegmentGeometryFailure.Empty)
{
geometryFailure?.Invoke(segment.Id, parseResult.Failure!.Value);
}

graph.AddEdge(edge);
}

return graph;
}
}
77 changes: 9 additions & 68 deletions src/WayfarerMobile/Services/TripLayerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -210,44 +210,24 @@ public void UpdateTripSegments(WritableLayer layer, IEnumerable<TripSegment> seg
var segmentCount = 0;
foreach (var segment in segmentList)
{
_logger.LogDebug("Processing segment {Id}: Mode={Mode}, Geometry length={Length}",
segment.Id, segment.TransportMode ?? "null",
segment.Geometry?.Length ?? 0);

if (string.IsNullOrEmpty(segment.Geometry))
{
_logger.LogWarning("Skipping segment {Id}: no geometry", segment.Id);
continue;
}

try
var parseResult = TripSegmentGeometryParser.Parse(segment.Geometry);
if (!parseResult.IsSuccess)
{
// Parse geometry - could be GeoJSON LineString or encoded polyline
List<(double Latitude, double Longitude)> coordinates;

if (segment.Geometry.TrimStart().StartsWith("{"))
{
// GeoJSON format: {"type":"LineString","coordinates":[[lon,lat],...]}
coordinates = ParseGeoJsonLineString(segment.Geometry);
_logger.LogDebug("Parsed GeoJSON segment {Id}: {PointCount} points", segment.Id, coordinates.Count);
}
else
{
// Encoded polyline format
var points = PolylineDecoder.Decode(segment.Geometry);
coordinates = points.Select(p => (p.Latitude, p.Longitude)).ToList();
_logger.LogDebug("Decoded polyline segment {Id}: {PointCount} points", segment.Id, coordinates.Count);
}

if (coordinates.Count < 2)
{
_logger.LogWarning("Skipping segment {Id}: only {Count} points after parsing",
segment.Id, coordinates.Count);
continue;
}
if (parseResult.Failure != SegmentGeometryFailure.Empty)
_logger.LogWarning("Skipping segment {SegmentId}: geometry failure {Failure}", segment.Id, parseResult.Failure);
continue;
}

try
{
// Convert to map coordinates
var mapCoordinates = coordinates
var mapCoordinates = parseResult.Coordinates
.Select(p =>
{
var (x, y) = SphericalMercator.FromLonLat(p.Longitude, p.Latitude);
Expand Down Expand Up @@ -559,43 +539,4 @@ public void ClearPlaceSelection(WritableLayer layer)

#endregion

#region GeoJSON Parsing

/// <summary>
/// Parses a GeoJSON LineString into coordinate pairs.
/// </summary>
/// <param name="geoJson">GeoJSON string with type "LineString".</param>
/// <returns>List of (Latitude, Longitude) tuples.</returns>
private static List<(double Latitude, double Longitude)> ParseGeoJsonLineString(string geoJson)
{
var result = new List<(double Latitude, double Longitude)>();

try
{
using var doc = System.Text.Json.JsonDocument.Parse(geoJson);
var root = doc.RootElement;

// GeoJSON LineString format: { "type": "LineString", "coordinates": [[lon,lat], [lon,lat], ...] }
if (root.TryGetProperty("coordinates", out var coordinates))
{
foreach (var point in coordinates.EnumerateArray())
{
if (point.GetArrayLength() >= 2)
{
var lon = point[0].GetDouble();
var lat = point[1].GetDouble();
result.Add((lat, lon));
}
}
}
}
catch
{
// Invalid GeoJSON, return empty list
}

return result;
}

#endregion
}
Loading
Loading