aboutsummaryrefslogtreecommitdiff
path: root/src
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 /src
parent0a9b2c2e5eca8a04e37fbdd423379882863237c2 (diff)
Adjust frontend coding style
Diffstat (limited to 'src')
-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
11 files changed, 534 insertions, 186 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);