From 01e0cd09df8323ea6afc7087844970812f8bcce2 Mon Sep 17 00:00:00 2001
From: Terry Truong <terry06890@gmail.com>
Date: Sun, 9 Oct 2022 13:49:08 +1100
Subject: Add timeline dragging

Add basic code for timeline pinch-zooming
---
 src/components/TimeLine.vue | 113 ++++++++++++++++++++++++++++++++------------
 1 file changed, 83 insertions(+), 30 deletions(-)

(limited to 'src/components/TimeLine.vue')

diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index 3fe474a..82c2f67 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -1,6 +1,9 @@
 <template>
 <div class="touch-none" :width="width" :height="height"
-	@wheel.exact.prevent="onShift" @wheel.shift.exact.prevent="onZoom" ref="rootRef">
+	@wheel.exact.prevent="onWheel" @wheel.shift.exact.prevent="onShiftWheel"
+	@pointerdown.prevent="onPointerDown" @pointermove.prevent="onPointerMove" @pointerup.prevent="onPointerUp"
+	@pointercancel.prevent="onPointerUp" @pointerout.prevent="onPointerUp" @pointerleave.prevent="onPointerUp"
+	ref="rootRef">
 	<svg :viewBox="`0 0 ${width} ${height}`">
 		<line stroke="yellow" stroke-width="2px" x1="50%" y1="0%" x2="50%" y2="100%"/>
 		<template v-for="n in ticks" :key="n">
@@ -34,25 +37,25 @@ const props = defineProps({
 	height: {type: Number, required: true},
 });
 
-// For date range
-const minDate = -1000;
+// Vars
+const minDate = -1000; // Lowest date that gets marked
 const maxDate = 1000;
+const startDate = ref(0); // Lowest date on displayed timeline
+const endDate = ref(0);
 const scales = [200, 50, 10, 1, 0.2]; // The timeline get divided into units of scales[0], then scales[1], etc
 let scaleIdx = 0; // Index of current scale in 'scales'
-const startDate = ref(0);
-const endDate = ref(0);
-const SHIFT_INC = 0.3; // Proportion of timeline length to shift by
+const ticks = ref(null); // Holds date value for each tick
+const SCROLL_SHIFT_CHG = 0.3; // Proportion of timeline length to shift by upon scroll
 const ZOOM_RATIO = 1.5; // When zooming out, the timeline length gets multiplied by this ratio
 const MIN_TICK_SEP = 30; // Smallest px separation between ticks
 const MIN_LAST_TICKS = 3; // When at smallest scale, don't zoom further if less than this many ticks would result
-const ticks = ref(null); // Holds date value for each tick
-let padUnits = 0.5; // Amount of extra scale units to add before/after min/max date
+let PAD_UNITS = 0.5; // Amount of extra scale units to add before/after min/max date
 
 // For initialisation
 function initTicks(): number[] {
 	// Find smallest usable scale
 	for (let i = 0; i < scales.length; i++){
-		let len = maxDate - minDate + (padUnits * scales[i]) * 2;
+		let len = maxDate - minDate + (PAD_UNITS * scales[i]) * 2;
 		if (props.height * (scales[i] / len) > MIN_TICK_SEP){
 			scaleIdx = i;
 		} else {
@@ -60,7 +63,7 @@ function initTicks(): number[] {
 		}
 	}
 	// Set start/end date
-	let extraPad = padUnits * scales[scaleIdx];
+	let extraPad = PAD_UNITS * scales[scaleIdx];
 	startDate.value = minDate - extraPad;
 	endDate.value = maxDate + extraPad;
 	// Get tick values
@@ -80,7 +83,7 @@ onMounted(() => nextTick(initTicks));
 // and adds/removes ticks upon a scale change
 function updateTicks(){
 	let len = endDate.value - startDate.value;
-	let shiftChg = len * SHIFT_INC;
+	let shiftChg = len * SCROLL_SHIFT_CHG;
 	let scaleChg = len * (ZOOM_RATIO - 1) / 2;
 	let scale = scales[scaleIdx];
 	// Get ticks in new range, and add hidden ticks that might transition in on a shift action
@@ -114,7 +117,7 @@ function updateTicks(){
 // Performs a shift action
 function shiftTimeline(n: number){
 	let len = endDate.value - startDate.value;
-	let extraPad = padUnits * scales[scaleIdx]
+	let extraPad = PAD_UNITS * scales[scaleIdx]
 	let paddedMinDate = minDate - extraPad;
 	let paddedMaxDate = maxDate + extraPad;
 	let chg = len * n;
@@ -144,18 +147,23 @@ function shiftTimeline(n: number){
 function zoomTimeline(frac: number){
 	let oldLen = endDate.value - startDate.value;
 	let newLen = oldLen * frac;
-	let extraPad = padUnits * scales[scaleIdx]
+	let extraPad = PAD_UNITS * scales[scaleIdx]
 	let paddedMinDate = minDate - extraPad;
 	let paddedMaxDate = maxDate + extraPad;
 	// Get new bounds
 	let newStart: number;
 	let newEnd: number;
-	if (cursorY == null){
+	if (pointerY == null){
 		let lenChg = newLen - oldLen
 		newStart = startDate.value - lenChg / 2;
 		newEnd = endDate.value + lenChg / 2;
 	} else {
-		let zoomCenter = startDate.value + (cursorY / props.height) * oldLen;
+		let y = 0; // Element-relative pointerY
+		if (rootRef.value != null){ // Can become null during dev-server hot-reload for some reason
+			let rect = rootRef.value.getBoundingClientRect();
+			y = pointerY - rect.top;
+		}
+		let zoomCenter = startDate.value + (y / props.height) * oldLen;
 		newStart = zoomCenter - (zoomCenter - startDate.value) * frac;
 		newEnd = zoomCenter + (endDate.value - zoomCenter) * frac;
 	}
@@ -210,26 +218,71 @@ function zoomTimeline(frac: number){
 	updateTicks();
 }
 
-// For cursor tracking
-let cursorX = null;
-let cursorY = null;
-onMounted(() => window.addEventListener('mousemove', (evt: MouseEvent) => {
-	if (rootRef.value != null){ // Can become null during dev-server hot-reload for some reason
-		let rect = rootRef.value.getBoundingClientRect();
-		cursorX = evt.clientX - rect.left;
-		cursorY = evt.clientY - rect.top;
-	}
-}))
-
 // For mouse/etc handling
-function onShift(evt: WheelEvent){
+let pointerX = null; // Stores pointer position (used for pointer-centered zooming)
+let pointerY = null;
+const ptrEvtCache = []; // Holds last captured PointerEvent for each pointerId (used for pinch-zoom)
+let lastPinchY = -1; // Holds last y-distance between two pointers that are down
+let dragDiffY = 0; // Holds accumlated change in pointer's y-coordinate while dragging
+let dragHandler = 0; // Set by a setTimeout() to a handler for pointer dragging
+function onPointerDown(evt: PointerEvent){
+	ptrEvtCache.push(evt);
+	// Update stored cursor position
+	pointerX = evt.clientX;
+	pointerY = evt.clientY;
+}
+function onPointerMove(evt: PointerEvent){
+	// Update event cache
+	const index = ptrEvtCache.findIndex((e) => e.pointerId == evt.pointerId);
+	ptrEvtCache[index] = evt;
+	//
+	if (ptrEvtCache.length == 1){
+		// Handle pointer dragging
+		let yDiff = evt.clientY - pointerY;
+		dragDiffY += yDiff;
+		if (dragHandler == 0){
+			dragHandler = setTimeout(() => {
+				shiftTimeline(-dragDiffY / props.height);
+				dragDiffY = 0;
+				dragHandler = 0;
+			}, 50);
+		}
+	} else if (ptrEvtCache.length == 2){
+		// Handle pinch-zoom
+		const pinchY = Math.abs(ptrEvtCache[0].clientY - ptrEvtCache[1].clientY);
+		if (lastPinchY > 0){
+			if (pinchY > lastPinchY){
+				console.log('Pinching out, zooming in');
+				//TODO: implement pinch-zooming
+			} else if (pinchY < lastPinchY){
+				console.log('Pinching in, zooming out');
+				//TODO: implement pinch-zooming
+			}
+		}
+		lastPinchY = pinchY;
+	}
+	// Update stored cursor position
+	pointerX = evt.clientX;
+	pointerY = evt.clientY;
+}
+function onPointerUp(evt: PointerEvent){
+	// Remove from event cache
+	const index = ptrEvtCache.findIndex((e) => e.pointerId == evt.pointerId);
+	ptrEvtCache.splice(index, 1);
+	// Reset other vars
+	if (ptrEvtCache.length < 2){
+		lastPinchY = -1;
+	}
+	dragDiffY = 0;
+}
+function onWheel(evt: WheelEvent){
 	if (evt.deltaY > 0){
-		shiftTimeline(SHIFT_INC);
+		shiftTimeline(SCROLL_SHIFT_CHG);
 	} else {
-		shiftTimeline(-SHIFT_INC);
+		shiftTimeline(-SCROLL_SHIFT_CHG);
 	}
 }
-function onZoom(evt: WheelEvent){
+function onShiftWheel(evt: WheelEvent){
 	if (evt.deltaY > 0){
 		zoomTimeline(ZOOM_RATIO);
 	} else {
-- 
cgit v1.2.3

