Fix forecast chart continuity and hover

This commit is contained in:
Peter Stockings
2026-09-06 13:59:50 +10:00
parent e2c4196792
commit cada28b54b
2 changed files with 41 additions and 14 deletions
+27 -11
View File
@@ -19,8 +19,8 @@
{% macro path_band(lower_points, upper_points, vb_height) %} {% macro path_band(lower_points, upper_points, vb_height) %}
{% if lower_points and upper_points %} {% if lower_points and upper_points %}
M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% if not loop.last %} L{% endif %}{% endfor %} M{% for value, position in lower_points %}{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% if not loop.last %} L{% endif %}{% endfor %}
{% for value, position in upper_points|reverse %} L{{ (position * vb_width)+margin | int }} {{ (vb_height - value)+margin | int }}{% endfor %} Z {% for value, position in upper_points|reverse %} L{{ (((position * vb_width) + margin) | int) }} {{ (((vb_height - value) + margin) | int) }}{% endfor %} Z
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
@@ -30,7 +30,16 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int
{% set y = (vb_height - value)+margin %} {% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle> <circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}
{% macro forecast_circles(data_points, labels, color) %}
{% for value, position in data_points %}
{% set x = (position * vb_width)+margin %}
{% set y = (vb_height - value)+margin %}
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1.6" fill="{{ color }}" stroke="{{ color }}"
class="pnt-{{ unique_id }}" data-msg="{{ labels[loop.index0][1] }}"></circle>
{% endfor %}
{% endmacro %}
{% macro plot_line(points, color) %} {% macro plot_line(points, color) %}
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" /> <path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
@@ -224,10 +233,10 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int
{% endif %} {% endif %}
<svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none"> <svg viewBox="0 0 {{ (vb_width + 2*margin) | int }} {{ (vb_height + 2*margin) | int }}" preserveAspectRatio="none">
{% if forecast_enabled and forecast.available %} {% if forecast_enabled and forecast.available %}
<path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.14" stroke="none"></path> <path d="{{ path_band(forecast_band_lower, forecast_band_upper, vb_height) }}" fill="#10b981" fill-opacity="0.10" stroke="none"></path>
<path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="1.5" stroke-dasharray="4,3" fill="none"></path> <path d="{{ path(forecast_points, vb_height) }}" stroke="#059669" stroke-width="2" stroke-dasharray="5,3" fill="none"></path>
{{ circles(forecast_points, '#059669') }} {{ forecast_circles(forecast_points, forecast_labels, '#059669') }}
<line x1="{{ (forecast_start_position * vb_width)+margin | int }}" y1="{{ margin }}" x2="{{ (forecast_start_position * vb_width)+margin | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.7"></line> <line x1="{{ ((forecast_start_position * vb_width) + margin) | int }}" y1="{{ margin }}" x2="{{ ((forecast_start_position * vb_width) + margin) | int }}" y2="{{ vb_height + margin }}" stroke="#059669" stroke-width="0.8" stroke-dasharray="2,2" stroke-opacity="0.5"></line>
{% endif %} {% endif %}
{% for plot in plots %} {% for plot in plots %}
<g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};"> <g class="{{ plot.label }}" style="fill: {{ plot.color }}; stroke: {{ plot.color }};">
@@ -236,15 +245,22 @@ M{% for value, position in lower_points %}{{ (position * vb_width)+margin | int
{% endfor %} {% endfor %}
<g style="fill-opacity: 0%"> <g style="fill-opacity: 0%">
{% for pos, message in plot_labels %} {% for pos, message in plot_labels %}
{% set x = (pos * vb_width) - (stroke_width/2) + margin %} {% set x = (pos * vb_width) - (stroke_width/2) + margin %}
{% set y = 0 %} {% set y = 0 %}
{% set width = stroke_width %} {% set width = stroke_width %}
{% set height = vb_height + margin %} {% set height = vb_height + margin %}
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}" <rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect> class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
{% endfor %} {% endfor %}
</g> {% if forecast_enabled and forecast.available %}
{% for pos, message in forecast_labels %}
{% set x = (pos * vb_width) - (stroke_width/2) + margin %}
<rect x="{{ x | int }}" y="0" width="{{ stroke_width | int }}" height="{{ (vb_height + margin) | int }}"
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
{% endfor %}
{% endif %}
</g>
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" <path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
stroke-opacity="60%" /> stroke-opacity="60%" />
+14 -3
View File
@@ -84,10 +84,14 @@ def build_forecast(values, dates, horizon_weeks=8, metric='E1RM'):
horizon_days = horizon_weeks * 7 horizon_days = horizon_weeks * 7
future_x = np.arange(0, horizon_days + 1, 7, dtype=float) + x[-1] future_x = np.arange(0, horizon_days + 1, 7, dtype=float) + x[-1]
future_dates = [last_date + timedelta(days=int(day - x[-1])) for day in future_x] future_dates = [last_date + timedelta(days=int(day - x[-1])) for day in future_x]
future_values = intercept + slope * future_x # Anchor the projection to the most recent observed value. A pure
# regression line can sit noticeably above or below the latest workout,
# which makes the forecast appear to jump when it starts.
future_values = observed_values[-1] + slope * (future_x - x[-1])
# A 95% prediction band, widened for points further from the observations. # A restrained 90% prediction band, widened for points further from the
prediction_error = 1.96 * residual_std * np.sqrt( # observations so the uncertainty remains readable on the small chart.
prediction_error = 1.645 * residual_std * np.sqrt(
1 + (1 / len(observed_values)) + ((future_x - mean_x) ** 2 / sum_squared_x) 1 + (1 / len(observed_values)) + ((future_x - mean_x) ** 2 / sum_squared_x)
) )
lower_values = np.maximum(future_values - prediction_error, 0) lower_values = np.maximum(future_values - prediction_error, 0)
@@ -212,6 +216,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
forecast_points = [] forecast_points = []
forecast_band_lower = [] forecast_band_lower = []
forecast_band_upper = [] forecast_band_upper = []
forecast_labels = []
forecast_start_position = None forecast_start_position = None
if forecast.get('available'): if forecast.get('available'):
forecast_start_position = round((observed_max_date - observed_min_date).days / total_span, 3) forecast_start_position = round((observed_max_date - observed_min_date).days / total_span, 3)
@@ -220,6 +225,11 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
forecast_points.append((round(((point['value'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_points.append((round(((point['value'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
forecast_band_lower.append((round(((point['lower'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_lower.append((round(((point['lower'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
forecast_band_upper.append((round(((point['upper'] - min_e1rm) / e1rm_range) * vb_height, 1), position)) forecast_band_upper.append((round(((point['upper'] - min_e1rm) / e1rm_range) * vb_height, 1), position))
forecast_labels.append((
position,
f"{point['date'].strftime('%d %b %y')} · Projected E1RM: {point['value']}kg "
f"(range {point['lower']}–{point['upper']}kg)"
))
# Return exercise data with SVG dimensions and data points # Return exercise data with SVG dimensions and data points
return { return {
@@ -243,6 +253,7 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
'forecast_points': forecast_points, 'forecast_points': forecast_points,
'forecast_band_lower': forecast_band_lower, 'forecast_band_lower': forecast_band_lower,
'forecast_band_upper': forecast_band_upper, 'forecast_band_upper': forecast_band_upper,
'forecast_labels': forecast_labels,
'forecast_start_position': forecast_start_position 'forecast_start_position': forecast_start_position
} }