изменение интерфейса тренировок
Gitea Actions Demo / build_and_push (push) Failing after 23s
Details
Gitea Actions Demo / build_and_push (push) Failing after 23s
Details
This commit is contained in:
parent
5e52b0eb2f
commit
cd58beb85a
|
|
@ -67,11 +67,18 @@ export type ChartDataset = {
|
|||
label: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
data: Array<number>;
|
||||
data: Array<number | null>;
|
||||
yAxisID?: string;
|
||||
};
|
||||
export type ChartData = {
|
||||
labels: Array<string>;
|
||||
labels: Array<string | number>;
|
||||
datasets: Array<ChartDataset>;
|
||||
};
|
||||
export type ChartDataByMetric = {
|
||||
speed?: ChartData;
|
||||
heartRate?: ChartData;
|
||||
power?: ChartData;
|
||||
elevation?: ChartData;
|
||||
};
|
||||
export default {};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
:data="data"
|
||||
:mapCenter="mapCenter"
|
||||
:lineCoordinates="lineCoordinates"
|
||||
:distances="distances"
|
||||
:dzenLink="dzenLink"
|
||||
:isPrivate="true"
|
||||
/>
|
||||
|
|
@ -19,7 +20,7 @@ import { ref } from "vue";
|
|||
import { useRoute } from "vue-router";
|
||||
import { useToast } from "vuestic-ui/web-components";
|
||||
import WorkoutItemComponent from "./components/WorkoutItem.vue";
|
||||
import { WorkoutItem, ChartData } from "./Definitions.vue";
|
||||
import { WorkoutItem, ChartDataByMetric } from "./Definitions.vue";
|
||||
import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout";
|
||||
|
||||
const { init } = useToast();
|
||||
|
|
@ -27,11 +28,9 @@ const route = useRoute();
|
|||
|
||||
const mapCenter = ref<Array<number>>([37.617644, 55.755819]);
|
||||
const lineCoordinates = ref<Array<Array<number>>>([]);
|
||||
const distances = ref<Array<number>>([]);
|
||||
const workoutItem = ref<WorkoutItem>();
|
||||
const data = ref<ChartData>({
|
||||
labels: [],
|
||||
datasets: [],
|
||||
});
|
||||
const data = ref<ChartDataByMetric>({});
|
||||
const dzenLink = ref("");
|
||||
|
||||
const initWorkout = (id: string) => {
|
||||
|
|
@ -39,6 +38,7 @@ const initWorkout = (id: string) => {
|
|||
.then((d: InitWorkoutItem) => {
|
||||
mapCenter.value = d.mapCenter;
|
||||
lineCoordinates.value = d.lineCoordinates;
|
||||
distances.value = d.distances;
|
||||
workoutItem.value = d.workoutItem;
|
||||
data.value = d.data;
|
||||
if (d.dzenLink != undefined) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
:data="data"
|
||||
:mapCenter="mapCenter"
|
||||
:lineCoordinates="lineCoordinates"
|
||||
:distances="distances"
|
||||
:dzenLink="dzenLink"
|
||||
:isPrivate="false"
|
||||
/>
|
||||
|
|
@ -19,7 +20,7 @@ import { ref } from "vue";
|
|||
import { useRoute } from "vue-router";
|
||||
import { useToast } from "vuestic-ui/web-components";
|
||||
import WorkoutItemComponent from "./components/WorkoutItem.vue";
|
||||
import { WorkoutItem, ChartData } from "./Definitions.vue";
|
||||
import { WorkoutItem, ChartDataByMetric } from "./Definitions.vue";
|
||||
import { GetWorkout, InitWorkoutItem } from "./components/GetWorkout";
|
||||
|
||||
const { init } = useToast();
|
||||
|
|
@ -27,11 +28,9 @@ const route = useRoute();
|
|||
|
||||
const mapCenter = ref<Array<number>>([37.617644, 55.755819]);
|
||||
const lineCoordinates = ref<Array<Array<number>>>([]);
|
||||
const distances = ref<Array<number>>([]);
|
||||
const workoutItem = ref<WorkoutItem>();
|
||||
const data = ref<ChartData>({
|
||||
labels: [],
|
||||
datasets: [],
|
||||
});
|
||||
const data = ref<ChartDataByMetric>({});
|
||||
const dzenLink = ref("");
|
||||
|
||||
const initWorkout = (id: string) => {
|
||||
|
|
@ -39,6 +38,7 @@ const initWorkout = (id: string) => {
|
|||
.then((d: InitWorkoutItem) => {
|
||||
mapCenter.value = d.mapCenter;
|
||||
lineCoordinates.value = d.lineCoordinates;
|
||||
distances.value = d.distances;
|
||||
workoutItem.value = d.workoutItem;
|
||||
data.value = d.data;
|
||||
if (d.dzenLink != undefined) {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { AxiosResponse, AxiosInstance } from "axios";
|
||||
import { inject } from "vue";
|
||||
import { WorkoutItem, ChartData } from "../Definitions.vue";
|
||||
import { WorkoutItem, ChartDataByMetric } from "../Definitions.vue";
|
||||
|
||||
let workoutItem: WorkoutItem;
|
||||
let mapCenter: Array<number> = [37.617644, 55.755819];
|
||||
let lineCoordinates: Array<Array<number>> = [];
|
||||
let data: ChartData;
|
||||
let distances: Array<number> = [];
|
||||
let data: ChartDataByMetric;
|
||||
let dzenLink: string;
|
||||
|
||||
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 = {
|
||||
workoutItem: WorkoutItem;
|
||||
mapCenter: Array<number>;
|
||||
lineCoordinates: Array<Array<number>>;
|
||||
data: ChartData;
|
||||
distances: Array<number>;
|
||||
data: ChartDataByMetric;
|
||||
dzenLink?: string;
|
||||
};
|
||||
|
||||
|
|
@ -61,51 +76,72 @@ export const GetWorkout = (url: string) => {
|
|||
|
||||
lineCoordinates = coords;
|
||||
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) {
|
||||
datasets.push({
|
||||
byMetric.speed = {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
yAxisID: "linearYSpeed",
|
||||
radius: 0,
|
||||
label: "Скорость",
|
||||
borderColor: "#00aa00",
|
||||
backgroundColor: "#00aa00",
|
||||
data: speed,
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (is_heart_rate) {
|
||||
datasets.push({
|
||||
byMetric.heartRate = {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
yAxisID: "linearAxis",
|
||||
radius: 0,
|
||||
label: "Пульс",
|
||||
borderColor: "#990000",
|
||||
backgroundColor: "#990000",
|
||||
data: heart_rate,
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (is_power) {
|
||||
datasets.push({
|
||||
byMetric.power = {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
yAxisID: "logAxis",
|
||||
radius: 0,
|
||||
label: "Мощность",
|
||||
borderColor: "#cccccc",
|
||||
backgroundColor: "#cccccc",
|
||||
data: power,
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (is_elevation) {
|
||||
datasets.push({
|
||||
byMetric.elevation = {
|
||||
labels: times,
|
||||
datasets: [
|
||||
{
|
||||
yAxisID: "yGroung",
|
||||
radius: 0,
|
||||
label: "Подъем",
|
||||
borderColor: "#000",
|
||||
backgroundColor: "#000",
|
||||
data: elevation,
|
||||
});
|
||||
}
|
||||
data = {
|
||||
labels: times,
|
||||
datasets: datasets,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
data = byMetric;
|
||||
if (
|
||||
response.data.workout.external_links &&
|
||||
response.data.workout.external_links.values &&
|
||||
|
|
@ -113,7 +149,14 @@ export const GetWorkout = (url: string) => {
|
|||
) {
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@
|
|||
if (mapX.length >= 2 || xIndex == undefined) {
|
||||
mapX = [];
|
||||
clickCoordinates = [];
|
||||
chart.chart.draw();
|
||||
group.redrawAll();
|
||||
}
|
||||
if (xIndex !== undefined) {
|
||||
clickCoordinates.push(e.coordinates);
|
||||
mapX.push(xIndex);
|
||||
chart.chart.draw();
|
||||
group.redrawAll();
|
||||
}
|
||||
},
|
||||
}"
|
||||
|
|
@ -182,16 +182,46 @@
|
|||
>
|
||||
<VaZoomOut />
|
||||
</VaButton>
|
||||
<!-- @vue-ignore -->
|
||||
<LineWithLineChart
|
||||
id="chart"
|
||||
ref="chart"
|
||||
:options="chartOptions"
|
||||
:plugins="chartPlugins"
|
||||
:data="data"
|
||||
pointerAxies="currentCoordinates"
|
||||
<div id="x-axis-switcher">
|
||||
<VaButton
|
||||
:color="xAxisMode === 'time' ? 'primary' : 'textPrimary'"
|
||||
:flat="xAxisMode === 'time'"
|
||||
preset="secondary"
|
||||
size="small"
|
||||
@click="switchXAxis('time')"
|
||||
>
|
||||
</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
|
||||
v-if="isPrivate"
|
||||
v-model="showModalTitle"
|
||||
|
|
@ -241,9 +271,9 @@ import VaZoomOut from "../../../components/icons/VaZoomOut.vue";
|
|||
|
||||
import "chartjs-adapter-moment";
|
||||
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 zoomPlugin, { resetZoom } from "chartjs-plugin-zoom";
|
||||
import zoomPlugin from "chartjs-plugin-zoom";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
|
|
@ -258,6 +288,7 @@ import {
|
|||
} from "chart.js";
|
||||
|
||||
import LineWithLineChart from "./LineWithLineChart.js";
|
||||
import ChartGroup from "./ChartGroup.js";
|
||||
import type { LngLat, YMap } from "@yandex/ymaps3-types";
|
||||
import {
|
||||
YandexMap,
|
||||
|
|
@ -273,31 +304,47 @@ import {
|
|||
speedConvert,
|
||||
formatTime,
|
||||
ChartData,
|
||||
ChartDataByMetric,
|
||||
} from "../Definitions.vue";
|
||||
|
||||
type XAxisMode = "time" | "distance";
|
||||
|
||||
interface Props {
|
||||
workoutItem?: WorkoutItem;
|
||||
data: ChartData;
|
||||
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);
|
||||
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>>([]);
|
||||
let currentCoordinates = ref<Array<number> | null>([]);
|
||||
let clickCoordinates = ref<LngLat[]>([]);
|
||||
|
|
@ -326,26 +373,6 @@ ChartJS.register(
|
|||
);
|
||||
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>) {
|
||||
for (const prop in obj) {
|
||||
if (Object.hasOwn(obj, prop)) {
|
||||
|
|
@ -354,132 +381,6 @@ function isEmpty(obj: Record<string, unknown>) {
|
|||
}
|
||||
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) => {
|
||||
if (label == "Скорость") {
|
||||
return " км/ч";
|
||||
|
|
@ -494,6 +395,526 @@ const getUnit = (label: string) => {
|
|||
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 saveLink = (hide: () => void) => {
|
||||
if (!dzenLink.value) {
|
||||
|
|
@ -567,7 +988,8 @@ const changePublic = (value: boolean) => {
|
|||
};
|
||||
|
||||
const resetChartZoom = () => {
|
||||
resetZoom(chart.value.chart);
|
||||
group.resetAll();
|
||||
activeSection.value = null;
|
||||
areaAvgData.value = {};
|
||||
};
|
||||
</script>
|
||||
|
|
@ -652,4 +1074,31 @@ h3 {
|
|||
.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-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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue