Fix forecast chart continuity and hover
This commit is contained in:
@@ -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%" />
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user