From 379fc207ef7b6e3286e7b8acc1137c4494176624 Mon Sep 17 00:00:00 2001
From: Terry Truong <terry06890@gmail.com>
Date: Thu, 20 Oct 2022 01:37:20 +1100
Subject: Make event layouting incorporate date-alignment and popularity

---
 src/components/TimeLine.vue | 175 +++++++++++++++++++++++++++++++++++++-------
 1 file changed, 147 insertions(+), 28 deletions(-)

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

diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index a175681..91743f6 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="touch-none relative"
+<div class="touch-none relative overflow-hidden"
 	@pointerdown.prevent="onPointerDown" @pointermove.prevent="onPointerMove" @pointerup.prevent="onPointerUp"
 	@pointercancel.prevent="onPointerUp" @pointerout.prevent="onPointerUp" @pointerleave.prevent="onPointerUp"
 	@wheel.exact.prevent="onWheel" @wheel.shift.exact.prevent="onShiftWheel"
@@ -125,7 +125,7 @@ const eventMinorSz = computed(() => props.vert ? eventWidth.value : eventHeight.
 const SPACING = 10;
 const sideMainline = computed( // True if unable to fit mainline in middle with events on both sides
 	() => availBreadth.value < MAINLINE_WIDTH + (eventMinorSz.value + SPACING * 2) * 2);
-const mainlineOffset = computed(() => { // Distance from side of display area
+const mainlineOffset = computed(() => { // Distance mainline-area line to side of display area
 	if (!sideMainline.value){
 		return availBreadth.value / 2 - MAINLINE_WIDTH /2 + LARGE_TICK_LEN;
 	} else {
@@ -312,43 +312,162 @@ const idToPos = computed(() => {
 		return new Map();
 	}
 	let map: Map<number, [number, number, number, number]> = new Map(); // Maps visible event IDs to x/y/w/h
-	// Do basic grid-like layout
-	let minorOffset = SPACING, majorOffset = SPACING; // Holds x and y for event element (or y and x if !props.vert)
-	let full = false;
-	for (let event of idToEvent.value.values()){
-		// Check if at end of column (or row if !props.vert)
-		if (majorOffset + eventMajorSz.value + SPACING > availLen.value){
-			majorOffset = SPACING;
-			minorOffset += eventMinorSz.value + SPACING;
-			// If finished last row
-			if (minorOffset + eventMinorSz.value + SPACING > availBreadth.value){
-				full = true;
+	// Determine columns to place event elements in (or rows if !props.vert)
+	let cols: [number, number][][] = []; // For each column, for each element, stores an ID and pixel offset
+	let colOffsets: number[] = []; // Stores the pixel offset of each column
+	let afterMainlineIdx: number | null = null; // Index of first column after the mainline, if there is one
+	if (!sideMainline.value){
+		// Get columns before mainline area
+		let columnOffset = availBreadth.value / 2 - MAINLINE_WIDTH / 2 - SPACING - eventMinorSz.value;
+		while (columnOffset >= SPACING){
+			cols.push([]);
+			colOffsets.push(columnOffset);
+			columnOffset -= eventMinorSz.value + SPACING;
+		}
+		colOffsets.reverse();
+		afterMainlineIdx = cols.length;
+		// Get columns after mainline area
+		columnOffset = availBreadth.value / 2 + MAINLINE_WIDTH / 2 + SPACING;
+		while (columnOffset + eventMinorSz.value + SPACING < availBreadth.value){
+			cols.push([]);
+			colOffsets.push(columnOffset);
+			columnOffset += eventMinorSz.value + SPACING;
+		}
+	} else {
+		// Get columns before mainline area
+		let columnOffset = mainlineOffset.value - SPACING - eventMinorSz.value - SPACING;
+		while (columnOffset >= SPACING){
+			cols.push([]);
+			colOffsets.push(columnOffset);
+			columnOffset -= eventMinorSz.value + SPACING;
+		}
+		colOffsets.reverse();
+	}
+	if (cols.length == 0){
+		console.log('WARNING: No space for events');
+		return map;
+	}
+	// Place events in columns, trying to minimise distance to points on mainline
+		// Note: Placing popular events first so the layout is more stable between event requests
+	let orderedEvents = [...idToEvent.value.values()];
+	orderedEvents.sort((x, y) => y.pop - x.pop);
+	let numUnits = getNumVisibleUnits();
+	for (let event of orderedEvents){
+		// Get preferred pixel offset in column
+		let unitOffset = getUnitDiff(event.start, startDate.value, scale.value) + startOffset.value;
+		let targetOffset = unitOffset / numUnits * availLen.value - eventMajorSz.value / 2;
+		// Find potential positions
+		let positions: [number, number, number][] = [];
+			// For each position, holds a column index, a within-column index to insert at, and an offset value
+		let colIdx = afterMainlineIdx == null ? cols.length - 1 : afterMainlineIdx - 1;
+			// Column index, used to iterate from columns closest to mainline outward
+		columnLoop:
+		while (colIdx >= 0){ // For each column
+			let bestOffset: number | null = null; // Best offset found so far
+			let bestIdx: number | null = null; // Index of insertion for bestOffset
+			// Check for empty column
+			if (cols[colIdx].length == 0){
+				let offset = Math.min(Math.max(SPACING, targetOffset), availLen.value - eventMajorSz.value - SPACING);
+				positions.push([colIdx, 0, offset]);
 				break;
 			}
+			// Check placement before first event in column
+			let offset = cols[colIdx][0][1] - eventMajorSz.value - SPACING;
+			if (offset >= SPACING){
+				if (offset >= targetOffset){
+					positions.push([colIdx, 0, Math.max(SPACING, targetOffset)]);
+					break;
+				} else {
+					bestOffset = offset;
+					bestIdx = 0;
+				}
+			}
+			// Check placement after each event element in column
+			for (let elIdx = 0; elIdx < cols[colIdx].length; elIdx++){
+				offset = cols[colIdx][elIdx][1] + eventMajorSz.value + SPACING;
+				if (elIdx == cols[colIdx].length - 1){ // If last element in column
+					if (offset < availLen.value - eventMajorSz.value - SPACING){
+						// Check for better offset
+						if (bestOffset == null
+								|| Math.abs(targetOffset - offset) < Math.abs(targetOffset - bestOffset)){
+							if (offset <= targetOffset){
+								offset = Math.min(targetOffset, availLen.value - eventMajorSz.value - SPACING);
+								positions.push([colIdx, elIdx + 1, offset]);
+								break columnLoop;
+							} else {
+								bestOffset = offset;
+								bestIdx = elIdx + 1;
+							}
+						}
+					}
+				} else { // If not last event in column
+					// Check for space between this and next element
+					let nextOffset = cols[colIdx][elIdx + 1][1];
+					if (nextOffset - offset < eventMajorSz.value + SPACING){
+						continue;
+					}
+					// Check for better offset
+					if (bestOffset == null || Math.abs(targetOffset - offset) < Math.abs(targetOffset - bestOffset)){
+						if (offset <= targetOffset && targetOffset <= nextOffset - eventMajorSz.value - SPACING){
+							positions.push([colIdx, elIdx + 1, targetOffset]);
+							break columnLoop;
+						} else {
+							if (offset > targetOffset){
+								bestOffset = offset;
+							} else {
+								bestOffset = nextOffset - eventMajorSz.value - SPACING;
+							}
+							bestIdx = elIdx + 1;
+						}
+					} else {
+						break;
+					}
+				}
+			}
+			// Add potential position
+			if (bestOffset != null){
+				positions.push([colIdx, bestIdx!, bestOffset]);
+			}
+			// Update colIdx
+			if (afterMainlineIdx == null){
+				colIdx -= 1;
+			} else {
+				if (colIdx < afterMainlineIdx){ // Swap to 'right' of mainline
+					colIdx = afterMainlineIdx + (afterMainlineIdx - 1 - colIdx);
+				} else { // Swap to 'left' of mainline, and move 'outward'
+					colIdx = afterMainlineIdx - 1 - (colIdx - afterMainlineIdx) - 1;
+				}
+			}
 		}
-		// Avoid collision with timeline
-		if (!sideMainline.value){
-			if (minorOffset <= availBreadth.value / 2 + MAINLINE_WIDTH / 2 + SPACING &&
-					minorOffset + eventMinorSz.value >= availBreadth.value / 2 - MAINLINE_WIDTH / 2 - SPACING){
-				minorOffset = availBreadth.value / 2 + MAINLINE_WIDTH / 2 + SPACING;
+		// Choose position with minimal distance
+		if (positions.length > 0){
+			let bestPos = positions[0]!;
+			for (let i = 1; i < positions.length; i++){
+				if (Math.abs(targetOffset - positions[i][2]) < Math.abs(targetOffset - bestPos[2])){
+					bestPos = positions[i];
+				}
 			}
-		} else if (minorOffset + eventMinorSz.value + SPACING > mainlineOffset.value){
-			break;
+			cols[bestPos[0]].splice(bestPos[1], 0, [event.id, bestPos[2]]);
 		}
-		// Add coords
-		if (props.vert){
-			map.set(event.id, [minorOffset, majorOffset, eventWidth.value, eventHeight.value]);
-		} else {
-			map.set(event.id, [majorOffset, minorOffset, eventWidth.value, eventHeight.value]);
+	}
+	// Add events to map
+	for (let colIdx = 0; colIdx < cols.length; colIdx++){
+		let minorOffset = colOffsets[colIdx];
+		for (let [eventId, majorOffset] of cols[colIdx]){
+			if (props.vert){
+				map.set(eventId, [minorOffset, majorOffset, eventWidth.value, eventHeight.value]);
+			} else {
+				map.set(eventId, [majorOffset, minorOffset, eventWidth.value, eventHeight.value]);
+			}
 		}
-		// Update to next position
-		majorOffset += eventMajorSz.value + SPACING;
 	}
 	// If more events could be displayed, notify parent
+	let colFillThreshold = (availLen.value - SPACING) / (eventMajorSz.value + SPACING) * 2/3;
+	let full = cols.every(col => col.length >= colFillThreshold);
 	if (!full){
 		emit('event-req', startDate.value, endDate.value);
 	} else { // Send displayed event IDs to parent
-		emit('event-display', [...idToEvent.value.keys()], ID);
+		emit('event-display', [...map.keys()], ID);
 	}
 	//
 	return map;
-- 
cgit v1.2.3

