strava-frontend/src/pages/workouts/components/WorkoutItem.vue

1175 lines
32 KiB
Vue

<template>
<div id="workout-container" v-if="workoutItem">
<div id="workout-map">
<yandex-map
v-model="map"
:settings="{
location: {
center: mapCenter as LngLat,
zoom: 9,
},
}"
width="100%"
height="350px"
>
<yandex-map-default-scheme-layer />
<yandex-map-default-features-layer />
<yandex-map-feature
:settings="{
geometry: {
type: 'LineString',
coordinates: lineCoordinates as LngLat[],
},
style: {
stroke: [{ color: '#007afce6', width: 2 }],
},
}"
/>
<yandex-map-listener
:settings="{
onClick: (_: any, e: any) => {
const xIndex = coordWithIndex.get(
getKey(e.coordinates[0], e.coordinates[1]),
);
if (mapX.length >= 2 || xIndex == undefined) {
mapX = [];
clickCoordinates = [];
group.redrawAll();
}
if (xIndex !== undefined) {
clickCoordinates.push(e.coordinates);
mapX.push(xIndex);
group.redrawAll();
}
},
}"
/>
<yandex-map-feature
v-if="markedCoordinats"
:settings="{
geometry: {
type: 'LineString',
coordinates: markedCoordinats as LngLat[],
},
style: {
stroke: [{ color: '#EE82EE', width: 4 }],
},
}"
/>
<yandex-map-default-marker
v-if="currentCoordinates"
:settings="{
coordinates: currentCoordinates as LngLat,
}"
/>
<yandex-map-default-marker
v-for="(coord, idx) in clickCoordinates"
:key="idx"
:settings="{
coordinates: coord,
color: 'gray',
}"
/>
</yandex-map>
</div>
<div id="workout-short-data">
<div class="workout-item-editable-title">
<h3>{{ workoutItem.name }}</h3>
<VaIcon
v-if="isPrivate"
name="edit"
@click="showModalTitle = !showModalTitle"
/>
</div>
<div class="workout-item-params" v-if="workoutItem.workouted_at">
<div class="workout-item-params-name">Дата:</div>
<div class="workout-item-params-value">
{{ formatTime(workoutItem.workouted_at) }}
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.distantion">
<div class="workout-item-params-name">Расстояние:</div>
<div class="workout-item-params-value">
{{ distConvert(workoutItem.distantion) }} км
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.heart_rate">
<div class="workout-item-params-name">Средний пульс:</div>
<div class="workout-item-params-value">
{{ Math.floor(workoutItem.heart_rate) }} уд./ мин.
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.max_heart_rate">
<div class="workout-item-params-name">Максимальный пульс:</div>
<div class="workout-item-params-value">
{{ Math.floor(workoutItem.max_heart_rate) }} уд./ мин.
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.speed">
<div class="workout-item-params-name">Средняя скорость:</div>
<div class="workout-item-params-value">
{{ speedConvert(workoutItem.speed) }} км / ч
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.max_speed">
<div class="workout-item-params-name">Максимальная скорость:</div>
<div class="workout-item-params-value">
{{ speedConvert(workoutItem.max_speed || 0) }} км / ч
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.temperature">
<div class="workout-item-params-name">Температура:</div>
<div class="workout-item-params-value">
{{ workoutItem.temperature }} °C
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.power">
<div class="workout-item-params-name">Средняя мощность:</div>
<div class="workout-item-params-value">
{{ Math.floor(workoutItem.power) }} Вт
</div>
</div>
<div class="workout-item-params" v-if="workoutItem.max_power">
<div class="workout-item-params-name">Максимальная мощность:</div>
<div class="workout-item-params-value">
{{ Math.floor(workoutItem.max_power) }} Вт
</div>
</div>
<div class="workout-item-params" v-if="isPrivate">
<div class="workout-item-params-name">Сделать публичной:&nbsp;</div>
<input
type="checkbox"
v-model="workoutItem.is_public"
v-on:change="changePublic(workoutItem.is_public)"
/>
</div>
<div class="workout-item-params">
<div class="workout-item-params-name">Ссылки на описание:&nbsp;</div>
<div v-if="dzenLink">
<a :href="dzenLink" target="_blank">Дзен</a>
<VaIcon
v-if="isPrivate"
name="edit"
@click="showModalLink = !showModalLink"
/>
</div>
<div class="workout-item-params-pointer" v-else>
<a @click="showModalLink = !showModalLink">Добавить</a>
</div>
</div>
<div v-if="!isEmpty(areaAvgData)" class="area-data">
<h4>Данные участка</h4>
<div
class="workout-item-params"
v-for="(item, index) in areaAvgData"
:key="item"
>
<div class="workout-item-params-name">{{ index }}:</div>
<div class="workout-item-params-value">{{ item }}</div>
</div>
</div>
<div v-else>
Чтобы увидеть информацию по области, выделите область на графике
</div>
</div>
</div>
<VaButton
v-on:click="resetChartZoom()"
preset="secondary"
color="textPrimary"
id="zoom-botton"
>
<VaZoomOut />
</VaButton>
<div id="x-axis-switcher">
<VaButton
:color="xAxisMode === 'time' ? 'primary' : 'textPrimary'"
:flat="xAxisMode === 'time'"
preset="secondary"
size="small"
@click="switchXAxis('time')"
>
Время
</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>
<span
v-if="config.key === 'power' && powerZones"
class="power-zones-legend"
>
<span
v-for="zone in powerZones"
:key="zone.label"
class="power-zone-chip"
:title="zone.label"
>
<span
class="power-zone-color"
:style="{ backgroundColor: zone.color }"
></span>
{{ zone.label }}
</span>
</span>
</h4>
<div class="workout-chart-canvas">
<LineWithLineChart
:ref="(el) => setChartRef(config.key, el)"
:options="config.options"
:plugins="chartPlugins"
:data="config.data"
/>
</div>
</div>
</div>
<VaModal
v-if="isPrivate"
v-model="showModalTitle"
ok-text="OK"
:beforeOk="saveName"
>
<VaForm ref="form" @submit.prevent="saveName" @keyup.enter="saveName">
<h3>Редактирование названия</h3>
<VaInput
v-if="workoutItem"
v-model="workoutItem.name"
:rules="[
(v: string) =>
v.length < 70 || `Слишком длинное название ${v.length}`,
]"
type="text"
placeholder="Название"
class="workout-item-input"
/>
<div v-else>Тренировка не найдена</div>
</VaForm>
</VaModal>
<VaModal
v-if="isPrivate"
v-model="showModalLink"
ok-text="OK"
:beforeOk="saveLink"
>
<VaForm ref="form" @submit.prevent="saveName" @keyup.enter="saveName">
<h3>Редактирование ссылок</h3>
<VaInput
v-model="dzenLink"
:rules="[
(v: string) =>
v.includes('https://dzen.ru/') ||
'Ссылка должна включать https://dzen.ru/',
]"
type="text"
placeholder="Название"
class="workout-item-input"
/>
</VaForm>
</VaModal>
</template>
<script setup lang="ts">
import VaZoomOut from "../../../components/icons/VaZoomOut.vue";
import "chartjs-adapter-moment";
import { AxiosResponse, AxiosInstance, AxiosError } from "axios";
import { ref, inject, shallowRef, computed, onMounted } from "vue";
import { useToast, useForm } from "vuestic-ui/web-components";
import zoomPlugin from "chartjs-plugin-zoom";
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
LinearScale,
CategoryScale,
LogarithmicScale,
PointElement,
TimeScale,
} from "chart.js";
import LineWithLineChart from "./LineWithLineChart.js";
import ChartGroup from "./ChartGroup.js";
import {
PowerZonesPlugin,
ftpToZones,
type PowerZone,
} from "./PowerZonesPlugin.js";
import type { LngLat, YMap } from "@yandex/ymaps3-types";
import {
YandexMap,
YandexMapDefaultSchemeLayer,
YandexMapFeature,
YandexMapDefaultFeaturesLayer,
YandexMapDefaultMarker,
YandexMapListener,
} from "vue-yandex-maps";
import {
WorkoutItem,
distConvert,
speedConvert,
formatTime,
ChartData,
ChartDataByMetric,
} from "../Definitions.vue";
type XAxisMode = "time" | "distance";
interface Props {
workoutItem?: WorkoutItem;
data: ChartDataByMetric;
mapCenter: Array<number>;
lineCoordinates: Array<Array<number>>;
distances: Array<number>;
isPrivate: boolean;
dzenLink?: string;
}
interface AvgData {
[key: string]: string;
}
type TooltipContext = {
dataIndex: number;
dataset: { data: Array<number | null>; label: string };
};
const xAxisMode = ref<XAxisMode>("time");
const areaAvgData = ref<AvgData>({});
const markedCoordinats = ref<Array<Array<number>>>([]);
const readoutValue = ref<Record<string, string>>({});
const {
workoutItem,
data,
mapCenter,
lineCoordinates,
distances,
dzenLink: dzenLinkProps,
} = defineProps<Props>();
const dzenLink = ref(dzenLinkProps);
const map = shallowRef<null | YMap>(null);
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>>([]);
let currentCoordinates = ref<Array<number> | null>([]);
let clickCoordinates = ref<LngLat[]>([]);
let coordWithIndex: Map<string, number> = new Map();
let showModalTitle = ref(false);
const showModalLink = ref(false);
const getKey = (x: number, y: number) => {
x = Math.round(x * 1000) / 1000;
y = Math.round(y * 1000) / 1000;
return `${x}-${y}`;
};
for (let i = 0; i < lineCoordinates.length; i++) {
coordWithIndex.set(getKey(lineCoordinates[i][0], lineCoordinates[i][1]), i);
}
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LogarithmicScale,
LinearScale,
PointElement,
zoomPlugin,
TimeScale,
);
const { init } = useToast();
function isEmpty(obj: Record<string, unknown>) {
for (const prop in obj) {
if (Object.hasOwn(obj, prop)) {
return false;
}
}
return true;
}
const getUnit = (label: string) => {
if (label == "Скорость") {
return " км/ч";
}
if (label == "Пульс") {
return " уд/мин";
}
if (label == "Мощность") {
return " Ватт";
}
if (label == "Подъем") {
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);
// 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<PowerZone> | null = null;
const powerZones = computed<Array<PowerZone> | 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<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();
}
},
},
// 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<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,
maintainAspectRatio: false,
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 saveLink = (hide: () => void) => {
if (!dzenLink.value) {
return;
}
if (!workoutItem) {
return;
}
if (!dzenLink.value.includes("https://dzen.ru/")) {
init({
message: "Неверный формат ссылки",
color: "error",
});
return;
}
axiosAuth
.patch(`/api/v0/workouts/${workoutItem.id}`, {
links: [{ value: dzenLink.value, type: "dzen" }],
})
.then((_response: AxiosResponse) => {
hide();
})
.catch((_error: AxiosError) => {
init({
message: "Что-то пошло не так.",
color: "error",
});
});
};
const { validate } = useForm("form");
const saveName = (hide: () => void) => {
if (!workoutItem) {
return;
}
if (!validate()) {
return;
}
if (workoutItem.name.length == 0) {
init({
message: "Название не может быть пустым",
color: "error",
});
return;
}
axiosAuth
.patch(`/api/v0/workouts/${workoutItem.id}`, { name: workoutItem.name })
.then((_response: AxiosResponse) => {
hide();
})
.catch((_error: AxiosError) => {
init({
message: "Что-то пошло не так.",
color: "error",
});
});
};
const changePublic = (value: boolean) => {
if (!workoutItem) {
return;
}
axiosAuth
.patch(`/api/v0/workouts/${workoutItem.id}`, { is_public: value })
.then((_response: AxiosResponse) => {})
.catch((_error: AxiosError) => {
init({
message: "Что-то пошло не так.",
color: "error",
});
});
};
const resetChartZoom = () => {
group.resetAll();
activeSection.value = null;
areaAvgData.value = {};
};
</script>
<style>
#zoom-botton {
display: block;
margin-top: 20px;
margin-bottom: -35px;
}
#workout-container {
display: flex;
width: 100%;
justify-content: space-between;
min-width: 350px;
}
#workout-map {
width: 70%;
}
#workout-short-data {
width: 30%;
padding: 0 0 0 20px;
}
@media (max-width: 550px) {
#workout-container {
flex-direction: column;
}
#workout-map {
width: 100%;
}
#workout-short-data {
margin: 11px auto 0 auto;
width: 80%;
}
}
@media (min-width: 551px) and (max-width: 805px) {
#workout-container {
flex-direction: column;
}
#workout-map {
width: 100%;
}
#workout-short-data {
width: 50%;
margin: 11px auto 0 auto;
}
}
h3 {
margin-bottom: 10px;
font-size: 20px;
font-family: sans-serif;
}
.area-data h4 {
text-decoration: underline;
}
.workout-item-params {
display: flex;
width: 100%;
justify-content: left;
color: #333;
padding: 0 0 5px 0;
}
.workout-item-params-name {
font-weight: bold;
}
.workout-item-params-value {
padding: 0 0 0 5px;
}
.workout-item-editable-title {
display: flex;
width: 100%;
justify-self: start;
}
.workout-item-editable-title h3 {
padding-right: 5px;
}
.workout-item-editable-title i {
cursor: pointer;
}
.workout-item-params-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-canvas {
position: relative;
width: 100%;
height: 160px;
}
.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;
}
.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;
}
</style>