Skip to content

Visualization Reference

Map visualizations

plot_track_line_on_map(data, *, zoom=13, height=None, width=None, line_width=2.5, map_style='open-street-map', **kwargs)

Plot the track line on a map.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing latitude and longitude data.

required
zoom int

Zoom level for the map, defaults to 13

13
height None | int

Height of the plot, defaults to None

None
width None | int

Width of the plot, defaults to None

None
line_width float

Width of the line on the map, defaults to 2.5

2.5
map_style str

Valid map_style for plotly mapbox_style, defaults to open-street-map

'open-street-map'
kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Plotly Figure object.

Source code in geo_track_analyzer/visualize/map.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def plot_track_line_on_map(
    data: pd.DataFrame,
    *,
    zoom: int = 13,
    height: None | int = None,
    width: None | int = None,
    line_width: float = 2.5,
    map_style: str = "open-street-map",
    **kwargs,
) -> Figure:
    """
    Plot the track line on a map.

    :param data: DataFrame containing latitude and longitude data.
    :param zoom: Zoom level for the map, defaults to 13
    :param height: Height of the plot, defaults to None
    :param width: Width of the plot, defaults to None
    :param line_width: Width of the line on the map, defaults to 2.5
    :param map_style: Valid map_style for plotly mapbox_style, defaults to
        open-street-map
    :param kwargs: Additional keyword arguments.

    :return: Plotly Figure object.
    """
    mask = data.moving

    center_lat, center_lon = center_geolocation(
        [(r["latitude"], r["longitude"]) for r in data[mask].to_dict("records")]
    )
    fig = px.line_mapbox(
        data[mask],
        lat="latitude",
        lon="longitude",
        zoom=zoom,
        center={"lon": center_lon, "lat": center_lat},
        height=height,
        width=width,
    )
    fig.update_traces(
        line=dict(width=line_width),
    )
    fig.update_layout(
        mapbox_style=map_style,
        margin={"r": 57, "t": 0, "l": 49, "b": 0},
    )

    return fig

plot_track_enriched_on_map(data, *, enrich_with_column='elevation', color_by_zone=False, zoom=13, height=None, width=None, overwrite_color_gradient=None, overwrite_unit_text=None, cbar_ticks=5, map_style='open-street-map', **kwargs)

Plot the track line enriched with additional information (as z-axis) on a map.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track data.

required
enrich_with_column Literal['elevation', 'speed', 'heartrate', 'cadence', 'power']

Column to enrich the track with, defaults to "elevation"

'elevation'
color_by_zone bool

If True, track will be covered by Zones. Only available for enrich_with_column heartrate, cadence and power.

False
zoom int

Zoom level for the map, defaults to 13

13
height None | int

Height of the plot, defaults to None

None
width None | int

Width of the plot, defaults to None

None
overwrite_color_gradient None | tuple[str, str]

Custom color gradient for the plot. Check track_analyzer.visualize.constants for defaults, defaults to None

None
overwrite_unit_text None | str

Custom unit text for the enrichment. Check track_analyzer.visualize.constants for defaults, defaults to None

None
cbar_ticks int

Number of color bar ticks, defaults to 5

5
map_style str

Valid map_style for plotly mapbox_style, defaults to open-street-map

'open-street-map'
kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Plotly Figure object.

Raises:

Type Description
VisualizationSetupError

If no data is in passed enrich_with_column column

VisualizationSetupError

If color_by_zone is passed and enricht_with_column is unsupported or Zones are not set for the supported values

Source code in geo_track_analyzer/visualize/map.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def plot_track_enriched_on_map(
    data: pd.DataFrame,
    *,
    enrich_with_column: Literal[
        "elevation", "speed", "heartrate", "cadence", "power"
    ] = "elevation",
    color_by_zone: bool = False,
    zoom: int = 13,
    height: None | int = None,
    width: None | int = None,
    overwrite_color_gradient: None | tuple[str, str] = None,
    overwrite_unit_text: None | str = None,
    cbar_ticks: int = 5,
    map_style: str = "open-street-map",
    **kwargs,
) -> Figure:
    """
    Plot the track line enriched with additional information (as z-axis) on a map.

    :param data: DataFrame containing track data.
    :param enrich_with_column: Column to enrich the track with, defaults to "elevation"
    :param color_by_zone: If True, track will be covered by Zones. Only available for
        enrich_with_column heartrate, cadence and power.
    :param zoom: Zoom level for the map, defaults to 13
    :param height: Height of the plot, defaults to None
    :param width: Width of the plot, defaults to None
    :param overwrite_color_gradient: Custom color gradient for the plot. Check
        track_analyzer.visualize.constants for defaults, defaults to None
    :param overwrite_unit_text: Custom unit text for the enrichment. Check
        track_analyzer.visualize.constants for defaults, defaults to None
    :param cbar_ticks: Number of color bar ticks, defaults to 5
    :param map_style: Valid map_style for plotly mapbox_style, defaults to
        open-street-map
    :param kwargs: Additional keyword arguments.
    :raises VisualizationSetupError: If no data is in passed enrich_with_column column
    :raises VisualizationSetupError: If color_by_zone is passed and enricht_with_column
        is unsupported or Zones are not set for the supported values
    :return: Plotly Figure object.
    """
    if color_by_zone and (
        (enrich_with_column not in ["heartrate", "cadence", "power"])
        or (f"{enrich_with_column}_zones" not in data.columns)
    ):
        raise VisualizationSetupError("Zone data is not provided in passed dataframe")

    mask = data.moving

    plot_data = data[mask]

    center_lat, center_lon = center_geolocation(
        [(r["latitude"], r["longitude"]) for r in data[mask].to_dict("records")]
    )

    # ~~~~~~~~~~~~ Enrichment data ~~~~~~~~~~~~~~~~
    enrich_unit = (
        ENRICH_UNITS[enrich_with_column]
        if overwrite_unit_text is None
        else overwrite_unit_text
    )
    enrich_type = (
        int if enrich_with_column in ["heartrate", "cadence", "power"] else float
    )

    # ~~~~~~~~~~~ Colors ~~~~~~~~~~~~~~~~~
    color_column_values = plot_data[enrich_with_column]

    if color_column_values.isna().all():
        raise VisualizationSetupError(
            f"Plotting not possible. No values for {enrich_with_column} in passed data."
        )

    if color_column_values.isna().any():
        logger.warning(
            "%s nan rows in %s. Dropping points",
            color_column_values.isna().sum(),
            enrich_with_column,
        )
        plot_data = plot_data[~color_column_values.isna()]
        color_column_values = color_column_values[~color_column_values.isna()]

    if enrich_with_column == "speed":
        color_column_values = color_column_values * 3.6
    diff_abs = color_column_values.max() - color_column_values.min()
    assert diff_abs > 0

    colorbar_trace = None
    # ~~~~~~~~~~~~~~~~ Color by Zone ~~~~~~~~~~~~~~~~~~~~~~~
    if color_by_zone:
        color_col = f"{enrich_with_column}_zone_colors"
        colors = plot_data[color_col]
        marker = go.scattermapbox.Marker(color=colors)
    # ~~~~~~~~~~~~~~~~ Generate color gradient from value ~~~~~~~~~~~~~~~
    else:
        if overwrite_color_gradient:
            color_min, color_max = overwrite_color_gradient
        else:
            if enrich_with_column in COLOR_GRADIENTS.keys():
                color_min, color_max = COLOR_GRADIENTS[enrich_with_column]
            else:
                color_min, color_max = DEFAULT_COLOR_GRADIENT
        color_map = pd.Series(
            data=get_color_gradient(color_min, color_max, round(diff_abs) + 1),
            index=range(
                round(color_column_values.min()), round(color_column_values.max()) + 1
            ),
        )

        def color_mapper(value: float) -> str:
            if value < color_map.index.start:
                return color_map.iloc[0]
            elif value > color_map.index.stop:
                return color_map.iloc[-1]
            else:
                return color_map.loc[int(value)]

        colors = color_column_values.apply(color_mapper).to_list()
        marker = go.scattermapbox.Marker(color=colors)

        # ~~~~~~~~~~~~~~~ Colorbar for the passed column ~~~~~~~~~~~~~~~~~~~~
        splits = 1 / (cbar_ticks - 1)
        factor = 0.0
        tick_vals = []
        tick_cols = []
        while factor <= 1:
            idx = int(diff_abs * factor)
            tick_vals.append(color_map.index[idx])
            tick_cols.append(color_map.iloc[idx])
            factor += splits

        colorbar_trace = go.Scatter(
            x=[None],
            y=[None],
            mode="markers",
            marker=dict(
                colorscale=color_map.to_list(),
                showscale=True,
                cmin=color_column_values.min(),
                cmax=color_column_values.max(),
                colorbar=dict(
                    title=enrich_with_column.capitalize(),
                    thickness=10,
                    tickvals=tick_vals,
                    ticktext=tick_vals,
                    outlinewidth=0,
                ),
            ),
            hoverinfo="none",
        )

    # ~~~~~~~~~~~~~~~ Build figure ~~~~~~~~~~~~~~~~~~~
    fig = go.Figure(
        go.Scattermapbox(
            lat=plot_data["latitude"],
            lon=plot_data["longitude"],
            mode="markers",
            marker=marker,
            hovertemplate=f"{enrich_with_column.capitalize()}: "
            + "<b>%{text}</b> "
            + f"{enrich_unit} <br>"
            + "<b>Lat</b>: %{lat:4.6f}°<br>"
            + "<b>Lon</b>: %{lon:4.6f}°<br>",
            text=[enrich_type(v) for v in color_column_values.to_list()],
            name="",
        )
    )

    if colorbar_trace is not None:
        fig.add_trace(colorbar_trace)

    fig.update_layout(mapbox_style=map_style)
    fig.update_layout(
        margin={"r": 57, "t": 5, "l": 49, "b": 5},
        mapbox={
            "style": map_style,
            "zoom": zoom,
            "center": {"lon": center_lon, "lat": center_lat},
        },
        height=height,
        hovermode="closest",
        width=width,
        showlegend=False,
    )

    return fig

plot_track_2d(data, *, include_velocity=False, include_heartrate=False, include_cadence=False, include_power=False, show_segment_borders=False, strict_data_selection=False, height=600, width=1800, pois=None, color_elevation=None, color_additional_trace=None, color_poi=None, color_segment_border=None, slider=False, split_by_zone=False, min_zone_size=0.0025, **kwargs)

Elevation profile of the track. May be enhanced with additional information like Velocity, Heartrate, Cadence, and Power.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track data

required
include_velocity bool

Plot velocity as second y-axis, defaults to False

False
include_heartrate bool

Plot heart rate as second y-axis, defaults to False

False
include_cadence bool

Plot cadence as second y-axis, defaults to False

False
include_power bool

Plot power as second y-axis, defaults to False

False
show_segment_borders bool

If True show vertical lines between segments in track, defaults to False. If no segments are present in data, no error is raised.

False
strict_data_selection bool

If True only included that passing the minimum speed requirements of the Track, defaults to False

False
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1800

1800
pois None | list[tuple[float, float]]

Optional lat/long coordingates to add to the plot as points of interest, defaults to None

None
color_elevation None | str

Color of the elevation as str interpretable by plotly, defaults to None

None
color_additional_trace None | str

Color of velocity/heartrate/cadence/power as str interpretable by plotly, defaults to None

None
color_poi None | str

Color of the pois as str interpretable by plotly, defaults to None

None
color_segment_border None | str

Color of the segment border lines as str interpretable by plotly, defaults to None

None
slider bool

Should a slide be included in the plot to zoom into the x-axis, defaults to False

False
split_by_zone bool

If True, and one for included_* flags is passed and Zones are set for the corresponding extension are set, the Zones will be colored according to the zone colors.

False
min_zone_size float

Minimum fraction of points required for a distinct zone, if split_by_Zone is passed.

0.0025

Returns:

Type Description
Figure

Plotly Figure object.

Raises:

Type Description
VisualizationSetupError

If more than one of include_velocity, include_heartrate, include_cadence, or include_power was set the True

VisualizationSetupError

If elevation data is missing in the data

VisualizationSetupError

If the data requried for the additional data is missing

VisualizationSetupError

If split_by_zone is passed but Zones are not set

Source code in geo_track_analyzer/visualize/profiles.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
def plot_track_2d(
    data: pd.DataFrame,
    *,
    include_velocity: bool = False,
    include_heartrate: bool = False,
    include_cadence: bool = False,
    include_power: bool = False,
    show_segment_borders: bool = False,
    strict_data_selection: bool = False,
    height: None | int = 600,
    width: None | int = 1800,
    pois: None | list[tuple[float, float]] = None,
    color_elevation: None | str = None,
    color_additional_trace: None | str = None,
    color_poi: None | str = None,
    color_segment_border: None | str = None,
    slider: bool = False,
    split_by_zone: bool = False,
    min_zone_size: float = 0.0025,
    **kwargs,
) -> Figure:
    """Elevation profile of the track. May be enhanced with additional information like
    Velocity, Heartrate, Cadence, and Power.

    :param data: DataFrame containing track data
    :param include_velocity: Plot velocity as second y-axis, defaults to False
    :param include_heartrate: Plot heart rate as second y-axis, defaults to False
    :param include_cadence: Plot cadence as second y-axis, defaults to False
    :param include_power: Plot power as second y-axis, defaults to False
    :param show_segment_borders: If True show vertical lines between segments in track,
        defaults to False. If no segments are present in data, no error is raised.
    :param strict_data_selection: If True only included that passing the minimum speed
        requirements of the Track, defaults to False
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1800
    :param pois: Optional lat/long coordingates to add to the plot as points of
        interest, defaults to None
    :param color_elevation: Color of the elevation as str interpretable by plotly,
        defaults to None
    :param color_additional_trace: Color of velocity/heartrate/cadence/power as str
        interpretable by plotly, defaults to None
    :param color_poi: Color of the pois as str interpretable by plotly, defaults to None
    :param color_segment_border: Color of the segment border lines as str interpretable
        by plotly, defaults to None
    :param slider: Should a slide be included in the plot to zoom into the x-axis,
        defaults to False
    :param split_by_zone: If True, and one for included_* flags is passed and Zones are
        set for the corresponding extension are set, the Zones will be colored
        according to the zone colors.
    :param min_zone_size: Minimum fraction of points required for a distinct zone, if
        split_by_Zone is passed.
    :raises VisualizationSetupError: If more than one of include_velocity,
        include_heartrate, include_cadence, or include_power was set the True
    :raises VisualizationSetupError: If elevation data is missing in the data
    :raises VisualizationSetupError: If the data requried for the additional data is
       missing
    :raises VisualizationSetupError: If split_by_zone is passed but Zones are not set

    :return: Plotly Figure object.
    """
    if split_by_zone:
        color_additional_trace = None
    if (
        sum(
            [
                int(include_velocity),
                int(include_heartrate),
                int(include_cadence),
                int(include_power),
            ]
        )
        > 1
    ):
        raise VisualizationSetupError(
            "Only one of include_velocity, include_heartrate, include_cadence, "
            "and include_power can be set to True"
        )
    mask = data.moving
    if strict_data_selection:
        mask = mask & data.in_speed_percentile

    data_for_plot: pd.DataFrame = data[mask].copy()  # type: ignore

    if show_segment_borders:
        show_segment_borders = _check_segment_availability(data_for_plot)

    if data_for_plot.elevation.isna().all():
        raise VisualizationSetupError("Can not plot profile w/o elevation information")

    fig = make_subplots(specs=[[{"secondary_y": True}]])
    fig.add_trace(
        go.Scatter(
            x=data_for_plot.cum_distance_moving,
            y=data_for_plot.elevation,
            mode="lines",
            name="Elevation [m]",
            fill="tozeroy",
            text=[
                "<b>Lat</b>: {lat:4.6f}°<br><b>Lon</b>: {lon:4.6f}°<br>".format(
                    lon=rcrd["longitude"], lat=rcrd["latitude"]
                )
                for rcrd in data_for_plot.to_dict("records")
            ],
            hovertemplate="<b>Distance</b>: %{x:.1f} km <br><b>Elevation</b>: "
            + "%{y:.1f} m <br>%{text}<extra></extra>",
            showlegend=False,
        ),
        secondary_y=False,
    )
    fig.update_yaxes(
        title_text="Elevation [m]",
        secondary_y=False,
        range=[
            data_for_plot.elevation.min() * 0.97,
            data_for_plot.elevation.max() * 1.05,
        ],
    )
    fig.update_xaxes(title_text="Distance [m]")

    secondary = None
    if include_velocity:
        secondary = "velocity"
    if include_heartrate:
        secondary = "heartrate"
    if include_cadence:
        secondary = "cadence"
    if include_power:
        secondary = "power"

    if secondary is not None:
        _add_secondary(
            fig=fig,
            data=data_for_plot,
            secondary=secondary,
            split_by_zone=split_by_zone,
            min_zone_size=min_zone_size,
        )

    if pois is not None:
        for i_poi, poi in enumerate(pois):
            lat, lng = poi
            poi_data = data_for_plot[
                (data_for_plot.latitude == lat) & (data_for_plot.longitude == lng)
            ]
            if poi_data.empty:
                logger.warning("Could not find POI in data. Skipping")
                continue
            poi_x = poi_data.iloc[0].cum_distance_moving
            poi_y = poi_data.iloc[0].elevation

            fig.add_scatter(
                name=f"PIO {i_poi} @ {lat} / {lng}",
                x=[poi_x],
                y=[poi_y],
                mode="markers",
                marker=dict(
                    size=20,
                    color="MediumPurple" if color_poi is None else color_poi,
                    symbol="triangle-up",
                    standoff=10,
                    angle=180,
                ),
                showlegend=False,
            )

    if show_segment_borders:
        _add_segment_borders(data_for_plot, fig, color_segment_border)

    fig.update_layout(
        showlegend=split_by_zone,
        autosize=False,
        margin={"r": 0, "t": 0, "l": 0, "b": 0},
    )
    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    fig.update_xaxes(
        range=[
            data_for_plot.iloc[0].cum_distance_moving,
            data_for_plot.iloc[-1].cum_distance_moving,
        ]
    )

    if slider:
        fig.update_layout(
            xaxis=dict(
                rangeslider=dict(visible=True),
            )
        )

    if color_elevation is not None:
        fig.data[0].marker.color = color_elevation  # type: ignore
    if color_additional_trace is not None and any(
        [include_velocity, include_heartrate, include_cadence, include_power]
    ):
        fig.data[1].marker.color = color_additional_trace  # type: ignore

    return fig

Track profile

plot_track_with_slope(data, *, slope_gradient_color=('#0000FF', '#00FF00', '#FF0000'), min_slope=-18, max_slope=18, show_segment_borders=False, height=600, width=1800, slider=False, color_segment_border=None, **kwargs)

Elevation profile with slopes between points.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track data

required
slope_gradient_color tuple[str, str, str]

Colors for the min, neutral, max slope values, defaults to ("#0000FF", "#00FF00", "#FF0000")

('#0000FF', '#00FF00', '#FF0000')
min_slope int

Minimum slope for the gradient also acts as floor for the displayed slope, defaults to -18

-18
max_slope int

Maximum slope for the gradient also acts as ceiling for the displayed slope, defaults to 18

18
show_segment_borders bool

If True show vertical lines between segments in track, defaults to False. If no segments are present in data, no error is raised.

False
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1800

1800
slider bool

Should a slide be included in the plot to zoom into the x-axis, defaults to False

False
color_segment_border None | str

Color of the segment border lines as str interpretable by plotly, defaults to None

None

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
VisualizationSetupError

If elevation data is missing in the data

Source code in geo_track_analyzer/visualize/profiles.py
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
def plot_track_with_slope(
    data: pd.DataFrame,
    *,
    slope_gradient_color: tuple[str, str, str] = ("#0000FF", "#00FF00", "#FF0000"),
    min_slope: int = -18,
    max_slope: int = 18,
    show_segment_borders: bool = False,
    height: None | int = 600,
    width: None | int = 1800,
    slider: bool = False,
    color_segment_border: None | str = None,
    **kwargs,
) -> Figure:
    """Elevation profile with slopes between points.

    :param data: DataFrame containing track data
    :param slope_gradient_color: Colors for the min, neutral, max slope values,
        defaults to ("#0000FF", "#00FF00", "#FF0000")
    :param min_slope: Minimum slope for the gradient also acts as floor for the
        displayed slope, defaults to -18
    :param max_slope: Maximum  slope for the gradient also acts as ceiling for the
        displayed slope, defaults to 18
    :param show_segment_borders: If True show vertical lines between segments in track,
        defaults to False. If no segments are present in data, no error is raised.
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1800
    :param slider: Should a slide be included in the plot to zoom into the x-axis,
        defaults to False
    :param color_segment_border: Color of the segment border lines as str interpretable
        by plotly, defaults to None
    :raises VisualizationSetupError: If elevation data is missing in the data

    :return: Plotly Figure object
    """
    slope_color_map = get_slope_colors(
        *slope_gradient_color, max_slope=max_slope, min_slope=min_slope
    )

    data = data[data.moving].copy()  # type: ignore

    if data.elevation.isna().all():
        raise VisualizationSetupError("Can not plot profile w/o elevation information")

    if show_segment_borders:
        show_segment_borders = _check_segment_availability(data)

    elevations = data.elevation.to_list()
    diff_elevation = [0]
    for i, elevation in enumerate(elevations[1:]):
        diff_elevation.append(elevation - elevations[i])

    data["elevation_diff"] = diff_elevation

    def calc_slope(row: pd.Series) -> int:
        try:
            slope = round((row.elevation_diff / row.distance) * 100)
        except ZeroDivisionError:
            slope = 0

        if slope > max_slope:
            slope = max_slope
        elif slope < min_slope:
            slope = min_slope

        return slope

    data["slope"] = data.apply(lambda row: calc_slope(row), axis=1).astype(int)

    fig = go.Figure()

    fig.add_trace(
        go.Scatter(
            x=data.cum_distance_moving,
            y=data.elevation,
            mode="lines",
            name="Elevation [m]",
            fill="tozeroy",
        )
    )

    if slider:
        fig.update_layout(
            xaxis=dict(
                rangeslider=dict(visible=True),
            )
        )

    for i in range(len(data)):
        this_data = data.iloc[i : i + 2]
        if len(this_data) == 1:
            continue

        slope_val = this_data.iloc[1].slope

        color = slope_color_map[slope_val]
        max_distance: float = max(this_data.cum_distance_moving)
        fig.add_trace(
            go.Scatter(
                x=this_data.cum_distance_moving,
                y=this_data.elevation,
                mode="lines",
                name=f"Distance {max_distance/1000:.1f} km",
                fill="tozeroy",
                marker_color=color,
                hovertemplate=f"Slope: {slope_val} %",
            )
        )

    if show_segment_borders:
        _add_segment_borders(data, fig, color_segment_border)

    fig.update_layout(
        showlegend=False, autosize=False, margin={"r": 0, "t": 0, "l": 0, "b": 0}
    )

    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    min_elevation: float = min(data.elevation)
    max_elevation: float = max(data.elevation)
    fig.update_yaxes(
        showspikes=True,
        spikemode="across",
        range=[min_elevation * 0.95, max_elevation * 1.05],
        title_text="Elevation [m]",
    )
    fig.update_xaxes(title_text="Distance [m]")

    return fig

plot_segments_on_map(data, *, zoom=13, height=None, width=None, line_width=2.5, average_only=True, map_style='open-street-map', **kwargs)

Plot track line on map. Segments are displayed individually.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track segment data.

required
zoom int

Zoom level for the map, defaults to 13

13
height None | int

Height of the plot, defaults to None

None
width None | int

Width of the plot, defaults to None

None
line_width float

Width of the line on the map, defaults to 2.5

2.5
average_only bool

Flag to display averages only, defaults to True

True
map_style str

Valid map_style for plotly mapbox_style, defaults to open-street-map

'open-street-map'
kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Figure

Plotly Figure object.

Raises:

Type Description
VisualizationSetupError

If no segment information is contained in the data

VisualizationSetupError

If there are not two or more segments in the data

Source code in geo_track_analyzer/visualize/map.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def plot_segments_on_map(
    data: pd.DataFrame,
    *,
    zoom: int = 13,
    height: None | int = None,
    width: None | int = None,
    line_width: float = 2.5,
    average_only: bool = True,
    map_style: str = "open-street-map",
    **kwargs,
) -> Figure:
    """
    Plot track line on map. Segments are displayed individually.

    :param data: DataFrame containing track segment data.
    :param zoom: Zoom level for the map, defaults to 13
    :param height: Height of the plot, defaults to None
    :param width: Width of the plot, defaults to None
    :param line_width: Width of the line on the map, defaults to 2.5
    :param average_only: Flag to display averages only, defaults to True
    :param map_style: Valid map_style for plotly mapbox_style, defaults to
        open-street-map
    :param kwargs: Additional keyword arguments.
    :raises VisualizationSetupError: If no segment information is contained in the data
    :raises VisualizationSetupError: If there are not two or more segments in the data

    :return: Plotly Figure object.
    """
    mask = data.moving

    plot_data = data[mask]

    if "segment" not in plot_data.columns:
        raise VisualizationSetupError(
            "Data has no **segment** in columns. Required for plot"
        )
    if len(plot_data.segment.unique()) < 2:
        raise VisualizationSetupError("Data does not have mulitple segments")

    center_lat, center_lon = center_geolocation(
        [(r["latitude"], r["longitude"]) for r in data[mask].to_dict("records")]
    )

    fig = go.Figure()
    for i_segment, frame in plot_data.groupby(by="segment"):
        mean_heartrate = frame.heartrate.agg("mean")
        min_heartrate = frame.heartrate.agg("min")
        max_heartrate = frame.heartrate.agg("max")

        mean_speed = frame.speed.agg("mean") * 3.6
        min_speed = frame.speed.agg("min") * 3.6
        max_speed = frame.speed.agg("max") * 3.6

        mean_power = frame.power.agg("mean")
        min_power = frame.power.agg("min")
        max_power = frame.power.agg("max")

        distance = frame.distance.sum() / 1000
        if frame.time.isna().all():
            total_time = None
        else:
            _total_time = frame.time.sum()  # in seconds
            total_time = timedelta(seconds=int(_total_time.astype(int)))
        min_elevation = frame.elevation.min()
        max_elevation = frame.elevation.max()

        text: str = (
            f"<b>Segment {i_segment}</b><br>"
            + f"<b>Distance</b>: {distance:.2f} km<br>"
        )
        if total_time is not None:
            text += f"<b>Time</b>: {format_timedelta(total_time)}<br>"

        text += (
            f"<b>Elevation</b>: &#8600; {min_elevation} m "
            + f"&#8599; {max_elevation} m<br>"
        )
        if not np.isnan(mean_speed):
            text += f"<b>Speed</b>: &#248; {mean_speed:.1f} "
            if not average_only:
                text += f" &#8600;{min_speed:.1f} &#8599;{max_speed:.1f} km/h <br>"
            text += " km/h <br>"
        if not np.isnan(mean_heartrate):
            text += f"<b>Heartrate</b>: &#248; {int(mean_heartrate)} "
            if not average_only:
                text += f"&#8600;{int(min_heartrate)} &#8599;{int(max_heartrate)}<br>"
            text += " bpm<br>"
        if not np.isnan(mean_power):
            text += f"<b>Power</b>: &#248; {mean_power:.1f} "
            if not average_only:
                text += f"&#8600;{min_power:.1f} &#8599;{max_power:.1f}<br>"
            text += " W<br>"

        fig.add_trace(
            go.Scattermapbox(
                lat=frame["latitude"],
                lon=frame["longitude"],
                mode="lines",
                hovertemplate="%{text} ",
                text=len(frame) * [text],
                line=dict(width=line_width),
                name="",
            )
        )

    fig.update_layout(
        margin={"r": 57, "t": 5, "l": 49, "b": 5},
        mapbox={
            "style": map_style,
            "zoom": zoom,
            "center": {"lon": center_lon, "lat": center_lat},
        },
        height=height,
        hovermode="closest",
        width=width,
        showlegend=False,
    )

    return fig

Other track visualizations

plot_track_3d(data, strict_data_selection=False)

Source code in geo_track_analyzer/visualize/interactive.py
10
11
12
13
14
15
16
17
def plot_track_3d(data: pd.DataFrame, strict_data_selection: bool = False) -> Figure:
    mask = data.moving
    if strict_data_selection:
        mask = mask & data.in_speed_percentile

    data_for_plot = data[mask]

    return px.line_3d(data_for_plot, x="latitude", y="longitude", z="elevation")

Summary visualizations

plot_track_zones(data, metric, aggregate, *, use_zone_colors=False, height=600, width=1200, strict_data_selection=False, as_pie_chart=False)

Aggregate a value per zone defined for heartrate, power, or cadence.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track and zone data

required
metric Literal['heartrate', 'power', 'cadence']

One of "heartrate", "cadence", or "power"

required
aggregate Literal['time', 'distance', 'speed']

Value to aggregate. Supported values are (total) "time", "distance", and (average) speed in a certain zone

required
use_zone_colors bool

If True, use distinct colors per zone (either set by the zone object or a default defined by the package). Otherwise alternating colors will be used, defaults to False.

False
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1200

1200
strict_data_selection bool

If True only included that passing the minimum speed requirements of the Track, defaults to False

False

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
VisualizationSetupError

Is raised if metric is not avaialable in the data

Source code in geo_track_analyzer/visualize/summary.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def plot_track_zones(
    data: pd.DataFrame,
    metric: Literal["heartrate", "power", "cadence"],
    aggregate: Literal["time", "distance", "speed"],
    *,
    use_zone_colors: bool = False,
    height: None | int = 600,
    width: None | int = 1200,
    strict_data_selection: bool = False,
    as_pie_chart: bool = False,
) -> Figure:
    """Aggregate a value per zone defined for heartrate, power, or cadence.

    :param data: DataFrame containing track and zone data
    :param metric: One of "heartrate", "cadence", or "power"
    :param aggregate: Value to aggregate. Supported values are (total) "time",
        "distance",  and (average) speed in a certain zone
    :param use_zone_colors: If True, use distinct colors per zone (either set by the
        zone object or a default defined by the package). Otherwise alternating colors
        will be used, defaults to False.
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1200
    :param strict_data_selection: If True only included that passing the minimum speed
        requirements of the Track, defaults to False
    :raises VisualizationSetupError: Is raised if metric is not avaialable in the data

    :return: Plotly Figure object
    """
    data_for_plot = _preprocess_data(data, metric, strict_data_selection)

    bin_data, y_title, tickformat = _aggregate_zone_data(
        data_for_plot,
        metric,
        aggregate,
        aggregation_method="mean" if aggregate == "speed" else "sum",
        time_as_timedelta=as_pie_chart,
    )

    if use_zone_colors:
        colors = bin_data.colors
    else:
        if as_pie_chart:
            col_a, col_b = COLOR_GRADIENTS.get(metric, DEFAULT_COLOR_GRADIENT)
            colors = get_color_gradient(col_a, col_b, len(bin_data))
        else:
            col_a, col_b = DEFAULT_BAR_COLORS
            colors = []
            for i in range(len(bin_data)):
                colors.append(col_a if i % 2 == 0 else col_b)

    if as_pie_chart:
        unit = ENRICH_UNITS.get(aggregate, "")
        hover_template = "{value:.2f} {unit}"
        if aggregate == "time":
            hover_template = "{value} {unit} "
        fig = go.Figure(
            go.Pie(
                labels=bin_data[f"{metric}_zones"],
                values=bin_data[aggregate],
                marker=dict(colors=colors, line=dict(color="#000000", width=1)),
                hovertemplate="%{hovertext}<extra></extra>",
                hovertext=[
                    hover_template.format(value=v, unit=unit)
                    for v in bin_data[aggregate]
                ],
                sort=False,
                direction="clockwise",
                hole=0.3,
                textinfo="label+percent",
                textposition="outside",
            )
        )
        fig.update_layout(showlegend=False)
    else:
        fig = go.Figure(
            go.Bar(
                x=bin_data[f"{metric}_zones"],
                y=bin_data[aggregate],
                marker_color=colors,
                hoverinfo="skip",
            ),
        )

    for i, rcrd in enumerate(bin_data.to_dict("records")):
        if as_pie_chart:
            continue
        kwargs: dict[str, Any] = dict(
            x=i,
            showarrow=False,
            yshift=10,
        )
        if aggregate == "time":
            kwargs.update(
                dict(
                    y=rcrd["time"],
                    text=rcrd["time"].time().isoformat(),
                )
            )
        elif aggregate == "distance":
            kwargs.update(
                dict(
                    y=rcrd["distance"],
                    text=f"{rcrd['distance']:.2f} km",
                )
            )
        elif aggregate == "speed":
            kwargs.update(
                dict(
                    y=rcrd["speed"],
                    text=f"{rcrd['speed']:.2f} km/h",
                )
            )
        else:
            raise NotImplementedError(f"Aggregate {aggregate} is not implemented")

        fig.add_annotation(**kwargs)

    fig.update_layout(
        title=f"{aggregate.capitalize()} in {metric.capitalize()} zones",
        yaxis=dict(tickformat=tickformat, title=y_title),
        bargap=0.0,
    )

    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    return fig

plot_segment_zones(data, metric, aggregate, *, bar_colors=None, height=600, width=1200, strict_data_selection=False)

Aggregate a value per zone defined for heartrate, power, or cadence, split into segments available in data.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track and zone data

required
metric Literal['heartrate', 'power', 'cadence']

One of heartrate, cadence, or power

required
aggregate Literal['time', 'distance', 'speed']

Value to aggregate. Supported values are (total) "time", "distance", and (average) speed in a certain zone

required
bar_colors None | tuple[str, str] | list[str]

Overwrite the default colors for the bar. If a tuple of two colors is passed, a colorscale will be generated based on these values and colors for segments will be picked from this scale. Furthermore, a list of colors can be passed that must at least be as long as the number of segments in the data

None
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1200

1200
strict_data_selection bool

If True only included that passing the minimum speed requirements of the Track, defaults to False

False

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
VisualizationSetupError

Is raised if metric is not avaialable in the data

VisualizationSetupError

Is raised if no segment information is available in the data

Source code in geo_track_analyzer/visualize/summary.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def plot_segment_zones(
    data: pd.DataFrame,
    metric: Literal["heartrate", "power", "cadence"],
    aggregate: Literal["time", "distance", "speed"],
    *,
    bar_colors: None | tuple[str, str] | list[str] = None,
    height: None | int = 600,
    width: None | int = 1200,
    strict_data_selection: bool = False,
) -> Figure:
    """Aggregate a value per zone defined for heartrate, power, or cadence, split into
    segments available in data.

    :param data: DataFrame containing track and zone data
    :param metric: One of heartrate, cadence, or power
    :param aggregate: Value to aggregate. Supported values are (total) "time",
        "distance",  and (average) speed in a certain zone
    :param bar_colors: Overwrite the default colors for the bar. If a tuple of two
        colors is passed, a colorscale will be generated based on these values and
        colors for segments will be picked from this scale. Furthermore, a list of
        colors can be passed that must at least be as long as the number of segments in
        the data
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1200
    :param strict_data_selection: If True only included that passing the minimum speed
        requirements of the Track, defaults to False
    :raises VisualizationSetupError: Is raised if metric is not avaialable in the data
    :raises VisualizationSetupError: Is raised if no segment information is available in
        the data

    :return: Plotly Figure object
    """
    if "segment" not in data.columns:
        raise VisualizationSetupError(
            "Data has no **segment** in columns. Required for plot"
        )
    data_for_plot = _preprocess_data(data, metric, strict_data_selection)

    fig = go.Figure()

    plot_segments = data_for_plot.segment.unique()

    if isinstance(bar_colors, list):
        if len(plot_segments) > len(bar_colors):
            raise VisualizationSetupError(
                "If a list of colors is passed, it must be at least same lenght as the "
                "segments in the data"
            )
        colors = bar_colors[0 : len(plot_segments)]
    else:
        colors = sample_colorscale(
            make_colorscale(DEFAULT_BAR_COLORS if bar_colors is None else bar_colors),
            len(plot_segments),
        )

    for color, segment in zip(colors, plot_segments):
        _data_for_plot = data_for_plot[data_for_plot.segment == segment]
        bin_data, y_title, tickformat = _aggregate_zone_data(
            _data_for_plot,
            metric,
            aggregate,
            aggregation_method="mean" if aggregate == "speed" else "sum",
        )

        hovertext = []
        for rcrd in bin_data.to_dict("records"):
            if aggregate == "time":
                hovertext.append(rcrd["time"].time().isoformat())

            elif aggregate == "distance":
                hovertext.append(f"{rcrd['distance']:.2f} km")

            elif aggregate == "speed":
                hovertext.append(f"{rcrd['speed']:.2f} km/h")

        fig.add_trace(
            go.Bar(
                x=bin_data[f"{metric}_zones"],
                y=bin_data[aggregate],
                name=f"Segment {segment}",
                marker_color=color,
                hovertext=hovertext,
                hovertemplate="%{hovertext}<extra></extra>",
            ),
        )

    fig.update_layout(
        title=f"{aggregate.capitalize()} in {metric.capitalize()} zones",
        yaxis=dict(tickformat=tickformat, title=y_title),
    )

    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    return fig

plot_segment_summary(data, aggregate, *, colors=None, height=600, width=1200, strict_data_selection=False)

summary

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track and zone data

required
aggregate Literal['total_time', 'total_distance', 'avg_speed', 'max_speed']

Value to aggregate. Supported values are "total_time", "total_distance", "avg_speed", and "max_speed"

required
colors None | tuple[str, str]

Overwrite the default alternating colors, defaults to None

None
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1200

1200
strict_data_selection bool

If True only included that passing the minimum speed requirements of the Track, defaults to False

False

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
VisualizationSetupError

Is raised if no segment information is available in the data

Source code in geo_track_analyzer/visualize/summary.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
def plot_segment_summary(
    data: pd.DataFrame,
    aggregate: Literal["total_time", "total_distance", "avg_speed", "max_speed"],
    *,
    colors: None | tuple[str, str] = None,
    height: None | int = 600,
    width: None | int = 1200,
    strict_data_selection: bool = False,
) -> Figure:
    """_summary_

    :param data: DataFrame containing track and zone data
    :param aggregate: Value to aggregate. Supported values are "total_time",
        "total_distance", "avg_speed", and "max_speed"
    :param colors: Overwrite the default alternating colors, defaults to None
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1200
    :param strict_data_selection: If True only included that passing the minimum speed
        requirements of the Track, defaults to False
    :raises VisualizationSetupError: Is raised if no segment information is available in
        the data

    :return: Plotly Figure object
    """
    if "segment" not in data.columns:
        raise VisualizationSetupError(
            "Data has no **segment** in columns. Required for plot"
        )

    if colors is None:
        colors = DEFAULT_BAR_COLORS
    col_a, col_b = colors

    mask = data.moving
    if strict_data_selection:
        mask = mask & data.in_speed_percentile

    _data_for_plot = data[mask]

    fig = go.Figure()

    if aggregate == "avg_speed":
        bin_data = _data_for_plot.groupby("segment").speed.agg("mean") * 3.6
        y_title = "Average velocity [km/h]"
        tickformat = ""
        hover_map_func = lambda v: str(f"{v:.2f} km/h")
    elif aggregate == "max_speed":
        bin_data = _data_for_plot.groupby("segment").speed.agg("max") * 3.6
        y_title = "Maximum velocity [km/h]"
        tickformat = ""
        hover_map_func = lambda v: str(f"{v:.2f} km/h")
    elif aggregate == "total_distance":
        bin_data = _data_for_plot.groupby("segment").distance.agg("sum") / 1000
        y_title = "Distance [km]"
        tickformat = ""
        hover_map_func = lambda v: str(f"{v:.2f} km")
    elif aggregate == "total_time":
        bin_data = pd.to_datetime(
            _data_for_plot.groupby("segment").time.agg("sum"), unit="s"
        )
        y_title = "Duration"
        tickformat = "%H:%M:%S"
        hover_map_func = lambda dt: str(dt.time())
    else:
        raise NotImplementedError(f"Aggregate {aggregate} is not implemented")

    fig.add_trace(
        go.Bar(
            x=[f"Segment {idx}" for idx in bin_data.index.to_list()],
            y=bin_data.to_list(),
            marker_color=[col_a if i % 2 == 0 else col_b for i in range(len(bin_data))],
            hovertext=list(map(hover_map_func, bin_data.to_list())),
            hovertemplate="%{hovertext}<extra></extra>",
        ),
    )

    fig.update_layout(
        yaxis=dict(tickformat=tickformat, title=y_title),
        bargap=0.0,
    )

    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    return fig

plot_segment_box_summary(data, metric, *, colors=None, height=600, width=1200, strict_data_selection=False)

Show the metric as boxplot for each segment in the data.

Parameters:

Name Type Description Default
data DataFrame

DataFrame containing track and zone data

required
metric Literal['heartrate', 'power', 'cadence', 'speed', 'elevation']

One of "heartrate", "cadence", "power", or "speed"

required
colors None | tuple[str, str]

Overwrite the default alternating colors, defaults to None

None
height None | int

Height of the plot, defaults to 600

600
width None | int

Width of the plot, defaults to 1200

1200
strict_data_selection bool

If True only included that passing the minimum speed requirements of the Track, defaults to False

False

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
VisualizationSetupError

Is raised if metric is not avaialable in the data

VisualizationSetupError

Is raised if no segment information is available in the data

Source code in geo_track_analyzer/visualize/summary.py
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def plot_segment_box_summary(
    data: pd.DataFrame,
    metric: Literal["heartrate", "power", "cadence", "speed", "elevation"],
    *,
    colors: None | tuple[str, str] = None,
    height: None | int = 600,
    width: None | int = 1200,
    strict_data_selection: bool = False,
) -> Figure:
    """Show the metric as boxplot for each segment in the data.

    :param data: DataFrame containing track and zone data
    :param metric: One of "heartrate", "cadence", "power", or "speed"
    :param colors: Overwrite the default alternating colors, defaults to None
    :param height: Height of the plot, defaults to 600
    :param width: Width of the plot, defaults to 1200
    :param strict_data_selection: If True only included that passing the minimum speed
        requirements of the Track, defaults to False
    :raises VisualizationSetupError: Is raised if metric is not avaialable in the data
    :raises VisualizationSetupError: Is raised if no segment information is available in
        the data

    :return: Plotly Figure object
    """
    if "segment" not in data.columns:
        raise VisualizationSetupError(
            "Data has no **segment** in columns. Required for plot"
        )

    if metric not in data.columns:
        raise VisualizationSetupError("Metric %s not part of the passed data" % metric)

    if colors is None:
        colors = DEFAULT_BAR_COLORS
    col_a, col_b = colors

    mask = data.moving
    if strict_data_selection:
        mask = mask & data.in_speed_percentile

    data_for_plot = data[mask]

    fig = go.Figure()

    for i, segment in enumerate(data_for_plot.segment.unique()):
        _data_for_plot = data_for_plot[data_for_plot.segment == segment]

        if metric == "speed":
            box_data = _data_for_plot["speed"] * 3.6
        else:
            box_data = _data_for_plot[metric]

        fig.add_trace(
            go.Box(
                y=box_data,
                name=f"Segment {segment}",
                boxpoints=False,
                line_color=col_a if i % 2 == 0 else col_b,
                marker_color=col_a if i % 2 == 0 else col_b,
            )
        )
    if height is not None:
        fig.update_layout(height=height)
    if width is not None:
        fig.update_layout(width=width)

    fig.update_layout(
        yaxis=dict(title=f"{metric.capitalize()} {ENRICH_UNITS[metric]}"),
        showlegend=False,
    )

    return fig