изменение интерфейса тренировок
Gitea Actions Demo / build_and_push (push) Successful in 46s Details

This commit is contained in:
artem 2026-09-05 23:07:49 +03:00
parent b92af7e58a
commit b4f3a762b0
2 changed files with 239 additions and 0 deletions

View File

@ -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<Chart>;
private broadcasting = false;
constructor(charts: Array<Chart>) {
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;

View File

@ -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<PowerZone> | 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<Record<string, unknown>> };
data: { datasets: Array<{ data: Array<number | null>; label?: string }> };
getDatasetMeta(index: number): { data: Array<PointLike> };
chartArea: { left: number; right: number; top: number; bottom: number };
scales: { linearAxis: { top: number; bottom: number } };
ctx: CanvasRenderingContext2D;
};
export type PowerZonesPluginDef = {
id: string;
zones: Ref<Array<PowerZone> | 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<number | null> = 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();
},
};