From 5465cfb444612ec87b33e40bdfdc7638e70b9c73 Mon Sep 17 00:00:00 2001
From: Terry Truong <terry06890@gmail.com>
Date: Wed, 12 Oct 2022 14:48:49 +1100
Subject: Convert to represent dates instead of numbers

- Add HistDate class
- Adapt TimeLine and BaseLine to work with HistDates
- For own-size-watching components, add onMounted() hook to run before
  ResizeObserver is set up
---
 src/components/TimeLine.vue | 382 ++++++++++++++++++++++++++++----------------
 1 file changed, 247 insertions(+), 135 deletions(-)

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

diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index bf886b1..7ed83d2 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -8,23 +8,26 @@
 		<!-- Main line (unit horizontal line that gets transformed, with extra length to avoid gaps when panning) -->
 		<line :stroke="store.color.alt" stroke-width="2px" x1="-1" y1="0" x2="2" y2="0" :style="mainlineStyles"/>
 		<!-- Tick markers -->
-		<template v-for="n in ticks" :key="n">
-			<line v-if="n == MIN_DATE || n == MAX_DATE"
+		<template v-for="date, idx in ticks.dates" :key="date.toInt()">
+			<line v-if="date.equals(MIN_DATE, scale) || date.equals(MAX_DATE, scale)"
 				:x1="vert ? -END_TICK_SZ : 0" :y1="vert ? 0 : -END_TICK_SZ"
 				:x2="vert ?  END_TICK_SZ : 0" :y2="vert ? 0 :  END_TICK_SZ"
 				:stroke="store.color.alt" :stroke-width="`${END_TICK_SZ * 2}px`"
-				:style="tickStyles(n)" class="animate-fadein"/>
-			<line v-else
+				:style="tickStyles(idx)" class="animate-fadein"/>
+			<line v-else-if="idx >= ticks.vStartIdx && idx <= ticks.vEndIdx"
 				:x1="vert ? -TICK_LEN : 0" :y1="vert ? 0 : -TICK_LEN"
 				:x2="vert ?  TICK_LEN : 0" :y2="vert ? 0 :  TICK_LEN"
-				:stroke="store.color.alt" stroke-width="1px" :style="tickStyles(n)" class="animate-fadein"/>
+				:stroke="store.color.alt" stroke-width="1px"
+				:style="tickStyles(idx)" class="animate-fadein"/>
 		</template>
 		<!-- Tick labels -->
-		<text :fill="store.color.textDark" v-for="n in ticks" :key="n"
-			x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle"
-			:style="tickLabelStyles(n)" class="text-sm animate-fadein">
-			{{Math.floor(n * 10) / 10}}
-		</text>
+		<template v-for="date, idx in ticks.dates" :key="date.toInt()">
+			<text v-if="idx >= ticks.vStartIdx && idx <= ticks.vEndIdx" :fill="store.color.textDark"
+				x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle"
+				:style="tickLabelStyles(idx)" class="text-sm animate-fadein">
+				{{date}}
+			</text>
+		</template>
 	</svg>
 	<!-- Buttons -->
 	<icon-button :size="30" class="absolute top-2 right-2"
@@ -36,13 +39,14 @@
 </template>
 
 <script setup lang="ts">
-import {ref, onMounted, computed, watch} from 'vue';
+import {ref, onMounted, computed, watch, PropType} from 'vue';
 // Components
 import IconButton from './IconButton.vue';
 // Icons
 import MinusIcon from './icon/MinusIcon.vue';
 // Other
-import {MIN_DATE, MAX_DATE, SCALES, WRITING_MODE_HORZ} from '../lib';
+import {WRITING_MODE_HORZ, MIN_DATE, MAX_DATE, MONTH_SCALE, DAY_SCALE, SCALES,
+	HistDate, stepDate, inDateScale} from '../lib';
 import {useStore} from '../store';
 
 // Refs
@@ -54,8 +58,8 @@ const store = useStore();
 // Props + events
 const props = defineProps({
 	vert: {type: Boolean, required: true},
-	initialStart: {type: Number, required: true},
-	initialEnd: {type: Number, required: true},
+	initialStart: {type: Object as PropType<HistDate>, required: true},
+	initialEnd: {type: Object as PropType<HistDate>, required: true},
 });
 const emit = defineEmits(['remove', 'range-chg']);
 
@@ -64,6 +68,13 @@ const width = ref(0);
 const height = ref(0);
 const availLen = computed(() => props.vert ? height.value : width.value);
 const prevVert = ref(props.vert); // For skipping transitions on horz/vert swap
+const mounted = ref(false);
+onMounted(() => {
+	let rootEl = rootRef.value!;
+	width.value = rootEl.offsetWidth;
+	height.value = rootEl.offsetHeight;
+	mounted.value = true;
+})
 const resizeObserver = new ResizeObserver((entries) => {
 	for (const entry of entries){
 		if (entry.contentBoxSize){
@@ -85,102 +96,193 @@ onMounted(() => resizeObserver.observe(rootRef.value as HTMLElement));
 // Timeline data
 const startDate = ref(props.initialStart); // Lowest date on displayed timeline
 const endDate = ref(props.initialEnd);
-let scaleIdx = 0; // Index of current scale in SCALES
-const padUnits = computed(() => props.vert ? 0.5 : 1); // Amount of extra scale units to add before/after min/max date
+const scaleIdx = ref(0); // Index of current scale in SCALES
+const scale = computed(() => SCALES[scaleIdx.value])
 // Initialise to smallest usable scale
 function initScale(){
-	let dateLen = endDate.value - startDate.value;
-	for (let i = 0; i < SCALES.length; i++){
-		if (availLen.value * (SCALES[i] / dateLen) > MIN_TICK_SEP){
-			scaleIdx = i;
+	if (startDate.value.year < -4713){ // If a bound is before the Julian period start of 4713 BCE, use a yearly scale
+		scaleIdx.value = getYearlyScale(startDate.value, endDate.value, availLen.value);
+	} else {
+		let dayDiff = startDate.value.getDayDiff(endDate.value);
+		// Check for day scale usability
+		if (availLen.value / dayDiff >= MIN_TICK_SEP){
+			scaleIdx.value = SCALES.findIndex(s => s == DAY_SCALE);
 		} else {
-			break;
+			// Check for month scale usability
+			let monthDiff = startDate.value.getMonthDiff(endDate.value);
+			if (availLen.value / monthDiff >= MIN_TICK_SEP){
+				scaleIdx.value = SCALES.findIndex(s => s == MONTH_SCALE);
+			} else { // Use a yearly scale
+				scaleIdx.value = getYearlyScale(startDate.value, endDate.value, availLen.value);
+			}
 		}
 	}
 }
+function getYearlyScale(startDate: HistDate, endDate: HistDate, availLen: number){
+	// Get the smallest yearly scale that divides a date range, without making ticks too close
+	let yearDiff = endDate.year - startDate.year;
+	let idx = 0;
+	while (SCALES[idx] > yearDiff){
+		idx++;
+	}
+	while (idx < SCALES.length - 1 && availLen * SCALES[idx + 1] / yearDiff > MIN_TICK_SEP){
+		idx++;
+	}
+	return idx;
+}
 onMounted(initScale);
 
 // Tick data
 const TICK_LEN = 8;
 const END_TICK_SZ = 4; // Size for MIN_DATE/MAX_DATE ticks
-const MIN_TICK_SEP = 30; // Smallest px separation between ticks
+const MIN_TICK_SEP = 5; // Smallest px separation between ticks
 const MIN_LAST_TICKS = 3; // When at smallest scale, don't zoom further into less than this many ticks
-const ticks = computed((): number[] => { // Array of date values for each tick
-	let dateLen = endDate.value - startDate.value;
-	let panLen = dateLen * store.scrollRatio;
-	let zoomLen = dateLen * (store.zoomRatio - 1) / 2;
-	let scale = SCALES[scaleIdx];
-	// Get ticks in new range, and add hidden ticks that might transition in after panning
-	let tempTicks: number[] = [];
-	let next = Math.ceil((Math.max(MIN_DATE, startDate.value - panLen) - MIN_DATE) / scale);
-	let last = Math.floor((Math.min(MAX_DATE, endDate.value + panLen) - MIN_DATE) / scale);
-	while (next <= last){
-		tempTicks.push(MIN_DATE + next * scale);
-		next++;
+function getNumTimeUnits(): number {
+	if (scale.value == DAY_SCALE){
+		return startDate.value.getDayDiff(endDate.value);
+	} else if (scale.value == MONTH_SCALE){
+		return startDate.value.getMonthDiff(endDate.value);
+	} else {
+		return Math.floor((endDate.value.year - startDate.value.year) / scale.value);
 	}
-	// Get hidden ticks that might transition in after zooming
-	let tempTicks2: number[] = [];
-	let tempTicks3: number[] = [];
-	if (scaleIdx > 0){
-		scale = SCALES[scaleIdx-1];
-		let first = Math.ceil((Math.max(MIN_DATE, startDate.value - zoomLen) - MIN_DATE) / scale);
-		while (MIN_DATE + first * scale < tempTicks[0]){
-			tempTicks2.push(MIN_DATE + first * scale);
-			first++;
+}
+const ticks = computed((): {dates: HistDate[], startIdx: number, endIdx: number,
+		vStartIdx: number, vEndIdx: number} => {
+		if (!mounted.value){
+		return {dates: [], startIdx: 0, endIdx: 0, vStartIdx: 0, vEndIdx: 0};
 		}
-		let last = Math.floor((Math.min(MAX_DATE, endDate.value + zoomLen) - MIN_DATE) / scale);
-		let next = Math.floor((tempTicks[tempTicks.length - 1] - MIN_DATE) / scale) + 1;
-		while (next <= last){
-			tempTicks3.push(MIN_DATE + next * scale);
-			next++;
+		// The result holds tick dates, and indexes indicating where the startDate and endDate are
+		let numUnits = getNumTimeUnits();
+		let tempTicks: HistDate[] = [];
+		let startIdx: number;
+		let endIdx: number;
+		let panUnits = Math.floor(numUnits * store.scrollRatio);
+		// Get hidden preceding ticks
+		let next: HistDate;
+		if (MIN_DATE.isEarlier(startDate.value, scale.value)){
+		next = startDate.value;
+		for (let i = 0; i < panUnits; i++){
+		next = stepDate(next, scale.value, {forward: false});
+		tempTicks.push(next);
+		if (MIN_DATE.equals(next, scale.value)){
+		break;
 		}
-	}
-	// Join into tick array
-	return [...tempTicks2, ...tempTicks, ...tempTicks3];
-});
-
-// For panning/zooming
-function panTimeline(n: number){
-	let dateLen = endDate.value - startDate.value;
-	let extraLen = padUnits.value * SCALES[scaleIdx]
-	let paddedMinDate = MIN_DATE - extraLen;
-	let paddedMaxDate = MAX_DATE + extraLen;
-	let chg = dateLen * n;
-	if (startDate.value + chg < paddedMinDate){
-		if (startDate.value == paddedMinDate){
-			console.log('Reached MIN_DATE limit')
-			return;
 		}
-		chg = paddedMinDate - startDate.value;
-		startDate.value = paddedMinDate;
-		endDate.value += chg;
-	} else if (endDate.value + chg > paddedMaxDate){
-		if (endDate.value == paddedMaxDate){
-			console.log('Reached MAX_DATE limit')
-			return;
+		tempTicks.reverse();
 		}
-		chg = paddedMaxDate - endDate.value;
-		endDate.value = paddedMaxDate;
-		startDate.value += chg;
-	} else {
-		startDate.value += chg;
-		endDate.value += chg;
+		startIdx = tempTicks.length;
+		// Get ticks between bounds
+		next = startDate.value.clone();
+		for (let i = 0; i < numUnits + 1; i++){
+			tempTicks.push(next);
+			next = stepDate(next, scale.value);
+		}
+		endIdx = tempTicks.length - 1;
+		// Get hidden following ticks
+		if (next.isEarlier(MAX_DATE, scale.value)){
+			for (let i = 0; i < panUnits; i++){
+				next = stepDate(next, scale.value);
+				tempTicks.push(next)
+					if (MAX_DATE.equals(next, scale.value)){
+						break;
+					}
+			}
+		}
+		// Get hidden ticks that might transition in after zooming
+		let tempTicks2: HistDate[] = [];
+		let tempTicks3: HistDate[] = [];
+		if (scaleIdx.value > 0 &&
+				availLen.value / (numUnits * store.zoomRatio) < MIN_TICK_SEP){ // If zoom-out would decrease scale
+			let newNumUnits = Math.floor(numUnits * store.zoomRatio) - numUnits - panUnits * 2;
+			let zoomedScale = SCALES[scaleIdx.value-1]
+				let unitsPerZoomedUnit = zoomedScale / scale.value;
+			let next = tempTicks[0];
+			if (MIN_DATE.isEarlier(next, scale.value)){
+				for (let i = 0; i < newNumUnits / unitsPerZoomedUnit; i++){ // Get preceding ticks
+					next = stepDate(next, zoomedScale, {forward: false});
+					tempTicks2.push(next);
+					if (MIN_DATE.equals(next, scale.value)){
+						break;
+					}
+				}
+				tempTicks2.reverse();
+			}
+			next = tempTicks[tempTicks.length - 1];
+			if (next.isEarlier(MAX_DATE, scale.value)){
+				for (let i = 0; i < newNumUnits / unitsPerZoomedUnit; i++){ // Get preceding ticks
+					next = stepDate(next, zoomedScale);
+					tempTicks3.push(next);
+					if (MAX_DATE.equals(next, scale.value)){
+						break;
+					}
+				}
+			}
+		}
+		// Join into single array
+		let vStartIdx = startIdx;
+		while (tempTicks[vStartIdx].isEarlier(MIN_DATE, scale.value)){
+			vStartIdx += 1;
+		}
+		let vEndIdx = endIdx;
+		while (MAX_DATE.isEarlier(tempTicks[vEndIdx], scale.value)){
+			vEndIdx -= 1;
+		}
+		startIdx += tempTicks2.length;
+		endIdx += tempTicks2.length;
+		vStartIdx += tempTicks2.length;
+		vEndIdx += tempTicks2.length;
+		let dates = [...tempTicks2, ...tempTicks, ...tempTicks3];
+		return {dates, startIdx, endIdx, vStartIdx, vEndIdx};
+		});
+
+// For panning/zooming
+function panTimeline(scrollRatio: number): boolean {
+	let numUnits = getNumTimeUnits();
+	let chgUnits = Math.trunc(numUnits * scrollRatio);
+	if (chgUnits == 0){
+		return false;
+	}
+	let paddedMinDate = stepDate(MIN_DATE, scale.value, {forward: false});
+	let paddedMaxDate = stepDate(MAX_DATE, scale.value);
+	if (scrollRatio < 0 && startDate.value.equals(paddedMinDate, scale.value)){
+		console.log('Reached minimum date limit');
+		return true;
+	}
+	if (scrollRatio > 0 && endDate.value.equals(paddedMaxDate, scale.value)){
+		console.log('Reached maximum date limit');
+		return true;
+	}
+	while (chgUnits < 0 && paddedMinDate.isEarlier(startDate.value, scale.value)){
+		stepDate(startDate.value, scale.value, {forward: false, inplace: true});
+		stepDate(endDate.value, scale.value, {forward: false, inplace: true});
+		chgUnits += 1;
+	}
+	while (chgUnits > 0 && endDate.value.isEarlier(paddedMaxDate, scale.value)){
+		stepDate(startDate.value, scale.value, {inplace: true});
+		stepDate(endDate.value, scale.value, {inplace: true});
+		chgUnits -= 1;
 	}
+	return true;
 }
-function zoomTimeline(frac: number){
-	let oldDateLen = endDate.value - startDate.value;
-	let newDateLen = oldDateLen * frac;
-	let extraLen = padUnits.value * SCALES[scaleIdx]
-	let paddedMinDate = MIN_DATE - extraLen;
-	let paddedMaxDate = MAX_DATE + extraLen;
-	// Get new bounds
-	let newStart: number;
-	let newEnd: number;
+function zoomTimeline(zoomRatio: number){
+	let paddedMinDate = stepDate(MIN_DATE, scale.value, {forward: false});
+	let paddedMaxDate = stepDate(MAX_DATE, scale.value);
+	if (zoomRatio > 1
+			&& startDate.value.equals(paddedMinDate, scale.value)
+			&& endDate.value.equals(paddedMaxDate, scale.value)){
+		console.log('Reached upper scale limit');
+		return;
+	}
+	let numUnits = getNumTimeUnits();
+	let newNumUnits = Math.floor(numUnits * zoomRatio);
+	// Get tentative bound changes
+	let startChg: number;
+	let endChg: number;
 	let ptrOffset = props.vert ? pointerY : pointerX;
 	if (ptrOffset == null){
-		let lenChg = newDateLen - oldDateLen
-		newStart = startDate.value - lenChg / 2;
-		newEnd = endDate.value + lenChg / 2;
+		let unitChg = Math.abs(newNumUnits - numUnits);
+		startChg = Math.ceil(unitChg / 2);
+		endChg = Math.floor(unitChg / 2);
 	} else { // Pointer-centered zoom
 		// Get element-relative ptrOffset
 		let innerOffset = 0;
@@ -189,53 +291,60 @@ function zoomTimeline(frac: number){
 			innerOffset = props.vert ? ptrOffset - rect.top : ptrOffset - rect.left;
 		}
 		//
-		let zoomCenter = startDate.value + (innerOffset / availLen.value) * oldDateLen;
-		newStart = zoomCenter - (zoomCenter - startDate.value) * frac;
-		newEnd = zoomCenter + (endDate.value - zoomCenter) * frac;
+		let zoomCenter = numUnits * (innerOffset / availLen.value);
+		startChg = Math.round(Math.abs(zoomCenter * (zoomRatio - 1)));
+		endChg = Math.abs(newNumUnits - numUnits) - startChg;
 	}
-	if (newStart < paddedMinDate){
-		newEnd += paddedMinDate - newStart;
-		newStart = paddedMinDate;
-		if (newEnd > paddedMaxDate){
-			if (startDate.value == paddedMinDate && endDate.value == paddedMaxDate){
-				console.log('Reached upper scale limit');
-				return;
-			} else {
-				newEnd = paddedMaxDate;
-			}
+	// Get new bounds
+	let newStart = startDate.value.clone();
+	let newEnd = endDate.value.clone();
+	if (zoomRatio <= 1){
+		stepDate(newStart, scale.value, {inplace: true, count: startChg});
+		stepDate(newEnd, scale.value, {forward: false, inplace: true, count: endChg});
+	} else {
+		while (startChg > 0 && paddedMinDate.isEarlier(newStart, scale.value)){
+			stepDate(newStart, scale.value, {forward: false, inplace: true});
+			startChg -= 1;
 		}
-	} else if (newEnd > paddedMaxDate){
-		newStart -= newEnd - paddedMaxDate;
-		newEnd = paddedMaxDate;
-		if (newStart < paddedMinDate){
-			if (startDate.value == paddedMinDate && endDate.value == paddedMaxDate){
-				console.log('Reached upper scale limit');
-				return;
-			} else {
-				newStart = paddedMinDate;
-			}
+		endChg += startChg; // Transfer excess into end expansion
+		while (endChg > 0 && newEnd.isEarlier(paddedMaxDate, scale.value)){
+			stepDate(newEnd, scale.value, {inplace: true});
+			endChg -= 1;
+		}
+		while (endChg > 0 && paddedMinDate.isEarlier(newStart, scale.value)){ // Transfer excess into start expansion
+			stepDate(newStart, scale.value, {forward: false, inplace: true});
+			endChg -= 1;
 		}
+		newNumUnits -= endChg;
 	}
 	// Possibly change the scale
-	newDateLen = newEnd - newStart;
-	let tickDiff = availLen.value * (SCALES[scaleIdx] / newDateLen);
-	if (tickDiff < MIN_TICK_SEP){
-		if (scaleIdx == 0){
+	let tickDiff = availLen.value / newNumUnits;
+	if (tickDiff < MIN_TICK_SEP){ // Possibly zoom out
+		if (scaleIdx.value == 0){
 			console.log('INFO: Reached zoom out limit');
 			return;
 		} else {
-			scaleIdx--;
+			scaleIdx.value -= 1;
 		}
-	} else {
-		if (scaleIdx < SCALES.length - 1){
-			if (tickDiff > MIN_TICK_SEP * SCALES[scaleIdx] / SCALES[scaleIdx + 1]){
-				scaleIdx++;
-			}
-		} else {
-			if (availLen.value / tickDiff < MIN_LAST_TICKS){
+	} else { // Possibly zoom in
+		if (scaleIdx.value == SCALES.length - 1){
+			if (newNumUnits < MIN_LAST_TICKS){
 				console.log('INFO: Reached zoom in limit');
 				return;
 			}
+		} else {
+			let nextScale = SCALES[scaleIdx.value + 1];
+			let zoomedTickDiff: number;
+			if (nextScale == MONTH_SCALE){
+				zoomedTickDiff = tickDiff / 12;
+			} else if (nextScale == DAY_SCALE){
+				zoomedTickDiff = tickDiff / 31;
+			} else {
+				zoomedTickDiff = tickDiff / (scale.value / nextScale);
+			}
+			if (zoomedTickDiff > MIN_TICK_SEP){
+				scaleIdx.value += 1;
+			}
 		}
 	}
 	//
@@ -285,8 +394,10 @@ function onPointerMove(evt: PointerEvent){
 		if (dragHandler == 0){
 			dragHandler = setTimeout(() => {
 				if (Math.abs(dragDiff) > 2){
-					panTimeline(-dragDiff / availLen.value);
-					dragDiff = 0;
+					const moved = panTimeline(-dragDiff / availLen.value);
+					if (moved){
+						dragDiff = 0;
+					}
 				}
 				dragHandler = 0;
 			}, 50);
@@ -362,24 +473,25 @@ const mainlineStyles = computed(() => ({
 	transitionDuration,
 	transitionTimingFunction,
 }));
-function tickStyles(tick: number){
-	let offset = (tick - startDate.value) / (endDate.value - startDate.value) * availLen.value;
-	let scale = 1;
-	if (scaleIdx > 0 && tick % SCALES[scaleIdx-1] == 0){ // If the tick exists on the scale directly above this one
-		scale = 2;
+function tickStyles(idx: number){
+	let offset = (idx - ticks.value.startIdx) / (ticks.value.endIdx - ticks.value.startIdx) * availLen.value;
+	let scaleFactor = 1;
+	if (scaleIdx.value > 0 &&
+			inDateScale(ticks.value.dates[idx], SCALES[scaleIdx.value-1])){ // If tick exists on larger scale
+		scaleFactor = 2;
 	}
 	return {
 		transform: props.vert ?
-			`translate(${width.value/2}px,  ${offset}px) scale(${scale})` :
-			`translate(${offset}px, ${height.value/2}px) scale(${scale})`,
+			`translate(${width.value/2}px,  ${offset}px) scale(${scaleFactor})` :
+			`translate(${offset}px, ${height.value/2}px) scale(${scaleFactor})`,
 		transitionProperty: skipTransition.value ? 'none' : 'transform, opacity',
 		transitionDuration,
 		transitionTimingFunction,
 		opacity: (offset >= 0 && offset <= availLen.value) ? 1 : 0,
 	}
 }
-function tickLabelStyles(tick: number){
-	let offset = (tick - startDate.value) / (endDate.value - startDate.value) * availLen.value;
+function tickLabelStyles(idx: number){
+	let offset = (idx - ticks.value.startIdx) / (ticks.value.endIdx - ticks.value.startIdx) * availLen.value;
 	let labelSz = props.vert ? 10 : 30;
 	return {
 		transform: props.vert ?
-- 
cgit v1.2.3

