изменение интерфейса тренировок
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;
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue