1175 lines
32 KiB
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">Сделать публичной: </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">Ссылки на описание: </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>
|