Skip to content

Sync chart cursor across route analysis charts - #5680

Open
aleksandr-tata wants to merge 26 commits into
masterfrom
task_5669_sync_chart_cursor
Open

Sync chart cursor across route analysis charts#5680
aleksandr-tata wants to merge 26 commits into
masterfrom
task_5669_sync_chart_cursor

Conversation

@aleksandr-tata

@aleksandr-tata aleksandr-tata commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Synchronize the selected route position across altitude, road type, steepness, surface, and smoothness charts in Route Details.
  • Add synchronized chart cursors to the Plan a Route Analyze tab.
  • Update all chart cursors when tapping or scrubbing any chart.
  • Synchronize chart viewports during zooming and panning while preserving the selected route position.
  • Align route attribute chart distance ranges with the primary chart, including routes with segment gaps.
  • Restore the synchronized cursor and viewport after scrolling and chart cell recreation.
  • Match the Android chart interaction and synchronization behavior.
  • Render a vertical cursor on route attribute charts and distance-axis tick marks in the Plan a route Analyze tab.

@aleksandr-tata
aleksandr-tata requested a review from tigrim August 21, 2026 16:03
@aleksandr-tata aleksandr-tata linked an issue Aug 24, 2026 that may be closed by this pull request
4 tasks
@aleksandr-tata aleksandr-tata changed the title Sync chart cursor across route analysis charts [Draft] Sync chart cursor across route analysis charts Aug 24, 2026
@tigrim
tigrim marked this pull request as draft August 24, 2026 09:53
@aleksandr-tata aleksandr-tata changed the title [Draft] Sync chart cursor across route analysis charts Sync chart cursor across route analysis charts Aug 24, 2026
@aleksandr-tata
aleksandr-tata marked this pull request as ready for review August 24, 2026 15:19
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let cell = cell as? AnalyzeCardCell else { return }
if let chart = cell.cardView.subviews.first(where: { $0 is ElevationChart }) as? ElevationChart {
cell.layoutIfNeeded()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

recognizer.state == .ended,
let chart = recognizer.view as? BarLineChartViewBase else { return }
refreshChartOnMap()
DispatchQueue.main.async { [weak self, weak chart] in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need [weak self, weak chart] here? There is no retain cycle with DispatchQueue.main.async

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

if ([recognizer.view isKindOfClass:BarLineChartViewBase.class])
{
__weak __typeof(self) weakSelf = self;
__weak BarLineChartViewBase *weakChart = (BarLineChartViewBase *)recognizer.view;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weakSelf and weakChart seem unnecessary here. The dispatched block is short-lived and doesn't create a retain cycle, so both self and chart can be captured strongly

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

primaryXAxisType == .distance
}

@objc(setPrimaryChart:) func setPrimaryChart(_ chart: ElevationChart) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove objc name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

barCharts.allObjects.forEach { clearHighlight(in: $0) }
}

@objc(reset) func reset() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@tigrim

tigrim commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Potential regression vs Android reference: in RouteChartSynchronizer.syncViewPort(from:) the .distance path stores and reapplies an absolute visible distance range (visibleXAxisRange). But the main elevation chart can be built with totalDistanceWithoutGaps via GpxUtils.calcWithoutGaps(...), while route-attribute bar charts are still built from analysis.totalDistance / route-stat segment distances in GpxUIHelper.buildStatisticChart. On routes/tracks with gaps this can shift the synchronized cursor/viewport, or put the selected distance outside the primary chart range.

Android handles this during graph binding by forcing every bar chart axis min/max to the main chart range (ChartAdapterHelper.bindGraphAdapters: barChart.getAxisRight().setAxisMinimum(mainChart.getXChartMin()), setAxisMaximum(mainChart.getXChartMax())). To keep iOS close to Android and avoid regressions, we should align the bar chart horizontal axis range to the primary chart range when registering/applying bar charts, or otherwise normalize distance sync instead of using each chart's own total distance.

@tigrim

tigrim commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Leave a comment to test Route Details, as this functionality was affected by the changes

@aleksandr-tata

Copy link
Copy Markdown
Contributor Author

Route Details is also affected because it uses the shared RouteChartSynchronizer

@tigrim

tigrim commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Found one more regression risk after the range-alignment update. alignHorizontalAxis(of:) sets custom leftAxis/rightAxis.axisMinimum and axisMaximum on every route-attribute bar chart while the primary chart uses .distance, which is the right direction and matches Android. However, when the user later switches the primary chart X-axis to Time or Time of day, setPrimaryChart(_:) only clears visibleXAxisRange; it does not restore those bar-chart axes back to their own data range. In DGCharts, assigning axisMinimum/axisMaximum makes them custom until resetCustomAxisMin/Max() is called, so notifyDataSetChanged() will keep the previous distance range.

That can leave the route-attribute charts clipped or scaled against the old primary distance range after Distance -> Time/Time of day switching, and then normalized synchronization uses that stale bar axis range. Could we reset both left/right bar axes to their data range when usesDistanceXAxis becomes false, or only apply the forced range transiently for the distance mode and undo it on mode changes?

@tigrim

tigrim commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Found one regression in the new synchronization path.

When the user already has a selected cursor and then pans/zooms the chart, RouteChartSynchronizer.syncViewPort(from:) reapplies the selection to the primary chart with callDelegate: false:

Sources/GPX/RouteChartSynchronizer.swift, around syncViewPort / applySelectionToPrimaryChart(callDelegate: false).

That keeps the visual highlight and the other charts in sync, but it skips the existing chartValueSelected handlers that refresh the route location on the map (PlanRouteAnalyzeViewController.refreshChartOnMap() and the route details trackChartHelper refreshChart...). The previous implementation did call highlightValue(..., callDelegate: true) from chartTranslated, so panning with an active cursor continued moving the map marker.

Please either trigger the map refresh explicitly after viewport sync when a selection exists, or provide a safe callback/mode from the synchronizer so the primary chart update can still notify the owning controller.

if let primaryChart = chart as? ElevationChart {
chartSynchronizer.setPrimaryChart(primaryChart)
} else if let barChart = chart as? HorizontalBarChartView {
chartSynchronizer.registerBarChart(barChart)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we unregister the chart in didEndDisplaying? RouteChartSynchronizer keeps every registered bar chart in the weak hash table, so an off-screen/reused chart can still participate in viewport/highlight synchronization while it remains alive. It would be safer to explicitly unregister charts when their cells leave the screen

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

applyVisibleRange(lowerValue...upperValue, to: chart)
}

private func applyVisibleRange(_ visibleRange: ClosedRange<Double>, to chart: BarLineChartViewBase) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyVisibleRange() rebuilds the touch matrix from fitScreen(), which also resets the target chart's Y scale/translation. Since this method can be applied to the primary ElevationChart as well, syncing only the horizontal viewport may unexpectedly reset its vertical viewport. Can we preserve the existing Y transform and update only scaleX/translationX?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

refreshChartOnMap()
DispatchQueue.main.async {
chart.layoutIfNeeded()
self.chartSynchronizer.syncViewPort(from: chart)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncViewPort is already called from chartScaled / chartTranslated. Do we really need to call it again here after the gesture ends? This causes the same viewport/selection synchronization to run twice for pinch gestures and may also trigger an extra highlightValue(..., callDelegate: true) cycle

private weak var chartView: ElevationChart?
private weak var yAxisButton: UIButton?
private weak var xAxisButton: UIButton?
private var chartView: ElevationChart?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a strong reference really needed here? It keeps the chart alive after its cell goes off-screen, while the synchronizer itself uses weak chart references


let handler = chart.viewPortHandler
var matrix = handler.touchMatrix
matrix.a = scale

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сould we avoid modifying touchMatrix.a directly here? This relies on the matrix containing only scale/translation and makes the synchronization dependent on DGCharts' internal transform representation. Would it be safer to use the chart/viewPortHandler APIs to update only the X scale and translation

}

func clearSynchronizedHighlights() {
selectedProgress = nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should clearing the synchronized selection also propagate the state change? Currently callers have to hide the map highlight separately, while selection updates are propagated through onChartStateChanged. It would be cleaner to keep this state transition inside RouteChartSynchronizer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@alex-dev-neo alex-dev-neo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on the distance-range alignment: pinning the bar-chart axis to primaryXAxisRange in updateHorizontalAxis fixes the axis, but the bar data is still scaled independently. buildStatisticChart derives divX and the stack values from analysis.totalDistance (GpxUIHelper.swift:851), while the primary chart uses calcWithoutGaps ? totalDistanceWithoutGaps : totalDistance (GpxUIHelper.swift:632).

On a route with gaps the bar data spans 0...totalDistance/divX against an axis pinned to 0...totalDistanceWithoutGaps/divX, so every attribute boundary is offset by the gap length and the far end of the bar is clipped. Plan a route passes overrideIsGeneralTrack: true and its tracks can contain gaps, so this is reachable.

There is also a sharper edge: if the two totals straddle a setupAxisDistance unit threshold (e.g. 950 m vs 1010 m with KILOMETERS_AND_METERS) the two charts pick different divX (1 vs 1000), so the bar values end up in kilometres against an axis forced to a metre range - the bar collapses to a sliver and the synchronized cursor is meaningless.

Android has the same construction (ChartUtils.buildStatisticChart uses analysis.getTotalDistance() and bindGraphAdapters forces the main chart range), so this is parity rather than an iOS-only regression - but it may be worth normalizing the bar data to the primary chart's distance basis instead of inheriting it.

Comment on lines +119 to +124
barCharts.allObjects.forEach {
updateHorizontalAxis(of: $0)
$0.notifyDataSetChanged()
}
}
applyStoredVisibleRange(to: chart)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyStoredVisibleRange is called for the primary chart only. In Route Details the bar-chart cells persist in _data and keep their touch matrices, so when updateRouteStatisticsGraph -> setPrimaryChart switches the X-axis mode, updateHorizontalAxis swaps each bar chart's axis range while its existing scaleX/translationX still encode the previous range. The bar viewports - and the cursor pixel that applySelection derives from them - drift from the elevation chart until the user pans again.

Could we call applyStoredVisibleRange(to: $0) inside this loop as well?

}

func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard let chart = (cell as? AnalyzeCardCell)?.chartView else { return }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three card types share AnalyzeCardCell.reuseIdentifier, prepareForReuse nils chartView, and cellForRowAt assigns the new one. Plain scrolling is safe, but under reloads or animated updates a cell can be reconfigured before its didEndDisplaying fires - then this unregisters the chart that is currently on screen. That chart stops syncing permanently, because it loses both its weak-table entry and its gesture targets, and willDisplay will not fire for it again.

Capturing the chart in willDisplay, or checking identity against the currently registered chart before unregistering, would remove the dependency on UIKit's ordering.

let proxy = AnalyzeChartDelegateProxy()
proxy.onNothingSelected = { [weak self] in
self?.hideChartLocation()
proxy.onNothingSelected = { [weak self] _ in

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onValueSelected below filters on chart === chartView, but this one clears the whole synchronized selection for any chart. The bar charts share this proxy and run with highlightPerDragEnabled = true, so a DGCharts drag on a bar chart that resolves to a nil highlight would wipe the cursor everywhere.

It looks unlikely to fire in practice given maxHighlightDistance = 10_000 and a single bar entry, but the same guard here would make it symmetric.

analysis:self.analysis
segment:self.segment];
}
if ([recognizer.view isKindOfClass:BarLineChartViewBase.class])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DGCharts already emits chartScaled: for both of these gestures (pinchGestureRecognized and doubleTapGestureRecognized in BarLineChartViewBase), and that is wired to syncViewPortFromChart: at line 968. This dispatch runs the same synchronization a second time, which re-enters applySelectionToPrimaryChart(callDelegate: true) -> chartValueSelected: -> refreshChart:fitTrack:YES, i.e. a second map fit per gesture.

Plan a route dropped its equivalent gesture hook entirely - should Route Details do the same, or is there a layout case here that chartScaled: misses?

positions: positions,
offset: offset)

guard axis.drawAxisLineEnabled else { return }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard means the tick marks never draw in Route Details: setupHorizontalGPXChart sets rightAxis.drawAxisLineEnabled = false (GpxUIHelper.swift:452), and only PlanRouteAnalyzeViewController turns it back on (line 874). The PR summary says tick marks are added to route attribute charts - is Plan a route only the intended scope?

Unrelated nit while here: @objc(syncViewPortFromChart:) at line 173 is still present from the earlier "remove objc name" comment. Dropping it changes the selector to syncViewPortFrom:, so the two .mm call sites would need updating.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the distance-axis tick marks are intended for the Plan a route Analyze tab only, following the Figma design. Route Details keeps its existing axis styling.

@alex-dev-neo

Copy link
Copy Markdown
Contributor

Re-checked at ce941b340d. First, a correction on my side: my earlier review was submitted against 82079a0d, which had already been superseded by the six fix commits from 2 - 3 Sep. Most of those inline comments are therefore marked outdated and describe code you had already fixed - apologies for the noise. Only the tick-mark comment on RouteChartSynchronizer.swift:36 is still current.

Verified fixed

  • Bar-chart distance basis (ce941b340d) - buildStatisticChart now takes calcWithoutGaps, derives divX from the same basis as the primary chart, and rescales each segment by targetDistance / sourceDistance. Both failure modes are gone: the two charts can no longer pick different divX, and the bar total now matches the forced axis range. One residual: the rescale is uniform, so with unevenly distributed gaps an individual attribute boundary can still sit slightly off the elevation-chart position. Better, but not exact.
  • Bar viewports after axis changes (e2ae519642) - applyStoredVisibleRange(to: barChart) in the loop, exactly right.
  • Selection reset filtering (a8a2bd5946) - now symmetric with onValueSelected.
  • Duplicate viewport sync (0485f92ec1) - dispatch block removed.
  • Objective-C selector (b71d2864ea) - @objc(...) gone, both call sites updated to syncViewPortFrom:.

Residual on the cell-reuse fix

789dfdf068 removes the dependency on cell.chartView, which was the part I flagged. But registeredChartsByCell is keyed by ObjectIdentifier(cell) and willDisplay overwrites the entry, so the original ordering hazard survives: if a cell is reconfigured and willDisplay fires for the new row before didEndDisplaying arrives for the old one, the dictionary already holds the new chart and that live chart is unregistered. Keying the removal on identity (only unregister when the stored chart is still the one that left the screen) would close it. Minor point: the dictionary retains charts strongly, while the synchronizer itself uses a weak table.

Points raised by ce941b340d

That commit changes more than the bar-chart scale, so a few things worth a second look:

  1. calcWithoutGaps is now derived by opposite rules on the two screens. Plan a route uses !analysis.joinSegments && analysis.totalDistanceWithoutGaps > 0, which matches Android (!isJoinSegments() && isGeneralTrack()). Route Details still calls GpxUtils.calcWithoutGaps, which returns overrideIsGeneralTrack && gpxDataItem.joinSegments - inverted. The inversion looks pre-existing rather than introduced here, but the two screens now disagree on the same flag.

  2. Plan a route's primary chart flips from calcWithoutGaps = false to effectively always true. Previously dataItem(for:) returned nil for the unsaved plan-route file, so the helper's guard let gpxDataItem else { return false } short-circuited. This changes the main elevation chart itself - gap points excluded, X axis on totalDistanceWithoutGaps - not only the attribute bars. Probably the intended behaviour, but it is a visible change to the primary chart that is worth calling out and checking on device.

  3. GpxUtils.getSegmentPointByDistance: abs(passedDistance - distanceToPoint) < 0.1 became passedDistance >= distanceToPoint, in the general-segment / joinSegments == false branch only. This looks in scope, since chartSegment now prefers getGeneralSegment() and passes joinSegments: false, and the old near-exact test would essentially never fire when currPoint.distance is unpopulated. Two questions: should the first branch get the same treatment, and was the effect on existing callers that reach this branch (track menu, Route Details with a general segment) checked?

  4. chartSegment now prefers getGeneralSegment(), which also changes the segment feeding prepareTrackChartPoints (segment colour) and rect(...) for map fitting in Plan a route. Necessary for cross-gap lookup, just noting it as a behaviour change.

Still open from the earlier review: the tick-mark question - setupHorizontalGPXChart leaves rightAxis.drawAxisLineEnabled = false and only PlanRouteAnalyzeViewController (line 876) turns it on, so RouteStatisticsYAxisRenderer draws nothing in Route Details.

Call sites check out: refreshBarChart gained calcWithoutGaps: and both callers were updated; refreshChart(state:) gained joinSegments: and its single caller was updated. As before, this is a read-only review - not built, not run - so none of it is runtime-verified, and points 2 and 3 in particular would benefit from a UI pass on a track with gaps.

# Conflicts:
#	Sources/Controllers/PlanRoute/Tabs/PlanRouteAnalyzeViewController.swift
#	Sources/Controllers/TargetMenu/Routing/OARouteDetailsViewController.mm
@aleksandr-tata

Copy link
Copy Markdown
Contributor Author

Plan a route → Analyze is the intended scope for the distance-axis tick marks. They were added there to match the Figma design and make the cursor position easier to read.

Route Details keeps its existing chart styling, so drawAxisLineEnabled remains false there and RouteStatisticsYAxisRenderer intentionally does not draw the tick marks. Cursor synchronization itself is supported on both screens.

@alex-dev-neo

Copy link
Copy Markdown
Contributor

Re-checked at 89cd1eb5. Everything I raised previously is now either fixed or answered.

Closed

3f4804cc28 - chart registration across cell reuse. The remaining hole is properly closed: registrations are now (cell, indexPath, chart) tuples with weak cell/chart references, matched on both cell identity and index path, and a chart is only unregistered when no other registration still references it. The "cell reconfigured before didEndDisplaying, live chart unregistered" path no longer reproduces, and the strong retention is gone too.

89cd1eb559 - gap alignment. The uniform-rescale approximation I flagged is replaced by a real layout: routeChartDistanceLayout splits pointAttributes into gap and non-gap distances, the route charts run on the full distance basis (calcWithoutGaps: false) through refreshRouteLineChart / refreshRouteBarChart, and statisticChartElements(_:inserting:) inserts transparent segments at the actual gap positions. Attribute boundaries now land where they belong. The sums line up (coloured = nonGapDistance, gaps = total - nonGap, overall = the axis basis), and the gap-insertion loop always makes progress.

That also resolves the three points from my previous comment:

  • The calcWithoutGaps split between the two screens is gone - the field was dropped from PlanRouteAnalysisData and shouldCalculateWithoutGaps was removed from Route Details, so neither screen depends on GpxUtils.calcWithoutGaps any more.
  • The getSegmentPointByDistance condition is reverted to abs(passedDistance - distanceToPoint) < 0.1, with the new behaviour isolated in generalSegmentPointByDistance. The shared helper is untouched - thanks for splitting it out.
  • Tick marks: understood, Plan a route only by design. Thanks for the clarification.

Also worth noting the new rect() branch keeps the same left/right/top/bottom polarity as the legacy one, and its hasBounds flag is an improvement over the old left == 0 && right == 0 sentinel, which misbehaves near the prime meridian.

Four smaller things from the new commit

  1. routeChartDistanceLayout is recomputed per frame. In TrackChartHelper.refreshChart(_ state:) it runs twice - once inside GpxUtils.location(at:) (in the if let distanceLayout = ... condition) and again in rect(...). Each call is a full pass over pointAttributes plus a [Double] allocation sized to the point count, and segment.points.compactMap { $0 as? WptPt } bridges every point of the general segment two more times. refreshChart(state:) fires on every drag frame via chartValueSelected / syncViewPort, so on a long route that is two O(n) passes with allocations per frame. The layout depends only on analysis - caching it by analysis identity would remove the whole cost.

  2. Dead API. GpxUIHelper.refreshBarChart(chartView:statistics:analysis:calcWithoutGaps:nightMode:) no longer has any caller - both route screens moved to refreshRouteBarChart. The calcWithoutGaps parameter was added in ce941b340d and orphaned in 89cd1eb559; the public overload looks safe to drop.

  3. Silent fallback when point counts disagree. Both generalSegmentPointByDistance and the new rect() branch check points.count == pointDistances.count and, when it fails, recompute distances geodesically and scale them to totalDistance - which spreads gaps proportionally across all points instead of placing them at the real positions. The two paths behave quite differently and there is no assert or logging, so if the counts ever diverge the cursor drifts systematically with nothing to point at it. The logic is also duplicated in the two places; extracting it would keep them honest.

  4. Minor, possibly intentional: the second condition in the new rect() loop includes both endpoints of any segment overlapping the visible range, so the fitted rectangle is slightly wider than the visible window.

As before this is read-only - not built, not run - so points 1 and 3 in particular would benefit from a pass on a real track with gaps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sync chart cursor across all charts in Route details and Plan a route

3 participants