aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2023-01-21 13:47:28 +1100
committerTerry Truong <terry06890@gmail.com>2023-01-21 13:47:28 +1100
commitbf357e48dc261dab08598bd93071ca53ef386402 (patch)
tree826d1cd1cb8d14fe65293c8efaa97b7e7622c876
parent0a9b2c2e5eca8a04e37fbdd423379882863237c2 (diff)
Adjust frontend coding style
-rw-r--r--src/App.vue105
-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
-rw-r--r--src/lib.ts208
-rw-r--r--src/rbtree.ts29
-rw-r--r--src/store.ts28
-rw-r--r--tests/lib.test.ts89
-rw-r--r--tests/rbtree.test.ts4
13 files changed, 584 insertions, 229 deletions
diff --git a/src/App.vue b/src/App.vue
index 534f528..0e7e912 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -20,6 +20,7 @@
<search-icon/>
</icon-button>
</div>
+
<!-- Content area -->
<div class="grow min-h-0 flex" :class="{'flex-col': !vert}"
:style="{backgroundColor: store.color.bg}" ref="contentAreaRef">
@@ -32,6 +33,7 @@
@info-click="onInfoClick" @pointerenter="currentTimelineIdx = idx"/>
<base-line v-if="store.showBaseLine" :vert="vert" :timelines="timelines" class='m-1 sm:m-2'/>
</div>
+
<!-- Modals -->
<transition name="fade">
<search-modal v-if="searchOpen" :eventTree="eventTree" :titleToEvent="titleToEvent"
@@ -55,7 +57,7 @@
<script setup lang="ts">
import {ref, computed, onMounted, onUnmounted, Ref, shallowRef, ShallowRef} from 'vue';
-// Components
+
import TimeLine from './components/TimeLine.vue';
import BaseLine from './components/BaseLine.vue';
import InfoModal from './components/InfoModal.vue';
@@ -64,12 +66,12 @@ import SettingsModal from './components/SettingsModal.vue';
import HelpModal from './components/HelpModal.vue';
import LoadingModal from './components/LoadingModal.vue';
import IconButton from './components/IconButton.vue';
-// Icons
+
import HelpIcon from './components/icon/HelpIcon.vue';
import SettingsIcon from './components/icon/SettingsIcon.vue';
import PlusIcon from './components/icon/PlusIcon.vue';
import SearchIcon from './components/icon/SearchIcon.vue';
-// Other
+
import {HistDate, HistEvent, queryServer,
EventResponseJson, jsonToHistEvent, EventInfo, EventInfoJson, jsonToEventInfo,
SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree,
@@ -77,16 +79,16 @@ import {HistDate, HistEvent, queryServer,
import {useStore} from './store';
import {RBTree, rbtree_shallow_copy} from './rbtree';
-// Refs
const contentAreaRef = ref(null as HTMLElement | null);
-// Global store
const store = useStore();
-// For content sizing (used to decide between horizontal and vertical mode)
+// ========== For content sizing ==========
+
const contentWidth = ref(1);
const contentHeight = ref(1);
const vert = computed(() => contentHeight.value > contentWidth.value);
+
function updateAreaDims(){
let contentAreaEl = contentAreaRef.value!;
contentWidth.value = contentAreaEl.offsetWidth;
@@ -94,10 +96,12 @@ function updateAreaDims(){
}
onMounted(updateAreaDims);
-// Timeline data
+// ========== Timeline data ==========
+
const timelines: Ref<TimelineState[]> = ref([]);
const currentTimelineIdx = ref(0);
let nextTimelineId = 1;
+
function addTimeline(){
if (timelines.value.length == 0){
timelines.value.push(new TimelineState(nextTimelineId, store.initialStartDate, store.initialEndDate));
@@ -112,17 +116,20 @@ function addTimeline(){
nextTimelineId += 1;
}
onMounted(addTimeline);
+
function onTimelineChg(state: TimelineState, idx: number){
timelines.value[idx] = state;
currentTimelineIdx.value = idx;
}
-// For timeline addition/removal
+// ========== For timeline add/remove ==========
+
const MIN_TIMELINE_BREADTH = store.mainlineBreadth + store.spacing * 2 + store.eventImgSz + store.eventLabelHeight;
const maxTimelines = computed(() => {
return vert.value && contentWidth.value / (timelines.value.length + 1) < MIN_TIMELINE_BREADTH
|| !vert.value && contentHeight.value / (timelines.value.length + 1) < MIN_TIMELINE_BREADTH
});
+
function onTimelineAdd(){
if (maxTimelines.value){
console.log('Ignored addition of timeline upon reaching max');
@@ -130,11 +137,13 @@ function onTimelineAdd(){
}
addTimeline();
}
+
function onTimelineClose(idx: number){
if (timelines.value.length == 1){
console.log('Ignored removal of last timeline')
return;
}
+
timelines.value.splice(idx, 1);
searchTargets.value.splice(idx, 1);
resetFlags.value.splice(idx, 1);
@@ -143,15 +152,18 @@ function onTimelineClose(idx: number){
}
}
-// For storing and looking up events
+// ========== For event data ==========
+
const eventTree: ShallowRef<RBTree<HistEvent>> = shallowRef(new RBTree(cmpHistEvent));
let idToEvent: Map<number, HistEvent> = new Map();
let titleToEvent: Map<string, HistEvent> = new Map();
const unitCountMaps: Ref<Map<number, number>[]> = ref(SCALES.map(() => new Map()));
// For each scale, maps units to event counts
+
// For keeping event data under a memory limit
const EXCESS_EVENTS_THRESHOLD = 10000;
let displayedEvents: Map<number, number[]> = new Map(); // Maps TimeLine IDs to IDs of displayed events
+
function reduceEvents(){
// Get events to keep
let eventsToKeep: Map<number, HistEvent> = new Map();
@@ -160,11 +172,13 @@ function reduceEvents(){
eventsToKeep.set(id, idToEvent.get(id)!);
}
}
+
// Create new event tree
let newTree = new RBTree(cmpHistEvent);
for (let [, event] of eventsToKeep){
newTree.insert(event);
}
+
// Create new unit-count maps
let newMaps: Map<number, number>[] = SCALES.map(() => new Map());
for (let timeline of timelines.value){
@@ -182,6 +196,7 @@ function reduceEvents(){
}
}
}
+
// Replace old data
eventTree.value = newTree;
unitCountMaps.value = newMaps;
@@ -191,21 +206,25 @@ function reduceEvents(){
titleToEvent.set(event.title, event);
}
}
-// For getting events from server
+
+// ========== For getting events from server ==========
+
const eventReqLimit = computed(() => {
- return Math.ceil(Math.max(contentWidth.value, contentHeight.value) / store.eventImgSz * 32);
- // As a rough heuristic, the number of events that could fit along the major axis,
+ // As a rough heuristic, is the number of events that could fit along the major axis,
// multiplied by a rough number of time points per event-occupied region,
// multiplied by the max number of events per time point (four).
+ return Math.ceil(Math.max(contentWidth.value, contentHeight.value) / store.eventImgSz * 32);
});
const MAX_EVENTS_PER_UNIT = 4; // Should equal MAX_DISPLAYED_PER_UNIT in backend gen_disp_data.py
-let queriedRanges: DateRangeTree[] = SCALES.map(() => new DateRangeTree());
- // For each scale, holds date ranges for which data has already been queried fromm the server
+let queriedRanges: DateRangeTree[] = // For each scale, holds date ranges for which data has already been queried
+ SCALES.map(() => new DateRangeTree());
let lastQueriedRange: [HistDate, HistDate] | null = null;
+
async function handleOnEventDisplay(
timelineId: number, eventIds: number[], firstDate: HistDate, lastDate: HistDate, scaleIdx: number){
let timelineIdx = timelines.value.findIndex((s : TimelineState) => s.id == timelineId);
let targetEvent = searchTargets.value[timelineIdx][0];
+
// Skip if range has been queried, and enough of its events have been obtained
if (queriedRanges[scaleIdx].contains([firstDate, lastDate])
&& (targetEvent == null || idToEvent.has(targetEvent.id))){
@@ -245,6 +264,7 @@ async function handleOnEventDisplay(
}
}
}
+
// Get events from server
if (lastQueriedRange != null && lastQueriedRange[0].equals(firstDate) && lastQueriedRange[1].equals(lastDate)
&& (targetEvent == null || idToEvent.has(targetEvent.id))){
@@ -254,7 +274,7 @@ async function handleOnEventDisplay(
lastQueriedRange = [firstDate, lastDate];
let urlParams = new URLSearchParams({
// Note: Intentionally not filtering by event categories (would need category-sensitive
- // unit count data to determine when enough events have been obtained)
+ // unit count data to determine when enough events have been obtained)
type: 'events',
range: `${firstDate}.${lastDate}`,
scale: String(SCALES[scaleIdx]),
@@ -272,6 +292,7 @@ async function handleOnEventDisplay(
return;
}
queriedRanges[scaleIdx].add([firstDate, lastDate]);
+
// Collect events
let eventAdded = false;
for (let eventObj of responseObj.events){
@@ -289,6 +310,7 @@ async function handleOnEventDisplay(
}
searchTargets.value[timelineIdx][0] = null;
}
+
// Collect unit counts
const unitCounts = responseObj.unitCounts;
if (unitCounts == null){
@@ -303,10 +325,12 @@ async function handleOnEventDisplay(
unitCountMaps.value[scaleIdx].set(unit, count)
}
}
+
// Notify components if new events were added
if (eventAdded){
eventTree.value = rbtree_shallow_copy(eventTree.value); // Note: triggerRef(eventTree) does not work here
}
+
// Check memory limit
displayedEvents.set(timelineId, eventIds);
if (eventTree.value.size > EXCESS_EVENTS_THRESHOLD){
@@ -317,8 +341,10 @@ async function handleOnEventDisplay(
}
const onEventDisplay = makeThrottled(handleOnEventDisplay, 200);
-// For info modal
+// ========== For info modal ==========
+
const infoModalData = ref(null as EventInfo | null);
+
async function onInfoClick(eventTitle: string){
// Query server for event info
let urlParams = new URLSearchParams({type: 'info', event: eventTitle});
@@ -330,10 +356,13 @@ async function onInfoClick(eventTitle: string){
}
}
-// For search modal
+// ========== For search modal ==========
+
const searchOpen = ref(false);
-const searchTargets = ref([] as [HistEvent | null, boolean][]); // For communicating search results to timelines
+const searchTargets = ref([] as [HistEvent | null, boolean][]);
+ // For communicating search results to timelines
// A boolean flag is used to trigger jumping even when the same event occurs twice
+
function onSearch(event: HistEvent){
searchOpen.value = false;
// Trigger jump in current timeline
@@ -341,11 +370,12 @@ function onSearch(event: HistEvent){
searchTargets.value.splice(currentTimelineIdx.value, 1, [event, !oldVal[1]]);
}
-// For settings modal
+// ========== For settings modal ==========
+
const settingsOpen = ref(false);
+
function onSettingChg(option: string){
- if (option == 'reqImgs' || option.startsWith('ctgs.')){
- // Reset event data
+ if (option == 'reqImgs' || option.startsWith('ctgs.')){ // Reset event data
eventTree.value = new RBTree(cmpHistEvent); // Will trigger event re-query
unitCountMaps.value = SCALES.map(() => new Map());
idToEvent.clear();
@@ -354,27 +384,35 @@ function onSettingChg(option: string){
}
}
-// For help modal
+// ========== For help modal ==========
+
const helpOpen = ref(false);
-// For loading modal
+// ========== For loading modal ==========
+
const SERVER_WAIT_MSG = 'Loading data';
const loadingMsg = ref(null as null | string);
const pendingLoadingRevealHdlr = ref(0); // Used to delay showing the loading modal
-function primeLoadInd(msg: string, delay?: number){ // Sets up a loading message to display after a timeout
+
+// Sets up a loading message to display after a timeout
+function primeLoadInd(msg: string, delay?: number){
clearTimeout(pendingLoadingRevealHdlr.value);
pendingLoadingRevealHdlr.value = window.setTimeout(() => {
loadingMsg.value = msg;
}, delay == null ? 500 : delay);
}
-function endLoadInd(){ // Cancels or closes a loading message
+
+// Cancels or closes a loading message
+function endLoadInd(){
clearTimeout(pendingLoadingRevealHdlr.value);
pendingLoadingRevealHdlr.value = 0;
if (loadingMsg.value != null){
loadingMsg.value = null;
}
}
-async function loadFromServer(urlParams: URLSearchParams, delay?: number){ // Like queryServer() but uses loading modal
+
+// Like queryServer() but uses loading modal
+async function loadFromServer(urlParams: URLSearchParams, delay?: number){
primeLoadInd(SERVER_WAIT_MSG, delay);
let responseObj = await queryServer(urlParams);
endLoadInd();
@@ -388,17 +426,20 @@ function onReset(){
resetFlags.value.splice(currentTimelineIdx.value, 1, !oldFlag);
}
-//
+// ========== For modals in general ==========
+
const modalOpen = computed(() =>
(infoModalData.value != null || searchOpen.value || settingsOpen.value || helpOpen.value));
-// For resize handling
+// ========== For resize handling ==========
+
const onResize = makeThrottledSpaced(updateAreaDims, 200);
// Note: If delay is too small, touch-device detection when swapping to/from mobile-mode gets unreliable
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));
-// For keyboard shortcuts
+// ========== For keyboard shortcuts ==========
+
function onKeyDown(evt: KeyboardEvent){
if (store.disableShortcuts){
return;
@@ -415,7 +456,6 @@ function onKeyDown(evt: KeyboardEvent){
}
} else if (evt.key == 'f' && evt.ctrlKey){
evt.preventDefault();
- // Open/focus search bar
if (!searchOpen.value){
searchOpen.value = true;
}
@@ -451,15 +491,18 @@ function onKeyDown(evt: KeyboardEvent){
onTimelineClose(currentTimelineIdx.value);
}
}
+
onMounted(() => {
window.addEventListener('keydown', onKeyDown);
// Note: Need 'keydown' instead of 'keyup' to override default CTRL-F
});
+
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
});
-// Styles
+// ========== For styles ==========
+
const buttonStyles = computed(() => ({
color: store.color.text,
backgroundColor: store.color.altDark2,
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 {
diff --git a/src/lib.ts b/src/lib.ts
index 9c796c6..7c65d6b 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -5,8 +5,11 @@
import {RBTree} from './rbtree';
export const DEBUG = true;
-// For detecting screen size
+
+// ========== For device detection ==========
+
export type Breakpoint = 'sm' | 'md' | 'lg';
+
export function getBreakpoint(): Breakpoint {
const w = window.innerWidth;
if (w < 768){
@@ -17,13 +20,16 @@ export function getBreakpoint(): Breakpoint {
return 'lg';
}
}
-// For detecting a touch device
+
+// Returns true for a touch device
export function onTouchDevice(){
return window.matchMedia('(pointer: coarse)').matches;
}
-// For detecting writing-mode
+
+// For detecting writing mode
// Used with ResizeObserver callbacks, to determine which resized dimensions are width and height
export let WRITING_MODE_HORZ = true;
+
if ('writing-mode' in window.getComputedStyle(document.body)){ // Can be null when testing
const bodyStyles = window.getComputedStyle(document.body);
if ('writing-mode' in bodyStyles){
@@ -31,14 +37,52 @@ if ('writing-mode' in window.getComputedStyle(document.body)){ // Can be null wh
}
}
+// ========== For handler throttling ==========
+
+// For creating throttled version of handler function
+export function makeThrottled(hdlr: (...args: any[]) => void, delay: number){
+ let timeout = 0;
+ return (...args: any[]) => {
+ clearTimeout(timeout);
+ timeout = window.setTimeout(async () => hdlr(...args), delay);
+ };
+}
+// Like makeThrottled(), but accepts an async function
+export function makeThrottledAsync(hdlr: (...args: any[]) => Promise<void>, delay: number){
+ let timeout = 0;
+ return async (...args: any[]) => {
+ clearTimeout(timeout);
+ timeout = window.setTimeout(async () => await hdlr(...args), delay);
+ };
+}
+// Like makeThrottled(), but, for runs of fast handler calls, calls it at spaced intervals, and at the start/end
+export function makeThrottledSpaced(hdlr: (...args: any[]) => void, delay: number){
+ let lastHdlrTime = 0; // Used for throttling
+ let endHdlr = 0; // Used to call handler after ending a run of calls
+ return (...args: any[]) => {
+ clearTimeout(endHdlr);
+ const currentTime = new Date().getTime();
+ if (currentTime - lastHdlrTime > delay){
+ lastHdlrTime = currentTime;
+ hdlr(...args);
+ lastHdlrTime = new Date().getTime();
+ } else {
+ endHdlr = window.setTimeout(async () => {
+ endHdlr = 0;
+ hdlr(...args);
+ lastHdlrTime = new Date().getTime();
+ }, delay);
+ }
+ };
+}
+
+// ========== General utility functions ==========
+
// Similar to %, but for negative LHS, return a positive offset from a lower RHS multiple
export function moduloPositive(x: number, y: number){
return x - Math.floor(x / y) * y;
}
-// Used to async-await for until after a timeout
-export async function timeout(ms: number){
- return new Promise(resolve => setTimeout(resolve, ms))
-}
+
// For positive int n, converts 1 to '1st', 2 to '2nd', etc
export function intToOrdinal(n: number){
if (n == 1 || n > 20 && n % 10 == 1){
@@ -51,6 +95,7 @@ export function intToOrdinal(n: number){
return String(n) + 'th';
}
}
+
// For positive int n, returns number of trailing zeros in decimal representation
export function getNumTrailingZeros(n: number): number {
let pow10 = 10;
@@ -62,12 +107,19 @@ export function getNumTrailingZeros(n: number): number {
}
throw new Error('Exceeded floating point precision');
}
+
// Removes a class from an element, triggers reflow, then adds the class
export function animateWithClass(el: HTMLElement, className: string){
el.classList.remove(className);
el.offsetWidth; // Triggers reflow
el.classList.add(className);
}
+
+// Used to async-await for until after a timeout
+export async function timeout(ms: number){
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
// For estimating text width (via https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript)
const _getTextWidthCanvas = document.createElement('canvas');
export function getTextWidth(text: string, font: string): number {
@@ -77,44 +129,8 @@ export function getTextWidth(text: string, font: string): number {
return metrics.width;
}
-// For creating throttled version of handler function
-export function makeThrottled(hdlr: (...args: any[]) => void, delay: number){
- let timeout = 0;
- return (...args: any[]) => {
- clearTimeout(timeout);
- timeout = window.setTimeout(async () => hdlr(...args), delay);
- };
-}
-// Like makeThrottled(), but accepts an async function
-export function makeThrottledAsync(hdlr: (...args: any[]) => Promise<void>, delay: number){
- let timeout = 0;
- return async (...args: any[]) => {
- clearTimeout(timeout);
- timeout = window.setTimeout(async () => await hdlr(...args), delay);
- };
-}
-// Like makeThrottled(), but, for runs of fast handler calls, calls it at spaced intervals, and at the start/end
-export function makeThrottledSpaced(hdlr: (...args: any[]) => void, delay: number){
- let lastHdlrTime = 0; // Used for throttling
- let endHdlr = 0; // Used to call handler after ending a run of calls
- return (...args: any[]) => {
- clearTimeout(endHdlr);
- const currentTime = new Date().getTime();
- if (currentTime - lastHdlrTime > delay){
- lastHdlrTime = currentTime;
- hdlr(...args);
- lastHdlrTime = new Date().getTime();
- } else {
- endHdlr = window.setTimeout(async () => {
- endHdlr = 0;
- hdlr(...args);
- lastHdlrTime = new Date().getTime();
- }, delay);
- }
- };
-}
+// ========== For calendar conversion (mostly copied from backend/hist_data/cal.py) ==========
-// For calendar conversion (mostly copied from backend/hist_data/cal.py)
export function gregorianToJdn(year: number, month: number, day: number): number {
if (year < 0){
year += 1;
@@ -126,6 +142,7 @@ export function gregorianToJdn(year: number, month: number, day: number): number
jdn += day - 32075;
return jdn;
}
+
export function julianToJdn(year: number, month: number, day: number): number {
if (year < 0){
year += 1;
@@ -136,6 +153,7 @@ export function julianToJdn(year: number, month: number, day: number): number {
jdn += day + 1729777;
return jdn;
}
+
export function jdnToGregorian(jdn: number): [number, number, number] {
const f = jdn + 1401 + Math.trunc((Math.trunc((4 * jdn + 274277) / 146097) * 3) / 4) - 38;
const e = 4 * f + 3;
@@ -149,6 +167,7 @@ export function jdnToGregorian(jdn: number): [number, number, number] {
}
return [Y, M, D];
}
+
export function jdnToJulian(jdn: number): [number, number, number] {
const f = jdn + 1401;
const e = 4 * f + 3;
@@ -162,30 +181,37 @@ export function jdnToJulian(jdn: number): [number, number, number] {
}
return [Y, M, D];
}
+
export function julianToGregorian(year: number, month: number, day: number): [number, number, number] {
return jdnToGregorian(julianToJdn(year, month, day));
}
+
export function gregorianToJulian(year: number, month: number, day: number): [number, number, number] {
return jdnToJulian(gregorianToJdn(year, month, day));
}
+
export function getDaysInMonth(year: number, month: number){
return gregorianToJdn(year, month + 1, 1) - gregorianToJdn(year, month, 1);
}
-// For date representation
+// ========== For date representation ==========
+
export const MIN_CAL_YEAR = -4713; // Earliest year where months/day scales are usable
export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
export class HistDate {
gcal: boolean | null;
year: number;
month: number;
day: number;
+
constructor(gcal: boolean | null, year: number, month: number, day: number){
this.gcal = gcal;
this.year = year;
this.month = gcal == null ? 1 : month;
this.day = gcal == null ? 1 : day;
}
+
equals(other: HistDate, scale=DAY_SCALE){ // Does not check gcal
if (scale == DAY_SCALE){
return this.year == other.year && this.month == other.month && this.day == other.day;
@@ -195,9 +221,11 @@ export class HistDate {
return Math.floor(this.year / scale) == Math.floor(other.year / scale);
}
}
+
clone(){
return new HistDate(this.gcal, this.year, this.month, this.day);
}
+
isEarlier(other: HistDate, scale=DAY_SCALE){
const yearlyScale = scale != DAY_SCALE && scale != MONTH_SCALE;
const thisYear = yearlyScale ? Math.floor(this.year / scale) : this.year;
@@ -212,6 +240,7 @@ export class HistDate {
}
}
}
+
cmp(other: HistDate, scale=DAY_SCALE){
if (this.isEarlier(other, scale)){
return -1;
@@ -221,11 +250,13 @@ export class HistDate {
return 0;
}
}
+
getDayDiff(other: HistDate){ // Assumes neither date has gcal=null
const jdn2 = gregorianToJdn(this.year, this.month, this.day);
const jdn1 = gregorianToJdn(other.year, other.month, other.day);
return Math.abs(jdn1 - jdn2);
}
+
getMonthDiff(other: HistDate){
// Determine earlier date
let earlier = this as HistDate;
@@ -234,7 +265,7 @@ export class HistDate {
earlier = other;
later = this as HistDate;
}
- //
+
const yearDiff = earlier.getYearDiff(later);
if (yearDiff == 0){
return later.month - earlier.month;
@@ -242,6 +273,7 @@ export class HistDate {
return (13 - earlier.month) + (yearDiff - 1) * 12 + later.month - 1;
}
}
+
getYearDiff(other: HistDate){
let yearDiff = Math.abs(this.year - other.year);
if (this.year * other.year < 0){ // Account for no 0 AD
@@ -249,6 +281,7 @@ export class HistDate {
}
return yearDiff;
}
+
toString(){
if (this.gcal != null){
return `${this.year}-${this.month}-${this.day}`;
@@ -256,6 +289,7 @@ export class HistDate {
return `${this.year}`;
}
}
+
toYearString(){
if (this.year >= 1000){
return String(this.year);
@@ -289,6 +323,7 @@ export class HistDate {
}
}
}
+
toTickString(){
if (this.month == 1 && this.day == 1){
return this.toYearString();
@@ -298,27 +333,32 @@ export class HistDate {
return intToOrdinal(this.day);
}
}
+
toInt(){ // Used for v-for keys
return this.day + this.month * 50 + this.year * 1000;
}
}
+
export class YearDate extends HistDate {
declare gcal: null;
declare year: number;
declare month: 1;
declare day: 1;
+
constructor(year: number){
// Note: Intentionally not enforcing year < MIN_CAL_YEAR here. This does mean a YearDate can be
- // interpreted as the same day as a CalDate, but it also avoids having HistEvents that span across
- // MIN_CAL_YEAR that have a mix of YearDates and CalDates.
+ // interpreted as the same day as a CalDate, but it also avoids having HistEvents that
+ // span across MIN_CAL_YEAR that have a mix of YearDates and CalDates.
super(null, year, 1, 1);
}
}
+
export class CalDate extends HistDate {
declare gcal: boolean;
declare year: number;
declare month: number;
declare day: number;
+
constructor(year: number, month: number, day: number, gcal=true){
if (year < MIN_CAL_YEAR){
throw new Error(`Year must not be before ${MIN_CAL_YEAR}`);
@@ -326,9 +366,11 @@ export class CalDate extends HistDate {
super(gcal, year, month, day);
}
}
+
export const MIN_CAL_DATE = new CalDate(MIN_CAL_YEAR, 1, 1);
-// For event representation
+// ========== For event representation ==========
+
export class HistEvent {
id: number;
title: string;
@@ -339,6 +381,7 @@ export class HistEvent {
ctg: string;
imgId: number;
pop: number;
+
constructor(
id: number, title: string, start: HistDate, startUpper: HistDate | null = null,
end: HistDate | null = null, endUpper: HistDate | null = null, ctg='', imgId=0, pop=0){
@@ -353,11 +396,13 @@ export class HistEvent {
this.pop = pop;
}
}
+
export class ImgInfo {
url: string;
license: string;
artist: string;
credit: string;
+
constructor(url: string, license: string, artist: string, credit: string){
this.url = url;
this.license = license;
@@ -365,11 +410,13 @@ export class ImgInfo {
this.credit = credit;
}
}
+
export class EventInfo {
event: HistEvent;
desc: string | null;
wikiId: number;
imgInfo: ImgInfo | null;
+
constructor(event: HistEvent, desc: string, wikiId: number, imgInfo: ImgInfo | null){
this.event = event;
this.desc = desc;
@@ -377,12 +424,14 @@ export class EventInfo {
this.imgInfo = imgInfo;
}
}
+
export function cmpHistEvent(event: HistEvent, event2: HistEvent){
const cmp = event.start.cmp(event2.start);
return cmp != 0 ? cmp : event.id - event2.id;
}
-// For date display
+// ========== For date display ==========
+
export function dateToDisplayStr(date: HistDate){
if (date.year <= -1e4){ // N.NNN billion/million/thousand years ago
if (date.year <= -1e9){
@@ -406,8 +455,9 @@ export function dateToDisplayStr(date: HistDate){
return `${intToOrdinal(date.day)} ${MONTH_NAMES[date.month-1]} ${Math.abs(date.year)}${bcSuffix}${calStr}`;
}
}
+
+// Converts a date with uncertain end bound to string for display
export function boundedDateToStr(start: HistDate, end: HistDate | null) : string {
- // Converts a date with uncertain end bound to string for display
if (end == null){
return dateToDisplayStr(start);
}
@@ -466,13 +516,16 @@ export function boundedDateToStr(start: HistDate, end: HistDate | null) : string
}
-// For server requests
+// ========== For server requests ==========
+
const SERVER_DATA_URL = (new URL(window.location.href)).origin + '/data/'
const SERVER_IMG_PATH = '/hist_data/img/'
+
export async function queryServer(params: URLSearchParams, serverDataUrl=SERVER_DATA_URL){
// Construct URL
const url = new URL(serverDataUrl);
url.search = params.toString();
+
// Query server
let responseObj;
try {
@@ -484,16 +537,20 @@ export async function queryServer(params: URLSearchParams, serverDataUrl=SERVER_
}
return responseObj;
}
+
export function getImagePath(imgId: number): string {
return SERVER_IMG_PATH + String(imgId) + '.jpg';
}
-// For server responses
+
+// ========== For server responses ==========
+
export type HistDateJson = {
gcal: boolean | null,
year: number,
month: number,
day: number,
}
+
export type HistEventJson = {
id: number,
title: string,
@@ -505,29 +562,35 @@ export type HistEventJson = {
imgId: number,
pop: number,
}
+
export type EventResponseJson = {
events: HistEventJson[],
unitCounts: {[x: number]: number} | null,
}
+
export type EventInfoJson = {
event: HistEventJson,
desc: string,
wikiId: number,
imgInfo: ImgInfoJson | null,
}
+
export type ImgInfoJson = {
url: string,
license: string,
artist: string,
credit: string,
}
+
export type SuggResponseJson = {
suggs: string[],
hasMore: boolean,
}
+
export function jsonToHistDate(json: HistDateJson): HistDate {
return new HistDate(json.gcal, json.year, json.month, json.day);
}
+
export function jsonToHistEvent(json: HistEventJson): HistEvent {
return new HistEvent(
json.id,
@@ -541,14 +604,17 @@ export function jsonToHistEvent(json: HistEventJson): HistEvent {
json.pop,
);
}
+
export function jsonToEventInfo(json: EventInfoJson): EventInfo {
return new EventInfo(jsonToHistEvent(json.event), json.desc, json.wikiId, jsonToImgInfo(json.imgInfo));
}
+
export function jsonToImgInfo(json: ImgInfoJson | null): ImgInfo | null {
return json == null ? null : new ImgInfo(json.url, json.license, json.artist, json.credit);
}
-// For dates in a timeline
+// ========== For dates in a timeline ==========
+
export const MIN_DATE = new YearDate(-13.8e9);
export const MAX_DATE = new CalDate(2030, 1, 1);
export const MONTH_SCALE = -1;
@@ -556,7 +622,8 @@ export const DAY_SCALE = -2;
export const SCALES = [1e9, 1e8, 1e7, 1e6, 1e5, 1e4, 1e3, 100, 10, 1, MONTH_SCALE, DAY_SCALE];
// The timeline will be divided into units of SCALES[0], then SCALES[1], etc
// Positive ints represent numbers of years, -1 represents 1 month, -2 represents 1 day
-if (DEBUG){
+
+if (DEBUG){ // Validate SCALES
if (SCALES[SCALES.length - 1] != DAY_SCALE
|| SCALES[SCALES.length - 2] != MONTH_SCALE
|| SCALES[SCALES.length - 3] != 1){
@@ -574,6 +641,7 @@ if (DEBUG){
}
}
}
+
export function stepDate( // Steps a date N units along a scale
date: HistDate, scale: number, {forward=true, count=1, inplace=false} = {}): HistDate {
// If stepping by month or years, leaves day value unchanged
@@ -650,6 +718,7 @@ export function stepDate( // Steps a date N units along a scale
}
return newDate;
}
+
export function inDateScale(date: HistDate, scale: number): boolean {
if (scale == DAY_SCALE){
return true;
@@ -659,8 +728,9 @@ export function inDateScale(date: HistDate, scale: number): boolean {
return (date.year == 1 || date.year % scale == 0) && date.month == 1 && date.day == 1;
}
}
+
+// Returns number of units in 'scale' per unit in 'scale2' (provides upper/lower value for days-per-month/year)
export function getScaleRatio(scale: number, scale2: number, lowerVal=false){
- // Returns number of units in 'scale' per unit in 'scale2' (provides upper/lower value for days-per-month/year)
const daysPerMonth = lowerVal ? 28 : 31;
if (scale == DAY_SCALE){
scale = 1 / 12 / daysPerMonth;
@@ -674,8 +744,9 @@ export function getScaleRatio(scale: number, scale2: number, lowerVal=false){
}
return scale2 / scale;
}
+
+// Returns number of sub-units for a unit starting at 'date' on scale for 'scaleIdx'
export function getNumSubUnits(date: HistDate, scaleIdx: number){
- // Returns number of sub-units for a unit starting at 'date' on scale for 'scaleIdx'
const scale = SCALES[scaleIdx]
if (scale == DAY_SCALE){
throw new Error('Cannot get sub-units for DAY_SCALE unit');
@@ -687,6 +758,7 @@ export function getNumSubUnits(date: HistDate, scaleIdx: number){
return scale / SCALES[scaleIdx + 1] - (date.year == 1 ? 1 : 0); // Account for lack of 0 AD
}
}
+
export function getUnitDiff(date: HistDate, date2: HistDate, scale: number): number {
if (scale == DAY_SCALE){
return date.getDayDiff(date2);
@@ -696,8 +768,9 @@ export function getUnitDiff(date: HistDate, date2: HistDate, scale: number): num
return date.getYearDiff(date2) / scale;
}
}
+
+// Returns smallest scale at which 'event's start-startUpper range is within one unit, or infinity
export function getEventPrecision(event: HistEvent): number {
- // Returns smallest scale at which 'event's start-startUpper range is within one unit, or infinity
// Note: Intentionally not adding an exception for century and millenia ranges like
// 101 to 200 (as opposed to 100 to 199) being interpreted as 'within' one 100/1000-year scale unit
const {start, startUpper} = event;
@@ -716,8 +789,9 @@ export function getEventPrecision(event: HistEvent): number {
}
return Number.POSITIVE_INFINITY;
}
+
+// For a YearDate and sub-yearly scale, uses the first day of the YearDate's year
export function dateToUnit(date: HistDate, scale: number): number {
- // For a YearDate and sub-yearly scale, uses the first day of the YearDate's year
if (scale >= 1){
return Math.floor(date.year / scale);
} else if (scale == MONTH_SCALE){
@@ -734,8 +808,9 @@ export function dateToUnit(date: HistDate, scale: number): number {
}
}
}
+
+// Returns a date representing the unit on 'scale' that 'date' is within
export function dateToScaleDate(date: HistDate, scale: number): HistDate {
- // Returns a date representing the unit on 'scale' that 'date' is within
if (scale == DAY_SCALE){
return new CalDate(date.year, date.month, date.day);
} else if (scale == MONTH_SCALE){
@@ -750,7 +825,8 @@ export function dateToScaleDate(date: HistDate, scale: number): HistDate {
}
}
-// For sending timeline-bound data to BaseLine
+// ========== For sending timeline-bound data to BaseLine ==========
+
export class TimelineState {
id: number;
startDate: HistDate;
@@ -769,16 +845,20 @@ export class TimelineState {
}
}
-// For managing sets of non-overlapping date ranges
+// ========== For managing sets of non-overlapping date ranges ==========
+
export type DateRange = [HistDate, HistDate];
+
export class DateRangeTree {
tree: RBTree<DateRange>;
constructor(){
this.tree = new RBTree((r1: DateRange, r2: DateRange) => r1[0].cmp(r2[0]));
}
+
add(range: DateRange){
const rangesToRemove: HistDate[] = []; // Holds starts of ranges to remove
const dummyDate = new YearDate(1);
+
// Find ranges to remove
const itr = this.tree.lowerBound([range[0], dummyDate]);
let prevRange = itr.prev();
@@ -802,15 +882,18 @@ export class DateRangeTree {
rangesToRemove.push(nextRange[0])
}
}
+
// Remove included/overlapping ranges
for (const start of rangesToRemove){
this.tree.remove([start, dummyDate]);
}
+
// Add possibly-merged range
const startDate = prevRange != null ? prevRange[0] : range[0];
const endDate = nextRange != null ? nextRange[1] : range[1];
this.tree.insert([startDate, endDate]);
}
+
contains(range: DateRange): boolean {
const itr = this.tree.lowerBound([range[0], new YearDate(1)]);
let r = itr.data();
@@ -829,6 +912,7 @@ export class DateRangeTree {
}
}
}
+
clear(){
this.tree.clear();
}
diff --git a/src/rbtree.ts b/src/rbtree.ts
index b4ae540..5366724 100644
--- a/src/rbtree.ts
+++ b/src/rbtree.ts
@@ -1,24 +1,28 @@
-// Copied from node_modules/bintrees/lib/, and adapted to use ES6, classes, and typescript
+/*
+ * Copied from node_modules/bintrees/lib/, and adapted to use ES6, classes, and typescript.
+ */
export class Node<T> {
data: T;
left: Node<T> | null;
right: Node<T> | null;
red: boolean;
+
constructor(data: T){
this.data = data;
this.left = null;
this.right = null;
this.red = true;
}
+
get_child(dir: boolean){
return dir ? this.right : this.left;
}
+
set_child(dir: boolean, val: Node<T> | null){
if (dir) {
this.right = val;
- }
- else {
+ } else {
this.left = val;
}
}
@@ -28,14 +32,17 @@ export class Iterator<T> {
_tree: RBTree<T>;
_ancestors: Node<T>[];
_cursor: Node<T> | null;
+
constructor(tree: RBTree<T>){
this._tree = tree;
this._ancestors = [];
this._cursor = null;
}
+
data(): T | null {
return this._cursor !== null ? this._cursor.data : null;
}
+
// if null-iterator, returns first node
// otherwise, returns next node
next(): T | null{
@@ -69,6 +76,7 @@ export class Iterator<T> {
}
return this._cursor !== null ? this._cursor.data : null;
}
+
// if null-iterator, returns last node
// otherwise, returns previous node
prev(): T | null {
@@ -99,6 +107,7 @@ export class Iterator<T> {
}
return this._cursor !== null ? this._cursor.data : null;
}
+
_minNode(start: Node<T>) {
while(start.left !== null) {
this._ancestors.push(start);
@@ -106,6 +115,7 @@ export class Iterator<T> {
}
this._cursor = start;
}
+
_maxNode(start: Node<T>) {
while(start.right !== null) {
this._ancestors.push(start);
@@ -119,16 +129,19 @@ export class RBTree<T> {
_root: Node<T> | null;
size: number;
_comparator: (a: T, b: T) => number;
+
constructor(comparator: (a: T, b: T) => number){
this._root = null;
this._comparator = comparator;
this.size = 0;
}
+
// removes all nodes from the tree
clear(){
this._root = null;
this.size = 0;
}
+
// returns node data if found, null otherwise
find(data: T): T | null{
let res = this._root;
@@ -143,6 +156,7 @@ export class RBTree<T> {
}
return null;
}
+
// returns iterator to node if found, null otherwise
findIter(data: T): Iterator<T> | null {
let res = this._root;
@@ -160,6 +174,7 @@ export class RBTree<T> {
}
return null;
}
+
// Returns an iterator to the tree node at or immediately after the item
lowerBound(item: T): Iterator<T> {
let cur = this._root;
@@ -185,6 +200,7 @@ export class RBTree<T> {
iter._ancestors.length = 0;
return iter;
}
+
// Returns an iterator to the tree node immediately after the item
upperBound(item: T): Iterator<T> {
const iter = this.lowerBound(item);
@@ -194,6 +210,7 @@ export class RBTree<T> {
}
return iter;
}
+
// returns null if tree is empty
min(): T | null {
let res = this._root;
@@ -205,6 +222,7 @@ export class RBTree<T> {
}
return res.data;
}
+
// returns null if tree is empty
max(): T | null {
let res = this._root;
@@ -216,11 +234,13 @@ export class RBTree<T> {
}
return res.data;
}
+
// returns a null iterator
// call next() or prev() to point to an element
iterator() {
return new Iterator(this);
}
+
// calls cb on each node's data, in order
each(cb: (x: T) => boolean) {
const it = this.iterator();
@@ -231,6 +251,7 @@ export class RBTree<T> {
}
}
}
+
// calls cb on each node's data, in reverse order
reach(cb: (x: T) => boolean) {
const it=this.iterator();
@@ -241,6 +262,7 @@ export class RBTree<T> {
}
}
}
+
// returns true if inserted, false if duplicate
insert(data: T): boolean {
let ret = false;
@@ -307,6 +329,7 @@ export class RBTree<T> {
this._root!.red = false;
return ret;
}
+
// returns true if removed, false if not found
remove(data: T): boolean {
if(this._root === null) {
diff --git a/src/store.ts b/src/store.ts
index 5da6241..d05b49f 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -6,10 +6,13 @@ import {defineStore} from 'pinia';
import {HistDate, CalDate} from './lib';
import {getBreakpoint, Breakpoint, onTouchDevice} from './lib';
+// ========== For store state ==========
+
export type StoreState = {
// Device info
touchDevice: boolean,
breakpoint: Breakpoint,
+
// Tick display
tickLen: number, //px
largeTickLen: number,
@@ -19,17 +22,20 @@ export type StoreState = {
minLastTicks: number // When at smallest scale, don't zoom further into less than this many ticks
defaultEndTickOffset: number, // Default fraction of a unit to offset start/end ticks
showMinorTicks: boolean,
+
// Mainline and event display
mainlineBreadth: number, // Breadth of mainline area (incl ticks and labels)
eventImgSz: number, // Width/height of event images
eventLabelHeight: number,
spacing: number, // Spacing between display edge, events, and mainline area
showEventLines: boolean,
+
// User input
scrollRatio: number, // Fraction of timeline length to move by upon scroll
zoomRatio: number, // Ratio of timeline expansion upon zooming out (eg: 1.5)
dragInertia: number, // Multiplied by final-drag-speed (pixels-per-sec) to get extra scroll distance
disableShortcuts: boolean,
+
// Other feature-specific
reqImgs: boolean, // Only show events with images
showEventCounts: boolean,
@@ -43,6 +49,7 @@ export type StoreState = {
work: boolean,
discovery: boolean,
},
+
// Other
initialStartDate: HistDate,
initialEndDate: HistDate, // Must be later than initialStartDate
@@ -66,6 +73,7 @@ export type StoreState = {
borderRadius: number, // px
transitionDuration: number, // ms
};
+
function getDefaultState(): StoreState {
const breakpoint = getBreakpoint();
const color = {
@@ -89,6 +97,7 @@ function getDefaultState(): StoreState {
// Device info
touchDevice: onTouchDevice(),
breakpoint: breakpoint,
+
// Tick display
tickLen: 16,
largeTickLen: 32,
@@ -98,17 +107,20 @@ function getDefaultState(): StoreState {
minLastTicks: 3,
defaultEndTickOffset: 0.5,
showMinorTicks: true,
+
// Mainline and event display
mainlineBreadth: 70,
eventImgSz: 100,
eventLabelHeight: 20,
spacing: 10,
showEventLines: true,
+
// User input
scrollRatio: 0.2,
zoomRatio: 1.5,
dragInertia: 0.1,
disableShortcuts: false,
+
// Other feature-specific
reqImgs: true,
showEventCounts: true,
@@ -122,6 +134,7 @@ function getDefaultState(): StoreState {
work: true,
discovery: true,
},
+
// Other
initialStartDate: new CalDate(1900, 1, 1),
initialEndDate: new CalDate(2030, 1, 1),
@@ -145,8 +158,11 @@ function getCompositeKeys(state: StoreState){
}
return compKeys;
}
+
const STORE_COMP_KEYS = getCompositeKeys(getDefaultState());
-// For getting/setting values in store
+
+// ========== For getting/setting/loading store state ==========
+
function getStoreVal(state: StoreState, compKey: string): any {
if (compKey in state){
return state[compKey as keyof StoreState];
@@ -160,6 +176,7 @@ function getStoreVal(state: StoreState, compKey: string): any {
}
return null;
}
+
function setStoreVal(state: StoreState, compKey: string, val: any): void {
if (compKey in state){
(state[compKey as keyof StoreState] as any) = val;
@@ -174,7 +191,7 @@ function setStoreVal(state: StoreState, compKey: string, val: any): void {
}
}
}
-// For loading settings into [initial] store state
+
function loadFromLocalStorage(state: StoreState){
for (const key of STORE_COMP_KEYS){
const item = localStorage.getItem(key)
@@ -184,16 +201,20 @@ function loadFromLocalStorage(state: StoreState){
}
}
+// ========== Main export ==========
+
export const useStore = defineStore('store', {
state: () => {
const state = getDefaultState();
loadFromLocalStorage(state);
return state;
},
+
actions: {
reset(): void {
Object.assign(this, getDefaultState());
},
+
resetOne(key: string){
const val = getStoreVal(this, key);
if (val != null){
@@ -203,14 +224,17 @@ export const useStore = defineStore('store', {
}
}
},
+
save(key: string){
if (STORE_COMP_KEYS.includes(key)){
localStorage.setItem(key, JSON.stringify(getStoreVal(this, key)));
}
},
+
load(): void {
loadFromLocalStorage(this);
},
+
clear(): void {
for (const key of STORE_COMP_KEYS){
localStorage.removeItem(key);
diff --git a/tests/lib.test.ts b/tests/lib.test.ts
index 439703f..407d1d1 100644
--- a/tests/lib.test.ts
+++ b/tests/lib.test.ts
@@ -1,18 +1,21 @@
import {
moduloPositive, intToOrdinal, getNumTrailingZeros,
gregorianToJdn, julianToJdn, jdnToGregorian, jdnToJulian, gregorianToJulian, julianToGregorian, getDaysInMonth,
- YearDate, CalDate, boundedDateToStr, eventDatesToStr, HistEvent,
+ YearDate, CalDate, dateToDisplayStr, boundedDateToStr, HistEvent,
queryServer, jsonToHistDate, jsonToHistEvent,
DAY_SCALE, MONTH_SCALE, stepDate, inDateScale, getScaleRatio, getUnitDiff,
getEventPrecision, dateToUnit, dateToScaleDate,
DateRangeTree,
} from '/src/lib.ts'
+// ========== General utility functions ==========
+
test('moduloPositive', () => {
expect(moduloPositive(4, 2)).toBe(0)
expect(moduloPositive(5, 3)).toBe(2)
expect(moduloPositive(-5, 3)).toBe(1)
})
+
test('intToOrdinal', () => {
expect(intToOrdinal(1)).toBe('1st')
expect(intToOrdinal(3)).toBe('3rd')
@@ -20,6 +23,7 @@ test('intToOrdinal', () => {
expect(intToOrdinal(294)).toBe('294th')
expect(intToOrdinal(10301)).toBe('10301st')
})
+
test('getNumTrailingZeros', () => {
expect(getNumTrailingZeros(1)).toBe(0)
expect(getNumTrailingZeros(20)).toBe(1)
@@ -29,40 +33,50 @@ test('getNumTrailingZeros', () => {
expect(getNumTrailingZeros(1e20)).toBe(20)
})
+// ========== For calendar conversion ==========
+
test('gregorianToJdn', () => {
expect(gregorianToJdn(2010, 11, 3)).toBe(2455504)
expect(gregorianToJdn(-4714, 11, 24)).toBe(0)
expect(gregorianToJdn(-1, 1, 1)).toBe(1721060)
})
+
test('julianToJdn', () => {
expect(julianToJdn(2010, 11, 3)).toBe(2455517)
expect(julianToJdn(-4713, 1, 1)).toBe(0)
expect(julianToJdn(-1, 1, 1)).toBe(1721058)
})
+
test('jdnToGregorian', () => {
expect(jdnToGregorian(2455504)).toEqual([2010, 11, 3])
expect(jdnToGregorian(0)).toEqual([-4714, 11, 24])
expect(jdnToGregorian(1721060)).toEqual([-1, 1, 1])
})
+
test('jdnToJulian', () => {
expect(jdnToJulian(2455517)).toEqual([2010, 11, 3])
expect(jdnToJulian(0)).toEqual([-4713, 1, 1])
expect(jdnToJulian(1721058)).toEqual([-1, 1, 1])
})
+
test('gregorianToJulian', () => {
expect(gregorianToJulian(2022, 9, 30)).toEqual([2022, 9, 17])
expect(gregorianToJulian(1616, 5, 3)).toEqual([1616, 4, 23])
})
+
test('julianToGregorian', () => {
expect(julianToGregorian(2022, 9, 17)).toEqual([2022, 9, 30])
expect(julianToGregorian(1616, 4, 23)).toEqual([1616, 5, 3])
})
+
test('getDaysInMonth', () => {
expect(getDaysInMonth(2022, 12)).toBe(31)
expect(getDaysInMonth(2022, 2)).toBe(28)
expect(getDaysInMonth(2000, 2)).toBe(29)
})
+// ========== For date representation ==========
+
describe('YearDate', () => {
test('cmp', () => {
expect((new YearDate(-5000)).equals(new YearDate(-5000))).toBe(true)
@@ -75,6 +89,7 @@ describe('YearDate', () => {
expect((new YearDate(-5000)).getYearDiff(new YearDate(-6000))).toBe(1000)
})
})
+
describe('CalDate', () => {
test('cmp', () => {
expect((new CalDate(2000, 1, 1)).equals(new CalDate(2000, 1, 1))).toBe(true)
@@ -90,21 +105,23 @@ describe('CalDate', () => {
expect((new CalDate(-1, 10, 3)).getYearDiff(new CalDate(1, 1, 1))).toBe(1)
})
})
+
test('toDisplayString', () => {
- expect(new YearDate(-14_000_000_000).toDisplayString()).toBe('14 billion years ago')
- expect(new YearDate(-14_300_000_000).toDisplayString()).toBe('14.3 billion years ago')
- expect(new YearDate( -1_230_000).toDisplayString()).toBe('1.23 million years ago')
- expect(new YearDate( -1_234_567).toDisplayString()).toBe('1.235 million years ago')
- expect(new YearDate( -123_456).toDisplayString()).toBe('123 thousand years ago')
- expect(new YearDate( -9_999).toDisplayString()).toBe('9,999 BC')
- expect(new YearDate( -200).toDisplayString()).toBe('200 BC')
- expect(new YearDate( 1).toDisplayString()).toBe('1 AD')
- expect(new YearDate( 1500).toDisplayString()).toBe('1500')
- expect(new CalDate(2000, 10, 3).toDisplayString()).toBe('3rd Oct 2000')
- expect(new CalDate(-2000, 1, 1).toDisplayString()).toBe('1st Jan 2000 BC')
- expect(new CalDate(1610, 8, 6, false).toDisplayString()).toBe('6th Aug 1610 (OS)')
- expect(new CalDate(-100, 2, 2, false).toDisplayString()).toBe('2nd Feb 100 BC (OS)')
+ expect(dateToDisplayStr(new YearDate(-14_000_000_000))).toBe('14 billion years ago')
+ expect(dateToDisplayStr(new YearDate(-14_300_000_000))).toBe('14.3 billion years ago')
+ expect(dateToDisplayStr(new YearDate( -1_230_000))).toBe('1.23 million years ago')
+ expect(dateToDisplayStr(new YearDate( -1_234_567))).toBe('1.235 million years ago')
+ expect(dateToDisplayStr(new YearDate( -123_456))).toBe('123 thousand years ago')
+ expect(dateToDisplayStr(new YearDate( -9_999))).toBe('9,999 BC')
+ expect(dateToDisplayStr(new YearDate( -200))).toBe('200 BC')
+ expect(dateToDisplayStr(new YearDate( 1))).toBe('1 AD')
+ expect(dateToDisplayStr(new YearDate( 1500))).toBe('1500')
+ expect(dateToDisplayStr(new CalDate(2000, 10, 3))).toBe('3rd Oct 2000')
+ expect(dateToDisplayStr(new CalDate(-2000, 1, 1))).toBe('1st Jan 2000 BC')
+ expect(dateToDisplayStr(new CalDate(1610, 8, 6, false))).toBe('6th Aug 1610 (O.S.)')
+ expect(dateToDisplayStr(new CalDate(-100, 2, 2, false))).toBe('2nd Feb 100 BC (O.S.)')
})
+
test('boundedDateToStr', () => {
// Start and end N billion/million/thousand years ago
expect(boundedDateToStr(new YearDate(-1e9), new YearDate(-1e9))).toBe('1 billion years ago')
@@ -126,39 +143,13 @@ test('boundedDateToStr', () => {
expect(boundedDateToStr(new CalDate(100, 1, 2), new CalDate(100, 10, 3))).toBe('2nd Jan to 3rd Oct 100 AD')
expect(boundedDateToStr(new CalDate(100, 1, 2), new CalDate(100, 1, 3))).toBe('2nd to 3rd Jan 100 AD')
expect(boundedDateToStr(new CalDate(100, 1, 1), new CalDate(100, 1, 31))).toBe('Jan 100 AD')
- expect(boundedDateToStr(new CalDate(100, 1, 1, false), new CalDate(100, 1, 31, false))).toBe('Jan 100 AD (OS)')
+ expect(boundedDateToStr(new CalDate(100, 1, 1, false), new CalDate(100, 1, 31, false))).toBe('Jan 100 AD (O.S.)')
// Other
expect(boundedDateToStr(new CalDate(10, 1, 2), null)).toBe('2nd Jan 10 AD')
expect(boundedDateToStr(new YearDate(-1e7), new CalDate(1610, 3, 2))).toBe('10 million years ago to 2nd Mar 1610')
})
-test('eventDatesToStr', () => {
- // Year-based start and end
- expect(eventDatesToStr(new YearDate(100), new YearDate(500), new YearDate(600)))
- .toEqual(['100 to 500 AD', '600 AD'])
- expect(eventDatesToStr(new YearDate(-2e6), new YearDate(-1e6 - 1), new YearDate(1)))
- .toEqual(['About 2 million years ago', '1 AD'])
- expect(eventDatesToStr(new YearDate(-3e9), null, new YearDate(-1e9), null))
- .toBe('3 to 1 billion years ago')
- expect(eventDatesToStr(new YearDate(-5e6), new YearDate(-5e6), new YearDate(-2.2e6), null))
- .toEqual('5 to 2.2 million years ago')
- expect(eventDatesToStr(new YearDate(1), new YearDate(1000), new YearDate(2001), new YearDate(3000)))
- .toEqual('1st to 3rd millenium')
- expect(eventDatesToStr(new YearDate(-1099), new YearDate(-1000), new YearDate(-499), new YearDate(-400)))
- .toEqual('11th to 5th century BC')
- expect(eventDatesToStr(new YearDate(13), null, new YearDate(300), new YearDate(300)))
- .toEqual('13 to 300 AD')
- // Calendar-based start and end
- expect(eventDatesToStr(new CalDate(1, 1, 1), null, new CalDate(1, 3, 2), null))
- .toEqual('1st Jan to 2nd Mar 1 AD')
- expect(eventDatesToStr(new CalDate(1, 1, 1), null, new CalDate(1, 1, 2), null))
- .toEqual('1st to 2nd Jan 1 AD')
- expect(eventDatesToStr(new CalDate(1670, 9, 1), new CalDate(1670, 9, 30),
- new CalDate(1670, 10, 1), new CalDate(1670, 10, 31)))
- .toEqual('Sep to Oct 1670')
- // Other
- expect(eventDatesToStr(new CalDate(10, 1, 2), null, null, null)).toBe('2nd Jan 10 AD')
- expect(eventDatesToStr(new YearDate(1000), null, new YearDate(1000), null)).toBe('1000 AD')
-})
+
+// ========== For server requests ==========
test('queryServer', async () => {
const oldFetch = fetch
@@ -167,10 +158,12 @@ test('queryServer', async () => {
expect(json).toEqual({test: 'value'})
fetch = oldFetch
})
+
test('jsonToHistDate', () => {
expect(jsonToHistDate({gcal: true, year: 1000, month: 1, day: 10})).toEqual(new CalDate(1000, 1, 10))
expect(jsonToHistDate({gcal: null, year: -5000, month: 1, day: 1})).toEqual(new YearDate(-5000))
})
+
test('jsonToHistEvent', () => {
const jsonEvent = {
id: 3,
@@ -196,6 +189,8 @@ test('jsonToHistEvent', () => {
});
})
+// ========== For dates in a timeline ==========
+
test('stepDate', () => {
expect(stepDate(new CalDate(2000, 1, 1), DAY_SCALE)).toEqual(new CalDate(2000, 1, 2))
expect(stepDate(new CalDate(2000, 1, 2), DAY_SCALE, {forward: false, count: 10})).toEqual(new CalDate(1999, 12, 23))
@@ -203,6 +198,7 @@ test('stepDate', () => {
expect(stepDate(new CalDate(2000, 1, 3), 1, {count: 10})).toEqual(new CalDate(2010, 1, 3))
expect(stepDate(new YearDate(-5000), 1e3, {forward: false, count: 6})).toEqual(new YearDate(-11000))
})
+
test('inDateScale', () => {
expect(inDateScale(new CalDate(100, 2, 3), DAY_SCALE)).toBe(true)
expect(inDateScale(new CalDate(100, 2, 3), MONTH_SCALE)).toBe(false)
@@ -212,18 +208,21 @@ test('inDateScale', () => {
expect(inDateScale(new YearDate(-5000), 1e3)).toBe(true)
expect(inDateScale(new YearDate(-5100), 1e3)).toBe(false)
})
+
test('getScaleRatio', () => {
expect(getScaleRatio(DAY_SCALE, MONTH_SCALE)).toBe(31)
expect(getScaleRatio(MONTH_SCALE, 1)).toBe(12)
expect(getScaleRatio(MONTH_SCALE, 10)).toBe(120)
expect(getScaleRatio(200, 10)).toBeCloseTo(1/20, 5)
})
+
test('getUnitDiff', () => {
expect(getUnitDiff(new CalDate(2000, 1, 1), (new CalDate(2000, 2, 2)), DAY_SCALE)).toBe(32)
expect(getUnitDiff(new CalDate(2000, 10, 10), (new CalDate(2001, 11, 2)), MONTH_SCALE)).toBe(13)
expect(getUnitDiff(new CalDate(-1, 1, 10), (new CalDate(10, 11, 2)), 1)).toBe(10)
expect(getUnitDiff(new YearDate(-5000), (new YearDate(-6500)), 10)).toBe(150)
})
+
test('getEventPrecision', () => {
expect(getEventPrecision(new HistEvent(1, 'one', new YearDate(-5000), new YearDate(-4991)))).toBe(10)
expect(getEventPrecision(new HistEvent(1, 'one', new YearDate(-5000), new YearDate(-4990)))).toBe(100)
@@ -231,6 +230,7 @@ test('getEventPrecision', () => {
expect(getEventPrecision(new HistEvent(1, 'one', new CalDate(1, 2, 3), new CalDate(1, 2, 25)))).toBe(MONTH_SCALE)
expect(getEventPrecision(new HistEvent(1, 'one', new CalDate(1, 2, 3), new CalDate(1, 2, 3)))).toBe(DAY_SCALE)
})
+
test('dateToUnit', () => {
expect(dateToUnit(new CalDate(2013), 1e3)).toBe(2)
expect(dateToUnit(new CalDate(2013), 100)).toBe(20)
@@ -239,6 +239,7 @@ test('dateToUnit', () => {
expect(dateToUnit(new CalDate(1911, 12, 3), MONTH_SCALE)).toBe(gregorianToJdn(1911, 12, 1))
expect(dateToUnit(new CalDate(1911, 12, 3, false), DAY_SCALE)).toBe(julianToJdn(1911, 12, 3))
})
+
test('dateToScaleDate', () => {
expect(dateToScaleDate(new CalDate(2013, 10, 3), DAY_SCALE)).toEqual(new CalDate(2013, 10, 3))
expect(dateToScaleDate(new CalDate(2013, 10, 3, false), MONTH_SCALE)).toEqual(new CalDate(2013, 10, 1))
@@ -248,6 +249,8 @@ test('dateToScaleDate', () => {
expect(dateToScaleDate(new YearDate(-1222333), 1e6)).toEqual(new YearDate(-2000000))
})
+// ========== For DateRangeTree ==========
+
test('DateRangeTree', () => {
const ranges = new DateRangeTree()
ranges.add([new CalDate(100, 1, 1), new CalDate(200, 1, 1)])
diff --git a/tests/rbtree.test.ts b/tests/rbtree.test.ts
index 5b2bc34..edb3135 100644
--- a/tests/rbtree.test.ts
+++ b/tests/rbtree.test.ts
@@ -3,6 +3,7 @@ import {RBTree, Iterator, rbtree_shallow_copy} from '/src/rbtree.ts'
function cmpInt(a: int, b: int){
return a - b;
}
+
function getIteratorEls<T>(itr: Iterator<T>): T[]{
let els: T[] = [];
if (itr.data() != null){
@@ -14,6 +15,7 @@ function getIteratorEls<T>(itr: Iterator<T>): T[]{
}
return els;
}
+
function getIteratorElsRev<T>(itr: Iterator<T>): T[]{
let els: T[] = [];
if (itr.data() != null){
@@ -40,6 +42,7 @@ test('insert and remove', () => {
expect(tree.min()).toBe(-1);
expect(tree.max()).toBe(20);
});
+
test('iteration', () => {
let vals = [10, 10, 20, 5, -1];
let tree = new RBTree(cmpInt);
@@ -51,6 +54,7 @@ test('iteration', () => {
sorted.reverse()
expect(getIteratorElsRev(tree.iterator())).toEqual(sorted);
});
+
test('find', () => {
let tree = new RBTree(cmpInt);
tree.insert(1);