Skip to content

Commit dd457c9

Browse files
authored
Implemented pinch to zoom (#170)
* Implemented pinch to zoom closes #106 * Fix glitches in plotlyjs ui * Moved touch handler to a separate class * Implements one finger zooming (click+click+drag). * Add yaml option to disable pinch-to-zoom
1 parent f08ae9e commit dd457c9

File tree

6 files changed

+183
-5
lines changed

6 files changed

+183
-5
lines changed

readme.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,14 @@ Caveats:
691691
significant_changes_only: true # defaults to false
692692
```
693693

694+
## disable_pinch_to_zoom
695+
696+
```yaml
697+
disable_pinch_to_zoom: true # defaults to false
698+
```
699+
700+
When true, the custom implementations of pinch-to-zoom and double-tap-drag-to-zooming will be disabled.
701+
694702
## minimal_response
695703

696704
When true, tell HA to only return last_changed and state for states other than the first and last state (much faster).

src/cache/Cache.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import {
1111
CachedEntity,
1212
CachedStatisticsEntity,
1313
CachedStateEntity,
14-
HassEntity,
1514
EntityData,
16-
YValue,
1715
} from "../types";
1816
import { groupBy } from "lodash";
1917
import { StatisticValue } from "../recorder-types";

src/parse-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,5 +212,6 @@ export default function parseConfig(config: InputConfig): Config {
212212
no_default_layout: config.no_default_layout ?? false,
213213
significant_changes_only: config.significant_changes_only ?? false,
214214
minimal_response: config.minimal_response ?? true,
215+
disable_pinch_to_zoom: config.disable_pinch_to_zoom ?? false,
215216
};
216217
}

src/plotly-graph-card.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { parseISO } from "date-fns";
2222
import { StatisticPeriod } from "./recorder-types";
2323
import { parseTimeDuration } from "./duration/duration";
2424
import parseConfig from "./parse-config";
25+
import { TouchController } from "./touch-controller";
2526

2627
const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev";
2728

@@ -71,19 +72,23 @@ export class PlotlyGraph extends HTMLElement {
7172
_hass?: HomeAssistant;
7273
isBrowsing = false;
7374
isInternalRelayout = 0;
74-
75+
touchController: TouchController;
7576
handles: {
7677
resizeObserver?: ResizeObserver;
7778
relayoutListener?: EventEmitter;
7879
restyleListener?: EventEmitter;
7980
refreshTimeout?: number;
8081
} = {};
82+
8183
disconnectedCallback() {
8284
this.handles.resizeObserver!.disconnect();
8385
this.handles.relayoutListener!.off("plotly_relayout", this.onRelayout);
8486
this.handles.restyleListener!.off("plotly_restyle", this.onRestyle);
8587
clearTimeout(this.handles.refreshTimeout!);
88+
this.resetButtonEl.removeEventListener("click", this.exitBrowsingMode);
89+
this.touchController.disconnect();
8690
}
91+
8792
constructor() {
8893
super();
8994
if (!isProduction) {
@@ -145,9 +150,29 @@ export class PlotlyGraph extends HTMLElement {
145150
this.contentEl = shadow.querySelector("div#plotly")!;
146151
this.resetButtonEl = shadow.querySelector("button#reset")!;
147152
this.titleEl = shadow.querySelector("ha-card > #title")!;
148-
this.resetButtonEl.addEventListener("click", this.exitBrowsingMode);
149153
insertStyleHack(shadow.querySelector("style")!);
150154
this.contentEl.style.visibility = "hidden";
155+
this.touchController = new TouchController({
156+
el: this.contentEl,
157+
onZoomStart: async (layout) => {
158+
await this.withoutRelayout(async () => {
159+
if (this.contentEl.layout.xaxis.autorange) {
160+
// when autoranging is set in the xaxis, pinch to zoom doesn't work well
161+
await Plotly.relayout(this.contentEl, { "xaxis.autorange": false });
162+
// for some reason, only relayout or plot aren't enough
163+
await this.plot();
164+
}
165+
});
166+
},
167+
onZoom: async (layout) => {
168+
await this.withoutRelayout(async () => {
169+
await Plotly.relayout(this.contentEl, layout);
170+
});
171+
},
172+
onZoomEnd: () => {
173+
this.onRelayout();
174+
},
175+
});
151176
this.withoutRelayout(() => Plotly.newPlot(this.contentEl, [], {}));
152177
}
153178
get hass() {
@@ -244,7 +269,10 @@ export class PlotlyGraph extends HTMLElement {
244269
"plotly_restyle",
245270
this.onRestyle
246271
)!;
272+
this.resetButtonEl.addEventListener("click", this.exitBrowsingMode);
273+
this.touchController.connect();
247274
}
275+
248276
getAutoFetchRange() {
249277
const ms = this.parsed_config.hours_to_show * 60 * 60 * 1000;
250278
return [
@@ -340,6 +368,7 @@ export class PlotlyGraph extends HTMLElement {
340368
const was = this.parsed_config;
341369
this.parsed_config = newConfig;
342370
const is = this.parsed_config;
371+
this.touchController.isEnabled = !is.disable_pinch_to_zoom;
343372
if (is.hours_to_show !== was?.hours_to_show || is.offset !== was?.offset) {
344373
this.exitBrowsingMode();
345374
} else {
@@ -548,6 +577,7 @@ export class PlotlyGraph extends HTMLElement {
548577
},
549578
{
550579
xaxis: {
580+
autorange: false,
551581
range: this.isBrowsing
552582
? this.getVisibleRange()
553583
: this.getAutoFetchRangeWithValueMargins(),

src/touch-controller.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Layout, LayoutAxis } from "plotly.js";
2+
3+
type PlotlyEl = Plotly.PlotlyHTMLElement & {
4+
data: (Plotly.PlotData & { entity: string })[];
5+
layout: Plotly.Layout;
6+
};
7+
const zoomedRange = (axis: Partial<LayoutAxis>, zoom: number) => {
8+
if (!axis || !axis.range) return undefined;
9+
const center = (+axis.range[1] + +axis.range[0]) / 2;
10+
if (isNaN(center)) return undefined; // probably a categorical axis. Don't zoom
11+
const radius = (+axis.range[1] - +axis.range[0]) / zoom / 2;
12+
return [center - radius, center + radius];
13+
};
14+
const ONE_FINGER_DOUBLE_TAP_ZOOM_MS_THRESHOLD = 250;
15+
export class TouchController {
16+
isEnabled = true;
17+
lastTouches?: TouchList;
18+
lastSingleTouchTimestamp = 0;
19+
elRect?: DOMRect;
20+
el: PlotlyEl;
21+
onZoomStart: Function;
22+
onZoom: Function;
23+
onZoomEnd: Function;
24+
state: "one finger" | "two fingers" | "idle" = "idle";
25+
constructor(param: {
26+
el: PlotlyEl;
27+
onZoomStart: Function;
28+
onZoom: (layout: Partial<Layout>) => any;
29+
onZoomEnd: Function;
30+
}) {
31+
this.el = param.el;
32+
this.onZoom = param.onZoom;
33+
this.onZoomStart = param.onZoomStart;
34+
this.onZoomEnd = param.onZoomEnd;
35+
}
36+
disconnect() {
37+
this.el.removeEventListener("touchmove", this.onTouchMove);
38+
this.el.removeEventListener("touchstart", this.onTouchStart);
39+
this.el.removeEventListener("touchend", this.onTouchEnd);
40+
}
41+
connect() {
42+
this.el.addEventListener("touchmove", this.onTouchMove, {
43+
capture: true,
44+
});
45+
this.el.addEventListener("touchstart", this.onTouchStart, {
46+
capture: true,
47+
});
48+
this.el.addEventListener("touchend", this.onTouchEnd, {
49+
capture: true,
50+
});
51+
}
52+
53+
onTouchStart = async (e: TouchEvent) => {
54+
if (!this.isEnabled) return;
55+
const stateWas = this.state;
56+
this.state = "idle";
57+
if (e.touches.length == 1) {
58+
const now = Date.now();
59+
if (
60+
now - this.lastSingleTouchTimestamp <
61+
ONE_FINGER_DOUBLE_TAP_ZOOM_MS_THRESHOLD
62+
) {
63+
e.stopPropagation();
64+
e.stopImmediatePropagation();
65+
this.state = "one finger";
66+
this.lastTouches = e.touches;
67+
this.elRect = this.el.getBoundingClientRect();
68+
} else {
69+
this.lastSingleTouchTimestamp = now;
70+
}
71+
} else if (e.touches.length == 2) {
72+
this.state = "two fingers";
73+
this.lastTouches = e.touches;
74+
}
75+
if (stateWas === "idle" && stateWas !== this.state) {
76+
this.onZoomStart();
77+
}
78+
};
79+
80+
onTouchMove = async (e: TouchEvent) => {
81+
if (!this.isEnabled) return;
82+
83+
if (e.touches.length === 1 && this.state === "one finger")
84+
this.handleSingleFingerZoom(e);
85+
if (e.touches.length === 2 && this.state === "two fingers")
86+
this.handleTwoFingersZoom(e);
87+
};
88+
async handleSingleFingerZoom(e: TouchEvent) {
89+
e.preventDefault();
90+
e.stopPropagation();
91+
e.stopImmediatePropagation();
92+
const ts_old = this.lastTouches!;
93+
this.lastTouches = e.touches;
94+
const ts_new = e.touches;
95+
const height = this.elRect?.height || 500;
96+
const dist = (ts_new[0].clientY - ts_old[0].clientY) / height;
97+
let zoom = 1;
98+
if (dist > 0) zoom = 1 + dist * 4;
99+
if (dist < 0) zoom = 1 / (1 - dist * 4);
100+
await this.handleZoom(zoom);
101+
}
102+
async handleTwoFingersZoom(e: TouchEvent) {
103+
e.preventDefault();
104+
e.stopPropagation();
105+
e.stopImmediatePropagation();
106+
const ts_old = this.lastTouches!;
107+
this.lastTouches = e.touches;
108+
const ts_new = e.touches;
109+
const spread_old = Math.sqrt(
110+
(ts_old[0].clientX - ts_old[1].clientX) ** 2 +
111+
(ts_old[0].clientY - ts_old[1].clientY) ** 2
112+
);
113+
const spread_new = Math.sqrt(
114+
(ts_new[0].clientX - ts_new[1].clientX) ** 2 +
115+
(ts_new[0].clientY - ts_new[1].clientY) ** 2
116+
);
117+
await this.handleZoom(spread_new / spread_old);
118+
}
119+
async handleZoom(zoom: number) {
120+
const oldLayout = this.el.layout;
121+
const layout = {};
122+
123+
layout["xaxis.range"] = zoomedRange(oldLayout.xaxis, zoom);
124+
layout["yaxis.range"] = zoomedRange(oldLayout.yaxis, zoom);
125+
for (let i = 2; i < 31; i++) {
126+
layout[`xaxis${i}.range`] = zoomedRange(oldLayout[`xaxis${i}`], zoom);
127+
layout[`yaxis${i}.range`] = zoomedRange(oldLayout[`yaxis${i}`], zoom);
128+
}
129+
this.onZoom(layout);
130+
}
131+
132+
onTouchEnd = () => {
133+
if (!this.isEnabled) return;
134+
135+
if (this.state !== "idle") {
136+
this.onZoomEnd();
137+
this.state = "idle";
138+
}
139+
};
140+
}

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Datum } from "plotly.js";
21
import { ColorSchemeArray, ColorSchemeNames } from "./color-schemes";
32
import { TimeDurationStr } from "./duration/duration";
43
import {
@@ -47,6 +46,7 @@ export type InputConfig = {
4746
no_default_layout?: boolean;
4847
significant_changes_only?: boolean; // defaults to false
4948
minimal_response?: boolean; // defaults to true
49+
disable_pinch_to_zoom?: boolean; // defaults to false
5050
};
5151

5252
export type EntityConfig = EntityIdConfig & {
@@ -82,6 +82,7 @@ export type Config = {
8282
no_default_layout: boolean;
8383
significant_changes_only: boolean;
8484
minimal_response: boolean;
85+
disable_pinch_to_zoom: boolean;
8586
};
8687
export type EntityIdStateConfig = {
8788
entity: string;

0 commit comments

Comments
 (0)