diff --git a/src/pages/workouts/Definitions.vue b/src/pages/workouts/Definitions.vue index fdd07a6..de5564c 100644 --- a/src/pages/workouts/Definitions.vue +++ b/src/pages/workouts/Definitions.vue @@ -67,11 +67,18 @@ export type ChartDataset = { label: string; backgroundColor: string; borderColor: string; - data: Array; + data: Array; + yAxisID?: string; }; export type ChartData = { - labels: Array; + labels: Array; datasets: Array; }; +export type ChartDataByMetric = { + speed?: ChartData; + heartRate?: ChartData; + power?: ChartData; + elevation?: ChartData; +}; export default {}; diff --git a/src/pages/workouts/WorkoutItem.vue b/src/pages/workouts/WorkoutItem.vue index d941a35..41374a3 100644 --- a/src/pages/workouts/WorkoutItem.vue +++ b/src/pages/workouts/WorkoutItem.vue @@ -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>([37.617644, 55.755819]); const lineCoordinates = ref>>([]); +const distances = ref>([]); const workoutItem = ref(); -const data = ref({ - labels: [], - datasets: [], -}); +const data = ref({}); 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) { diff --git a/src/pages/workouts/WorkoutPublicItem.vue b/src/pages/workouts/WorkoutPublicItem.vue index 7d8d53e..97579d8 100644 --- a/src/pages/workouts/WorkoutPublicItem.vue +++ b/src/pages/workouts/WorkoutPublicItem.vue @@ -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>([37.617644, 55.755819]); const lineCoordinates = ref>>([]); +const distances = ref>([]); const workoutItem = ref(); -const data = ref({ - labels: [], - datasets: [], -}); +const data = ref({}); 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) { diff --git a/src/pages/workouts/components/GetWorkout.ts b/src/pages/workouts/components/GetWorkout.ts index ed13df3..178b3f9 100644 --- a/src/pages/workouts/components/GetWorkout.ts +++ b/src/pages/workouts/components/GetWorkout.ts @@ -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 = [37.617644, 55.755819]; let lineCoordinates: Array> = []; -let data: ChartData; +let distances: Array = []; +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, b: Array): 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; lineCoordinates: Array>; - data: ChartData; + distances: Array; + 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({ - yAxisID: "linearYSpeed", - radius: 0, - label: "Скорость", - borderColor: "#00aa00", - backgroundColor: "#00aa00", - data: speed, - }); + byMetric.speed = { + labels: times, + datasets: [ + { + yAxisID: "linearYSpeed", + radius: 0, + label: "Скорость", + borderColor: "#00aa00", + backgroundColor: "#00aa00", + data: speed, + }, + ], + }; } if (is_heart_rate) { - datasets.push({ - yAxisID: "linearAxis", - radius: 0, - label: "Пульс", - borderColor: "#990000", - backgroundColor: "#990000", - data: heart_rate, - }); + byMetric.heartRate = { + labels: times, + datasets: [ + { + yAxisID: "linearAxis", + radius: 0, + label: "Пульс", + borderColor: "#990000", + backgroundColor: "#990000", + data: heart_rate, + }, + ], + }; } if (is_power) { - datasets.push({ - yAxisID: "logAxis", - radius: 0, - label: "Мощность", - borderColor: "#cccccc", - backgroundColor: "#cccccc", - data: power, - }); + byMetric.power = { + labels: times, + datasets: [ + { + yAxisID: "logAxis", + radius: 0, + label: "Мощность", + borderColor: "#cccccc", + backgroundColor: "#cccccc", + data: power, + }, + ], + }; } if (is_elevation) { - datasets.push({ - yAxisID: "yGroung", - radius: 0, - label: "Подъем", - borderColor: "#000", - backgroundColor: "#000", - data: elevation, - }); + 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); }; diff --git a/src/pages/workouts/components/WorkoutItem.vue b/src/pages/workouts/components/WorkoutItem.vue index dd14bed..8806bd9 100644 --- a/src/pages/workouts/components/WorkoutItem.vue +++ b/src/pages/workouts/components/WorkoutItem.vue @@ -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 @@ > - - - +
+ + Время + + + Дистанция + +
+
+
+

+ {{ config.title }} + + {{ readoutValue[config.key] }} + +

+ +
+
; lineCoordinates: Array>; + distances: Array; isPrivate: boolean; dzenLink?: string; } interface AvgData { [key: string]: string; } +type TooltipContext = { + dataIndex: number; + dataset: { data: Array; label: string }; +}; +const xAxisMode = ref("time"); const areaAvgData = ref({}); const markedCoordinats = ref>>([]); +const readoutValue = ref>({}); const { workoutItem, data, mapCenter, lineCoordinates, + distances, dzenLink: dzenLinkProps, } = defineProps(); const dzenLink = ref(dzenLinkProps); const map = shallowRef(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>. +const chartRefs: Array = []; +const group = new ChartGroup([]); const mapX = ref>([]); let currentCoordinates = ref | null>([]); let clickCoordinates = ref([]); @@ -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) { for (const prop in obj) { if (Object.hasOwn(obj, prop)) { @@ -354,132 +381,6 @@ function isEmpty(obj: Record) { } 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(null); +// Inner section boundaries (point indexes, never 0 or the last point). +const sectionBoundaries = computed>(() => + 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 }; + }>; + }; + }; + // Hidden while zoomed/panned, same rule as the mapX lines in LineWithLineChart. + if (c.isZoomedOrPanned()) { + return; + } + let boundaries: Array = []; + 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 => { + 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, + mode: XAxisMode, +) => { + const scales: Record> = { + 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 }> = { + 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; +}; +// The time labels live in any metric's ChartData; all metrics share them. +const times: Array = + 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>(() => { + const labels = xAxisMode.value === "time" ? times : distances; + const configs: Array = []; + const push = ( + key: string, + title: string, + chartData: ChartData | undefined, + yScaleId: string, + yScaleOptions: Record, + ) => { + 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 = []; + 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 = {}; }; @@ -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; +}