diff options
| author | Terry Truong <terry06890@gmail.com> | 2023-01-21 13:47:28 +1100 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2023-01-21 13:47:28 +1100 |
| commit | bf357e48dc261dab08598bd93071ca53ef386402 (patch) | |
| tree | 826d1cd1cb8d14fe65293c8efaa97b7e7622c876 /src | |
| parent | 0a9b2c2e5eca8a04e37fbdd423379882863237c2 (diff) | |
Adjust frontend coding style
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.vue | 105 | ||||
| -rw-r--r-- | src/components/BaseLine.vue | 27 | ||||
| -rw-r--r-- | src/components/HelpModal.vue | 26 | ||||
| -rw-r--r-- | src/components/InfoModal.vue | 21 | ||||
| -rw-r--r-- | src/components/SCollapsible.vue | 14 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 38 | ||||
| -rw-r--r-- | src/components/SettingsModal.vue | 32 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 192 | ||||
| -rw-r--r-- | src/lib.ts | 208 | ||||
| -rw-r--r-- | src/rbtree.ts | 29 | ||||
| -rw-r--r-- | src/store.ts | 28 |
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 { @@ -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); |
