diff options
| -rw-r--r-- | src/App.vue | 45 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 236 |
2 files changed, 278 insertions, 3 deletions
diff --git a/src/App.vue b/src/App.vue index f30339e..880a046 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,18 +13,57 @@ </icon-button> </div> <!-- Content area --> - <div class="grow min-h-0 relative bg-stone-800"> - <div class="text-stone-50 p-4">Content</div> + <div class="grow min-h-0 bg-stone-800" ref="contentAreaRef"> + <time-line :width="contentWidth" :height="contentHeight"/> </div> </div> </template> <script setup lang="ts"> -import {ref, computed, watch, onMounted, onUnmounted, nextTick} from 'vue'; +import {ref, onMounted, onUnmounted} from 'vue'; // Components +import TimeLine from './components/TimeLine.vue'; import IconButton from './components/IconButton.vue'; // Icons import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; +// Refs +const contentAreaRef = ref(null as HTMLElement | null); + +// For content sizing +const contentWidth = ref(0); +const contentHeight = ref(0); +function updateAreaDims(){ + let contentAreaEl = contentAreaRef.value!; + contentWidth.value = contentAreaEl.offsetWidth; + contentHeight.value = contentAreaEl.offsetHeight; +} +onMounted(updateAreaDims) + +// For resize handling +let lastResizeHdlrTime = 0; // Used to throttle resize handling +let afterResizeHdlr = 0; // Used to trigger handler after ending a run of resize events +async function onResize(){ + // Handle event if not recently done + let handleResize = async () => { + updateAreaDims(); + }; + let currentTime = new Date().getTime(); + if (currentTime - lastResizeHdlrTime > 200){ + lastResizeHdlrTime = currentTime; + await handleResize(); + lastResizeHdlrTime = new Date().getTime(); + } + // Setup a handler to execute after ending a run of resize events + clearTimeout(afterResizeHdlr); + afterResizeHdlr = setTimeout(async () => { + afterResizeHdlr = 0; + await handleResize(); + lastResizeHdlrTime = new Date().getTime(); + }, 200); // If too small, touch-device detection when swapping to/from mobile-mode gets unreliable +} +onMounted(() => window.addEventListener('resize', onResize)); +onUnmounted(() => window.removeEventListener('resize', onResize)); + </script> 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> |
