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; +}