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

This commit is contained in:
artem 2026-09-05 22:29:55 +03:00
parent 5e52b0eb2f
commit cd58beb85a
5 changed files with 715 additions and 216 deletions

View File

@ -67,11 +67,18 @@ export type ChartDataset = {
label: string; label: string;
backgroundColor: string; backgroundColor: string;
borderColor: string; borderColor: string;
data: Array<number>; data: Array<number | null>;
yAxisID?: string;
}; };
export type ChartData = { export type ChartData = {
labels: Array<string>; labels: Array<string | number>;
datasets: Array<ChartDataset>; datasets: Array<ChartDataset>;
}; };
export type ChartDataByMetric = {
speed?: ChartData;
heartRate?: ChartData;
power?: ChartData;
elevation?: ChartData;
};
export default {}; export default {};
</script> </script>

View File

@ -6,6 +6,7 @@
:data="data" :data="data"
:mapCenter="mapCenter" :mapCenter="mapCenter"
:lineCoordinates="lineCoordinates" :lineCoordinates="lineCoordinates"
:distances="distances"
:dzenLink="dzenLink" :dzenLink="dzenLink"
:isPrivate="true" :isPrivate="true"
/> />
@ -19,7 +20,7 @@ import { ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useToast } from "vuestic-ui/web-components"; import { useToast } from "vuestic-ui/web-components";
import WorkoutItemComponent from "./components/WorkoutItem.vue"; import WorkoutItemComponent from "./components/WorkoutItem.vue";
import { WorkoutItem, ChartData } from "./Definitions.vue"; import { WorkoutItem, ChartDataByMetric } from "./Definitions.vue";
import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout"; import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout";
const { init } = useToast(); const { init } = useToast();
@ -27,11 +28,9 @@ const route = useRoute();
const mapCenter = ref<Array<number>>([37.617644, 55.755819]); const mapCenter = ref<Array<number>>([37.617644, 55.755819]);
const lineCoordinates = ref<Array<Array<number>>>([]); const lineCoordinates = ref<Array<Array<number>>>([]);
const distances = ref<Array<number>>([]);
const workoutItem = ref<WorkoutItem>(); const workoutItem = ref<WorkoutItem>();
const data = ref<ChartData>({ const data = ref<ChartDataByMetric>({});
labels: [],
datasets: [],
});
const dzenLink = ref(""); const dzenLink = ref("");
const initWorkout = (id: string) => { const initWorkout = (id: string) => {
@ -39,6 +38,7 @@ const initWorkout = (id: string) => {
.then((d: InitWorkoutItem) => { .then((d: InitWorkoutItem) => {
mapCenter.value = d.mapCenter; mapCenter.value = d.mapCenter;
lineCoordinates.value = d.lineCoordinates; lineCoordinates.value = d.lineCoordinates;
distances.value = d.distances;
workoutItem.value = d.workoutItem; workoutItem.value = d.workoutItem;
data.value = d.data; data.value = d.data;
if (d.dzenLink != undefined) { if (d.dzenLink != undefined) {

View File

@ -6,6 +6,7 @@
:data="data" :data="data"
:mapCenter="mapCenter" :mapCenter="mapCenter"
:lineCoordinates="lineCoordinates" :lineCoordinates="lineCoordinates"
:distances="distances"
:dzenLink="dzenLink" :dzenLink="dzenLink"
:isPrivate="false" :isPrivate="false"
/> />
@ -19,7 +20,7 @@ import { ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useToast } from "vuestic-ui/web-components"; import { useToast } from "vuestic-ui/web-components";
import WorkoutItemComponent from "./components/WorkoutItem.vue"; import WorkoutItemComponent from "./components/WorkoutItem.vue";
import { WorkoutItem, ChartData } from "./Definitions.vue"; import { WorkoutItem, ChartDataByMetric } from "./Definitions.vue";
import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout"; import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout";
const { init } = useToast(); const { init } = useToast();
@ -27,11 +28,9 @@ const route = useRoute();
const mapCenter = ref<Array<number>>([37.617644, 55.755819]); const mapCenter = ref<Array<number>>([37.617644, 55.755819]);
const lineCoordinates = ref<Array<Array<number>>>([]); const lineCoordinates = ref<Array<Array<number>>>([]);
const distances = ref<Array<number>>([]);
const workoutItem = ref<WorkoutItem>(); const workoutItem = ref<WorkoutItem>();
const data = ref<ChartData>({ const data = ref<ChartDataByMetric>({});
labels: [],
datasets: [],
});
const dzenLink = ref(""); const dzenLink = ref("");
const initWorkout = (id: string) => { const initWorkout = (id: string) => {
@ -39,6 +38,7 @@ const initWorkout = (id: string) => {
.then((d: InitWorkoutItem) => { .then((d: InitWorkoutItem) => {
mapCenter.value = d.mapCenter; mapCenter.value = d.mapCenter;
lineCoordinates.value = d.lineCoordinates; lineCoordinates.value = d.lineCoordinates;
distances.value = d.distances;
workoutItem.value = d.workoutItem; workoutItem.value = d.workoutItem;
data.value = d.data; data.value = d.data;
if (d.dzenLink != undefined) { if (d.dzenLink != undefined) {

View File

@ -1,19 +1,34 @@
import { AxiosResponse, AxiosInstance } from "axios"; import { AxiosResponse, AxiosInstance } from "axios";
import { inject } from "vue"; import { inject } from "vue";
import { WorkoutItem, ChartData } from "../Definitions.vue"; import { WorkoutItem, ChartDataByMetric } from "../Definitions.vue";
let workoutItem: WorkoutItem; let workoutItem: WorkoutItem;
let mapCenter: Array<number> = [37.617644, 55.755819]; let mapCenter: Array<number> = [37.617644, 55.755819];
let lineCoordinates: Array<Array<number>> = []; let lineCoordinates: Array<Array<number>> = [];
let data: ChartData; let distances: Array<number> = [];
let data: ChartDataByMetric;
let dzenLink: string; let dzenLink: string;
const msToKmh = (ms: number) => ms * 3.6; const msToKmh = (ms: number) => ms * 3.6;
/** Distance between two [lon, lat] coordinates in meters (sphere haversine). */
const haversine = (a: Array<number>, b: Array<number>): number => {
const R = 6371000;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(b[1] - a[1]);
const dLon = toRad(b[0] - a[0]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
};
export type InitWorkoutItem = { export type InitWorkoutItem = {
workoutItem: WorkoutItem; workoutItem: WorkoutItem;
mapCenter: Array<number>; mapCenter: Array<number>;
lineCoordinates: Array<Array<number>>; lineCoordinates: Array<Array<number>>;
data: ChartData; distances: Array<number>;
data: ChartDataByMetric;
dzenLink?: string; dzenLink?: string;
}; };
@ -61,51 +76,72 @@ export const GetWorkout = (url: string) => {
lineCoordinates = coords; lineCoordinates = coords;
mapCenter = [coords[0][0], coords[0][1]]; mapCenter = [coords[0][0], coords[0][1]];
const datasets = []; distances = [0];
for (let i = 1; i < coords.length; i++) {
distances.push(distances[i - 1] + haversine(coords[i - 1], coords[i]));
}
const byMetric: ChartDataByMetric = {};
if (is_speed) { if (is_speed) {
datasets.push({ byMetric.speed = {
yAxisID: "linearYSpeed", labels: times,
radius: 0, datasets: [
label: "Скорость", {
borderColor: "#00aa00", yAxisID: "linearYSpeed",
backgroundColor: "#00aa00", radius: 0,
data: speed, label: "Скорость",
}); borderColor: "#00aa00",
backgroundColor: "#00aa00",
data: speed,
},
],
};
} }
if (is_heart_rate) { if (is_heart_rate) {
datasets.push({ byMetric.heartRate = {
yAxisID: "linearAxis", labels: times,
radius: 0, datasets: [
label: "Пульс", {
borderColor: "#990000", yAxisID: "linearAxis",
backgroundColor: "#990000", radius: 0,
data: heart_rate, label: "Пульс",
}); borderColor: "#990000",
backgroundColor: "#990000",
data: heart_rate,
},
],
};
} }
if (is_power) { if (is_power) {
datasets.push({ byMetric.power = {
yAxisID: "logAxis", labels: times,
radius: 0, datasets: [
label: "Мощность", {
borderColor: "#cccccc", yAxisID: "logAxis",
backgroundColor: "#cccccc", radius: 0,
data: power, label: "Мощность",
}); borderColor: "#cccccc",
backgroundColor: "#cccccc",
data: power,
},
],
};
} }
if (is_elevation) { if (is_elevation) {
datasets.push({ byMetric.elevation = {
yAxisID: "yGroung", labels: times,
radius: 0, datasets: [
label: "Подъем", {
borderColor: "#000", yAxisID: "yGroung",
backgroundColor: "#000", radius: 0,
data: elevation, label: "Подъем",
}); borderColor: "#000",
backgroundColor: "#000",
data: elevation,
},
],
};
} }
data = { data = byMetric;
labels: times,
datasets: datasets,
};
if ( if (
response.data.workout.external_links && response.data.workout.external_links &&
response.data.workout.external_links.values && response.data.workout.external_links.values &&
@ -113,7 +149,14 @@ export const GetWorkout = (url: string) => {
) { ) {
dzenLink = response.data.workout.external_links.values[0].value; dzenLink = response.data.workout.external_links.values[0].value;
} }
return { workoutItem, mapCenter, lineCoordinates, data, dzenLink }; return {
workoutItem,
mapCenter,
lineCoordinates,
distances,
data,
dzenLink,
};
}); });
return Promise.resolve(query); return Promise.resolve(query);
}; };

View File

@ -34,12 +34,12 @@
if (mapX.length >= 2 || xIndex == undefined) { if (mapX.length >= 2 || xIndex == undefined) {
mapX = []; mapX = [];
clickCoordinates = []; clickCoordinates = [];
chart.chart.draw(); group.redrawAll();
} }
if (xIndex !== undefined) { if (xIndex !== undefined) {
clickCoordinates.push(e.coordinates); clickCoordinates.push(e.coordinates);
mapX.push(xIndex); mapX.push(xIndex);
chart.chart.draw(); group.redrawAll();
} }
}, },
}" }"
@ -182,16 +182,46 @@
> >
<VaZoomOut /> <VaZoomOut />
</VaButton> </VaButton>
<!-- @vue-ignore --> <div id="x-axis-switcher">
<LineWithLineChart <VaButton
id="chart" :color="xAxisMode === 'time' ? 'primary' : 'textPrimary'"
ref="chart" :flat="xAxisMode === 'time'"
:options="chartOptions" preset="secondary"
:plugins="chartPlugins" size="small"
:data="data" @click="switchXAxis('time')"
pointerAxies="currentCoordinates" >
> Время
</LineWithLineChart> </VaButton>
<VaButton
:color="xAxisMode === 'distance' ? 'primary' : 'textPrimary'"
:flat="xAxisMode === 'distance'"
preset="secondary"
size="small"
@click="switchXAxis('distance')"
>
Дистанция
</VaButton>
</div>
<div id="workout-charts" v-if="chartConfigs.length > 0">
<div
class="workout-chart"
v-for="config in chartConfigs"
:key="config.key + xAxisMode"
>
<h4 class="workout-chart-title">
{{ config.title }}
<span class="workout-chart-readout" v-if="readoutValue[config.key]">
{{ readoutValue[config.key] }}
</span>
</h4>
<LineWithLineChart
:ref="(el) => setChartRef(config.key, el)"
:options="config.options"
:plugins="chartPlugins"
:data="config.data"
/>
</div>
</div>
<VaModal <VaModal
v-if="isPrivate" v-if="isPrivate"
v-model="showModalTitle" v-model="showModalTitle"
@ -241,9 +271,9 @@ import VaZoomOut from "../../../components/icons/VaZoomOut.vue";
import "chartjs-adapter-moment"; import "chartjs-adapter-moment";
import { AxiosResponse, AxiosInstance, AxiosError } from "axios"; import { AxiosResponse, AxiosInstance, AxiosError } from "axios";
import { ref, inject, shallowRef } from "vue"; import { ref, inject, shallowRef, computed, onMounted } from "vue";
import { useToast, useForm } from "vuestic-ui/web-components"; import { useToast, useForm } from "vuestic-ui/web-components";
import zoomPlugin, { resetZoom } from "chartjs-plugin-zoom"; import zoomPlugin from "chartjs-plugin-zoom";
import { import {
Chart as ChartJS, Chart as ChartJS,
Title, Title,
@ -258,6 +288,7 @@ import {
} from "chart.js"; } from "chart.js";
import LineWithLineChart from "./LineWithLineChart.js"; import LineWithLineChart from "./LineWithLineChart.js";
import ChartGroup from "./ChartGroup.js";
import type { LngLat, YMap } from "@yandex/ymaps3-types"; import type { LngLat, YMap } from "@yandex/ymaps3-types";
import { import {
YandexMap, YandexMap,
@ -273,31 +304,47 @@ import {
speedConvert, speedConvert,
formatTime, formatTime,
ChartData, ChartData,
ChartDataByMetric,
} from "../Definitions.vue"; } from "../Definitions.vue";
type XAxisMode = "time" | "distance";
interface Props { interface Props {
workoutItem?: WorkoutItem; workoutItem?: WorkoutItem;
data: ChartData; data: ChartDataByMetric;
mapCenter: Array<number>; mapCenter: Array<number>;
lineCoordinates: Array<Array<number>>; lineCoordinates: Array<Array<number>>;
distances: Array<number>;
isPrivate: boolean; isPrivate: boolean;
dzenLink?: string; dzenLink?: string;
} }
interface AvgData { interface AvgData {
[key: string]: string; [key: string]: string;
} }
type TooltipContext = {
dataIndex: number;
dataset: { data: Array<number | null>; label: string };
};
const xAxisMode = ref<XAxisMode>("time");
const areaAvgData = ref<AvgData>({}); const areaAvgData = ref<AvgData>({});
const markedCoordinats = ref<Array<Array<number>>>([]); const markedCoordinats = ref<Array<Array<number>>>([]);
const readoutValue = ref<Record<string, string>>({});
const { const {
workoutItem, workoutItem,
data, data,
mapCenter, mapCenter,
lineCoordinates, lineCoordinates,
distances,
dzenLink: dzenLinkProps, dzenLink: dzenLinkProps,
} = defineProps<Props>(); } = defineProps<Props>();
const dzenLink = ref(dzenLinkProps); const dzenLink = ref(dzenLinkProps);
const map = shallowRef<null | YMap>(null); const map = shallowRef<null | YMap>(null);
const chart = ref(); type ChartRefEl = { chart: ChartJS };
// Plain (non-reactive) array: the component instances exist once at mount
// and are never re-rendered, so no Ref wrapper is needed. Using a bare array
// also sidesteps deep generic instantiation of Ref<Array<{ chart: Chart }>>.
const chartRefs: Array<ChartRefEl> = [];
const group = new ChartGroup([]);
const mapX = ref<Array<number>>([]); const mapX = ref<Array<number>>([]);
let currentCoordinates = ref<Array<number> | null>([]); let currentCoordinates = ref<Array<number> | null>([]);
let clickCoordinates = ref<LngLat[]>([]); let clickCoordinates = ref<LngLat[]>([]);
@ -326,26 +373,6 @@ ChartJS.register(
); );
const { init } = useToast(); const { init } = useToast();
type afterEventEvent = {
type: string;
};
type afterEventArgs = {
event: afterEventEvent;
};
const chartPlugins = [
{
id: "eventPlugin",
afterEvent(_chart: unknown, args: afterEventArgs) {
if (args.event.type == "mouseout") {
currentCoordinates.value = null;
}
},
},
{
id: "yandexMapLine",
mapX: mapX,
},
];
function isEmpty(obj: Record<string, unknown>) { function isEmpty(obj: Record<string, unknown>) {
for (const prop in obj) { for (const prop in obj) {
if (Object.hasOwn(obj, prop)) { if (Object.hasOwn(obj, prop)) {
@ -354,132 +381,6 @@ function isEmpty(obj: Record<string, unknown>) {
} }
return true; return true;
} }
const chartOptions = {
animation: {
duration: 0,
},
responsive: true,
scales: {
linearAxis: {
id: "linearAxis",
type: "linear",
display: false,
position: "left",
animation: false,
suggestedMin: 0,
suggestedMax: 250,
},
linearYSpeed: {
id: "linearYSpeed",
type: "linear",
display: false,
position: "right",
suggestedMin: 0,
suggestedMax: 80,
animation: false,
scaleOverride: true,
},
yGroung: {
id: "yGroung",
type: "linear",
display: true,
position: "left",
animation: false,
},
logAxis: {
id: "logAxis",
display: false,
type: "logarithmic",
position: "right",
stacked: false,
ticks: {
beginAtZero: false,
},
gridLines: {
display: true,
},
animation: false,
},
x: {
animation: false,
type: "time",
time: {
displayFormats: { hour: "HH:mm" },
},
},
},
plugins: {
tooltip: {
enabled: true,
intersect: false,
footerMarginTop: 10,
displayColors: false,
callbacks: {
label: function (context: { dataIndex: number }) {
currentCoordinates.value = lineCoordinates[context.dataIndex];
let show_data = [];
for (let i = 0; i < data.datasets.length; i++) {
let value =
data.datasets[i].label +
" :" +
Math.floor(data.datasets[i].data[context.dataIndex]);
value += getUnit(data.datasets[i].label);
show_data.push(value);
}
return show_data;
},
},
},
zoom: {
zoom: {
onZoomComplete: function (chart: {
chart: { scales: { x: { min: number; max: number } } };
}) {
const d1 = new Date(chart.chart.scales.x.min);
const d2 = new Date(chart.chart.scales.x.max);
let start = null;
let end = null;
for (let i = 0; i < data.labels.length; i++) {
let d3 = new Date(data.labels[i]);
if (start == null && d3 > d1) {
start = i;
}
if (end == null && d3 > d2) {
end = i;
}
}
if (start == null) {
start = 0;
}
if (end == null) {
end = lineCoordinates.length;
}
markedCoordinats.value = lineCoordinates.slice(start, end);
let avgData: AvgData = {};
for (var key in data.datasets) {
const array = data.datasets[key].data.slice(start, end);
let sum = array.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0,
);
let average = sum / array.length;
avgData[data.datasets[key].label] =
Math.floor(average).toString() +
getUnit(data.datasets[key].label);
}
areaAvgData.value = avgData;
},
drag: {
enabled: true,
},
pinch: {
enabled: true,
},
mode: "x",
},
},
},
};
const getUnit = (label: string) => { const getUnit = (label: string) => {
if (label == "Скорость") { if (label == "Скорость") {
return " км/ч"; return " км/ч";
@ -494,6 +395,526 @@ const getUnit = (label: string) => {
return " м"; return " м";
} }
}; };
/** Computes "Данные участка" over every metric for the given point range. */
const computeAreaData = (start: number, end: number) => {
markedCoordinats.value = lineCoordinates.slice(start, end);
let avgData: AvgData = {};
for (const config of chartConfigs.value) {
const values = config.data.datasets[0].data.slice(start, end);
const present = values.filter((v) => v !== null);
if (present.length == 0) {
continue;
}
const sum = present.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0,
);
avgData[config.data.datasets[0].label] =
Math.floor(sum / present.length).toString() +
getUnit(config.data.datasets[0].label);
}
areaAvgData.value = avgData;
};
/** Splits the points into sections: 10-minute steps in time mode, 5-km steps in distance mode. */
const sectionBounds = computed<
Array<{ startIdx: number; endIdx: number; label: string }>
>(() => {
const n = lineCoordinates.length;
const bounds: Array<{ startIdx: number; endIdx: number; label: string }> = [];
let start = 0;
let sectionNo = 1;
const push = (endIdx: number) => {
bounds.push({ startIdx: start, endIdx, label: `Секция ${sectionNo}` });
start = endIdx;
sectionNo += 1;
};
if (xAxisMode.value === "time") {
const stepMs = 10 * 60 * 1000;
const first = Number(new Date(times[0] as string));
let next = first + stepMs;
for (let i = 1; i < n; i++) {
const t = Number(new Date(times[i] as string));
if (t >= next) {
push(i);
next = t + stepMs;
}
}
} else {
const step = 5000;
let next = step;
for (let i = 1; i < n; i++) {
const d = Number(distances[i]);
if (d >= next) {
push(i);
next += step;
}
}
}
if (start < n) {
push(n);
}
return bounds;
});
const activeSection = ref<number | null>(null);
// Inner section boundaries (point indexes, never 0 or the last point).
const sectionBoundaries = computed<Array<number>>(() =>
sectionBounds.value
.map((b) => b.startIdx)
.filter((idx) => idx > 0 && idx < lineCoordinates.length),
);
type AfterEventArgs = {
event: { type: string; x: number | null; y: number | null };
};
/**
* Last mousedown position (relative chart coordinates). A click that moved
* more than the drag threshold from it was a drag-zoom, not a section click.
*/
let lastMouseDown: { x: number; y: number } | null = null;
const chartPlugins = [
{
id: "eventPlugin",
afterEvent(_chart: unknown, args: AfterEventArgs) {
if (args.event.type == "mousedown") {
if (args.event.x !== null && args.event.y !== null) {
lastMouseDown = { x: args.event.x, y: args.event.y };
}
}
if (args.event.type == "mouseout") {
currentCoordinates.value = null;
readoutValue.value = {};
group.clearHover();
}
},
},
{
id: "yandexMapLine",
mapX: mapX,
},
{
// Section separators: thin vertical lines at the inner section boundaries.
// Boundaries are inner point indexes (never 0 or the last point) and are
// recomputed when the x-axis mode changes (the canvas remounts anyway).
id: "sectionPlugin",
sectionBoundaries: sectionBoundaries,
afterDraw(chart: unknown) {
const c = chart as {
isZoomedOrPanned(): boolean;
scales: {
x: { getPixelForValue(v: number): number };
linearAxis: { top: number; bottom: number };
};
chartArea: { left: number; right: number };
ctx: CanvasRenderingContext2D;
config: {
plugins?: Array<{
id?: string;
sectionBoundaries?: { value: Array<number> };
}>;
};
};
// Hidden while zoomed/panned, same rule as the mapX lines in LineWithLineChart.
if (c.isZoomedOrPanned()) {
return;
}
let boundaries: Array<number> = [];
for (const p of c.config.plugins ?? []) {
if (p.id === "sectionPlugin" && p.sectionBoundaries) {
boundaries = p.sectionBoundaries.value;
}
}
if (boundaries.length === 0) {
return;
}
const labels = chartConfigs.value[0]?.data.labels ?? [];
const mode = xAxisMode.value;
const ctx = c.ctx;
for (const idx of boundaries) {
if (idx <= 0 || idx >= labels.length) {
continue;
}
const value =
mode === "time"
? Number(new Date(labels[idx] as string))
: Number(labels[idx]);
if (!Number.isFinite(value)) {
continue;
}
const x = c.scales.x.getPixelForValue(value);
if (
!Number.isFinite(x) ||
x < c.chartArea.left ||
x > c.chartArea.right
) {
continue;
}
ctx.save();
ctx.beginPath();
ctx.moveTo(x, c.scales.linearAxis.top);
ctx.lineTo(x, c.scales.linearAxis.bottom);
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.stroke();
ctx.restore();
}
},
},
];
/** X scale per mode: time scale for "time", plain meters for "distance". */
const xScaleOptions = (mode: XAxisMode): Record<string, unknown> => {
if (mode === "time") {
return {
animation: false,
type: "time",
time: {
displayFormats: { hour: "HH:mm" },
},
};
}
return {
animation: false,
type: "linear",
ticks: {
callback: (value: number | string): string => {
const v = Number(value);
if (!Number.isFinite(v)) {
return "";
}
return v < 1000 ? `${v} м` : `${Math.round(v / 1000)} км`;
},
},
};
};
/**
* Builds a full options object for one metric chart (own Y scale).
* The hidden linearAxis is always present because LineWithLineChart.draw()
* uses it as the vertical extent for the sync lines.
*/
const buildChartOptions = (
yScaleId: string,
yScaleOptions: Record<string, unknown>,
mode: XAxisMode,
) => {
const scales: Record<string, Record<string, unknown>> = {
x: xScaleOptions(mode),
linearAxis: { ...yScaleById.linearAxis.options },
[yScaleId]: { ...yScaleOptions },
};
return {
animation: {
duration: 0,
},
responsive: true,
scales: scales,
plugins: {
tooltip: {
enabled: true,
intersect: false,
footerMarginTop: 10,
displayColors: false,
callbacks: {
label: function (context: TooltipContext) {
const idx = context.dataIndex;
currentCoordinates.value = lineCoordinates[idx];
group.setHover(idx);
readoutValue.value = {};
for (const config of chartConfigs.value) {
const value = config.data.datasets[0].data[idx];
if (value === null || value === undefined) {
continue;
}
readoutValue.value[config.key] =
config.title +
" : " +
Math.floor(value) +
getUnit(config.title);
}
const value = context.dataset.data[idx];
if (value === null || value === undefined) {
return [];
}
return [
context.dataset.label +
" :" +
Math.floor(value) +
getUnit(context.dataset.label),
];
},
},
},
zoom: {
zoom: {
onZoomComplete: function (chart: {
chart: { scales: { x: { min: number; max: number } } };
}) {
// Receivers updated via broadcastZoom must not re-broadcast.
if (group.isBroadcasting()) {
return;
}
const min = chart.chart.scales.x.min;
const max = chart.chart.scales.x.max;
// Resolve the visible range to point indexes; labels are
// ascending in both modes, so the first label beyond a bound wins.
const labels = chartConfigs.value[0]?.data.labels ?? [];
let start = 0;
let end = lineCoordinates.length;
if (xAxisMode.value === "time") {
const d1 = new Date(min);
const d2 = new Date(max);
for (let i = 0; i < labels.length; i++) {
const d3 = new Date(labels[i] as string);
if (start === 0 && i !== 0 && d3 > d1) {
start = i;
break;
}
if (d3 > d2) {
end = i;
break;
}
}
} else {
for (let i = 0; i < labels.length; i++) {
if (i !== 0 && Number(labels[i]) > min) {
start = i;
break;
}
if (Number(labels[i]) > max) {
end = i;
break;
}
}
}
// A manual zoom ends any earlier section selection.
activeSection.value = null;
computeAreaData(start, end);
group.broadcastZoom(min, max);
},
drag: {
enabled: true,
// Sub-threshold drags fall through to the top-level onClick below.
threshold: 5,
},
pinch: {
enabled: true,
},
mode: "x",
},
},
},
// A plain click (no drag movement) selects the whole section under the
// cursor; drag-zoom (movement > threshold) is left to the zoom plugin.
onClick: (
evt: { x: number; y: number },
_elements: unknown[],
chart: {
scales: { x: { getValueForPixel(px: number): number | string | null } };
},
) => {
if (lastMouseDown !== null) {
const dx = evt.x - lastMouseDown.x;
const dy = evt.y - lastMouseDown.y;
if (Math.sqrt(dx * dx + dy * dy) > 5) {
lastMouseDown = null;
return;
}
}
lastMouseDown = null;
// No isZoomedOrPanned() guard on purpose: the chart is zoomed while a
// section is selected, and the click must still toggle it off. A real
// drag-zoom click is already swallowed by the zoom plugin.
const value = chart.scales.x.getValueForPixel(evt.x);
if (value === null || value === undefined) {
return;
}
const labels = chartConfigs.value[0]?.data.labels ?? [];
const toValue = (l: string | number): number =>
xAxisMode.value === "time" ? Number(new Date(l as string)) : Number(l);
const clicked = Number(value);
if (!Number.isFinite(clicked)) {
return;
}
let idx = 0;
let best = Infinity;
for (let i = 0; i < labels.length; i++) {
const d = Math.abs(toValue(labels[i]) - clicked);
if (d < best) {
best = d;
idx = i;
}
}
if (idx < 0 || idx >= lineCoordinates.length) {
return;
}
const bounds = sectionBounds.value;
let sectionNo: number | null = null;
let startIdx = 0;
let endIdx = lineCoordinates.length;
for (let i = 0; i < bounds.length; i++) {
if (idx >= bounds[i].startIdx && idx < bounds[i].endIdx) {
sectionNo = i;
startIdx = bounds[i].startIdx;
endIdx = bounds[i].endIdx;
break;
}
}
if (sectionNo === null) {
return;
}
// Toggle: a second click on the already selected section resets.
if (activeSection.value === sectionNo) {
activeSection.value = null;
group.resetAll();
areaAvgData.value = {};
return;
}
activeSection.value = sectionNo;
const min = toValue(labels[startIdx]);
const max = toValue(labels[endIdx - 1]);
computeAreaData(startIdx, endIdx);
group.broadcastZoom(min, max);
},
};
};
const yScaleById: Record<string, { options: Record<string, unknown> }> = {
linearAxis: {
options: {
id: "linearAxis",
type: "linear",
display: false,
position: "left",
animation: false,
suggestedMin: 0,
suggestedMax: 250,
},
},
linearYSpeed: {
options: {
id: "linearYSpeed",
type: "linear",
display: false,
position: "right",
suggestedMin: 0,
suggestedMax: 80,
animation: false,
scaleOverride: true,
},
},
yGroung: {
options: {
id: "yGroung",
type: "linear",
display: true,
position: "left",
animation: false,
},
},
logAxis: {
options: {
id: "logAxis",
display: false,
type: "logarithmic",
position: "right",
stacked: false,
ticks: {
beginAtZero: false,
},
gridLines: {
display: true,
},
animation: false,
},
},
};
type ChartConfig = {
key: string;
title: string;
data: ChartData;
yScaleId: string;
options: Record<string, unknown>;
};
// The time labels live in any metric's ChartData; all metrics share them.
const times: Array<string | number> =
data.speed?.labels ??
data.heartRate?.labels ??
data.power?.labels ??
data.elevation?.labels ??
[];
// Rebuilt per x-axis mode: labels switch between times and distances and
// options get a fresh x-scale, so every canvas must remount on mode change.
const chartConfigs = computed<Array<ChartConfig>>(() => {
const labels = xAxisMode.value === "time" ? times : distances;
const configs: Array<ChartConfig> = [];
const push = (
key: string,
title: string,
chartData: ChartData | undefined,
yScaleId: string,
yScaleOptions: Record<string, unknown>,
) => {
if (!chartData) {
return;
}
configs.push({
key,
title,
data: { ...chartData, labels },
yScaleId,
options: buildChartOptions(yScaleId, yScaleOptions, xAxisMode.value),
});
};
push(
"speed",
"Скорость",
data.speed,
"linearYSpeed",
yScaleById.linearYSpeed.options,
);
push(
"heartRate",
"Пульс",
data.heartRate,
"linearAxis",
yScaleById.linearAxis.options,
);
push("power", "Мощность", data.power, "logAxis", yScaleById.logAxis.options);
push(
"elevation",
"Подъем",
data.elevation,
"yGroung",
yScaleById.yGroung.options,
);
return configs;
});
const collectCharts = () => {
const charts: Array<ChartJS> = [];
for (const item of chartRefs) {
charts.push(item.chart);
}
return charts;
};
const setChartRef = (_key: string, el: unknown) => {
const r = el as ChartRefEl | null;
if (r && r.chart && chartRefs.length < chartConfigs.value.length) {
chartRefs.push(r);
}
if (chartRefs.length === chartConfigs.value.length) {
group.charts = collectCharts();
}
};
const switchXAxis = (mode: XAxisMode) => {
if (mode === xAxisMode.value) {
return;
}
// Drop the old instances before the remount so setChartRef collects
// exactly the new set; hover/zoom state is intentionally reset.
chartRefs.length = 0;
group.charts = [];
activeSection.value = null;
xAxisMode.value = mode;
};
onMounted(() => {
group.charts = collectCharts();
});
const axiosAuth = inject("axiosAuth") as AxiosInstance; const axiosAuth = inject("axiosAuth") as AxiosInstance;
const saveLink = (hide: () => void) => { const saveLink = (hide: () => void) => {
if (!dzenLink.value) { if (!dzenLink.value) {
@ -567,7 +988,8 @@ const changePublic = (value: boolean) => {
}; };
const resetChartZoom = () => { const resetChartZoom = () => {
resetZoom(chart.value.chart); group.resetAll();
activeSection.value = null;
areaAvgData.value = {}; areaAvgData.value = {};
}; };
</script> </script>
@ -652,4 +1074,31 @@ h3 {
.workout-item-params-pointer { .workout-item-params-pointer {
cursor: pointer; cursor: pointer;
} }
#x-axis-switcher {
width: 100%;
display: flex;
gap: 8px;
margin-top: 20px;
}
#workout-charts {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.workout-chart {
width: 100%;
}
.workout-chart-title {
margin: 0 0 5px 0;
font-size: 16px;
font-family: sans-serif;
}
.workout-chart-readout {
margin-left: 10px;
font-size: 13px;
font-weight: normal;
color: #07c;
}
</style> </style>