aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/BaseLine.vue27
-rw-r--r--src/components/HelpModal.vue26
-rw-r--r--src/components/InfoModal.vue21
-rw-r--r--src/components/SCollapsible.vue14
-rw-r--r--src/components/SearchModal.vue38
-rw-r--r--src/components/SettingsModal.vue32
-rw-r--r--src/components/TimeLine.vue192
7 files changed, 262 insertions, 88 deletions
diff --git a/src/components/BaseLine.vue b/src/components/BaseLine.vue
index f3a9e93..3cca6d7 100644
--- a/src/components/BaseLine.vue
+++ b/src/components/BaseLine.vue
@@ -1,11 +1,13 @@
<template>
<div class="flex relative" :class="{'flex-col': vert}"
:style="{color: store.color.text}" ref="rootRef">
+ <!-- Time periods -->
<div v-for="p in periods" :key="p.label" class="relative" :style="periodStyles(p)">
<div :style="labelStyles">{{p.label}}</div>
<div v-if="props.vert" class="absolute bottom-0 w-full h-6"
style="background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1))"></div>
</div>
+ <!-- Timeline spans -->
<TransitionGroup name="fade" v-if="mounted">
<div v-for="(state, idx) in timelines" :key="state.id" class="absolute" :style="spanStyles(idx)"></div>
</TransitionGroup>
@@ -17,20 +19,19 @@ import {ref, computed, onMounted, PropType, Ref} from 'vue';
import {MIN_DATE, MAX_DATE, SCALES, MONTH_SCALE, DAY_SCALE, WRITING_MODE_HORZ, TimelineState, stepDate} from '../lib';
import {useStore} from '../store';
-// Refs
const rootRef = ref(null as HTMLElement | null);
-// Global store
const store = useStore();
-// Props
const props = defineProps({
vert: {type: Boolean, required: true},
timelines: {type: Object as PropType<TimelineState[]>, required: true},
});
-// Static time periods
+// ========== Static time periods ==========
+
type Period = {label: string, len: number};
+
const periods: Ref<Period[]> = ref([
{label: 'Pre Hadean', len: 8},
{label: 'Hadean', len: 1},
@@ -39,20 +40,24 @@ const periods: Ref<Period[]> = ref([
{label: 'Phanerozoic', len: 0.5},
]);
-// For skipping transitions on startup
+// ========== For skipping transitions on startup ==========
+
const skipTransition = ref(true);
onMounted(() => setTimeout(() => {skipTransition.value = false}, 100));
-// For size and mount-status tracking
+// ========== For size and mount-status tracking ==========
+
const width = ref(0);
const height = ref(0);
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){
@@ -64,7 +69,8 @@ const resizeObserver = new ResizeObserver((entries) => {
});
onMounted(() => resizeObserver.observe(rootRef.value as HTMLElement));
-// Styles
+// ========== For styles ==========
+
function periodStyles(period: Period){
return {
backgroundColor: store.color.bgDark2,
@@ -73,17 +79,20 @@ function periodStyles(period: Period){
overflow: 'hidden',
};
}
+
const labelStyles = computed((): Record<string, string> => ({
transform: props.vert ? 'rotate(90deg) translate(50%, 0)' : 'none',
whiteSpace: 'nowrap',
width: props.vert ? '40px' : 'auto',
padding: props.vert ? '0' : '4px',
}));
+
function spanStyles(stateIdx: number){
const state = props.timelines[stateIdx];
let styles: Record<string,string>;
const availLen = props.vert ? height.value : width.value;
const availBreadth = props.vert ? width.value : height.value;
+
// Determine start/end date
if (state.startOffset == null || state.endOffset == null || state.scaleIdx == null){
return {display: 'none'};
@@ -95,6 +104,7 @@ function spanStyles(stateIdx: number){
stepDate(start, 1, {forward: false, count: Math.floor(state.startOffset * scale), inplace: true});
stepDate(end, 1, {count: Math.floor(state.endOffset * scale), inplace: true});
}
+
// Determine positions in full timeline (only uses year information)
let startFrac = (start.year - MIN_DATE.year) / (MAX_DATE.year - MIN_DATE.year);
let lenFrac = (end.year - start.year) / (MAX_DATE.year - MIN_DATE.year);
@@ -104,10 +114,11 @@ function spanStyles(stateIdx: number){
lenPx = 3;
startPx -= Math.max(0, startPx + lenPx - availLen);
}
+
// Account for multiple timelines
const breadth = availBreadth / props.timelines.length;
const sidePx = breadth * stateIdx;
- //
+
if (props.vert){
styles = {
top: startPx + 'px',
diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue
index 34a8bd3..ab1c73d 100644
--- a/src/components/HelpModal.vue
+++ b/src/components/HelpModal.vue
@@ -9,6 +9,7 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
</p>
<div class="flex flex-col gap-2 p-2">
+ <!-- Licensing and Credits -->
<s-collapsible :class="scClasses">
<template #summary="slotProps">
<div :class="scSummaryClasses">
@@ -70,6 +71,8 @@
</div>
</template>
</s-collapsible>
+
+ <!-- FAQs -->
<s-collapsible :class="scClasses">
<template #summary="slotProps">
<div :class="scSummaryClasses">
@@ -100,36 +103,35 @@ import CloseIcon from './icon/CloseIcon.vue';
import DownIcon from './icon/DownIcon.vue';
import {useStore} from '../store';
-// Refs
const rootRef = ref(null as HTMLDivElement | null)
const closeRef = ref(null as typeof CloseIcon | null);
-// Global store
const store = useStore();
-// Props + events
const emit = defineEmits(['close']);
-// Event handlers
+// ========== Event handlers ==========
+
function onClose(evt: Event){
if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
emit('close');
}
}
-// Styles
+// ========== For styles ==========
+
+const scClasses = 'border border-stone-400 rounded';
+const scSummaryClasses = 'relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-yellow-200 md:p-2';
+const downIconClasses = 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
+const downIconExpandedClasses = computed(() => downIconClasses + ' -rotate-90');
+const contentClasses = 'py-2 px-2 text-sm md:text-base';
+
const styles = computed(() => ({
backgroundColor: store.color.bgAlt,
borderRadius: store.borderRadius + 'px',
}));
+
const aStyles = computed(() => ({
color: store.color.altDark,
}));
-
-// Classes
-const scClasses = 'border border-stone-400 rounded';
-const scSummaryClasses = 'relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-yellow-200 md:p-2';
-const downIconClasses = 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
-const downIconExpandedClasses = computed(() => downIconClasses + ' -rotate-90');
-const contentClasses = 'py-2 px-2 text-sm md:text-base';
</script>
diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue
index b9aeb74..6b32ed0 100644
--- a/src/components/InfoModal.vue
+++ b/src/components/InfoModal.vue
@@ -7,6 +7,7 @@
<h1 class="text-center text-xl font-bold pt-2 pb-1 md:text-2xl md:pt-3 md:pb-1">
{{event.title}}
</h1>
+
<!-- Time Display -->
<div class="text-center text-sm md:text-base">
Start: {{datesDisplayStrs[0]}}
@@ -14,11 +15,13 @@
<div v-if="datesDisplayStrs[1] != null" class="text-center text-sm md:text-base">
End: {{datesDisplayStrs[1]}}
</div>
+
<!-- Main content -->
<div class="border-t border-stone-400 p-2 md:p-3">
<div v-if="eventInfo.imgInfo != null" class="mt-1 mr-2 md:mb-2 md:mr-4 md:float-left">
<!-- Image -->
<a :href="eventInfo.imgInfo.url" target="_blank" class="block w-fit mx-auto" :style="imgStyles"></a>
+
<!-- Image Source -->
<s-collapsible class="text-sm text-center w-fit max-w-full md:max-w-[200px] mx-auto">
<template v-slot:summary="slotProps">
@@ -67,6 +70,8 @@
</template>
</s-collapsible>
</div>
+
+ <!-- Description -->
<div v-if="eventInfo.desc != null">{{eventInfo.desc}}</div>
<div v-else class="text-center text-stone-500 text-sm">(No description found)</div>
<div v-if="event.id > 0" class="text-sm text-right">
@@ -88,26 +93,27 @@ import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
import {EventInfo, boundedDateToStr, getImagePath} from '../lib';
import {useStore} from '../store';
-// Refs
const rootRef = ref(null as HTMLDivElement | null);
const closeRef = ref(null as typeof CloseIcon | null);
-// Global store
const store = useStore();
-// Props + events
const props = defineProps({
eventInfo: {type: Object as PropType<EventInfo>, required: true},
});
+
const emit = defineEmits(['close']);
-// For data display
+// ========== For data display ==========
+
const event = computed(() => props.eventInfo.event)
+
const datesDisplayStrs = computed(() => {
let startStr = boundedDateToStr(event.value.start, event.value.startUpper);
let endStr = event.value.end == null ? null : boundedDateToStr(event.value.end, event.value.endUpper);
return [startStr, endStr];
});
+
function licenseToUrl(license: string){
license = license.toLowerCase().replaceAll('-', ' ');
if (license == 'cc0'){
@@ -139,19 +145,22 @@ function licenseToUrl(license: string){
}
}
-// Close handling
+// ========== Close handling ==========
+
function onClose(evt: Event){
if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
emit('close');
}
}
-// Styles
+// ========== For styles ==========
+
const styles = computed(() => ({
backgroundColor: store.color.bgAlt,
borderRadius: store.borderRadius + 'px',
overflow: 'visible auto',
}));
+
const imgStyles = computed(() => {
return {
width: '200px',
diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue
index 39b4283..9008cbc 100644
--- a/src/components/SCollapsible.vue
+++ b/src/components/SCollapsible.vue
@@ -14,15 +14,17 @@
<script setup lang="ts">
import {ref, computed, watch} from 'vue';
-// Props + events
const props = defineProps({
modelValue: {type: Boolean, default: false}, // For using v-model on the component
});
+
const emit = defineEmits(['update:modelValue', 'open']);
-// For open status
+// ========== For open status ==========
+
const open = ref(false);
watch(() => props.modelValue, (newVal) => {open.value = newVal})
+
function onClick(){
open.value = !open.value;
emit('update:modelValue', open.value);
@@ -31,10 +33,12 @@ function onClick(){
}
}
-// Styles
+// ========== For styles ==========
+
const styles = computed(() => ({
overflow: open.value ? 'visible' : 'hidden',
}));
+
const contentStyles = computed(() => ({
overflow: 'hidden',
opacity: open.value ? '1' : '0',
@@ -43,18 +47,20 @@ const contentStyles = computed(() => ({
transitionTimingFunction: 'ease-in-out',
}));
-// Open/close transitions
function onEnter(el: HTMLDivElement){
el.style.maxHeight = el.scrollHeight + 'px';
}
+
function onAfterEnter(el: HTMLDivElement){
el.style.maxHeight = 'none';
// Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short
}
+
function onBeforeLeave(el: HTMLDivElement){
el.style.maxHeight = el.scrollHeight + 'px';
el.offsetWidth; // Triggers reflow
}
+
function onLeave(el: HTMLDivElement){
el.style.maxHeight = '0';
}
diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue
index be8df51..543c7f3 100644
--- a/src/components/SearchModal.vue
+++ b/src/components/SearchModal.vue
@@ -2,12 +2,17 @@
<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex"
:style="styles">
+ <!-- Input field -->
<input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef"
@keyup.enter="onSearch" @keyup.esc="onClose"
@input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/>
+
+ <!-- Search button -->
<div class="p-1 hover:cursor-pointer">
<search-icon @click.stop="onSearch" class="w-8 h-8"/>
</div>
+
+ <!-- Search suggestions -->
<div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles">
<div v-for="(sugg, idx) of searchSuggs" :key="sugg"
:style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}"
@@ -35,24 +40,24 @@ import {HistEvent, queryServer, EventInfoJson, jsonToEventInfo, SuggResponseJson
import {useStore} from '../store';
import {RBTree} from '../rbtree';
-// Refs
const rootRef = ref(null as HTMLDivElement | null);
const inputRef = ref(null as HTMLInputElement | null);
-// Global store
const store = useStore();
-// Props + events
const props = defineProps({
eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true},
titleToEvent: {type: Object as PropType<Map<string, HistEvent>>, required: true},
});
+
const emit = defineEmits(['search', 'close', 'info-click', 'net-wait', 'net-get']);
-// Search-suggestion data
+// ========== Search-suggestion data ==========
+
const searchSuggs = ref([] as string[]);
const hasMoreSuggs = ref(false);
const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text)
+
const suggDisplayStrings = computed((): [string, string, string][] => {
let result: [string, string, string][] = [];
let input = suggsInput.value;
@@ -71,15 +76,19 @@ const suggDisplayStrings = computed((): [string, string, string][] => {
}
return result;
});
+
const focusedSuggIdx = ref(null as null | number); // Index of a suggestion selected using the arrow keys
-// For server requests
+// ========== For server requests ==========
+
const lastReqTime = ref(0);
const pendingReqParams = ref(null as null | URLSearchParams); // Holds data for latest request to make
const pendingReqInput = ref(''); // Holds the user input associated with pendingReqData
const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for making a request despite a previous one still waiting
+
async function onInput(){
let input = inputRef.value!;
+
// Check for empty input
if (input.value.length == 0){
searchSuggs.value = [];
@@ -87,6 +96,7 @@ async function onInput(){
focusedSuggIdx.value = null;
return;
}
+
// Create URL params
let urlParams = new URLSearchParams({
type: 'sugg',
@@ -100,6 +110,7 @@ async function onInput(){
if (store.reqImgs){
urlParams.append('imgonly', 'true');
}
+
// Code for querying server
pendingReqParams.value = urlParams;
pendingReqInput.value = input.value;
@@ -119,6 +130,7 @@ async function onInput(){
focusedSuggIdx.value = null;
}
};
+
// Query server, delaying/skipping if a request was recently sent
let currentTime = new Date().getTime();
if (lastReqTime.value == 0){
@@ -139,7 +151,8 @@ async function onInput(){
}
}
-// For search events
+// ========== For search events ==========
+
function onSearch(){
if (focusedSuggIdx.value == null){
let input = inputRef.value!.value;
@@ -148,6 +161,7 @@ function onSearch(){
resolveSearch(searchSuggs.value[focusedSuggIdx.value]);
}
}
+
async function resolveSearch(eventTitle: string){
if (eventTitle == ''){
return;
@@ -156,6 +170,7 @@ async function resolveSearch(eventTitle: string){
if (Object.values(store.ctgs).some((b: boolean) => !b)){
visibleCtgs = Object.entries(store.ctgs).filter(([, enabled]) => enabled).map(([ctg, ]) => ctg);
}
+
// Check if the event data is already here
if (props.titleToEvent.has(eventTitle)){
let event = props.titleToEvent.get(eventTitle)!;
@@ -171,6 +186,7 @@ async function resolveSearch(eventTitle: string){
emit('search', event);
return;
}
+
// Query server for event
let urlParams = new URLSearchParams({type: 'info', event: eventTitle});
if (visibleCtgs != null){
@@ -194,23 +210,27 @@ async function resolveSearch(eventTitle: string){
}
}
-// More event handling
+// ========== More event handling ==========
+
function onClose(evt: Event){
if (evt.target == rootRef.value){
emit('close');
}
}
+
function onDownKey(){
if (focusedSuggIdx.value != null){
focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length;
}
}
+
function onUpKey(){
if (focusedSuggIdx.value != null){
focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length;
// The addition after '-1' is to avoid becoming negative
}
}
+
function onInfoIconClick(eventTitle: string){
emit('info-click', eventTitle);
}
@@ -218,7 +238,8 @@ function onInfoIconClick(eventTitle: string){
// Focus input on mount
onMounted(() => inputRef.value!.focus())
-// Styles
+// ========== For styles ==========
+
const styles = computed((): Record<string,string> => {
let br = store.borderRadius;
return {
@@ -226,6 +247,7 @@ const styles = computed((): Record<string,string> => {
borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
};
});
+
const suggContainerStyles = computed((): Record<string,string> => {
let br = store.borderRadius;
return {
diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue
index 04b5e76..bb1370e 100644
--- a/src/components/SettingsModal.vue
+++ b/src/components/SettingsModal.vue
@@ -5,6 +5,8 @@
<close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer" />
<h1 class="text-xl md:text-2xl font-bold text-center py-2" :class="borderBClasses">Settings</h1>
+
+ <!-- Categories -->
<div class="pb-2" :class="borderBClasses">
<h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Categories</h2>
<ul class="px-2 grid grid-cols-3">
@@ -24,6 +26,8 @@
@change="onSettingChg('ctgs.discovery')"/> Discovery </label> </li>
</ul>
</div>
+
+ <!-- Display settings -->
<div class="pb-2" :class="borderBClasses">
<h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Display</h2>
<div class="grid grid-cols-2">
@@ -49,6 +53,8 @@
</div>
</div>
</div>
+
+ <!-- Input settings -->
<div v-if="store.touchDevice == false" class="pb-2" :class="borderBClasses">
<h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Input</h2>
<div class="px-2">
@@ -72,10 +78,14 @@
<div class="my-auto text-right">{{store.zoomRatio}}</div>
</div>
</div>
+
+ <!-- Reset button -->
<s-button class="mx-auto my-2" :style="{color: store.color.text, backgroundColor: store.color.bg}"
@click="onReset">
Reset
</s-button>
+
+ <!-- Save indicator -->
<transition name="fade">
<div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndRef"> Saved </div>
</transition>
@@ -89,19 +99,18 @@ import SButton from './SButton.vue';
import CloseIcon from './icon/CloseIcon.vue';
import {useStore} from '../store';
-// Refs
const rootRef = ref(null as HTMLDivElement | null);
const closeRef = ref(null as typeof CloseIcon | null);
const saveIndRef = ref(null as HTMLDivElement | null);
-// Global store
const store = useStore();
-// Events
const emit = defineEmits(['close', 'change']);
-// Settings change handling
+// ========== Settings change handling ==========
+
const saved = ref(false); // Set to true after a setting is saved
+
const lastCtg = computed(() => { // When all but one category is disabled, names the remaining category
let enabledCtgs = Object.entries(store.ctgs).filter(([, enabled]) => enabled).map(([ctg, ]) => ctg);
if (enabledCtgs.length == 1){
@@ -110,7 +119,9 @@ const lastCtg = computed(() => { // When all but one category is disabled, names
return null;
}
});
+
let changedCtg: string | null = null; // Used to defer signalling of a category change until modal closes
+
function onSettingChg(option: string){
store.save(option);
if (option.startsWith('ctgs.')){
@@ -128,17 +139,20 @@ function onSettingChg(option: string){
el.classList.add('animate-flash-yellow');
}
}
+
function onResetOne(option: string){
store.resetOne(option);
onSettingChg(option);
}
+
function onReset(){
store.reset();
store.clear();
saved.value = false;
}
-// Close handling
+// ========== Close handling ==========
+
function onClose(evt: Event){
if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
emit('close');
@@ -148,11 +162,13 @@ function onClose(evt: Event){
}
}
-// Styles and classes
+// ========== For styling ==========
+
+const borderBClasses = 'border-b border-stone-400';
+const rLabelClasses = "w-fit hover:cursor-pointer hover:text-yellow-600"; // For reset-upon-click labels
+
const styles = computed(() => ({
backgroundColor: store.color.bgAlt,
borderRadius: store.borderRadius + 'px',
}));
-const borderBClasses = 'border-b border-stone-400';
-const rLabelClasses = "w-fit hover:cursor-pointer hover:text-yellow-600"; // For reset-upon-click labels
</script>
diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index 073261c..ab37342 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -3,10 +3,14 @@
@pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp"
@pointercancel="onPointerUp" @pointerout="onPointerUp" @pointerleave="onPointerUp"
@wheel.exact="onWheel" @wheel.shift.exact="onShiftWheel">
+
+ <!-- Event count indicators -->
<template v-if="store.showEventCounts">
<div v-for="[tickIdx, count] in tickToCount.entries()" :key="ticks[tickIdx].date.toInt()"
:style="countDivStyles(tickIdx, count)" class="absolute animate-fadein"></div>
</template>
+
+ <!-- SVG area -->
<svg :viewBox="`0 0 ${width} ${height}`" class="relative z-10" ref="svgRef">
<defs>
<linearGradient id="eventLineGradient">
@@ -14,6 +18,7 @@
<stop offset="95%" stop-color="gold"/>
</linearGradient>
</defs>
+
<!-- Event lines (dashed line indicates imprecise start date) -->
<template v-if="store.showEventLines">
<line v-for="id in eventLines.keys()" :key="id"
@@ -24,14 +29,17 @@
<!-- Note: With a fully vertical or horizontal line, nothing gets displayed -->
<!-- Note: Can't use :x2="1" with scaling in :style="", as it makes dashed-lines non-uniform -->
</template>
+
<!-- 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 -->
<line v-for="tick in ticks" :key="tick.date.toInt()"
:x1="tick.x1" :y1="tick.y1" :x2="tick.x2" :y2="tick.y2"
:stroke="store.color.alt" :stroke-width="`${tick.width}px`"
:style="tickStyles(tick)" class="animate-fadein"
:class="{'max-tick': tick.bound == 'max', 'min-tick': tick.bound == 'min'}"/>
+
<!-- Tick labels -->
<template v-for="tick, idx in ticks" :key="tick.date.toInt()">
<text v-if="tick.major || store.showMinorTicks"
@@ -42,10 +50,12 @@
</text>
</template>
</svg>
+
<!-- Movement fail indicators -->
<div class="absolute z-20" :style="failDivStyles(true)" ref="minFailRef"></div>
<div class="absolute z-20" :style="failDivStyles(false)" ref="maxFailRef"></div>
<div class="absolute top-0 left-0 w-full h-full z-20" ref="bgFailRef"></div>
+
<!-- Events -->
<div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)">
<!-- Image -->
@@ -56,11 +66,13 @@
{{idToEvent.get(id)!.title}}
</div>
</div>
+
<!-- Timeline position label -->
<div class="absolute top-1 left-2 z-20 text-lg" :class="[current ? 'text-yellow-300' : 'text-stone-50']"
style="text-shadow: 0px 0px 5px black">
{{timelinePosStr}}
</div>
+
<!-- Buttons -->
<icon-button v-if="closeable" :size="30" class="absolute top-2 right-2 z-20"
:style="{color: store.color.text, backgroundColor: store.color.altDark2}"
@@ -72,11 +84,10 @@
<script setup lang="ts">
import {ref, onMounted, onUnmounted, computed, watch, PropType, Ref} from 'vue';
-// Components
+
import IconButton from './IconButton.vue';
-// Icons
import CloseIcon from './icon/CloseIcon.vue';
-// Other
+
import {WRITING_MODE_HORZ, MIN_DATE, MAX_DATE, MONTH_SCALE, DAY_SCALE, SCALES, MONTH_NAMES, MIN_CAL_DATE,
getDaysInMonth, HistDate, stepDate, getScaleRatio, getNumSubUnits, getUnitDiff,
getEventPrecision, dateToUnit, dateToScaleDate,
@@ -85,17 +96,14 @@ import {WRITING_MODE_HORZ, MIN_DATE, MAX_DATE, MONTH_SCALE, DAY_SCALE, SCALES, M
import {useStore} from '../store';
import {RBTree} from '../rbtree';
-// Refs
const rootRef: Ref<HTMLElement | null> = ref(null);
const svgRef: Ref<HTMLElement | null> = ref(null);
const minFailRef: Ref<HTMLElement | null> = ref(null);
const maxFailRef: Ref<HTMLElement | null> = ref(null);
const bgFailRef: Ref<HTMLElement | null> = ref(null);
-// Global store
const store = useStore();
-// Props + events
const props = defineProps({
vert: {type: Boolean, required: true},
closeable: {type: Boolean, default: true},
@@ -106,20 +114,24 @@ const props = defineProps({
searchTarget: {type: Object as PropType<[null | HistEvent, boolean]>, required: true},
reset: {type: Boolean, required: true},
});
+
const emit = defineEmits(['close', 'state-chg', 'event-display', 'info-click']);
-// For size tracking
+// ========== For size tracking ==========
+
const width = ref(0);
const height = ref(0);
const availLen = computed(() => props.vert ? height.value : width.value);
const availBreadth = computed(() => props.vert ? width.value : height.value);
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){
@@ -135,13 +147,16 @@ const resizeObserver = new ResizeObserver((entries) => {
});
onMounted(() => resizeObserver.observe(rootRef.value as HTMLElement));
-//
+// ========== Computed values used for layout ==========
+
const eventWidth = computed(() => store.eventImgSz);
const eventHeight = computed(() => store.eventImgSz + store.eventLabelHeight);
const eventMajorSz = computed(() => props.vert ? eventHeight.value : eventWidth.value);
const eventMinorSz = computed(() => props.vert ? eventWidth.value : eventHeight.value)
+
const sideMainline = computed( // True if unable to fit mainline in middle with events on both sides
() => availBreadth.value < store.mainlineBreadth + (eventMinorSz.value + store.spacing * 2) * 2);
+
const mainlineOffset = computed(() => { // Distance from mainline-area line to left/top of display area
if (!sideMainline.value){
return availBreadth.value / 2 - store.mainlineBreadth /2 + store.largeTickLen / 2;
@@ -151,16 +166,20 @@ const mainlineOffset = computed(() => { // Distance from mainline-area line to l
}
});
-// Timeline data
+// ========== Timeline data ==========
+
const ID = props.initialState.id as number;
+
const startDate = ref(props.initialState.startDate); // Earliest date in scale to display
const endDate = ref(props.initialState.endDate); // Latest date in scale to display (may equal startDate)
const startOffset = ref(store.defaultEndTickOffset); // Fraction of a scale unit before startDate to show
// Note: Without this, the timeline can only move if the distance is over one unit, which makes dragging awkward,
// can cause unexpected jumps when zooming, and limits display when a unit has many ticks on the next scale
const endOffset = ref(store.defaultEndTickOffset);
+
const scaleIdx = ref(0); // Index of current scale in SCALES
const scale = computed(() => SCALES[scaleIdx.value])
+
const hasMinorScale = computed(() => { // If true, display subset of ticks of next lower scale
let yearlyScaleOnly = startDate.value.isEarlier(MIN_CAL_DATE);
if (scale.value == DAY_SCALE || yearlyScaleOnly && scale.value == 1){
@@ -178,6 +197,8 @@ const hasMinorScale = computed(() => { // If true, display subset of ticks of ne
});
const minorScaleIdx = computed(() => scaleIdx.value + (hasMinorScale.value ? 1 : 0));
const minorScale = computed(() => SCALES[minorScaleIdx.value]);
+
+// Start/end date/offset initialisation
if (props.initialState.startOffset != null){
startOffset.value = props.initialState.startOffset as number;
}
@@ -189,11 +210,13 @@ if (props.initialState.scaleIdx != null){
} else {
onMounted(initScale);
}
-//
-function initScale(){ // Initialises to smallest usable scale
+
+// Initialises to smallest usable scale
+function initScale(){
let yearlyScaleOnly = startDate.value.isEarlier(MIN_CAL_DATE);
let yearDiff = startDate.value.getYearDiff(endDate.value);
let monthDiff = startDate.value.getMonthDiff(endDate.value);
+
// Get largest scale with units no larger than startDate-to-endDate range
let idx = 0;
if (yearDiff > 0){
@@ -208,6 +231,7 @@ function initScale(){ // Initialises to smallest usable scale
} else {
idx = SCALES.findIndex(s => s == DAY_SCALE);
}
+
// Check for usable smaller scale
while (SCALES[idx] > 1){
let nextScale = SCALES[idx + 1];
@@ -232,26 +256,28 @@ function initScale(){ // Initialises to smallest usable scale
}
}
}
- //
+
scaleIdx.value = idx;
onStateChg();
}
-// Tick data
+// ========== Tick data ==========
+
const tickLabelMargin = computed(() => props.vert ? 20 : 30); // Distance from label to mainline
const tickLabelWidth = computed(() => store.mainlineBreadth - store.largeTickLen / 2 - tickLabelMargin.value);
+
class Tick {
date: HistDate;
major: boolean; // False if tick is on the minor scale
offset: number; // Distance from start of visible timeline, in major units
bound: null | 'min' | 'max'; // Indicates MIN_DATE or MAX_DATE tick
- // SVG attributes
+
x1: number;
y1: number;
x2: number;
y2: number;
width: number;
- //
+
constructor(date: HistDate, major: boolean, offset: number, bound=null as null | 'min' | 'max'){
this.date = date;
this.major = major;
@@ -272,7 +298,9 @@ class Tick {
}
}
}
-function getNumDisplayUnits({inclOffsets=true} = {}): number { // Get num major units in display range
+
+// Gets num major units in display range
+function getNumDisplayUnits({inclOffsets=true} = {}): number {
let unitDiff = Math.ceil(getUnitDiff(startDate.value, endDate.value, scale.value));
// Note: Rounding up due to cases like 1 AD to 10 AD with 10-year scale
if (inclOffsets){
@@ -280,8 +308,9 @@ function getNumDisplayUnits({inclOffsets=true} = {}): number { // Get num major
}
return unitDiff;
}
+
+// For a major unit, returns an array specifying minor ticks to show
function getMinorTicks(date: HistDate, scaleIdx: number, majorUnitSz: number, majorOffset: number): Tick[] {
- // For a major unit, returns an array specifying minor ticks to show
if (!hasMinorScale.value){
return [];
}
@@ -290,6 +319,7 @@ function getMinorTicks(date: HistDate, scaleIdx: number, majorUnitSz: number, ma
let minorUnitSz = majorUnitSz / numMinorUnits;
let minStep = Math.ceil(store.minTickSep / minorUnitSz);
let stepFrac = numMinorUnits / Math.floor(numMinorUnits / minStep);
+
// Iterate through fractional indexes, using rounded differences to step dates
let idxFrac = stepFrac;
let idx = Math.floor(idxFrac);
@@ -303,6 +333,7 @@ function getMinorTicks(date: HistDate, scaleIdx: number, majorUnitSz: number, ma
}
return minorTicks;
}
+
const ticks = computed((): Tick[] => {
let ticks: Tick[] = [];
if (!mounted.value){
@@ -310,6 +341,7 @@ const ticks = computed((): Tick[] => {
}
let numUnits = getNumDisplayUnits();
let majorUnitSz = availLen.value / numUnits;
+
// Get before-startDate ticks (including start-offset ticks and hidden ticks)
let panUnits = Math.floor(numUnits * store.scrollRatio); // Potential shift distance upon a pan action
let date = startDate.value;
@@ -326,6 +358,7 @@ const ticks = computed((): Tick[] => {
ticks.push(new Tick(date, true, startOffset.value - (i + 1)));
}
ticks.reverse();
+
// Get startDate-to-endDate ticks
date = startDate.value.clone();
let numMajorUnits = getNumDisplayUnits({inclOffsets: false});
@@ -346,6 +379,7 @@ const ticks = computed((): Tick[] => {
ticks.push(...minorTicks);
date = stepDate(date, scale.value);
}
+
// Get after-endDate ticks (including end-offset ticks and hidden ticks)
let endDateOffset = ticks[ticks.length - 1].offset;
for (let i = 0; i < panUnits + Math.ceil(endOffset.value); i++){
@@ -359,6 +393,7 @@ const ticks = computed((): Tick[] => {
date = stepDate(date, scale.value);
ticks.push(new Tick(date, true, endDateOffset + (i + 1)));
}
+
// Get hidden ticks that might transition in after zooming
let ticksBefore: Tick[] = [];
let ticksAfter: Tick[] = [];
@@ -391,12 +426,14 @@ const ticks = computed((): Tick[] => {
}
}
}
- //
+
ticks = [...ticksBefore, ...ticks, ...ticksAfter];
return ticks;
});
-const firstIdx = computed((): number => { // Index of first major tick after which events are visible (-1 if none)
- // Looks for a first visible major tick, and uses a preceding tick if present
+
+// Index of first major tick after which events are visible (-1 if none)
+const firstIdx = computed((): number => {
+ // Look for a first visible major tick, and uses a preceding tick if present
let idx = -1;
for (let i = 0; i < ticks.value.length; i++){
let tick = ticks.value[i];
@@ -410,9 +447,10 @@ const firstIdx = computed((): number => { // Index of first major tick after whi
}
return idx;
});
-const firstDate = computed(() => firstIdx.value < 0 ? startDate.value : ticks.value[firstIdx.value]!.date);
-const lastIdx = computed((): number => { // Index of last major tick before which events are visible (-1 if none)
- // Looks for a last visible major tick, and uses a following tick if present
+
+// Index of last major tick before which events are visible (-1 if none)
+const lastIdx = computed((): number => {
+ // Look for a last visible major tick, and uses a following tick if present
let numUnits = getNumDisplayUnits();
let idx = -1;
for (let i = ticks.value.length - 1; i >= 0; i--){
@@ -427,7 +465,10 @@ const lastIdx = computed((): number => { // Index of last major tick before whic
}
return idx;
});
+
+const firstDate = computed(() => firstIdx.value < 0 ? startDate.value : ticks.value[firstIdx.value]!.date);
const lastDate = computed(() => lastIdx.value < 0 ? endDate.value : ticks.value[lastIdx.value]!.date);
+
const startIsFirstVisible = computed(() => {
if (ticks.value.length == 0){
return true;
@@ -435,6 +476,7 @@ const startIsFirstVisible = computed(() => {
return ticks.value.find((tick: Tick) => tick.offset >= 0)!.date.equals(startDate.value);
}
});
+
const endIsLastVisible = computed(() => {
if (ticks.value.length == 0){
return true;
@@ -444,7 +486,8 @@ const endIsLastVisible = computed(() => {
}
});
-// For displayed events
+// ========== For displayed events ==========
+
function dateToOffset(date: HistDate){ // Assumes 'date' is >=firstDate and <=lastDate
// Find containing major tick
let tickIdx = firstIdx.value;
@@ -457,6 +500,7 @@ function dateToOffset(date: HistDate){ // Assumes 'date' is >=firstDate and <=la
}
}
}
+
// Get offset within unit
const tick = ticks.value[tickIdx];
if (!hasMinorScale.value){
@@ -470,7 +514,9 @@ function dateToOffset(date: HistDate){ // Assumes 'date' is >=firstDate and <=la
return tick.offset + getUnitDiff(tick.date, date, minorScale.value) / getNumSubUnits(tick.date, scaleIdx.value);
}
}
+
const idToEvent: Ref<Map<number, HistEvent>> = ref(new Map()); // Maps visible event IDs to HistEvents
+
function updateIdToEvent(){
let map: Map<number, HistEvent> = new Map();
// Find events to display
@@ -493,15 +539,18 @@ function updateIdToEvent(){
}
watch(() => props.eventTree, updateIdToEvent);
watch(ticks, updateIdToEvent);
+
const idToPos: Ref<Map<number, [number, number, number, number]>> = ref(new Map()); // Maps event IDs to x/y/w/h
const idsToSkipTransition: Ref<Set<number>> = ref(new Set()); // Used to prevent events moving across mainline
type LineCoords = [number, number, number, number]; // x, y, length, angle
const eventLines: Ref<Map<number, LineCoords>> = ref(new Map()); // Maps event ID to event line data
+
function getEventLayout(): Map<number, [number, number, number, number]> {
let map: Map<number, [number, number, number, number]> = new Map();
if (!mounted.value){
return map;
}
+
// Determine columns to place event elements in (or rows if !props.vert)
let cols: [number, number][][] = []; // For each column, for each laid out event, stores an ID and pixel offset
let colOffsets: number[] = []; // Stores the pixel offset of each column
@@ -541,6 +590,7 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
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 MAX_ANGLE = 30 / 180 * Math.PI; // Max event-line angle difference (radians) from perpendicular-to-mainline
@@ -560,6 +610,7 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
// Get preferred offset in column
let pxOffset = dateToOffset(event.start) / numUnits * availLen.value - eventMajorSz.value / 2;
let targetOffset = Math.max(Math.min(pxOffset, maxOffset), minOffset);
+
// 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
@@ -570,6 +621,7 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
let bestOffset: number | null = null; // Best offset found so far
let bestIdx: number | null = null; // Index of insertion for bestOffset
let colMainlineDist = Math.abs(colOffsets[colIdx] - mainlineOffset.value);
+
if (Math.atan2(Math.abs(pxOffset - targetOffset), colMainlineDist) > MAX_ANGLE){
// Invalid angle, skip column
} else if (cols[colIdx].length == 0){ // Check for empty column
@@ -634,10 +686,12 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
}
}
}
+
// Add potential position
if (bestOffset != null){
positions.push([colIdx, bestIdx!, bestOffset]);
}
+
// Update colIdx
if (afterMainlineIdx == null){
colIdx -= 1;
@@ -649,6 +703,7 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
}
}
}
+
// Choose position with minimal distance
if (positions.length > 0){
let bestPos = positions[0]!;
@@ -660,6 +715,7 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
cols[bestPos[0]].splice(bestPos[1], 0, [event.id, bestPos[2]]);
}
}
+
// Add events to map
for (let colIdx = 0; colIdx < cols.length; colIdx++){
let minorOffset = colOffsets[colIdx];
@@ -673,8 +729,11 @@ function getEventLayout(): Map<number, [number, number, number, number]> {
}
return map;
}
-function updateLayout(){ // Updates idToPos and eventLines
+
+// Updates idToPos and eventLines
+function updateLayout(){
let map = getEventLayout();
+
// Check for events that cross mainline
for (let [eventId, [x, y, , ]] of map.entries()){
if (idToPos.value.has(eventId)){
@@ -686,6 +745,7 @@ function updateLayout(){ // Updates idToPos and eventLines
}
}
setTimeout(() => idsToSkipTransition.value.clear(), store.transitionDuration);
+
// Update idToPos // Note: For some reason, if the map is assigned directly, events won't consistently transition
let toDelete = [];
for (let eventId of idToPos.value.keys()){
@@ -702,6 +762,7 @@ function updateLayout(){ // Updates idToPos and eventLines
if (pendingSearch && idToPos.value.has(searchEvent.value!.id)){
pendingSearch = false;
}
+
// Update event lines
let newEventLines: Map<number, LineCoords> = new Map();
let numUnits = getNumDisplayUnits();
@@ -743,6 +804,7 @@ function updateLayout(){ // Updates idToPos and eventLines
newEventLines.set(id, [x, y, l, a]);
}
eventLines.value = newEventLines;
+
// Notify parent
emit('event-display', ID, [...map.keys()], firstDate.value, lastDate.value, minorScaleIdx.value);
}
@@ -750,9 +812,11 @@ watch(idToEvent, updateLayout);
watch(width, updateLayout);
watch(height, updateLayout);
-// For event-count indicators
+// ========== For event-count indicators ==========
+
+// Maps tick index to event count
const tickToCount = computed((): Map<number, number> => {
- let tickToCount: Map<number, number> = new Map(); // Maps tick index to event count
+ let tickToCount: Map<number, number> = new Map();
if (ticks.value.length == 0){
return tickToCount;
}
@@ -773,7 +837,8 @@ const tickToCount = computed((): Map<number, number> => {
return tickToCount;
});
-// For timeline position label
+// ========== For timeline position label ==========
+
const timelinePosStr = computed((): string => {
const date1 = startIsFirstVisible.value ? startDate.value : firstDate.value;
const date2 = endIsLastVisible.value ? endDate.value : lastDate.value;
@@ -792,7 +857,8 @@ const timelinePosStr = computed((): string => {
}
});
-// For panning/zooming
+// ========== For panning/zooming ==========
+
function panTimeline(scrollRatio: number){
let numUnits = getNumDisplayUnits();
let chgUnits = numUnits * scrollRatio;
@@ -800,6 +866,7 @@ function panTimeline(scrollRatio: number){
let newEnd = endDate.value.clone();
let [numStartSteps, numEndSteps, newStartOffset, newEndOffset] =
getMovedBounds(startOffset.value, endOffset.value, chgUnits, chgUnits);
+
if (scrollRatio > 0){
while (true){ // Incrementally update newStart and newEnd using getMovedBounds() result
if (newEnd.isEarlier(MAX_DATE, scale.value)){
@@ -875,6 +942,7 @@ function panTimeline(scrollRatio: number){
}
}
}
+
if (newStart.isEarlier(MIN_CAL_DATE, scale.value) && (scale.value == MONTH_SCALE || scale.value == DAY_SCALE)){
console.log('Unable to pan into dates where months/days are invalid');
return;
@@ -888,6 +956,7 @@ function panTimeline(scrollRatio: number){
startOffset.value = newStartOffset;
endOffset.value = newEndOffset;
}
+
function zoomTimeline(zoomRatio: number, ignorePointer=false){
if (zoomRatio > 1
&& startDate.value.equals(MIN_DATE, scale.value)
@@ -898,6 +967,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
}
let numUnits = getNumDisplayUnits();
let newNumUnits = numUnits * zoomRatio;
+
// Get tentative bound changes
let startChg: number;
let endChg: number;
@@ -918,6 +988,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
startChg = -(zoomCenter * (zoomRatio - 1));
endChg = (numUnits - zoomCenter) * (zoomRatio - 1)
}
+
// Get new bounds
let newStart = startDate.value.clone();
let newEnd = endDate.value.clone();
@@ -966,6 +1037,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
let oldUnitsPerNew = getScaleRatio(scale.value, newScale);
newStartOffset /= oldUnitsPerNew;
newEndOffset /= oldUnitsPerNew;
+
// Shift starting and ending points to align with new scale
let newStartSubUnits =
(scale.value == DAY_SCALE) ? getDaysInMonth(newStart.year, newStart.month) :
@@ -1016,7 +1088,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
newEnd.year = 1;
}
}
- //
+
scaleIdx.value -= 1;
}
} else { // If trying to zoom in
@@ -1042,6 +1114,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
&& newStartOffset % 1 > store.defaultEndTickOffset){
newStartOffset = store.defaultEndTickOffset;
}
+
// Update newEnd
newEndOffset *= newUnitsPerOld;
stepDate(newEnd, newScale, {count: Math.floor(newEndOffset), inplace: true});
@@ -1052,6 +1125,7 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
if (newEnd.equals(MAX_DATE, newScale) && newEndOffset % 1 > store.defaultEndTickOffset){
newEndOffset = store.defaultEndTickOffset;
}
+
// Account for zooming into sub-year dates before MIN_CAL_DATE
if (newStart.isEarlier(MIN_CAL_DATE, newScale) && (newScale == MONTH_SCALE || newScale == DAY_SCALE)){
console.log('Unable to zoom into range where month/day scale is invalid');
@@ -1062,15 +1136,16 @@ function zoomTimeline(zoomRatio: number, ignorePointer=false){
}
}
}
- //
+
startDate.value = newStart;
endDate.value = newEnd;
startOffset.value = newStartOffset;
endOffset.value = newEndOffset;
}
+
+// Returns a number of start and end steps to take, and new start and end offset values
function getMovedBounds(
startOffset: number, endOffset: number, startChg: number, endChg: number): [number, number, number, number] {
- // Returns a number of start and end steps to take, and new start and end offset values
let numStartSteps: number;
let numEndSteps: number;
let newStartOffset: number;
@@ -1092,7 +1167,8 @@ function getMovedBounds(
return [numStartSteps, numEndSteps, newStartOffset, newEndOffset];
}
-// For mouse/etc handling
+// ========== For mouse/etc handling ==========
+
let pointerX: number | null = null; // Used for pointer-centered zooming
let pointerY: number | null = null;
const ptrEvtCache: PointerEvent[] = []; // Holds last captured PointerEvent for each pointerId (used for pinch-zoom)
@@ -1103,11 +1179,14 @@ let dragVelocity: number; // Used to add 'drag momentum'
let vUpdateTime: number; // Holds timestamp for last update of 'dragVelocity'
let vPrevPointer: null | number; // Holds pointerX/pointerY used for last update of 'dragVelocity'
let vUpdater = 0; // Set by a setInterval(), used to update 'dragVelocity'
+
function onPointerDown(evt: PointerEvent){
ptrEvtCache.push(evt);
+
// Update pointer position
pointerX = evt.clientX;
pointerY = evt.clientY;
+
// Update data for dragging
dragDiff = 0;
dragVelocity = 0;
@@ -1123,13 +1202,14 @@ function onPointerDown(evt: PointerEvent){
vPrevPointer = (props.vert ? pointerY : pointerX);
}, 50);
}
+
function onPointerMove(evt: PointerEvent){
// Update event cache
if (ptrEvtCache.length > 0){
const index = ptrEvtCache.findIndex((e) => e.pointerId == evt.pointerId);
ptrEvtCache[index] = evt;
}
- //
+
if (ptrEvtCache.length == 1){
// Handle pointer dragging
dragDiff += props.vert ? evt.clientY - pointerY! : evt.clientX - pointerX!;
@@ -1158,20 +1238,24 @@ function onPointerMove(evt: PointerEvent){
}
lastPinchDiff = pinchDiff;
}
+
// Update stored cursor position
pointerX = evt.clientX;
pointerY = evt.clientY;
}
+
function onPointerUp(evt: PointerEvent){
// Ignore for dragging between child elements
if (evt.relatedTarget != null && rootRef.value!.contains(evt.relatedTarget as HTMLElement)){
return;
}
+
// Remove from event cache
if (ptrEvtCache.length > 0){
const index = ptrEvtCache.findIndex((e) => e.pointerId == evt.pointerId);
ptrEvtCache.splice(index, 1);
}
+
// Possibly trigger 'drag momentum'
if (vUpdater != 0){ // Might be zero on pointerleave/etc
clearInterval(vUpdater);
@@ -1181,22 +1265,25 @@ function onPointerUp(evt: PointerEvent){
panTimeline(-scrollChg / availLen.value);
}
}
- //
+
if (ptrEvtCache.length < 2){
lastPinchDiff = -1;
}
dragDiff = 0;
}
+
function onWheel(evt: WheelEvent){
let shiftDir = (evt.deltaY > 0 ? 1 : -1) * (!props.vert ? -1 : 1);
panTimeline(shiftDir * store.scrollRatio);
}
+
function onShiftWheel(evt: WheelEvent){
let zoomRatio = evt.deltaY > 0 ? store.zoomRatio : 1/store.zoomRatio;
zoomTimeline(zoomRatio);
}
-// For bound-change signalling
+// ========== For bound-change signalling ==========
+
function onStateChg(){
emit('state-chg', new TimelineState(
ID, startDate.value, endDate.value, startOffset.value, endOffset.value, scaleIdx.value
@@ -1204,9 +1291,10 @@ function onStateChg(){
}
watch(startDate, onStateChg);
-// For jumping to search result
+// ========== For jumping to search result ==========
const searchEvent = ref(null as null | HistEvent); // Holds most recent search result
let pendingSearch = false;
+
watch(() => props.searchTarget, () => {
const event = props.searchTarget[0];
if (event == null){
@@ -1217,6 +1305,7 @@ watch(() => props.searchTarget, () => {
animateFailDiv('max');
return;
}
+
if (!idToPos.value.has(event.id)){ // If not already visible
// Determine new time range
let tempScale = scale.value;
@@ -1241,6 +1330,7 @@ watch(() => props.searchTarget, () => {
}
targetEnd = MAX_DATE;
}
+
// Jump to range
if (startDate.value.equals(targetStart) && endDate.value.equals(targetEnd) && scale.value == tempScale){
updateIdToEvent();
@@ -1251,15 +1341,19 @@ watch(() => props.searchTarget, () => {
}
pendingSearch = true;
}
+
searchEvent.value = event;
});
-watch(idToEvent, () => { // Remove highlighting of search results that have become out of range
+
+// Remove highlighting of search results that have become out of range
+watch(idToEvent, () => {
if (searchEvent.value != null && !idToEvent.value.has(searchEvent.value.id) && !pendingSearch){
searchEvent.value = null;
}
});
-// For resets
+// ========== For resets ==========
+
watch(() => props.reset, () => {
startDate.value = store.initialStartDate;
endDate.value = store.initialEndDate;
@@ -1268,7 +1362,8 @@ watch(() => props.reset, () => {
initScale();
});
-// For keyboard shortcuts
+// ========== For keyboard shortcuts ==========
+
function onKeyDown(evt: KeyboardEvent){
if (!props.current || store.disableShortcuts){
return;
@@ -1302,11 +1397,13 @@ onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
});
-// For skipping transitions on startup (and on horz/vert swap)
+// ========== For skipping transitions on startup (and on horz/vert swap) ==========
+
const skipTransition = ref(true);
onMounted(() => setTimeout(() => {skipTransition.value = false}, 100));
-// Styles
+// ========== For styles ==========
+
const mainlineStyles = computed(() => {
return {
transform: props.vert ?
@@ -1317,6 +1414,7 @@ const mainlineStyles = computed(() => {
transitionTimingFunction: 'ease-out',
};
});
+
function tickStyles(tick: Tick){
let numMajorUnits = getNumDisplayUnits();
let pxOffset = tick.offset / numMajorUnits * availLen.value;
@@ -1331,12 +1429,15 @@ function tickStyles(tick: Tick){
opacity: (pxOffset >= 0 && pxOffset <= availLen.value) ? 1 : 0,
}
}
+
const REF_LABEL = '9999 BC'; // Used as a reference for preventing tick label overlap
const refTickLabelWidth = getTextWidth(REF_LABEL, '14px Ubuntu') + 10;
const tickLabelTexts = computed(() => ticks.value.map((tick: Tick) => tick.date.toTickString()));
+
const tickLabelStyles = computed((): Record<string,string>[] => {
let numMajorUnits = getNumDisplayUnits();
let labelSz = props.vert ? store.tickLabelHeight : tickLabelWidth.value;
+
// Get offsets, and check for label overlap
let pxOffsets: number[] = [];
let hasLongLabel = false; // True if a label has text longer than REF_LABEL (labels will be rotated)
@@ -1371,6 +1472,7 @@ const tickLabelStyles = computed((): Record<string,string>[] => {
}
}
}
+
// Determine styles
let styles: Record<string,string>[] = [];
for (let i = 0; i < ticks.value.length; i++){
@@ -1388,6 +1490,7 @@ const tickLabelStyles = computed((): Record<string,string>[] => {
}
return styles;
});
+
function eventStyles(eventId: number){
const [x, y, w, h] = idToPos.value.get(eventId)!;
return {
@@ -1400,6 +1503,7 @@ function eventStyles(eventId: number){
transitionTimingFunction: 'ease-out',
};
}
+
function eventImgStyles(eventId: number){
const event = idToEvent.value.get(eventId)!;
let isSearchResult = searchEvent.value != null && searchEvent.value.id == eventId;
@@ -1415,6 +1519,7 @@ function eventImgStyles(eventId: number){
boxShadow: isSearchResult ? '0 0 6px 4px ' + color : 'none',
};
}
+
function eventLineStyles(eventId: number){
const [x, y, , a] = eventLines.value.get(eventId)!;
return {
@@ -1424,6 +1529,7 @@ function eventLineStyles(eventId: number){
transitionTimingFunction: 'ease-out',
};
}
+
function countDivStyles(tickIdx: number, count: number): Record<string,string> {
let tick = ticks.value[tickIdx];
let numMajorUnits = getNumDisplayUnits();
@@ -1443,6 +1549,7 @@ function countDivStyles(tickIdx: number, count: number): Record<string,string> {
transitionTimingFunction: 'linear',
}
}
+
function animateFailDiv(which: 'min' | 'max' | 'both' | 'bg'){
if (which == 'min'){
animateWithClass(minFailRef.value!, 'animate-show-then-fade');
@@ -1455,6 +1562,7 @@ function animateFailDiv(which: 'min' | 'max' | 'both' | 'bg'){
animateWithClass(bgFailRef.value!, 'animate-red-then-fade');
}
}
+
function failDivStyles(minDiv: boolean){
const gradientDir = props.vert ? (minDiv ? 'top' : 'bottom') : (minDiv ? 'left' : 'right');
return {