aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/TimeLine.vue236
1 files changed, 236 insertions, 0 deletions
diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
new file mode 100644
index 0000000..9dcf7f8
--- /dev/null
+++ b/src/components/TimeLine.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="touch-none" :width="width" :height="height"
+ @wheel.exact.prevent="onShift" @wheel.shift.exact.prevent="onZoom" ref="rootRef">
+ <svg :viewBox="`0 0 ${width} ${height}`">
+ <line stroke="yellow" stroke-width="2px" x1="50%" y1="0%" x2="50%" y2="100%"/>
+ <line v-for="n in ticks" :key="n"
+ :x1="width/2-8" y1="0" :x2="width/2+8" y2="0" stroke="yellow" stroke-width="1px"
+ :style="tickStyles(n)" class="animate-fadein"/>
+ <text fill="#606060" v-for="n in ticks" :key="n"
+ :x="width/2 + 12" y="0"
+ text-anchor="start" :style="tickLabelStyles(n)" class="text-sm animate-fadein">
+ {{Math.round(n * 100) / 100}}
+ </text>
+ </svg>
+</div>
+</template>
+
+<script setup lang="ts">
+import {ref, onMounted, nextTick} from 'vue';
+
+// Refs
+const rootRef = ref(null as HTMLElement | null);
+
+// Props
+const props = defineProps({
+ width: {type: Number, required: true},
+ height: {type: Number, required: true},
+});
+
+// For date range
+const minDate = -1000;
+const maxDate = 1000;
+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(minDate);
+const endDate = ref(maxDate);
+const SHIFT_INC = 0.3; // Proportion of timeline length to shift by
+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
+
+// For initialisation
+function initTicks(): number[] {
+ let len = maxDate - minDate;
+ // Find smallest usable scale
+ for (let i = 0; i < scales.length; i++){
+ if (props.height * (scales[i] / len) > MIN_TICK_SEP){
+ scaleIdx = i;
+ } else {
+ break;
+ }
+ }
+ // Get tick values
+ let newTicks = [];
+ let next = minDate;
+ while (next <= maxDate){
+ newTicks.push(next);
+ next += scales[scaleIdx];
+ }
+ ticks.value = newTicks;
+ //
+ updateTicks();
+}
+onMounted(() => nextTick(initTicks));
+
+// Adds extra ticks outside the visible area (which can transition in upon shift/zoom),
+// and adds/removes ticks upon a scale change
+function updateTicks(){
+ let len = endDate.value - startDate.value;
+ let shiftChg = len * SHIFT_INC;
+ 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
+ let tempTicks = [];
+ let next = Math.ceil((Math.max(minDate, startDate.value - shiftChg) - minDate) / scale);
+ let last = Math.floor((Math.min(maxDate, endDate.value + shiftChg) - minDate) / scale);
+ while (next <= last){
+ tempTicks.push(minDate + next * scale);
+ next++;
+ }
+ // Get hidden ticks that might transition in on a zoom action
+ let tempTicks2 = [];
+ let tempTicks3 = [];
+ if (scaleIdx > 0){
+ scale = scales[scaleIdx-1];
+ let first = Math.ceil((Math.max(minDate, startDate.value - scaleChg) - minDate) / scale);
+ while ((minDate + first * scale) < tempTicks[0]){
+ tempTicks2.push(minDate + first * scale);
+ first++;
+ }
+ let last = Math.floor((Math.min(maxDate, endDate.value + scaleChg) - minDate) / scale);
+ let next = Math.floor((tempTicks[tempTicks.length - 1] - minDate) / scale) + 1;
+ while (next <= last){
+ tempTicks3.push(minDate + next * scale);
+ next++;
+ }
+ }
+ //
+ ticks.value = [].concat(tempTicks2, tempTicks, tempTicks3);
+}
+// Performs a shift action
+function shiftTimeline(n: number){
+ let len = endDate.value - startDate.value;
+ let chg = len * n;
+ if (startDate.value + chg < minDate){
+ if (startDate.value == minDate){
+ console.log('Reached minDate limit')
+ return;
+ }
+ chg = minDate - startDate.value;
+ startDate.value = minDate;
+ endDate.value += chg;
+ } else if (endDate.value + chg > maxDate){
+ if (endDate.value == maxDate){
+ console.log('Reached maxDate limit')
+ return;
+ }
+ chg = maxDate - endDate.value;
+ endDate.value = maxDate;
+ startDate.value += chg;
+ } else {
+ startDate.value += chg;
+ endDate.value += chg;
+ }
+ updateTicks();
+}
+// Performs a zoom action
+function zoomTimeline(frac: number){
+ let oldLen = endDate.value - startDate.value;
+ let newLen = oldLen * frac;
+ // Possibly change the scale
+ let tickDiff = props.height * (scales[scaleIdx] / newLen);
+ if (tickDiff < MIN_TICK_SEP){
+ if (scaleIdx == 0){
+ console.log('INFO: Reached zoom out limit');
+ return;
+ } else {
+ scaleIdx--;
+ }
+ } else {
+ if (scaleIdx < scales.length - 1){
+ if (tickDiff > MIN_TICK_SEP * scales[scaleIdx] / scales[scaleIdx + 1]){
+ scaleIdx++;
+ }
+ } else {
+ if (newLen / tickDiff < MIN_LAST_TICKS){
+ console.log('INFO: Reached zoom in limit');
+ return;
+ }
+ }
+ }
+ // Get new bounds
+ let endChg = (newLen - oldLen) / 2;
+ if (startDate.value - endChg < minDate){
+ let tempChg = startDate.value - minDate;
+ if (endDate.value + endChg + tempChg > maxDate){
+ if (startDate.value == minDate && endDate.value == maxDate){
+ console.log('Reached upper scale limit');
+ return;
+ } else {
+ startDate.value = minDate;
+ endDate.value = maxDate;
+ }
+ } else {
+ startDate.value = minDate;
+ endDate.value += endChg + tempChg;
+ }
+ } else if (endDate.value + endChg > maxDate){
+ let tempChg = maxDate - endDate.value;
+ if (startDate.value - endChg - tempChg < minDate){
+ if (startDate.value == minDate && endDate.value == maxDate){
+ console.log('Reached upper scale limit');
+ return;
+ } else {
+ startDate.value = minDate;
+ endDate.value = maxDate;
+ }
+ } else {
+ startDate.value -= endChg + tempChg
+ endDate.value = maxDate;
+ }
+ } else {
+ startDate.value -= endChg;
+ endDate.value += endChg;
+ }
+ //
+ updateTicks();
+}
+
+// For mouse/etc handling
+function onShift(evt: WheelEvent){
+ if (evt.deltaY > 0){
+ shiftTimeline(SHIFT_INC);
+ } else {
+ shiftTimeline(-SHIFT_INC);
+ }
+}
+function onZoom(evt: WheelEvent){
+ if (evt.deltaY > 0){
+ zoomTimeline(ZOOM_RATIO);
+ } else {
+ zoomTimeline(1/ZOOM_RATIO);
+ }
+}
+
+// Styles
+function tickStyles(tick: number){
+ return {
+ transform: `translate(0, ${(tick - startDate.value) / (endDate.value - startDate.value) * props.height}px)`,
+ transition: 'transform 300ms linear',
+ }
+}
+function tickLabelStyles(tick: number){
+ return {
+ transform: `translate(0, ${(tick - startDate.value) / (endDate.value - startDate.value) * props.height + 5}px)`,
+ transition: 'transform 300ms linear',
+ }
+}
+</script>
+
+<style>
+.animate-fadein {
+ animation-name: fadein;
+ animation-duration: 300ms;
+ animation-timing-function: ease-in;
+}
+@keyframes fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+</style>