diff --git a/src/pages/workouts/components/WorkoutItem.vue b/src/pages/workouts/components/WorkoutItem.vue
index 8806bd9..bc531c3 100644
--- a/src/pages/workouts/components/WorkoutItem.vue
+++ b/src/pages/workouts/components/WorkoutItem.vue
@@ -213,6 +213,23 @@
{{ readoutValue[config.key] }}
+
+
+
+ {{ zone.label }}
+
+
(null);
+// Power zones keyed by FTP; FTP is not a separate field, so the average
+// power is used as the FTP heuristic (Strava-style). Null -> no heatmap.
+// Absolute watt bounds do not depend on the x-axis mode, so the result is
+// memoized per FTP value (computed cache + no re-allocation).
+let powerZonesFtp: number | undefined | null = NaN;
+let powerZonesMemo: Array | null = null;
+const powerZones = computed | null>(() => {
+ const ftp = workoutItem?.power;
+ if (ftp !== powerZonesFtp) {
+ powerZonesFtp = ftp;
+ powerZonesMemo = ftpToZones(ftp);
+ }
+ return powerZonesMemo;
+});
+// Expose the memoized zones to the shared plugin instance (read at draw time).
+PowerZonesPlugin.zones = powerZones;
// Inner section boundaries (point indexes, never 0 or the last point).
const sectionBoundaries = computed>(() =>
sectionBounds.value
@@ -558,6 +596,10 @@ const chartPlugins = [
}
},
},
+ // Power-zone heatmap: shared instance for all charts; the hook no-ops on
+ // every canvas except the power one (dataset label check). The zone list
+ // is exposed through the shared object, same pattern as the mapX ref.
+ PowerZonesPlugin,
];
/** X scale per mode: time scale for "time", plain meters for "distance". */
const xScaleOptions = (mode: XAxisMode): Record => {
@@ -1101,4 +1143,24 @@ h3 {
font-weight: normal;
color: #07c;
}
+.power-zones-legend {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ margin-left: 10px;
+ font-size: 11px;
+ font-weight: normal;
+ color: #555;
+}
+.power-zone-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+}
+.power-zone-color {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+}