From 950ca489e5ee7606f88ec4752d8fec6eb9415b5d Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 18:47:25 +0200 Subject: [PATCH] UI Charts: swipe on charts to switch time range (#32614) --- .../components/Battery/BatteryHistoryCard.vue | 33 +++++++++++++++++++ assets/js/components/Battery/WindowNav.vue | 4 +++ .../js/components/Sessions/DateNavigator.vue | 4 ++- assets/js/utils/haptic.ts | 5 +-- assets/js/utils/swipe.ts | 14 ++++---- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/assets/js/components/Battery/BatteryHistoryCard.vue b/assets/js/components/Battery/BatteryHistoryCard.vue index a7ded3a3b..27241fd49 100644 --- a/assets/js/components/Battery/BatteryHistoryCard.vue +++ b/assets/js/components/Battery/BatteryHistoryCard.vue @@ -5,6 +5,8 @@ :label="windowLabel" :prev-disabled="prevDisabled" :next-disabled="nextDisabled" + :highlight-prev="highlightPrev" + :highlight-next="highlightNext" @prev="changeOffset(-1)" @next="changeOffset(1)" /> @@ -44,6 +46,8 @@ import { batteryColor } from "@/colors"; import type { Legend } from "../Sessions/types"; import type { BatterySeries } from "./types"; import Card from "../Helper/Card.vue"; +import { attachSwipeHandler } from "@/utils/swipe"; +import { hapticFeedback } from "@/utils/haptic"; import SelectGroup from "../Helper/SelectGroup.vue"; import LegendList from "../Sessions/LegendList.vue"; import WindowNav from "./WindowNav.vue"; @@ -71,6 +75,10 @@ export default defineComponent({ dayOffset: 0, focusedBattery: null as number | null, selectedUnit: (settings.batteryUnit || "soc") as "soc" | "energy", + highlightPrev: false, + highlightNext: false, + highlightTimer: null as ReturnType | null, + detachSwipe: null as (() => void) | null, }; }, computed: { @@ -145,7 +153,32 @@ export default defineComponent({ } }, }, + mounted() { + this.detachSwipe = attachSwipeHandler(this.$el as HTMLElement, { + onSwipeLeft: () => this.swipe(1), + onSwipeRight: () => this.swipe(-1), + }); + }, + unmounted() { + this.detachSwipe?.(); + if (this.highlightTimer) clearTimeout(this.highlightTimer); + }, methods: { + swipe(dir: number) { + if (dir < 0 ? this.prevDisabled : this.nextDisabled) return; + this.changeOffset(dir); + this.flashHighlight(dir < 0 ? "prev" : "next"); + hapticFeedback("medium"); + }, + flashHighlight(dir: "prev" | "next") { + if (this.highlightTimer) clearTimeout(this.highlightTimer); + this.highlightPrev = dir === "prev"; + this.highlightNext = dir === "next"; + this.highlightTimer = setTimeout(() => { + this.highlightPrev = false; + this.highlightNext = false; + }, 300); + }, updateUnit(value: string | number | boolean | null) { this.selectedUnit = value === "energy" ? "energy" : "soc"; settings.batteryUnit = this.selectedUnit; diff --git a/assets/js/components/Battery/WindowNav.vue b/assets/js/components/Battery/WindowNav.vue index 2fb1249bd..0b5f63ccc 100644 --- a/assets/js/components/Battery/WindowNav.vue +++ b/assets/js/components/Battery/WindowNav.vue @@ -3,6 +3,7 @@ @@ -12,6 +13,7 @@ @@ -29,6 +31,8 @@ export default defineComponent({ label: { type: String, default: "" }, prevDisabled: Boolean, nextDisabled: Boolean, + highlightPrev: Boolean, + highlightNext: Boolean, }, emits: ["prev", "next"], }); diff --git a/assets/js/components/Sessions/DateNavigator.vue b/assets/js/components/Sessions/DateNavigator.vue index f8cca98a3..a8bb37084 100644 --- a/assets/js/components/Sessions/DateNavigator.vue +++ b/assets/js/components/Sessions/DateNavigator.vue @@ -146,6 +146,7 @@ import DateNavigatorButton from "./DateNavigatorButton.vue"; import formatter from "@/mixins/formatter"; import type { SelectOption } from "@/types/evcc"; import { attachSwipeHandler } from "@/utils/swipe"; +import { hapticFeedback } from "@/utils/haptic"; function daysInMonth(year: number, month: number) { return new Date(year, month, 0).getDate(); @@ -279,7 +280,6 @@ export default defineComponent({ this.detachSwipe = attachSwipeHandler(document.body, { onSwipeLeft: () => this.swipeNext(), onSwipeRight: () => this.swipePrev(), - ignoreSelector: "canvas, [_echarts_instance_]", }); }, unmounted() { @@ -393,6 +393,7 @@ export default defineComponent({ else if (this.showYear && this.hasPrevYear) this.emitPrevYear(); else return; this.flashHighlight("prev"); + hapticFeedback("medium"); }, swipeNext() { if (this.showDay && this.hasNextDay) this.emitNextDay(); @@ -400,6 +401,7 @@ export default defineComponent({ else if (this.showYear && this.hasNextYear) this.emitNextYear(); else return; this.flashHighlight("next"); + hapticFeedback("medium"); }, }, }); diff --git a/assets/js/utils/haptic.ts b/assets/js/utils/haptic.ts index 272d134cd..a1270b6f0 100644 --- a/assets/js/utils/haptic.ts +++ b/assets/js/utils/haptic.ts @@ -1,3 +1,4 @@ -export function hapticFeedback(): void { - navigator.vibrate?.(5); +// the native app shims navigator.vibrate; duration maps to impact strength (<=50 light, <=100 medium) +export function hapticFeedback(style: "light" | "medium" = "light"): void { + navigator.vibrate?.(style === "medium" ? 60 : 5); } diff --git a/assets/js/utils/swipe.ts b/assets/js/utils/swipe.ts index 0dd00250c..3dd49f984 100644 --- a/assets/js/utils/swipe.ts +++ b/assets/js/utils/swipe.ts @@ -1,7 +1,6 @@ interface SwipeOptions { onSwipeLeft?: () => void; onSwipeRight?: () => void; - ignoreSelector?: string; minDistance?: number; maxDuration?: number; } @@ -119,8 +118,7 @@ export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () = ignored = true; return; } - const target = e.target as Element | null; - ignored = !!(options.ignoreSelector && target?.closest(options.ignoreSelector)); + ignored = false; startX = e.touches[0].clientX; startY = e.touches[0].clientY; startTime = Date.now(); @@ -139,11 +137,13 @@ export function attachSwipeHandler(el: HTMLElement, options: SwipeOptions): () = else options.onSwipeRight?.(); }; - el.addEventListener("touchstart", onTouchStart, { passive: true }); - el.addEventListener("touchend", onTouchEnd, { passive: true }); + // capture phase, so the touch tooltip gate's stopPropagation on chart elements + // cannot starve the swipe detection + el.addEventListener("touchstart", onTouchStart, { capture: true, passive: true }); + el.addEventListener("touchend", onTouchEnd, { capture: true, passive: true }); return () => { - el.removeEventListener("touchstart", onTouchStart); - el.removeEventListener("touchend", onTouchEnd); + el.removeEventListener("touchstart", onTouchStart, { capture: true }); + el.removeEventListener("touchend", onTouchEnd, { capture: true }); }; }