ssscoring.notebook

Utility reusable code for notebooks.

  1# See: https://github.com/pr3d4t0r/SSScoring/blob/master/LICENSE.txt
  2
  3"""
  4## Utility reusable code for notebooks.
  5"""
  6
  7from ssscoring.calc import forwardLateralDisplacement
  8from ssscoring.calc import jumpRunBearing
  9from ssscoring.constants import DEFAULT_PLOT_MAX_V_SCALE
 10from ssscoring.constants import DEFAULT_SPEED_ACCURACY_SCALE
 11from ssscoring.constants import MAX_ALTITUDE_FT
 12from ssscoring.constants import MAX_HORIZONTAL_DISTANCE
 13from ssscoring.constants import SAFE_HORIZONTAL_COLOR
 14from ssscoring.constants import SAFE_HORIZONTAL_DISTANCE
 15from ssscoring.constants import SPEED_ACCURACY_THRESHOLD
 16from ssscoring.constants import UNSAFE_HORIZONTAL_COLOR
 17from ssscoring.datatypes import PerformanceWindow
 18from ssscoring.errors import SSScoringError
 19
 20import pandas as pd
 21import plotly.graph_objects as go
 22
 23
 24# *** constants ***
 25
 26DEFAULT_AXIS_COLOR = 'lightsteelblue'
 27"""
 28CSS color name for the axis colors used in notebooks and Streamlit with Plotly.
 29"""
 30
 31
 32# Ref:  https://www.w3schools.com/colors/colors_groups.asp
 33SPEED_COLORS = colors = ('#32cd32', '#0000ff', '#ff6347', '#40e0d0', '#00bfff', '#22b822', '#ff7f50', '#008b8b',)
 34"""
 35Colors used for tracking the lines in a multi-jump plot, so that each track is
 36associated with a different color and easier to visualize.  8 distinct colors,
 37corresponding to each jump in a competition.
 38"""
 39
 40COLOR_FASTEST = '#32cd32'
 41"""Limegreen — fastest jump track color in aggregate displays; matches the table's max-score highlight."""
 42
 43COLOR_SLOWEST = '#ff4500'
 44"""Orangered — slowest jump track color in aggregate displays; matches the table's min-score highlight."""
 45
 46COLORS_OTHERS = (
 47    '#1e90ff',
 48    '#4169e1',
 49    '#0000cd',
 50    '#00bfff',
 51    '#6495ed',
 52    '#4682b4',
 53)
 54"""Six distinct blue shades for all non-fastest, non-slowest jumps in aggregate displays."""
 55
 56
 57# Map Bokeh-era named ranges to Plotly y-axis IDs.  Preserves the rangeName
 58# kwarg API on graphAltitude/graphAngle/graphAcceleration callers.
 59_Y_AXIS_MAP = {
 60    'speed':         'y',     # default — vKMh, hKMh, score scatter
 61    'altitudeFt':    'y2',
 62    'angle':         'y3',
 63    'vAccelMS2':     'y4',
 64    'speedAccuracy': 'y5',
 65}
 66
 67
 68# *** functions ***
 69
 70def initializePlot(jumpTitle: str,
 71                   height=500,
 72                   width=900,
 73                   xLabel='seconds from exit',
 74                   yLabel='km/h',
 75                   xMax=35.0,
 76                   yMax=DEFAULT_PLOT_MAX_V_SCALE,
 77                   backgroundColorName='#1a1a1a',
 78                   colorName=DEFAULT_AXIS_COLOR):
 79    """
 80    Initialize a Plotly figure for SSScoring jump plots, configured with the
 81    main speed (km/h) Y axis.  Extra Y axes for altitude / angle / acceleration /
 82    speed-accuracy are added by initializeExtraYRanges().
 83
 84    Returns
 85    -------
 86    A `plotly.graph_objects.Figure`.
 87    """
 88    fig = go.Figure()
 89    fig.update_layout(
 90        title=dict(text=jumpTitle, font=dict(color=colorName)),
 91        height=height,
 92        autosize=True,
 93        plot_bgcolor=backgroundColorName,
 94        paper_bgcolor=backgroundColorName,
 95        font=dict(color=colorName),
 96        hovermode='x unified',
 97        showlegend=True,
 98        legend=dict(font=dict(color=colorName)),
 99        xaxis=dict(
100            title=dict(text=xLabel, font=dict(color=colorName)),
101            autorange=True,
102            color=colorName,
103            tickfont=dict(color=colorName),
104            showgrid=True,                              # ← changed
105            gridcolor='rgba(255,255,255,0.08)',         # ← added — subtle grid on dark bg
106            showline=True,                              # ← added — visible axis line
107            linecolor=colorName,                        # ← added
108            zeroline=False,
109        ),
110        yaxis=dict(
111            title=dict(text=yLabel, font=dict(color=colorName)),
112            autorange=True,
113            color=colorName,
114            tickfont=dict(color=colorName),
115            anchor='x',
116            side='left',
117            showgrid=True,                              # ← changed
118            gridcolor='rgba(255,255,255,0.08)',         # ← added
119            showline=True,                              # ← added
120            linecolor=colorName,                        # ← added
121            zeroline=False,
122        ),
123    )
124    return fig
125
126
127def _graphSegment(fig,
128                  x0=0.0,
129                  y0=0.0,
130                  x1=0.0,
131                  y1=0.0,
132                  lineWidth=1,
133                  color='black'):
134    """
135    Draw a line segment annotation on the plot's main axes.  Plotly equivalent
136    of Bokeh's plot.segment().
137    """
138    fig.add_shape(
139        type='line',
140        x0=x0, y0=y0,
141        x1=x1, y1=y1,
142        line=dict(color=color, width=lineWidth),
143        xref='x', yref='y',
144    )
145
146
147def initializeExtraYRanges(fig,
148                           startY: float = 0.0,
149                           endY: float = MAX_ALTITUDE_FT,
150                           maxSpeedAccuracy: float | None = None):
151    """
152    Configure additional Y axes on the plot for altitude (ft), angle, vertical
153    acceleration, and speed accuracy traces.  Plotly equivalent of Bokeh's
154    extra_y_ranges + LinearAxis layout.
155    """
156    LEFT_MARGIN = 0.24
157    AXIS_SPACING = 0.06
158    POSITIONS = [n*AXIS_SPACING for n in range(4)]  # 0.00, 0.06, 0.12, 0.18
159
160    speedAccuracyEnd = DEFAULT_SPEED_ACCURACY_SCALE
161    if maxSpeedAccuracy is not None and maxSpeedAccuracy >= SPEED_ACCURACY_THRESHOLD:
162        speedAccuracyEnd = maxSpeedAccuracy * 1.1
163
164    color = DEFAULT_AXIS_COLOR
165
166    fig.update_xaxes(domain=(LEFT_MARGIN, 1.0))
167
168    mainYTitle = (fig.layout.yaxis.title.text or 'km/h')
169    fig.update_yaxes(title=None)
170
171    fig.update_layout(
172        yaxis2=dict(
173            autorange=True,
174            anchor='free', overlaying='y', side='left', position=POSITIONS[0],
175            color=color, tickfont=dict(color=color),
176            showline=True, linecolor=color,
177            showgrid=False, zeroline=False,
178        ),
179        yaxis3=dict(
180            autorange=True,
181            anchor='free', overlaying='y', side='left', position=POSITIONS[1],
182            color=color, tickfont=dict(color=color),
183            showline=True, linecolor=color,
184            showgrid=False, zeroline=False,
185        ),
186        yaxis4=dict(
187            autorange=True,
188            anchor='free', overlaying='y', side='left', position=POSITIONS[2],
189            color=color, tickfont=dict(color=color),
190            showline=True, linecolor=color,
191            showgrid=False, zeroline=False,
192        ),
193        yaxis5=dict(
194            range=(0.0, speedAccuracyEnd),
195            anchor='free', overlaying='y', side='left', position=POSITIONS[3],
196            color=color, tickfont=dict(color=color),
197            showline=True, linecolor=color,
198            showgrid=False, zeroline=False,
199        ),
200    )
201
202    titles = [
203        (POSITIONS[0], 'Alt (ft)'),
204        (POSITIONS[1], 'angle'),
205        (POSITIONS[2], 'Vertical acceleration m/s²'),
206        (POSITIONS[3], 'Speed accuracy ISC'),
207        (LEFT_MARGIN,  mainYTitle),
208    ]
209    for pos, text in titles:
210        fig.add_annotation(
211            xref='paper', yref='paper',
212            x=pos + 0.012, y=0.5,
213            text=text,
214            showarrow=False,
215            textangle=-90,
216            font=dict(color=color, size=11),
217            xanchor='center', yanchor='middle',
218        )
219
220    return fig
221
222
223def validationWindowDataFrom(data: pd.DataFrame, window: PerformanceWindow) -> pd.DataFrame:
224    """
225    Generate the validation window dataset for plotting the ISC speed accuracy
226    values.  Subset defined from the end of the scoring window to the
227    validation start.
228
229    NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to
230    pd.DataFrame as part of the Plotly migration.  Columns: 'x', 'y'.
231    """
232    validationData = data[data.altitudeAGL <= window.validationStart]
233    return pd.DataFrame({
234        'x': validationData.plotTime.values,
235        'y': validationData.speedAccuracyISC.values,
236    })
237
238
239def _plotSpeedAccuracy(fig, data, window):
240    accuracyData = validationWindowDataFrom(data, window)
241    fig.add_trace(go.Scatter(
242        x=accuracyData['x'],
243        y=accuracyData['y'],
244        mode='lines',
245        name='Speed accuracy ISC',
246        line=dict(color='lime', width=5.0),
247        yaxis='y5',
248        hovertemplate='accuracy: %{y:.2f}<extra></extra>',
249    ))
250    validationData = data[data.altitudeAGL <= window.validationStart]
251    fig.add_trace(go.Scatter(
252        x=validationData.plotTime,
253        y=[SPEED_ACCURACY_THRESHOLD] * len(validationData),
254        mode='lines',
255        line=dict(color='lime', width=1.0, dash='dash'),
256        yaxis='y5',
257        showlegend=False,
258        hoverinfo='skip',
259    ))
260
261
262def graphJumpResult(fig,
263                    jumpResult,
264                    lineColor='green',
265                    legend='speed',
266                    showIt=True,
267                    showAccuracy=True):
268    """
269    Graph the jump results onto the initialized Plotly figure.
270
271    Arguments
272    ---------
273        fig
274    A Plotly Figure where to render the plot.
275
276        jumpResult: ssscoring.JumpResults
277    A jump results named tuple with score, max speed, scores, data, etc.
278
279        lineColor: str
280    A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
281    colors used in multi-jump plots.
282
283        legend: str
284    Legend label for the main speed trace.
285
286        showIt: bool
287    If True, render the max speed marker, horizontal speed, and score brackets.
288    Used to discriminate between single-jump plots and aggregate competition
289    overlays.
290
291    Streamlit usage:
292
293```python
294    graphJumpResult(fig, result)
295    st.plotly_chart(fig, width='stretch')
296```
297    """
298    if jumpResult.data is not None:
299        data = jumpResult.data
300        scores = jumpResult.scores
301        score = jumpResult.score
302
303        # Main speed line
304        fig.add_trace(go.Scatter(
305            x=data.plotTime,
306            y=data.vKMh,
307            mode='lines',
308            name=legend,
309            line=dict(color=lineColor, width=2),
310            yaxis='y',
311            hovertemplate='v: %{y:.2f} km/h<extra></extra>',
312        ))
313
314        if showIt:
315            maxSpeed = data.vKMh.max()
316            t = data[data.vKMh == maxSpeed].iloc[0].plotTime
317
318            # Horizontal speed
319            fig.add_trace(go.Scatter(
320                x=data.plotTime,
321                y=data.hKMh,
322                mode='lines',
323                name='H-speed',
324                line=dict(color='red', width=2),
325                yaxis='y',
326                hovertemplate='h: %{y:.2f} km/h<extra></extra>',
327            ))
328
329            _plotSpeedAccuracy(fig, data, jumpResult.window)
330
331            if scores is not None:
332                # Score window brackets
333                _graphSegment(fig, scores[score]+3.0, 0.0, scores[score]+3.0, score, 1, 'darkseagreen')
334                _graphSegment(fig, scores[score],     0.0, scores[score],     score, 1, 'darkseagreen')
335                # Score marker
336                fig.add_trace(go.Scatter(
337                    x=[scores[score]+1.5],
338                    y=[score],
339                    mode='markers',
340                    marker=dict(symbol='circle-cross', size=15,
341                                line=dict(color='limegreen', width=2),
342                                color='darkgreen'),
343                    name='score',
344                    yaxis='y',
345                    hovertemplate='score: %{y:.2f} km/h<extra></extra>',
346                ))
347                # Max-speed marker
348                fig.add_trace(go.Scatter(
349                    x=[t],
350                    y=[maxSpeed],
351                    mode='markers',
352                    marker=dict(symbol='diamond-dot', size=20,
353                                line=dict(color='yellow', width=2),
354                                color='red'),
355                    name='max speed',
356                    yaxis='y',
357                    hovertemplate='max: %{y:.2f} km/h<extra></extra>',
358                ))
359
360
361def graphAltitude(fig,
362                  jumpResult,
363                  label='Alt (ft)',
364                  lineColor='palegoldenrod',
365                  rangeName='altitudeFt'):
366    """
367    Graph altitude trace on the dedicated altitudeFt Y axis.
368    """
369    data = jumpResult.data
370    yaxis = _Y_AXIS_MAP[rangeName]
371    fig.add_trace(go.Scatter(
372        x=data.plotTime,
373        y=data.altitudeAGLFt,
374        mode='lines',
375        name=label,
376        line=dict(color=lineColor, width=2),
377        yaxis=yaxis,
378        hovertemplate='alt: %{y:.0f} ft<extra></extra>',
379    ))
380
381
382def graphAngle(fig,
383               jumpResult,
384               label='angle',
385               lineColor='deepskyblue',
386               rangeName='angle'):
387    """
388    Graph the flight angle trace on the dedicated angle Y axis.
389    """
390    data = jumpResult.data
391    yaxis = _Y_AXIS_MAP[rangeName]
392    fig.add_trace(go.Scatter(
393        x=data.plotTime,
394        y=data.speedAngle,
395        mode='lines',
396        name=label,
397        line=dict(color=lineColor, width=2),
398        yaxis=yaxis,
399        hovertemplate='angle: %{y:.2f}°<extra></extra>',
400    ))
401
402
403def graphAcceleration(fig,
404                      jumpResult,
405                      label='V-accel m/s²',
406                      lineColor='magenta',
407                      rangeName='vAccelMS2'):
408    """
409    Graph the flight vertical acceleration curve and its EMA-smoothed companion
410    on the dedicated vAccelMS2 Y axis.
411    """
412    data = jumpResult.data
413    data['vAccelEMA'] = data.vAccelMS2.ewm(span=20, adjust=False).mean()
414    yaxis = _Y_AXIS_MAP[rangeName]
415    fig.add_trace(go.Scatter(
416        x=data.plotTime,
417        y=data.vAccelMS2,
418        mode='lines',
419        name=label,
420        line=dict(color='dimgrey', width=2),
421        yaxis=yaxis,
422        hovertemplate='a: %{y:.2f} m/s²<extra></extra>',
423    ))
424    fig.add_trace(go.Scatter(
425        x=data.plotTime,
426        y=data.vAccelEMA,
427        mode='lines',
428        name=label + ' (EMA)',
429        line=dict(color=lineColor, width=2),
430        yaxis=yaxis,
431        hovertemplate='a (EMA): %{y:.2f} m/s²<extra></extra>',
432    ))
433
434
435def initializeGroundTrackPlot(jumpTitle: str,
436                              height=450,
437                              backgroundColorName='#1a1a1a',
438                              colorName=DEFAULT_AXIS_COLOR):
439    """
440    Initialize a Plotly figure for the ground-track plot, configured with equal
441    axis scaling so that forward and lateral distances are not distorted.
442
443    X axis: metres forward along the jump run from exit.
444    Y axis: metres lateral (left = positive, right = negative).
445
446    Unlike initializePlot(), no extra Y ranges are added — this canvas is
447    dedicated to spatial displacement only.
448
449    Arguments
450    ---------
451        jumpTitle: str
452    Figure title, usually the jump tag.
453
454        height: int
455    Plot height in pixels.  Default 450 — shorter than the main plot since
456    this is a companion chart.
457
458        backgroundColorName: str
459    CSS colour name or hex string for the plot and paper background.
460
461        colorName: str
462    CSS colour name or hex string for axes, tick labels, and title text.
463
464    Returns
465    -------
466    A `plotly.graph_objects.Figure` ready to receive graphGroundTrack() traces.
467    """
468    figure = go.Figure()
469    figure.update_layout(
470        title=dict(text=jumpTitle, font=dict(color=colorName)),
471        height=height,
472        autosize=True,
473        plot_bgcolor=backgroundColorName,
474        paper_bgcolor=backgroundColorName,
475        font=dict(color=colorName),
476        hovermode='closest',
477        showlegend=True,
478        legend=dict(font=dict(color=colorName)),
479        xaxis=dict(
480            title=dict(text='forward (m)', font=dict(color=colorName)),
481            autorange=True,
482            color=colorName,
483            tickfont=dict(color=colorName),
484            showgrid=True,
485            gridcolor='rgba(255,255,255,0.08)',
486            showline=True,
487            linecolor=colorName,
488            zeroline=True,
489            zerolinecolor='rgba(255,255,255,0.25)',
490            zerolinewidth=1,
491        ),
492        yaxis=dict(
493            title=dict(text='lateral (m)', font=dict(color=colorName)),
494            autorange=True,
495            color=colorName,
496            tickfont=dict(color=colorName),
497            showgrid=True,
498            gridcolor='rgba(255,255,255,0.08)',
499            showline=True,
500            linecolor=colorName,
501            zeroline=True,
502            zerolinecolor='rgba(255,255,255,0.25)',
503            zerolinewidth=1,
504            scaleanchor='x',
505            scaleratio=1,
506        ),
507    )
508    return figure
509
510
511def graphGroundTrack(figure,
512                     jumpResult,
513                     lineColor='deepskyblue'):
514    """
515    Graph the skydiver's ground track during the performance window as forward
516    vs. lateral displacement from exit, with markers coloured by vertical speed.
517
518    X axis = metres forward along the jump run (negative = reversed, i.e. back-
519    fall).  Y axis = metres lateral (positive = left of jump run, negative =
520    right).  Marker colour encodes vKMh so the speed progression is visible
521    without a separate time axis.
522
523    A clean belly-to-earth run produces a smooth rightward curve staying close
524    to the lateral zero line.  A back-fall reverses toward the origin; the
525    line literally doubles back on itself — unmistakable at a glance.
526
527    Requires a figure initialised by initializeGroundTrackPlot() so that the
528    spatial axes are equal-scaled and the zero lines are present.
529
530    Arguments
531    ---------
532        figure
533    A Plotly Figure initialised by initializeGroundTrackPlot().
534
535        jumpResult: ssscoring.JumpResults
536    A jump results named tuple.  jumpResult.data must contain latitude,
537    longitude, plotTime, and vKMh columns (all present after processJump()).
538
539        lineColor: str
540    A valid CSS colour name or hex string for the connecting track line.
541
542    Streamlit usage:
543
544```python
545    figure = initializeGroundTrackPlot(tag)
546    graphGroundTrack(figure, jumpResult)
547    st.plotly_chart(figure, width='stretch')
548```
549    """
550    data = jumpResult.data
551    exitLat = float(data.latitude.iloc[0])
552    exitLon = float(data.longitude.iloc[0])
553    bearing = jumpRunBearing(data)
554    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
555
556    figure.add_trace(go.Scatter(
557        x=displacement.forwardM,
558        y=displacement.lateralM,
559        mode='lines',
560        name='track',
561        line=dict(color='rgba(255,255,255,0.15)', width=1),
562        showlegend=False,
563        hoverinfo='skip',
564    ))
565
566    figure.add_trace(go.Scatter(
567        x=displacement.forwardM,
568        y=displacement.lateralM,
569        mode='markers',
570        name='fwd (m)',
571        marker=dict(
572            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
573            colorscale=[
574                [0.0, SAFE_HORIZONTAL_COLOR],
575                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
576                [1.0, UNSAFE_HORIZONTAL_COLOR],
577            ],
578            cmin=0,
579            cmax=MAX_HORIZONTAL_DISTANCE,
580            cauto=False,
581            size=5,
582            showscale=True,
583            colorbar=dict(
584                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
585                tickfont=dict(color=DEFAULT_AXIS_COLOR),
586                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
587                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
588                thickness=12,
589                len=0.75,
590            ),
591        ),
592        hovertemplate='fwd: %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
593    ))
594
595    figure.add_trace(go.Scatter(
596        x=[displacement.forwardM.iloc[0]],
597        y=[displacement.lateralM.iloc[0]],
598        mode='markers',
599        name='exit',
600        marker=dict(symbol='circle', size=10,
601                    color=SAFE_HORIZONTAL_COLOR,
602                    line=dict(color='white', width=1)),
603        hovertemplate='exit<extra></extra>',
604    ))
605
606    figure.add_trace(go.Scatter(
607        x=[displacement.forwardM.iloc[-1]],
608        y=[displacement.lateralM.iloc[-1]],
609        mode='markers',
610        name='end',
611        marker=dict(symbol='square', size=10,
612                    color=UNSAFE_HORIZONTAL_COLOR,
613                    line=dict(color='white', width=1)),
614        hovertemplate='end: fwd %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
615    ))
616
617
618def graphForwardDisplacement(figure,
619                             jumpResult):
620    """
621    Graph the forward displacement (metres along the jump run from exit) as a
622    time series on the primary Y axis.
623
624    A skydiver on a clean belly run produces a monotonically rising curve.  A
625    back-fall inflects and drops — the onset time and reversal depth are
626    immediately readable from the shape of the line and the zero reference.
627
628    Markers are coloured by the same green→red gradient used in the ground
629    track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
630    transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
631    red beyond.
632
633    Pair this with graphJumpResult() on a second figure (same plotTime X axis)
634    to show the temporal relationship between displacement reversal and speed
635    loss.
636
637    Arguments
638    ---------
639        figure
640    A Plotly Figure initialised by initializePlot() with xLabel='seconds from
641    exit' and yLabel='forward (m)'.
642
643        jumpResult: ssscoring.JumpResults
644    A jump results named tuple.  jumpResult.data must contain latitude,
645    longitude, and plotTime (all present after processJump()).
646
647    Streamlit usage:
648
649```python
650    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
651    graphForwardDisplacement(figure, jumpResult)
652    st.plotly_chart(figure, width='stretch')
653```
654    """
655    data = jumpResult.data
656    exitLat = float(data.latitude.iloc[0])
657    exitLon = float(data.longitude.iloc[0])
658    bearing = jumpRunBearing(data)
659    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
660
661    figure.add_trace(go.Scatter(
662        x=displacement.plotTime,
663        y=displacement.forwardM,
664        mode='lines',
665        line=dict(color='rgba(255,255,255,0.15)', width=1),
666        showlegend=False,
667        hoverinfo='skip',
668    ))
669
670    figure.add_trace(go.Scatter(
671        x=displacement.plotTime,
672        y=displacement.forwardM,
673        mode='markers',
674        name='fwd (m)',
675        marker=dict(
676            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
677            colorscale=[
678                [0.0, SAFE_HORIZONTAL_COLOR],
679                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
680                [1.0, UNSAFE_HORIZONTAL_COLOR],
681            ],
682            cmin=0,
683            cmax=MAX_HORIZONTAL_DISTANCE,
684            cauto=False,
685            size=4,
686            showscale=True,
687            colorbar=dict(
688                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
689                tickfont=dict(color=DEFAULT_AXIS_COLOR),
690                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
691                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
692                thickness=12,
693                len=0.75,
694            ),
695        ),
696        yaxis='y',
697        hovertemplate='t: %{x:.1f} s  fwd: %{y:.1f} m<extra></extra>',
698    ))
699
700    figure.add_trace(go.Scatter(
701        x=[displacement.plotTime.iloc[0], displacement.plotTime.iloc[-1]],
702        y=[0.0, 0.0],
703        mode='lines',
704        line=dict(color='rgba(255,255,255,0.25)', width=1, dash='dot'),
705        showlegend=False,
706        hoverinfo='skip',
707    ))
708
709
710def resolveJumpColors(jumpResults: dict) -> dict:
711    """
712    Build a tag→hex-color mapping for a set of jump results.
713
714    The fastest jump (highest score) gets `COLOR_FASTEST`, the slowest gets
715    `COLOR_SLOWEST`, and all remaining valid jumps cycle through `COLORS_OTHERS`.
716    """
717    validScores = {
718        tag: result.score
719        for tag, result in jumpResults.items()
720        if result.score is not None
721    }
722    if not validScores:
723        return {
724            tag: COLORS_OTHERS[i % len(COLORS_OTHERS)]
725            for i, tag in enumerate(jumpResults)
726        }
727    fastestTag = max(validScores, key=validScores.get)
728    slowestTag = min(validScores, key=validScores.get)
729    tagColors = {}
730    blueIndex = 0
731    for tag in jumpResults:
732        if tag == fastestTag:
733            tagColors[tag] = COLOR_FASTEST
734        elif tag == slowestTag:
735            tagColors[tag] = COLOR_SLOWEST
736        else:
737            tagColors[tag] = COLORS_OTHERS[blueIndex % len(COLORS_OTHERS)]
738            blueIndex += 1
739    return tagColors
740
741
742def convertHexColorToRGB(color: str) -> list:
743    """
744    Converts a color in the format `#a0b1c2` to its RGB equivalent as a list
745    of three values 0-255.
746    """
747    if not isinstance(color, str):
748        raise TypeError('Invalid color type - must be str')
749    color = color.replace('#', '')
750    if len(color) != 6:
751        raise SSScoringError('Invalid hex value length')
752    result = [int(color[x:x+2], 16) for x in range(0, len(color), 2)]
753    return result
DEFAULT_AXIS_COLOR = 'lightsteelblue'

CSS color name for the axis colors used in notebooks and Streamlit with Plotly.

COLOR_FASTEST = '#32cd32'

Limegreen — fastest jump track color in aggregate displays; matches the table's max-score highlight.

COLOR_SLOWEST = '#ff4500'

Orangered — slowest jump track color in aggregate displays; matches the table's min-score highlight.

COLORS_OTHERS = ('#1e90ff', '#4169e1', '#0000cd', '#00bfff', '#6495ed', '#4682b4')

Six distinct blue shades for all non-fastest, non-slowest jumps in aggregate displays.

def initializePlot( jumpTitle: str, height=500, width=900, xLabel='seconds from exit', yLabel='km/h', xMax=35.0, yMax=550.0, backgroundColorName='#1a1a1a', colorName='lightsteelblue'):
 71def initializePlot(jumpTitle: str,
 72                   height=500,
 73                   width=900,
 74                   xLabel='seconds from exit',
 75                   yLabel='km/h',
 76                   xMax=35.0,
 77                   yMax=DEFAULT_PLOT_MAX_V_SCALE,
 78                   backgroundColorName='#1a1a1a',
 79                   colorName=DEFAULT_AXIS_COLOR):
 80    """
 81    Initialize a Plotly figure for SSScoring jump plots, configured with the
 82    main speed (km/h) Y axis.  Extra Y axes for altitude / angle / acceleration /
 83    speed-accuracy are added by initializeExtraYRanges().
 84
 85    Returns
 86    -------
 87    A `plotly.graph_objects.Figure`.
 88    """
 89    fig = go.Figure()
 90    fig.update_layout(
 91        title=dict(text=jumpTitle, font=dict(color=colorName)),
 92        height=height,
 93        autosize=True,
 94        plot_bgcolor=backgroundColorName,
 95        paper_bgcolor=backgroundColorName,
 96        font=dict(color=colorName),
 97        hovermode='x unified',
 98        showlegend=True,
 99        legend=dict(font=dict(color=colorName)),
100        xaxis=dict(
101            title=dict(text=xLabel, font=dict(color=colorName)),
102            autorange=True,
103            color=colorName,
104            tickfont=dict(color=colorName),
105            showgrid=True,                              # ← changed
106            gridcolor='rgba(255,255,255,0.08)',         # ← added — subtle grid on dark bg
107            showline=True,                              # ← added — visible axis line
108            linecolor=colorName,                        # ← added
109            zeroline=False,
110        ),
111        yaxis=dict(
112            title=dict(text=yLabel, font=dict(color=colorName)),
113            autorange=True,
114            color=colorName,
115            tickfont=dict(color=colorName),
116            anchor='x',
117            side='left',
118            showgrid=True,                              # ← changed
119            gridcolor='rgba(255,255,255,0.08)',         # ← added
120            showline=True,                              # ← added
121            linecolor=colorName,                        # ← added
122            zeroline=False,
123        ),
124    )
125    return fig

Initialize a Plotly figure for SSScoring jump plots, configured with the main speed (km/h) Y axis. Extra Y axes for altitude / angle / acceleration / speed-accuracy are added by initializeExtraYRanges().

Returns

A plotly.graph_objects.Figure.

def initializeExtraYRanges( fig, startY: float = 0.0, endY: float = 14000.0, maxSpeedAccuracy: float | None = None):
148def initializeExtraYRanges(fig,
149                           startY: float = 0.0,
150                           endY: float = MAX_ALTITUDE_FT,
151                           maxSpeedAccuracy: float | None = None):
152    """
153    Configure additional Y axes on the plot for altitude (ft), angle, vertical
154    acceleration, and speed accuracy traces.  Plotly equivalent of Bokeh's
155    extra_y_ranges + LinearAxis layout.
156    """
157    LEFT_MARGIN = 0.24
158    AXIS_SPACING = 0.06
159    POSITIONS = [n*AXIS_SPACING for n in range(4)]  # 0.00, 0.06, 0.12, 0.18
160
161    speedAccuracyEnd = DEFAULT_SPEED_ACCURACY_SCALE
162    if maxSpeedAccuracy is not None and maxSpeedAccuracy >= SPEED_ACCURACY_THRESHOLD:
163        speedAccuracyEnd = maxSpeedAccuracy * 1.1
164
165    color = DEFAULT_AXIS_COLOR
166
167    fig.update_xaxes(domain=(LEFT_MARGIN, 1.0))
168
169    mainYTitle = (fig.layout.yaxis.title.text or 'km/h')
170    fig.update_yaxes(title=None)
171
172    fig.update_layout(
173        yaxis2=dict(
174            autorange=True,
175            anchor='free', overlaying='y', side='left', position=POSITIONS[0],
176            color=color, tickfont=dict(color=color),
177            showline=True, linecolor=color,
178            showgrid=False, zeroline=False,
179        ),
180        yaxis3=dict(
181            autorange=True,
182            anchor='free', overlaying='y', side='left', position=POSITIONS[1],
183            color=color, tickfont=dict(color=color),
184            showline=True, linecolor=color,
185            showgrid=False, zeroline=False,
186        ),
187        yaxis4=dict(
188            autorange=True,
189            anchor='free', overlaying='y', side='left', position=POSITIONS[2],
190            color=color, tickfont=dict(color=color),
191            showline=True, linecolor=color,
192            showgrid=False, zeroline=False,
193        ),
194        yaxis5=dict(
195            range=(0.0, speedAccuracyEnd),
196            anchor='free', overlaying='y', side='left', position=POSITIONS[3],
197            color=color, tickfont=dict(color=color),
198            showline=True, linecolor=color,
199            showgrid=False, zeroline=False,
200        ),
201    )
202
203    titles = [
204        (POSITIONS[0], 'Alt (ft)'),
205        (POSITIONS[1], 'angle'),
206        (POSITIONS[2], 'Vertical acceleration m/s²'),
207        (POSITIONS[3], 'Speed accuracy ISC'),
208        (LEFT_MARGIN,  mainYTitle),
209    ]
210    for pos, text in titles:
211        fig.add_annotation(
212            xref='paper', yref='paper',
213            x=pos + 0.012, y=0.5,
214            text=text,
215            showarrow=False,
216            textangle=-90,
217            font=dict(color=color, size=11),
218            xanchor='center', yanchor='middle',
219        )
220
221    return fig

Configure additional Y axes on the plot for altitude (ft), angle, vertical acceleration, and speed accuracy traces. Plotly equivalent of Bokeh's extra_y_ranges + LinearAxis layout.

def validationWindowDataFrom( data: pandas.DataFrame, window: ssscoring.datatypes.PerformanceWindow) -> pandas.DataFrame:
224def validationWindowDataFrom(data: pd.DataFrame, window: PerformanceWindow) -> pd.DataFrame:
225    """
226    Generate the validation window dataset for plotting the ISC speed accuracy
227    values.  Subset defined from the end of the scoring window to the
228    validation start.
229
230    NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to
231    pd.DataFrame as part of the Plotly migration.  Columns: 'x', 'y'.
232    """
233    validationData = data[data.altitudeAGL <= window.validationStart]
234    return pd.DataFrame({
235        'x': validationData.plotTime.values,
236        'y': validationData.speedAccuracyISC.values,
237    })

Generate the validation window dataset for plotting the ISC speed accuracy values. Subset defined from the end of the scoring window to the validation start.

NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to pd.DataFrame as part of the Plotly migration. Columns: 'x', 'y'.

def graphJumpResult( fig, jumpResult, lineColor='green', legend='speed', showIt=True, showAccuracy=True):
263def graphJumpResult(fig,
264                    jumpResult,
265                    lineColor='green',
266                    legend='speed',
267                    showIt=True,
268                    showAccuracy=True):
269    """
270    Graph the jump results onto the initialized Plotly figure.
271
272    Arguments
273    ---------
274        fig
275    A Plotly Figure where to render the plot.
276
277        jumpResult: ssscoring.JumpResults
278    A jump results named tuple with score, max speed, scores, data, etc.
279
280        lineColor: str
281    A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
282    colors used in multi-jump plots.
283
284        legend: str
285    Legend label for the main speed trace.
286
287        showIt: bool
288    If True, render the max speed marker, horizontal speed, and score brackets.
289    Used to discriminate between single-jump plots and aggregate competition
290    overlays.
291
292    Streamlit usage:
293
294```python
295    graphJumpResult(fig, result)
296    st.plotly_chart(fig, width='stretch')
297```
298    """
299    if jumpResult.data is not None:
300        data = jumpResult.data
301        scores = jumpResult.scores
302        score = jumpResult.score
303
304        # Main speed line
305        fig.add_trace(go.Scatter(
306            x=data.plotTime,
307            y=data.vKMh,
308            mode='lines',
309            name=legend,
310            line=dict(color=lineColor, width=2),
311            yaxis='y',
312            hovertemplate='v: %{y:.2f} km/h<extra></extra>',
313        ))
314
315        if showIt:
316            maxSpeed = data.vKMh.max()
317            t = data[data.vKMh == maxSpeed].iloc[0].plotTime
318
319            # Horizontal speed
320            fig.add_trace(go.Scatter(
321                x=data.plotTime,
322                y=data.hKMh,
323                mode='lines',
324                name='H-speed',
325                line=dict(color='red', width=2),
326                yaxis='y',
327                hovertemplate='h: %{y:.2f} km/h<extra></extra>',
328            ))
329
330            _plotSpeedAccuracy(fig, data, jumpResult.window)
331
332            if scores is not None:
333                # Score window brackets
334                _graphSegment(fig, scores[score]+3.0, 0.0, scores[score]+3.0, score, 1, 'darkseagreen')
335                _graphSegment(fig, scores[score],     0.0, scores[score],     score, 1, 'darkseagreen')
336                # Score marker
337                fig.add_trace(go.Scatter(
338                    x=[scores[score]+1.5],
339                    y=[score],
340                    mode='markers',
341                    marker=dict(symbol='circle-cross', size=15,
342                                line=dict(color='limegreen', width=2),
343                                color='darkgreen'),
344                    name='score',
345                    yaxis='y',
346                    hovertemplate='score: %{y:.2f} km/h<extra></extra>',
347                ))
348                # Max-speed marker
349                fig.add_trace(go.Scatter(
350                    x=[t],
351                    y=[maxSpeed],
352                    mode='markers',
353                    marker=dict(symbol='diamond-dot', size=20,
354                                line=dict(color='yellow', width=2),
355                                color='red'),
356                    name='max speed',
357                    yaxis='y',
358                    hovertemplate='max: %{y:.2f} km/h<extra></extra>',
359                ))

Graph the jump results onto the initialized Plotly figure.

Arguments
---------
    fig
A Plotly Figure where to render the plot.

    jumpResult: ssscoring.JumpResults
A jump results named tuple with score, max speed, scores, data, etc.

    lineColor: str
A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
colors used in multi-jump plots.

    legend: str
Legend label for the main speed trace.

    showIt: bool
If True, render the max speed marker, horizontal speed, and score brackets.
Used to discriminate between single-jump plots and aggregate competition
overlays.

Streamlit usage:
    graphJumpResult(fig, result)
    st.plotly_chart(fig, width='stretch')
def graphAltitude( fig, jumpResult, label='Alt (ft)', lineColor='palegoldenrod', rangeName='altitudeFt'):
362def graphAltitude(fig,
363                  jumpResult,
364                  label='Alt (ft)',
365                  lineColor='palegoldenrod',
366                  rangeName='altitudeFt'):
367    """
368    Graph altitude trace on the dedicated altitudeFt Y axis.
369    """
370    data = jumpResult.data
371    yaxis = _Y_AXIS_MAP[rangeName]
372    fig.add_trace(go.Scatter(
373        x=data.plotTime,
374        y=data.altitudeAGLFt,
375        mode='lines',
376        name=label,
377        line=dict(color=lineColor, width=2),
378        yaxis=yaxis,
379        hovertemplate='alt: %{y:.0f} ft<extra></extra>',
380    ))

Graph altitude trace on the dedicated altitudeFt Y axis.

def graphAngle( fig, jumpResult, label='angle', lineColor='deepskyblue', rangeName='angle'):
383def graphAngle(fig,
384               jumpResult,
385               label='angle',
386               lineColor='deepskyblue',
387               rangeName='angle'):
388    """
389    Graph the flight angle trace on the dedicated angle Y axis.
390    """
391    data = jumpResult.data
392    yaxis = _Y_AXIS_MAP[rangeName]
393    fig.add_trace(go.Scatter(
394        x=data.plotTime,
395        y=data.speedAngle,
396        mode='lines',
397        name=label,
398        line=dict(color=lineColor, width=2),
399        yaxis=yaxis,
400        hovertemplate='angle: %{y:.2f}°<extra></extra>',
401    ))

Graph the flight angle trace on the dedicated angle Y axis.

def graphAcceleration( fig, jumpResult, label='V-accel m/s²', lineColor='magenta', rangeName='vAccelMS2'):
404def graphAcceleration(fig,
405                      jumpResult,
406                      label='V-accel m/s²',
407                      lineColor='magenta',
408                      rangeName='vAccelMS2'):
409    """
410    Graph the flight vertical acceleration curve and its EMA-smoothed companion
411    on the dedicated vAccelMS2 Y axis.
412    """
413    data = jumpResult.data
414    data['vAccelEMA'] = data.vAccelMS2.ewm(span=20, adjust=False).mean()
415    yaxis = _Y_AXIS_MAP[rangeName]
416    fig.add_trace(go.Scatter(
417        x=data.plotTime,
418        y=data.vAccelMS2,
419        mode='lines',
420        name=label,
421        line=dict(color='dimgrey', width=2),
422        yaxis=yaxis,
423        hovertemplate='a: %{y:.2f} m/s²<extra></extra>',
424    ))
425    fig.add_trace(go.Scatter(
426        x=data.plotTime,
427        y=data.vAccelEMA,
428        mode='lines',
429        name=label + ' (EMA)',
430        line=dict(color=lineColor, width=2),
431        yaxis=yaxis,
432        hovertemplate='a (EMA): %{y:.2f} m/s²<extra></extra>',
433    ))

Graph the flight vertical acceleration curve and its EMA-smoothed companion on the dedicated vAccelMS2 Y axis.

def initializeGroundTrackPlot( jumpTitle: str, height=450, backgroundColorName='#1a1a1a', colorName='lightsteelblue'):
436def initializeGroundTrackPlot(jumpTitle: str,
437                              height=450,
438                              backgroundColorName='#1a1a1a',
439                              colorName=DEFAULT_AXIS_COLOR):
440    """
441    Initialize a Plotly figure for the ground-track plot, configured with equal
442    axis scaling so that forward and lateral distances are not distorted.
443
444    X axis: metres forward along the jump run from exit.
445    Y axis: metres lateral (left = positive, right = negative).
446
447    Unlike initializePlot(), no extra Y ranges are added — this canvas is
448    dedicated to spatial displacement only.
449
450    Arguments
451    ---------
452        jumpTitle: str
453    Figure title, usually the jump tag.
454
455        height: int
456    Plot height in pixels.  Default 450 — shorter than the main plot since
457    this is a companion chart.
458
459        backgroundColorName: str
460    CSS colour name or hex string for the plot and paper background.
461
462        colorName: str
463    CSS colour name or hex string for axes, tick labels, and title text.
464
465    Returns
466    -------
467    A `plotly.graph_objects.Figure` ready to receive graphGroundTrack() traces.
468    """
469    figure = go.Figure()
470    figure.update_layout(
471        title=dict(text=jumpTitle, font=dict(color=colorName)),
472        height=height,
473        autosize=True,
474        plot_bgcolor=backgroundColorName,
475        paper_bgcolor=backgroundColorName,
476        font=dict(color=colorName),
477        hovermode='closest',
478        showlegend=True,
479        legend=dict(font=dict(color=colorName)),
480        xaxis=dict(
481            title=dict(text='forward (m)', font=dict(color=colorName)),
482            autorange=True,
483            color=colorName,
484            tickfont=dict(color=colorName),
485            showgrid=True,
486            gridcolor='rgba(255,255,255,0.08)',
487            showline=True,
488            linecolor=colorName,
489            zeroline=True,
490            zerolinecolor='rgba(255,255,255,0.25)',
491            zerolinewidth=1,
492        ),
493        yaxis=dict(
494            title=dict(text='lateral (m)', font=dict(color=colorName)),
495            autorange=True,
496            color=colorName,
497            tickfont=dict(color=colorName),
498            showgrid=True,
499            gridcolor='rgba(255,255,255,0.08)',
500            showline=True,
501            linecolor=colorName,
502            zeroline=True,
503            zerolinecolor='rgba(255,255,255,0.25)',
504            zerolinewidth=1,
505            scaleanchor='x',
506            scaleratio=1,
507        ),
508    )
509    return figure

Initialize a Plotly figure for the ground-track plot, configured with equal axis scaling so that forward and lateral distances are not distorted.

X axis: metres forward along the jump run from exit. Y axis: metres lateral (left = positive, right = negative).

Unlike initializePlot(), no extra Y ranges are added — this canvas is dedicated to spatial displacement only.

Arguments

jumpTitle: str

Figure title, usually the jump tag.

height: int

Plot height in pixels. Default 450 — shorter than the main plot since this is a companion chart.

backgroundColorName: str

CSS colour name or hex string for the plot and paper background.

colorName: str

CSS colour name or hex string for axes, tick labels, and title text.

Returns

A plotly.graph_objects.Figure ready to receive graphGroundTrack() traces.

def graphGroundTrack(figure, jumpResult, lineColor='deepskyblue'):
512def graphGroundTrack(figure,
513                     jumpResult,
514                     lineColor='deepskyblue'):
515    """
516    Graph the skydiver's ground track during the performance window as forward
517    vs. lateral displacement from exit, with markers coloured by vertical speed.
518
519    X axis = metres forward along the jump run (negative = reversed, i.e. back-
520    fall).  Y axis = metres lateral (positive = left of jump run, negative =
521    right).  Marker colour encodes vKMh so the speed progression is visible
522    without a separate time axis.
523
524    A clean belly-to-earth run produces a smooth rightward curve staying close
525    to the lateral zero line.  A back-fall reverses toward the origin; the
526    line literally doubles back on itself — unmistakable at a glance.
527
528    Requires a figure initialised by initializeGroundTrackPlot() so that the
529    spatial axes are equal-scaled and the zero lines are present.
530
531    Arguments
532    ---------
533        figure
534    A Plotly Figure initialised by initializeGroundTrackPlot().
535
536        jumpResult: ssscoring.JumpResults
537    A jump results named tuple.  jumpResult.data must contain latitude,
538    longitude, plotTime, and vKMh columns (all present after processJump()).
539
540        lineColor: str
541    A valid CSS colour name or hex string for the connecting track line.
542
543    Streamlit usage:
544
545```python
546    figure = initializeGroundTrackPlot(tag)
547    graphGroundTrack(figure, jumpResult)
548    st.plotly_chart(figure, width='stretch')
549```
550    """
551    data = jumpResult.data
552    exitLat = float(data.latitude.iloc[0])
553    exitLon = float(data.longitude.iloc[0])
554    bearing = jumpRunBearing(data)
555    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
556
557    figure.add_trace(go.Scatter(
558        x=displacement.forwardM,
559        y=displacement.lateralM,
560        mode='lines',
561        name='track',
562        line=dict(color='rgba(255,255,255,0.15)', width=1),
563        showlegend=False,
564        hoverinfo='skip',
565    ))
566
567    figure.add_trace(go.Scatter(
568        x=displacement.forwardM,
569        y=displacement.lateralM,
570        mode='markers',
571        name='fwd (m)',
572        marker=dict(
573            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
574            colorscale=[
575                [0.0, SAFE_HORIZONTAL_COLOR],
576                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
577                [1.0, UNSAFE_HORIZONTAL_COLOR],
578            ],
579            cmin=0,
580            cmax=MAX_HORIZONTAL_DISTANCE,
581            cauto=False,
582            size=5,
583            showscale=True,
584            colorbar=dict(
585                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
586                tickfont=dict(color=DEFAULT_AXIS_COLOR),
587                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
588                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
589                thickness=12,
590                len=0.75,
591            ),
592        ),
593        hovertemplate='fwd: %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
594    ))
595
596    figure.add_trace(go.Scatter(
597        x=[displacement.forwardM.iloc[0]],
598        y=[displacement.lateralM.iloc[0]],
599        mode='markers',
600        name='exit',
601        marker=dict(symbol='circle', size=10,
602                    color=SAFE_HORIZONTAL_COLOR,
603                    line=dict(color='white', width=1)),
604        hovertemplate='exit<extra></extra>',
605    ))
606
607    figure.add_trace(go.Scatter(
608        x=[displacement.forwardM.iloc[-1]],
609        y=[displacement.lateralM.iloc[-1]],
610        mode='markers',
611        name='end',
612        marker=dict(symbol='square', size=10,
613                    color=UNSAFE_HORIZONTAL_COLOR,
614                    line=dict(color='white', width=1)),
615        hovertemplate='end: fwd %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
616    ))

Graph the skydiver's ground track during the performance window as forward vs. lateral displacement from exit, with markers coloured by vertical speed.

X axis = metres forward along the jump run (negative = reversed, i.e. back-
fall).  Y axis = metres lateral (positive = left of jump run, negative =
right).  Marker colour encodes vKMh so the speed progression is visible
without a separate time axis.

A clean belly-to-earth run produces a smooth rightward curve staying close
to the lateral zero line.  A back-fall reverses toward the origin; the
line literally doubles back on itself — unmistakable at a glance.

Requires a figure initialised by initializeGroundTrackPlot() so that the
spatial axes are equal-scaled and the zero lines are present.

Arguments
---------
    figure
A Plotly Figure initialised by initializeGroundTrackPlot().

    jumpResult: ssscoring.JumpResults
A jump results named tuple.  jumpResult.data must contain latitude,
longitude, plotTime, and vKMh columns (all present after processJump()).

    lineColor: str
A valid CSS colour name or hex string for the connecting track line.

Streamlit usage:
    figure = initializeGroundTrackPlot(tag)
    graphGroundTrack(figure, jumpResult)
    st.plotly_chart(figure, width='stretch')
def graphForwardDisplacement(figure, jumpResult):
619def graphForwardDisplacement(figure,
620                             jumpResult):
621    """
622    Graph the forward displacement (metres along the jump run from exit) as a
623    time series on the primary Y axis.
624
625    A skydiver on a clean belly run produces a monotonically rising curve.  A
626    back-fall inflects and drops — the onset time and reversal depth are
627    immediately readable from the shape of the line and the zero reference.
628
629    Markers are coloured by the same green→red gradient used in the ground
630    track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
631    transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
632    red beyond.
633
634    Pair this with graphJumpResult() on a second figure (same plotTime X axis)
635    to show the temporal relationship between displacement reversal and speed
636    loss.
637
638    Arguments
639    ---------
640        figure
641    A Plotly Figure initialised by initializePlot() with xLabel='seconds from
642    exit' and yLabel='forward (m)'.
643
644        jumpResult: ssscoring.JumpResults
645    A jump results named tuple.  jumpResult.data must contain latitude,
646    longitude, and plotTime (all present after processJump()).
647
648    Streamlit usage:
649
650```python
651    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
652    graphForwardDisplacement(figure, jumpResult)
653    st.plotly_chart(figure, width='stretch')
654```
655    """
656    data = jumpResult.data
657    exitLat = float(data.latitude.iloc[0])
658    exitLon = float(data.longitude.iloc[0])
659    bearing = jumpRunBearing(data)
660    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
661
662    figure.add_trace(go.Scatter(
663        x=displacement.plotTime,
664        y=displacement.forwardM,
665        mode='lines',
666        line=dict(color='rgba(255,255,255,0.15)', width=1),
667        showlegend=False,
668        hoverinfo='skip',
669    ))
670
671    figure.add_trace(go.Scatter(
672        x=displacement.plotTime,
673        y=displacement.forwardM,
674        mode='markers',
675        name='fwd (m)',
676        marker=dict(
677            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
678            colorscale=[
679                [0.0, SAFE_HORIZONTAL_COLOR],
680                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
681                [1.0, UNSAFE_HORIZONTAL_COLOR],
682            ],
683            cmin=0,
684            cmax=MAX_HORIZONTAL_DISTANCE,
685            cauto=False,
686            size=4,
687            showscale=True,
688            colorbar=dict(
689                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
690                tickfont=dict(color=DEFAULT_AXIS_COLOR),
691                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
692                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
693                thickness=12,
694                len=0.75,
695            ),
696        ),
697        yaxis='y',
698        hovertemplate='t: %{x:.1f} s  fwd: %{y:.1f} m<extra></extra>',
699    ))
700
701    figure.add_trace(go.Scatter(
702        x=[displacement.plotTime.iloc[0], displacement.plotTime.iloc[-1]],
703        y=[0.0, 0.0],
704        mode='lines',
705        line=dict(color='rgba(255,255,255,0.25)', width=1, dash='dot'),
706        showlegend=False,
707        hoverinfo='skip',
708    ))

Graph the forward displacement (metres along the jump run from exit) as a time series on the primary Y axis.

A skydiver on a clean belly run produces a monotonically rising curve.  A
back-fall inflects and drops — the onset time and reversal depth are
immediately readable from the shape of the line and the zero reference.

Markers are coloured by the same green→red gradient used in the ground
track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
red beyond.

Pair this with graphJumpResult() on a second figure (same plotTime X axis)
to show the temporal relationship between displacement reversal and speed
loss.

Arguments
---------
    figure
A Plotly Figure initialised by initializePlot() with xLabel='seconds from
exit' and yLabel='forward (m)'.

    jumpResult: ssscoring.JumpResults
A jump results named tuple.  jumpResult.data must contain latitude,
longitude, and plotTime (all present after processJump()).

Streamlit usage:
    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
    graphForwardDisplacement(figure, jumpResult)
    st.plotly_chart(figure, width='stretch')
def resolveJumpColors(jumpResults: dict) -> dict:
711def resolveJumpColors(jumpResults: dict) -> dict:
712    """
713    Build a tag→hex-color mapping for a set of jump results.
714
715    The fastest jump (highest score) gets `COLOR_FASTEST`, the slowest gets
716    `COLOR_SLOWEST`, and all remaining valid jumps cycle through `COLORS_OTHERS`.
717    """
718    validScores = {
719        tag: result.score
720        for tag, result in jumpResults.items()
721        if result.score is not None
722    }
723    if not validScores:
724        return {
725            tag: COLORS_OTHERS[i % len(COLORS_OTHERS)]
726            for i, tag in enumerate(jumpResults)
727        }
728    fastestTag = max(validScores, key=validScores.get)
729    slowestTag = min(validScores, key=validScores.get)
730    tagColors = {}
731    blueIndex = 0
732    for tag in jumpResults:
733        if tag == fastestTag:
734            tagColors[tag] = COLOR_FASTEST
735        elif tag == slowestTag:
736            tagColors[tag] = COLOR_SLOWEST
737        else:
738            tagColors[tag] = COLORS_OTHERS[blueIndex % len(COLORS_OTHERS)]
739            blueIndex += 1
740    return tagColors

Build a tag→hex-color mapping for a set of jump results.

The fastest jump (highest score) gets COLOR_FASTEST, the slowest gets COLOR_SLOWEST, and all remaining valid jumps cycle through COLORS_OTHERS.

def convertHexColorToRGB(color: str) -> list:
743def convertHexColorToRGB(color: str) -> list:
744    """
745    Converts a color in the format `#a0b1c2` to its RGB equivalent as a list
746    of three values 0-255.
747    """
748    if not isinstance(color, str):
749        raise TypeError('Invalid color type - must be str')
750    color = color.replace('#', '')
751    if len(color) != 6:
752        raise SSScoringError('Invalid hex value length')
753    result = [int(color[x:x+2], 16) for x in range(0, len(color), 2)]
754    return result

Converts a color in the format #a0b1c2 to its RGB equivalent as a list of three values 0-255.