From b4f3a762b09f93e28f986ee0422cc2d782a916ed Mon Sep 17 00:00:00 2001 From: artem Date: Sat, 5 Sep 2026 23:07:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0=20=D1=82=D1=80=D0=B5=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/workouts/components/ChartGroup.ts | 80 +++++++++ .../workouts/components/PowerZonesPlugin.ts | 159 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/pages/workouts/components/ChartGroup.ts create mode 100644 src/pages/workouts/components/PowerZonesPlugin.ts diff --git a/src/pages/workouts/components/ChartGroup.ts b/src/pages/workouts/components/ChartGroup.ts new file mode 100644 index 0000000..db14efe --- /dev/null +++ b/src/pages/workouts/components/ChartGroup.ts @@ -0,0 +1,80 @@ +import { Chart } from "chart.js"; +import { resetZoom } from "chartjs-plugin-zoom"; + +/** + * Synchronizes a group of per-metric charts: hover (tooltips + blue line), + * zoom (shared time range), reset and redraw. + */ +export class ChartGroup { + charts: Array; + private broadcasting = false; + + constructor(charts: Array) { + this.charts = charts; + } + + /** True while a zoom is being propagated to receiver charts. */ + isBroadcasting(): boolean { + return this.broadcasting; + } + + setBroadcasting(value: boolean): void { + this.broadcasting = value; + } + + /** + * Activates the tooltip at `dataIndex` on every chart. All metrics share + * the same point order (a common time axis), so the index is identical. + */ + setHover(dataIndex: number): void { + for (const chart of this.charts) { + if (!chart.tooltip) { + continue; + } + chart.tooltip.setActiveElements([{ datasetIndex: 0, index: dataIndex }], { + x: 0, + y: 0, + }); + chart.draw(); + } + } + + clearHover(): void { + for (const chart of this.charts) { + if (!chart.tooltip) { + continue; + } + chart.tooltip.setActiveElements([], { x: 0, y: 0 }); + chart.draw(); + } + } + + /** Applies the same x range to every chart without re-triggering onZoomComplete. */ + broadcastZoom(min: number, max: number): void { + this.broadcasting = true; + for (const chart of this.charts) { + if (!chart.options?.scales?.x) { + continue; + } + chart.options.scales.x.min = min; + chart.options.scales.x.max = max; + chart.update("none"); + } + this.broadcasting = false; + } + + resetAll(): void { + for (const chart of this.charts) { + resetZoom(chart); + } + this.clearHover(); + } + + redrawAll(): void { + for (const chart of this.charts) { + chart.draw(); + } + } +} + +export default ChartGroup; diff --git a/src/pages/workouts/components/PowerZonesPlugin.ts b/src/pages/workouts/components/PowerZonesPlugin.ts new file mode 100644 index 0000000..b836ecc --- /dev/null +++ b/src/pages/workouts/components/PowerZonesPlugin.ts @@ -0,0 +1,159 @@ +import { Ref } from "vue"; + +// FTP-relative power zones (fractions of FTP, ascending). FTP is not a +// separate field, so the average power (workoutItem.power) is used as the +// FTP heuristic, same as Strava does when FTP is unknown. +export type PowerZone = { + min: number; + max: number | undefined; + color: string; + label: string; +}; + +/** Dataset label of the power chart (GetWorkout.ts); the plugin is a no-op elsewhere. */ +export const POWER_DATASET_LABEL = "Мощность"; + +const ZONE_FRACTIONS: Array<{ + min: number; + max: number | undefined; + color: string; + label: string; +}> = [ + { min: 0, max: 0.55, color: "#43a047", label: "Z1 Recovery" }, + { min: 0.55, max: 0.75, color: "#7cb342", label: "Z2 Endurance" }, + { min: 0.75, max: 0.9, color: "#fdd835", label: "Z3 Tempo" }, + { min: 0.9, max: 1.05, color: "#fb8c00", label: "Z4 Threshold" }, + { min: 1.05, max: 1.2, color: "#e53935", label: "Z5 VO2max" }, + { min: 1.2, max: 1.6, color: "#8e24aa", label: "Z6 Anaerobic" }, + { + min: 1.6, + max: undefined, + color: "#6a1b9a", + label: "Z7 Neural capacity", + }, +]; + +/** Zones with absolute watt boundaries, or null when FTP is missing/zero. */ +export const ftpToZones = ( + ftp: number | undefined, +): Array | null => { + if (ftp === undefined || !Number.isFinite(ftp) || ftp <= 0) { + return null; + } + return ZONE_FRACTIONS.map((z) => ({ + min: z.min * ftp, + max: z.max === undefined ? undefined : z.max * ftp, + color: z.color, + label: z.label, + })); +}; + +type PointLike = { x: number } | null | undefined; + +type PowerZoneChart = { + config: { plugins?: Array> }; + data: { datasets: Array<{ data: Array; label?: string }> }; + getDatasetMeta(index: number): { data: Array }; + chartArea: { left: number; right: number; top: number; bottom: number }; + scales: { linearAxis: { top: number; bottom: number } }; + ctx: CanvasRenderingContext2D; +}; + +export type PowerZonesPluginDef = { + id: string; + zones: Ref | null> | null; + beforeDatasetsDraw(chart: unknown): void; +}; + +// The single plugin instance is shared across all metric charts (the same +// array is passed via the :plugins prop, like sectionPlugin/yandexMapLine), +// so the hook must be a no-op on every chart that is not the power one. The +// component publishes the memoized zone list through the shared `zones` ref. +export const PowerZonesPlugin: PowerZonesPluginDef = { + id: "powerZones", + zones: null, + beforeDatasetsDraw(chart: unknown) { + const c = chart as PowerZoneChart; + const datasets = c.data?.datasets; + if (!datasets || datasets.length === 0) { + return; + } + // Applied only to the power canvas; every other chart is a no-op. + if (datasets[0].label !== POWER_DATASET_LABEL) { + return; + } + const zones = PowerZonesPlugin.zones?.value ?? null; + if (zones === null) { + return; + } + const values = c.data?.datasets?.[0]?.data; + if (!values || values.length < 2) { + return; + } + const pts = c.getDatasetMeta(0).data; + const n = values.length; + const left = c.chartArea.left; + const right = c.chartArea.right; + const top = c.scales.linearAxis.top; + const bottom = c.scales.linearAxis.bottom; + // Pixel x of a point, clamped into the visible chart area; falls back to + // the neighbour when the point element is missing. + const px = (i: number): number => { + const x = pts[i]?.x; + if (x === undefined || !Number.isFinite(x)) { + const nb = pts[i > 0 ? i - 1 : i + 1]?.x; + if (nb === undefined || !Number.isFinite(nb)) { + return left; + } + return Math.min(Math.max(nb, left), right); + } + return Math.min(Math.max(x, left), right); + }; + // Per-point zone index; null for missing values. O(n) compares per redraw. + const zoneOf: Array = new Array(n); + for (let i = 0; i < n; i++) { + const v = values[i]; + if (v === null || v === undefined) { + zoneOf[i] = null; + continue; + } + let z = 0; + for (; z < zones.length; z++) { + if (v < (zones[z].max ?? Infinity)) { + break; + } + } + zoneOf[i] = Math.min(z, zones.length - 1); + } + const ctx = c.ctx; + ctx.save(); + ctx.beginPath(); + ctx.rect(left, c.chartArea.top, right - left, c.chartArea.bottom - top); + ctx.clip(); + ctx.globalAlpha = 0.3; + // Consecutive points of one zone are merged into a single band, so at + // most one fillRect per zone run (and at most zones.length per chart). + let i = 0; + while (i < n) { + const z = zoneOf[i]; + if (z === null) { + i += 1; + continue; + } + let j = i + 1; + while (j < n && zoneOf[j] === z) { + j += 1; + } + // Band edges: midpoints to the neighbouring points, clamped to the + // chart area for the first/last runs. + const startX = i === 0 ? left : (px(i - 1) + px(i)) / 2; + const endX = j === n ? right : (px(j - 1) + px(j)) / 2; + if (endX - startX >= 1) { + ctx.fillStyle = zones[z].color; + ctx.fillRect(startX, top, endX - startX, bottom - top); + } + i = j; + } + ctx.restore(); + }, +};