aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xbackend/histplorer.py2
-rw-r--r--package.json6
-rw-r--r--src/App.vue123
-rw-r--r--src/components/BaseLine.vue4
-rw-r--r--src/components/TimeLine.vue32
-rw-r--r--src/lib.ts321
-rw-r--r--src/rbtree.ts4
-rw-r--r--tests/lib.test.ts161
-rw-r--r--tests/rbtree.test.ts65
-rw-r--r--tsconfig.json3
-rw-r--r--vite.config.ts5
11 files changed, 486 insertions, 240 deletions
diff --git a/backend/histplorer.py b/backend/histplorer.py
index 9f9e8ad..2c23b8e 100755
--- a/backend/histplorer.py
+++ b/backend/histplorer.py
@@ -41,7 +41,7 @@ class HistDate:
- 'month' and 'day' are at least 1, if given
- 'gcal' may be:
- True: Indicates a Gregorian calendar date
- - False: Means the date should be converted and displayed as a Julian calendar date
+ - False: Means the date should, for display, be converted to a Julian calendar date
- None: 'month' and 'day' are 1 (used for dates before the Julian period starting year 4713 BCE)
"""
def __init__(self, gcal: bool | None, year: int, month=1, day=1):
diff --git a/package.json b/package.json
index abcc393..a109c09 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
- "tsc": "vue-tsc --noEmit"
+ "tsc": "vue-tsc --noEmit",
+ "test": "vitest"
},
"author": "Terry Truong",
"license": "MIT",
@@ -17,17 +18,20 @@
"vue": "^3.2.37"
},
"devDependencies": {
+ "@testing-library/vue": "^6.6.1",
"@typescript-eslint/eslint-plugin": "^5.39.0",
"@typescript-eslint/parser": "^5.39.0",
"@vitejs/plugin-vue": "^3.1.0",
"autoprefixer": "^10.4.12",
"eslint": "^8.24.0",
"eslint-plugin-vue": "^9.6.0",
+ "happy-dom": "^8.1.0",
"postcss": "^8.4.17",
"smartcrop-cli": "^2.0.3",
"tailwindcss": "^3.1.8",
"typescript": "^4.6.4",
"vite": "^3.1.0",
+ "vitest": "^0.25.8",
"vue-tsc": "^0.40.4"
}
}
diff --git a/src/App.vue b/src/App.vue
index c3cf6bf..d3fbd69 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -39,8 +39,8 @@ import PlusIcon from './components/icon/PlusIcon.vue';
import SettingsIcon from './components/icon/SettingsIcon.vue';
import HelpIcon from './components/icon/HelpIcon.vue';
// Other
-import {HistDate, YearDate, TimelineState, HistEvent, queryServer, HistEventJson, jsonToHistEvent, cmpHistEvent,
- timeout} from './lib';
+import {timeout, HistDate, YearDate, HistEvent, queryServer, HistEventJson, jsonToHistEvent,
+ TimelineState, cmpHistEvent, DateRangeTree} from './lib';
import {useStore} from './store';
import {RBTree, rbtree_shallow_copy} from './rbtree';
@@ -102,92 +102,7 @@ function onTimelineRemove(idx: number){
// For storing and looking up events
const eventTree: ShallowRef<RBTree<HistEvent>> = shallowRef(new RBTree(cmpHistEvent));
let idToEvent: Map<number, HistEvent> = new Map();
-// For tracking ranges for which the server has no more events
-let exhaustedRanges = new RBTree(cmpDatePairs);
-function cmpDatePairs(datePair1: [HistDate, HistDate], datePair2: [HistDate, HistDate]){
- return datePair1[0].cmp(datePair2[0]);
-}
-function isExhaustedRange(startDate: HistDate, endDate: HistDate): boolean {
- // Check if input range is contained in a stored exhausted range
- let itr = exhaustedRanges.lowerBound([startDate, new YearDate()]);
- let datePair = itr.data();
- if (datePair == null){
- datePair = itr.prev();
- if (datePair == null){
- return false;
- } else {
- return !datePair[1].isEarlier(endDate);
- }
- } else {
- if (startDate.isEarlier(datePair[0])){
- return false;
- } else {
- return !datePair[1].isEarlier(endDate);
- }
- }
-}
-function addExhaustedRange(startDate: HistDate, endDate: HistDate){
- let rangesToRemove: HistDate[] = []; // Holds starts of ranges to remove
- // Find ranges to remove
- let itr = exhaustedRanges.lowerBound([startDate, new YearDate()]);
- let prevRange = itr.prev();
- if (prevRange != null){ // Check for start-overlapping range
- if (prevRange[1].isEarlier(startDate)){
- prevRange = null;
- } else {
- rangesToRemove.push(prevRange[0]);
- }
- }
- let datePair = itr.next();
- while (datePair != null && !endDate.isEarlier(datePair[1])){ // Check for included ranges
- rangesToRemove.push(datePair[0]);
- datePair = itr.next();
- }
- let nextRange = itr.data();
- if (nextRange != null){ // Check for end-overlapping range
- if (endDate.isEarlier(nextRange[0])){
- nextRange = null;
- } else {
- rangesToRemove.push(nextRange[0])
- }
- }
- // Remove included/overlapping ranges
- for (let start of rangesToRemove){
- exhaustedRanges.remove([start, new YearDate()]);
- }
- // Add possibly-merged range
- if (prevRange != null){
- startDate = prevRange[0];
- }
- if (nextRange != null){
- endDate = nextRange[1];
- }
- exhaustedRanges.insert([startDate, endDate]);
-}
-// 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 onEventDisplay(eventIds: number[], timelineId: number){
- displayedEvents.set(timelineId, eventIds);
-}
-function reduceEvents(){
- // Get events to keep
- let eventsToKeep: Map<number, HistEvent> = new Map();
- for (let [, ids] of displayedEvents){
- for (let id of ids){
- eventsToKeep.set(id, idToEvent.get(id)!);
- }
- }
- // Create new event tree
- let newTree = new RBTree(cmpHistEvent);
- for (let [, event] of eventsToKeep){
- newTree.insert(event);
- }
- // Replace old data
- eventTree.value = newTree;
- idToEvent = eventsToKeep;
- exhaustedRanges.clear();
-}
+let exhaustedRanges = new DateRangeTree(); // Holds ranges for which the server has no more events
// For getting events from server
const EVENT_REQ_LIMIT = 30;
const REQ_EXCLS_LIMIT = 100;
@@ -197,8 +112,8 @@ async function onEventReq(startDate: HistDate, endDate: HistDate){
await timeout(100);
}
pendingReq = true;
- // Exclude exhausted range
- if (isExhaustedRange(startDate, endDate)){
+ // Skip if exhausted range
+ if (exhaustedRanges.has([startDate, endDate])){
pendingReq = false;
return;
}
@@ -244,7 +159,7 @@ async function onEventReq(startDate: HistDate, endDate: HistDate){
if (added){
eventTree.value = rbtree_shallow_copy(eventTree.value); // Note: triggerRef(eventTree) does not work here
} else {
- addExhaustedRange(startDate, endDate); // Mark as exhausted range
+ exhaustedRanges.add([startDate, endDate]); // Mark as exhausted range
}
// Check memory limit
if (eventTree.value.size > EXCESS_EVENTS_THRESHOLD){
@@ -252,6 +167,30 @@ async function onEventReq(startDate: HistDate, endDate: HistDate){
}
pendingReq = false;
}
+// 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 onEventDisplay(eventIds: number[], timelineId: number){
+ displayedEvents.set(timelineId, eventIds);
+}
+function reduceEvents(){
+ // Get events to keep
+ let eventsToKeep: Map<number, HistEvent> = new Map();
+ for (let [, ids] of displayedEvents){
+ for (let id of ids){
+ eventsToKeep.set(id, idToEvent.get(id)!);
+ }
+ }
+ // Create new event tree
+ let newTree = new RBTree(cmpHistEvent);
+ for (let [, event] of eventsToKeep){
+ newTree.insert(event);
+ }
+ // Replace old data
+ eventTree.value = newTree;
+ idToEvent = eventsToKeep;
+ exhaustedRanges.clear();
+}
// For resize handling
let lastResizeHdlrTime = 0; // Used to throttle resize handling
@@ -269,7 +208,7 @@ async function onResize(){
}
// Setup a handler to execute after ending a run of resize events
clearTimeout(afterResizeHdlr);
- afterResizeHdlr = setTimeout(async () => {
+ afterResizeHdlr = window.setTimeout(async () => {
afterResizeHdlr = 0;
await handleResize();
lastResizeHdlrTime = new Date().getTime();
diff --git a/src/components/BaseLine.vue b/src/components/BaseLine.vue
index 33d8a88..0187b20 100644
--- a/src/components/BaseLine.vue
+++ b/src/components/BaseLine.vue
@@ -43,7 +43,7 @@ const periods: Ref<Period[]> = ref([
const skipTransition = ref(true);
onMounted(() => setTimeout(() => {skipTransition.value = false}, 100));
-// For size tracking (used to prevent time spans shrinking below 1 pixel)
+// For size and mount-status tracking
const width = ref(0);
const height = ref(0);
const mounted = ref(false);
@@ -87,7 +87,7 @@ function spanStyles(state: TimelineState){
let start = state.startDate.clone();
let end = state.endDate.clone();
let scale = SCALES[state.scaleIdx];
- if (scale != MONTH_SCALE && scale != DAY_SCALE){ // Possibly incorporate offsets
+ if (scale != MONTH_SCALE && scale != DAY_SCALE){ // Account for offsets
stepDate(start, 1, {forward: false, count: Math.floor(state.startOffset * scale), inplace: true});
stepDate(end, 1, {count: Math.floor(state.endOffset * scale), inplace: true});
}
diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index 8a6ddc4..fd119be 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -11,6 +11,8 @@
<stop offset="95%" stop-color="gold"/>
</linearGradient>
</defs>
+ <!-- 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 -->
<template v-for="date, idx in ticks.dates" :key="date.toInt()">
<line v-if="date.equals(MIN_DATE, scale) || date.equals(MAX_DATE, scale)"
@@ -24,19 +26,17 @@
:stroke="store.color.alt" stroke-width="1px"
:style="tickStyles(idx)" class="animate-fadein"/>
</template>
- <!-- Event lines -->
- <line v-for="id in eventLines.keys()" :key="id"
- x1="0" y1="0" x2="1" y2="0.01" stroke="url('#eventLineGradient')" stroke-width="1px"
- :style="eventLineStyles(id)" class="animate-fadein"/>
- <!-- Note: With a fully vertical or horizontal line, nothing gets displayed -->
<!-- Tick labels -->
<text v-for="date, idx in ticks.dates" :key="date.toInt()"
x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle"
:fill="store.color.textDark" :style="tickLabelStyles(idx)" class="text-sm animate-fadein">
{{date.toDisplayString()}}
</text>
- <!-- 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"/>
+ <!-- Event lines -->
+ <line v-for="id in eventLines.keys()" :key="id"
+ x1="0" y1="0" x2="1" y2="0.01" stroke="url('#eventLineGradient')" stroke-width="1px"
+ :style="eventLineStyles(id)" class="animate-fadein"/>
+ <!-- Note: With a fully vertical or horizontal line, nothing gets displayed -->
</svg>
<!-- Events -->
<div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein" :style="eventStyles(id)">
@@ -88,7 +88,7 @@ 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 prevVert = ref(props.vert); // For skipping transitions on horz/vert swap
+const prevVert = ref(props.vert); // Previous 'vert' value, used for skipping transitions on horz/vert swap
const mounted = ref(false);
onMounted(() => {
let rootEl = rootRef.value!;
@@ -121,7 +121,7 @@ const eventMajorSz = computed(() => props.vert ? eventHeight.value : eventWidth.
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 mainline-area line to side of display area
+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;
} else {
@@ -675,9 +675,9 @@ function zoomTimeline(zoomRatio: number){
}
newNumUnits += newStartOffset + newEndOffset;
}
- // Possibly change the scale
+ // Possibly zoom in/out
let tickDiff = availLen.value / newNumUnits;
- if (tickDiff < store.minTickSep){ // Possibly zoom out
+ if (tickDiff < store.minTickSep){ // If trying to zoom out
if (scaleIdx.value == 0){
console.log('Reached zoom out limit');
return;
@@ -688,8 +688,8 @@ function zoomTimeline(zoomRatio: number){
newStartOffset /= oldUnitsPerNew;
newEndOffset /= oldUnitsPerNew;
// Shift starting and ending points to align with new scale
- // Note: There is some distortion due to not accounting for no year 0 CE here
- // But the result seems tolerable, and resolving it adds a fair bit of code complexity
+ // Note: There is some distortion due to not fully accounting for no year 0 CE here,
+ // but the result seems tolerable, and resolving it adds a fair bit of code complexity
let newStartSubUnits =
(scale.value == DAY_SCALE) ? getDaysInMonth(newStart.year, newStart.month) :
(scale.value == MONTH_SCALE) ? 12 :
@@ -742,7 +742,7 @@ function zoomTimeline(zoomRatio: number){
//
scaleIdx.value -= 1;
}
- } else { // Possibly zoom in
+ } else { // If trying to zoom in
if (scaleIdx.value == SCALES.length - 1){
if (newNumUnits < store.minLastTicks){
console.log('Reached zoom in limit');
@@ -824,7 +824,7 @@ function onPointerDown(evt: PointerEvent){
dragVelocity = 0;
vUpdateTime = Date.now();
vPrevPointer = null;
- vUpdater = setInterval(() => {
+ vUpdater = window.setInterval(() => {
if (vPrevPointer != null){
let time = Date.now();
let ptrDiff = (props.vert ? pointerY! : pointerX!) - vPrevPointer;
@@ -843,7 +843,7 @@ function onPointerMove(evt: PointerEvent){
// Handle pointer dragging
dragDiff += props.vert ? evt.clientY - pointerY! : evt.clientX - pointerX!;
if (dragHandler == 0){
- dragHandler = setTimeout(() => {
+ dragHandler = window.setTimeout(() => {
if (Math.abs(dragDiff) > 2){
panTimeline(-dragDiff / availLen.value);
dragDiff = 0;
diff --git a/src/lib.ts b/src/lib.ts
index fa803bc..4726387 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -2,10 +2,14 @@
* Project-wide globals
*/
+import {RBTree} from './rbtree';
+
export const DEBUG = true;
-export const WRITING_MODE_HORZ =
- window.getComputedStyle(document.body)['writing-mode' as any].startsWith('horizontal');
+export let WRITING_MODE_HORZ = true;
// Used with ResizeObserver callbacks, to determine which resized dimensions are width and height
+if ('writing-mode' in window.getComputedStyle(document.body)){ // Can be null when testing
+ WRITING_MODE_HORZ = window.getComputedStyle(document.body)['writing-mode' as any].startsWith('horizontal');
+}
// Similar to %, but for negative LHS, return a positive offset from a lower RHS multiple
export function moduloPositive(x: number, y: number){
@@ -16,7 +20,7 @@ export async function timeout(ms: number){
return new Promise(resolve => setTimeout(resolve, ms))
}
-// 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;
@@ -75,7 +79,7 @@ export function getDaysInMonth(year: number, month: number){
}
// For date representation
-export const MIN_CAL_YEAR = -4713; // Year after which months/day scales are usable
+export const MIN_CAL_YEAR = -4713; // Earliest year where months/day scales are usable
export class HistDate {
gcal: boolean | null;
year: number;
@@ -84,8 +88,8 @@ export class HistDate {
constructor(gcal: boolean | null, year: number, month: number, day: number){
this.gcal = gcal;
this.year = year;
- this.month = month;
- this.day = day;
+ this.month = gcal == null ? 1 : month;
+ this.day = gcal == null ? 1 : day;
}
equals(other: HistDate, scale=DAY_SCALE){
if (scale == DAY_SCALE){
@@ -96,6 +100,9 @@ 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;
@@ -119,8 +126,33 @@ export class HistDate {
return 0;
}
}
- toInt(){
- return this.day + this.month * 50 + this.year * 1000;
+ 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;
+ let later = other;
+ if (other.year < this.year || other.year == this.year && other.month < this.month){
+ earlier = other;
+ later = this as HistDate;
+ }
+ //
+ const yearDiff = earlier.getYearDiff(later);
+ if (yearDiff == 0){
+ return later.month - earlier.month;
+ } else {
+ 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 CE
+ yearDiff -= 1;
+ }
+ return yearDiff;
}
toString(){
if (this.gcal != null){
@@ -175,36 +207,8 @@ export class HistDate {
}
}
}
- 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;
- let later = other;
- if (other.year < this.year || other.year == this.year && other.month < this.month){
- earlier = other;
- later = this as HistDate;
- }
- //
- const yearDiff = earlier.getYearDiff(later);
- if (yearDiff == 0){
- return later.month - earlier.month;
- } else {
- 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 CE
- yearDiff -= 1;
- }
- return yearDiff;
- }
- clone(){
- return new HistDate(this.gcal, this.year, this.month, this.day);
+ toInt(){ // Used for v-for keys
+ return this.day + this.month * 50 + this.year * 1000;
}
}
export class YearDate extends HistDate {
@@ -232,7 +236,97 @@ export class CalDate extends HistDate {
}
}
-// Timeline parameters
+// For event representation
+export class HistEvent {
+ id: number;
+ title: string;
+ start: HistDate;
+ startUpper: HistDate | null;
+ end: HistDate | null;
+ endUpper: HistDate | null;
+ 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){
+ this.id = id;
+ this.title = title;
+ this.start = start;
+ this.startUpper = startUpper;
+ this.end = end;
+ this.endUpper = endUpper;
+ this.ctg = ctg;
+ this.imgId = imgId;
+ this.pop = pop;
+ }
+}
+export function cmpHistEvent(event: HistEvent, event2: HistEvent){
+ const cmp = event.start.cmp(event2.start);
+ return cmp != 0 ? cmp : event.id - event2.id;
+}
+
+// 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 {
+ const response = await fetch(url.toString());
+ responseObj = await response.json();
+ } catch (error){
+ console.log(`Error with querying ${url.toString()}: ${error}`);
+ return null;
+ }
+ return responseObj;
+}
+export function getImagePath(imgId: number): string {
+ return SERVER_IMG_PATH + String(imgId) + '.jpg';
+}
+// For server responses
+export type HistDateJson = {
+ gcal: boolean | null,
+ year: number,
+ month: number,
+ day: number,
+}
+export type HistEventJson = {
+ id: number,
+ title: string,
+ start: HistDateJson,
+ startUpper: HistDateJson | null,
+ end: HistDateJson | null,
+ endUpper: HistDateJson | null,
+ ctg: string,
+ imgId: number,
+ pop: number,
+}
+export function jsonToHistDate(json: HistDateJson){
+ if (json.gcal == null){
+ return new YearDate(json.year);
+ } else {
+ return new CalDate(json.year, json.month, json.day, json.gcal);
+ }
+}
+export function jsonToHistEvent(json: HistEventJson){
+ return {
+ id: json.id,
+ title: json.title,
+ start: jsonToHistDate(json.start),
+ startUpper: json.startUpper == null ? null : jsonToHistDate(json.startUpper),
+ end: json.end == null ? null : jsonToHistDate(json.end),
+ endUpper: json.endUpper == null ? null : jsonToHistDate(json.endUpper),
+ ctg: json.ctg,
+ imgId: json.imgId,
+ pop: json.pop,
+ };
+}
+
+// For dates in a timeline
const currentDate = new Date();
export const MIN_DATE = new YearDate(-13.8e9);
export const MAX_DATE = new CalDate(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate());
@@ -259,7 +353,8 @@ if (DEBUG){
}
}
}
-export function stepDate(date: HistDate, scale: number, {forward=true, count=1, inplace=false} = {}): HistDate {
+export function stepDate( // If stepping by month or years, leaves day value unchanged
+ date: HistDate, scale: number, {forward=true, count=1, inplace=false} = {}): HistDate {
const newDate = inplace ? date : date.clone();
if (count < 0){
count = -count;
@@ -342,7 +437,7 @@ export function inDateScale(date: HistDate, scale: number): boolean {
}
}
export function getScaleRatio(scale: number, scale2: number){
- // Returns upper number of units in 'scale' per unit in 'scale2'
+ // Returns number of units in 'scale' per unit in 'scale2' (provides an upper value for days-per-month/year)
if (scale == DAY_SCALE){
scale = 1 / 12 / 31;
} else if (scale == MONTH_SCALE){
@@ -384,91 +479,67 @@ export class TimelineState {
}
}
-export class HistEvent {
- id: number;
- title: string;
- start: HistDate;
- startUpper: HistDate | null;
- end: HistDate | null;
- endUpper: HistDate | null;
- 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){
- this.id = id;
- this.title = title;
- this.start = start;
- this.startUpper = startUpper;
- this.end = end;
- this.endUpper = endUpper;
- this.ctg = ctg;
- this.imgId = imgId;
- this.pop = pop;
+// 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]));
}
-}
-export function cmpHistEvent(event: HistEvent, event2: HistEvent){
- const cmp = event.start.cmp(event2.start);
- return cmp != 0 ? cmp : event.id - event2.id;
-}
-
-// 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){
- // Construct URL
- const url = new URL(SERVER_DATA_URL);
- url.search = params.toString();
- // Query server
- let responseObj;
- try {
- const response = await fetch(url.toString());
- responseObj = await response.json();
- } catch (error){
- console.log(`Error with querying ${url.toString()}: ${error}`);
- return null;
+ add(range: DateRange){
+ let rangesToRemove: HistDate[] = []; // Holds starts of ranges to remove
+ let dummyDate = new YearDate();
+ // Find ranges to remove
+ let itr = this.tree.lowerBound([range[0], dummyDate]);
+ let prevRange = itr.prev();
+ if (prevRange != null){ // Check for start-overlapping range
+ if (prevRange[1].isEarlier(range[0])){
+ prevRange = null;
+ } else {
+ rangesToRemove.push(prevRange[0]);
+ }
+ }
+ let r = itr.next();
+ while (r != null && !range[1].isEarlier(r[1])){ // Check for included ranges
+ rangesToRemove.push(r[0]);
+ r = itr.next();
+ }
+ let nextRange = itr.data();
+ if (nextRange != null){ // Check for end-overlapping range
+ if (range[1].isEarlier(nextRange[0])){
+ nextRange = null;
+ } else {
+ rangesToRemove.push(nextRange[0])
+ }
+ }
+ // Remove included/overlapping ranges
+ for (let start of rangesToRemove){
+ this.tree.remove([start, dummyDate]);
+ }
+ // Add possibly-merged range
+ let startDate = prevRange != null ? prevRange[0] : range[0];
+ let endDate = nextRange != null ? nextRange[1] : range[1];
+ this.tree.insert([startDate, endDate]);
}
- return responseObj;
-}
-export function getImagePath(imgId: number): string {
- return SERVER_IMG_PATH + String(imgId) + '.jpg';
-}
-// For server responses
-export type HistDateJson = {
- gcal: boolean | null,
- year: number,
- month: number,
- day: number,
-}
-export type HistEventJson = {
- id: number,
- title: string,
- start: HistDateJson,
- startUpper: HistDateJson | null,
- end: HistDateJson | null,
- endUpper: HistDateJson | null,
- ctg: string,
- imgId: number,
- pop: number,
-}
-export function jsonToHistDate(json: HistDateJson){
- if (json.gcal == null){
- return new YearDate(json.year);
- } else {
- return new CalDate(json.year, json.month, json.day, json.gcal);
+ has(range: DateRange): boolean {
+ let itr = this.tree.lowerBound([range[0], new YearDate()]);
+ let r = itr.data();
+ if (r == null){
+ r = itr.prev();
+ if (r == null){
+ return false;
+ } else {
+ return !r[1].isEarlier(range[1]);
+ }
+ } else {
+ if (range[0].isEarlier(r[0])){
+ return false;
+ } else {
+ return !r[1].isEarlier(range[1]);
+ }
+ }
+ }
+ clear(){
+ this.tree.clear();
}
-}
-export function jsonToHistEvent(json: HistEventJson){
- return {
- id: json.id,
- title: json.title,
- start: jsonToHistDate(json.start),
- startUpper: json.startUpper == null ? null : jsonToHistDate(json.startUpper),
- end: json.end == null ? null : jsonToHistDate(json.end),
- endUpper: json.endUpper == null ? null : jsonToHistDate(json.endUpper),
- ctg: json.ctg,
- imgId: json.imgId,
- pop: json.pop,
- };
}
diff --git a/src/rbtree.ts b/src/rbtree.ts
index 8422dec..b4ae540 100644
--- a/src/rbtree.ts
+++ b/src/rbtree.ts
@@ -1,6 +1,6 @@
// Copied from node_modules/bintrees/lib/, and adapted to use ES6, classes, and typescript
-class Node<T> {
+export class Node<T> {
data: T;
left: Node<T> | null;
right: Node<T> | null;
@@ -24,7 +24,7 @@ class Node<T> {
}
}
-class Iterator<T> {
+export class Iterator<T> {
_tree: RBTree<T>;
_ancestors: Node<T>[];
_cursor: Node<T> | null;
diff --git a/tests/lib.test.ts b/tests/lib.test.ts
new file mode 100644
index 0000000..9421ca1
--- /dev/null
+++ b/tests/lib.test.ts
@@ -0,0 +1,161 @@
+import {moduloPositive,
+ gregorianToJdn, julianToJdn, jdnToGregorian, jdnToJulian, gregorianToJulian, julianToGregorian, getDaysInMonth,
+ HistDate, YearDate, CalDate,
+ queryServer, jsonToHistDate, jsonToHistEvent,
+ DAY_SCALE, MONTH_SCALE, stepDate, inDateScale, getScaleRatio, getUnitDiff,
+ DateRangeTree,
+} from '/src/lib.ts'
+
+test('moduloPositive', () => {
+ expect(moduloPositive(4, 2)).toBe(0)
+ expect(moduloPositive(5, 3)).toBe(2)
+ expect(moduloPositive(-5, 3)).toBe(1)
+})
+
+test('gregorianToJdn', () => {
+ expect(gregorianToJdn(2010, 11, 3)).toBe(2455504)
+ expect(gregorianToJdn(-4714, 11, 24)).toBe(0)
+ expect(gregorianToJdn(-1, 1, 1)).toBe(1721060)
+})
+test('julianToJdn', () => {
+ expect(julianToJdn(2010, 11, 3)).toBe(2455517)
+ expect(julianToJdn(-4713, 1, 1)).toBe(0)
+ expect(julianToJdn(-1, 1, 1)).toBe(1721058)
+})
+test('jdnToGregorian', () => {
+ expect(jdnToGregorian(2455504)).toEqual([2010, 11, 3])
+ expect(jdnToGregorian(0)).toEqual([-4714, 11, 24])
+ expect(jdnToGregorian(1721060)).toEqual([-1, 1, 1])
+})
+test('jdnToJulian', () => {
+ expect(jdnToJulian(2455517)).toEqual([2010, 11, 3])
+ expect(jdnToJulian(0)).toEqual([-4713, 1, 1])
+ expect(jdnToJulian(1721058)).toEqual([-1, 1, 1])
+})
+test('gregorianToJulian', () => {
+ expect(gregorianToJulian(2022, 9, 30)).toEqual([2022, 9, 17])
+ expect(gregorianToJulian(1616, 5, 3)).toEqual([1616, 4, 23])
+})
+test('julianToGregorian', () => {
+ expect(julianToGregorian(2022, 9, 17)).toEqual([2022, 9, 30])
+ expect(julianToGregorian(1616, 4, 23)).toEqual([1616, 5, 3])
+})
+test('getDaysInMonth', () => {
+ expect(getDaysInMonth(2022, 12)).toBe(31)
+ expect(getDaysInMonth(2022, 2)).toBe(28)
+ expect(getDaysInMonth(2000, 2)).toBe(29)
+})
+
+describe('YearDate', () => {
+ test('constructor', () => {
+ expect(() => new YearDate(2000)).toThrowError()
+ expect(() => new YearDate(-5000)).not.toThrowError()
+ })
+ test('cmp', () => {
+ expect((new YearDate(-5000)).equals(new YearDate(-5000))).toBe(true)
+ expect((new YearDate(-6000)).equals(new YearDate(-5999))).toBe(false)
+ expect((new YearDate(-6000)).isEarlier(new YearDate(-5000))).toBe(true)
+ expect((new YearDate(-5000)).cmp(new YearDate(-6000))).toBe(1)
+ })
+ test('diff', () => {
+ expect((new YearDate(-5000)).getMonthDiff(new YearDate(-5001))).toBe(12)
+ expect((new YearDate(-5000)).getYearDiff(new YearDate(-6000))).toBe(1000)
+ })
+})
+describe('CalDate', () => {
+ test('cmp', () => {
+ expect((new CalDate(2000, 1, 1)).equals(new CalDate(2000, 1, 1))).toBe(true)
+ expect((new CalDate(2000, 1, 1)).equals(new CalDate(-1, 1, 1))).toBe(false)
+ expect((new CalDate(-1, 1, 1)).isEarlier(new CalDate(1, 1, 1))).toBe(true)
+ expect((new CalDate(1, 11, 1)).cmp(new CalDate(2, 1, 11))).toBe(-1)
+ expect((new CalDate(100, 12, 1)).cmp(new CalDate(100, 11, 30))).toBe(1)
+ expect((new CalDate(10, 3, 10)).cmp(new CalDate(10, 3, 20))).toBe(-1)
+ })
+ test('diff', () => {
+ expect((new CalDate(2000, 1, 1)).getDayDiff(new CalDate(2001, 1, 1))).toBe(366)
+ expect((new CalDate(100, 11, 30)).getMonthDiff(new CalDate(101, 4, 1))).toBe(5)
+ expect((new CalDate(-1, 10, 3)).getYearDiff(new CalDate(1, 1, 1))).toBe(1)
+ })
+})
+
+test('queryServer', async () => {
+ let oldFetch = fetch
+ fetch = vi.fn(() => ({json: () => ({test: 'value'})}))
+ let json = await queryServer('', 'http://example.com/')
+ expect(json).toEqual({test: 'value'})
+ fetch = oldFetch
+})
+test('jsonToHistDate', () => {
+ expect(jsonToHistDate({gcal: true, year: 1000, month: 1, day: 10})).toEqual(new CalDate(1000, 1, 10))
+ expect(jsonToHistDate({gcal: null, year: -5000, month: 1, day: 1})).toEqual(new YearDate(-5000))
+})
+test('jsonToHistEvent', () => {
+ let jsonEvent = {
+ id: 3,
+ title: 'abc',
+ start: {gcal: true, year: 2000, month: 10, day: 5},
+ startUper: null,
+ end: {gcal: true, year: 2010, month: 1, day: 1},
+ endUpper: null,
+ ctg: 'event',
+ imgId: 100,
+ pop: 301,
+ }
+ expect(jsonToHistEvent(jsonEvent)).toEqual({
+ id: 3,
+ title: 'abc',
+ start: new CalDate(2000, 10, 5),
+ startUpper: null,
+ end: new CalDate(2010, 1, 1),
+ endUpper: null,
+ ctg: 'event',
+ imgId: 100,
+ pop: 301,
+ });
+})
+
+test('stepDate', () => {
+ expect(stepDate(new CalDate(2000, 1, 1), DAY_SCALE)).toEqual(new CalDate(2000, 1, 2))
+ expect(stepDate(new CalDate(2000, 1, 2), DAY_SCALE, {forward: false, count: 10})).toEqual(new CalDate(1999, 12, 23))
+ expect(stepDate(new CalDate(2000, 10, 11), MONTH_SCALE, {count: 5})).toEqual(new CalDate(2001, 3, 11))
+ expect(stepDate(new CalDate(2000, 1, 3), 1, {count: 10})).toEqual(new CalDate(2010, 1, 3))
+ expect(stepDate(new YearDate(-5000), 1e3, {forward: false, count: 6})).toEqual(new YearDate(-11000))
+})
+test('inDateScale', () => {
+ expect(inDateScale(new CalDate(100, 2, 3), DAY_SCALE)).toBe(true)
+ expect(inDateScale(new CalDate(100, 2, 3), MONTH_SCALE)).toBe(false)
+ expect(inDateScale(new CalDate(100, 2, 1), MONTH_SCALE)).toBe(true)
+ expect(inDateScale(new CalDate(100, 2, 1), 1)).toBe(false)
+ expect(inDateScale(new CalDate(100, 1, 1), 1)).toBe(true)
+ expect(inDateScale(new YearDate(-5000), 1e3)).toBe(true)
+ expect(inDateScale(new YearDate(-5100), 1e3)).toBe(false)
+})
+test('getScaleRatio', () => {
+ expect(getScaleRatio(DAY_SCALE, MONTH_SCALE)).toBe(31)
+ expect(getScaleRatio(MONTH_SCALE, 1)).toBe(12)
+ expect(getScaleRatio(MONTH_SCALE, 10)).toBe(120)
+ expect(getScaleRatio(200, 10)).toBeCloseTo(1/20, 5)
+})
+test('getUnitDiff', () => {
+ expect(getUnitDiff(new CalDate(2000, 1, 1), (new CalDate(2000, 2, 2)), DAY_SCALE)).toBe(32)
+ expect(getUnitDiff(new CalDate(2000, 10, 10), (new CalDate(2001, 11, 2)), MONTH_SCALE)).toBe(13)
+ expect(getUnitDiff(new CalDate(-1, 1, 10), (new CalDate(10, 11, 2)), 1)).toBe(10)
+ expect(getUnitDiff(new YearDate(-5000), (new YearDate(-6500)), 10)).toBe(150)
+})
+
+test('DateRangeTree', () => {
+ let ranges = new DateRangeTree()
+ ranges.add([new CalDate(100, 1, 1), new CalDate(200, 1, 1)])
+ ranges.add([new CalDate(300, 1, 1), new CalDate(400, 1, 1)])
+ expect(ranges.tree.size).toBe(2)
+ expect(ranges.has([new CalDate(300, 1, 1), new CalDate(400, 1, 1)])).toBe(true)
+ ranges.add([new CalDate(-100, 1, 1), new CalDate(150, 1, 1)])
+ ranges.add([new CalDate(400, 1, 1), new CalDate(500, 1, 1)])
+ expect(ranges.tree.size).toBe(2)
+ expect(ranges.has([new CalDate(-100, 1, 1), new CalDate(200, 1, 1)])).toBe(true)
+ expect(ranges.has([new CalDate(300, 1, 1), new CalDate(500, 1, 1)])).toBe(true)
+ ranges.add([new CalDate(-1000, 1, 1), new CalDate(310, 10, 2)])
+ expect(ranges.tree.size).toBe(1)
+ expect(ranges.has([new CalDate(-1000, 1, 1), new CalDate(500, 1, 1)])).toBe(true)
+ expect(ranges.has([new CalDate(-1, 1, 1), new CalDate(1, 1, 1)])).toBe(true)
+})
diff --git a/tests/rbtree.test.ts b/tests/rbtree.test.ts
new file mode 100644
index 0000000..5b2bc34
--- /dev/null
+++ b/tests/rbtree.test.ts
@@ -0,0 +1,65 @@
+import {RBTree, Iterator, rbtree_shallow_copy} from '/src/rbtree.ts'
+
+function cmpInt(a: int, b: int){
+ return a - b;
+}
+function getIteratorEls<T>(itr: Iterator<T>): T[]{
+ let els: T[] = [];
+ if (itr.data() != null){
+ els.push(itr.data());
+ }
+ let el: T | null;
+ while ((el = itr.next()) != null){
+ els.push(el);
+ }
+ return els;
+}
+function getIteratorElsRev<T>(itr: Iterator<T>): T[]{
+ let els: T[] = [];
+ if (itr.data() != null){
+ els.push(itr.data());
+ }
+ let el: T | null;
+ while ((el = itr.prev()) != null){
+ els.push(el);
+ }
+ return els;
+}
+
+test('insert and remove', () => {
+ let tree = new RBTree(cmpInt);
+ expect(tree.insert(10)).toBe(true);
+ expect(tree.insert(10)).toBe(false);
+ expect(tree.insert(20)).toBe(true);
+ expect(tree.insert(-1)).toBe(true);
+ //
+ expect(tree.remove(100)).toBe(false);
+ expect(tree.remove(10)).toBe(true);
+ //
+ expect(tree.size).toBe(2);
+ expect(tree.min()).toBe(-1);
+ expect(tree.max()).toBe(20);
+});
+test('iteration', () => {
+ let vals = [10, 10, 20, 5, -1];
+ let tree = new RBTree(cmpInt);
+ for (let v of vals){
+ tree.insert(v);
+ }
+ let sorted = Array.from(new Set(vals)).sort(cmpInt);
+ expect(getIteratorEls(tree.iterator())).toEqual(sorted);
+ sorted.reverse()
+ expect(getIteratorElsRev(tree.iterator())).toEqual(sorted);
+});
+test('find', () => {
+ let tree = new RBTree(cmpInt);
+ tree.insert(1);
+ tree.insert(10);
+ tree.insert(50);
+ tree.insert(100);
+ //
+ expect(tree.find(40)).toBe(null);
+ expect(tree.find(50)).toBe(50);
+ expect(getIteratorEls(tree.lowerBound(10))).toEqual([10, 50, 100]);
+ expect(getIteratorEls(tree.upperBound(10))).toEqual([50, 100]);
+});
diff --git a/tsconfig.json b/tsconfig.json
index d4aefa2..8ebe6d7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,8 @@
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
- "skipLibCheck": true
+ "skipLibCheck": true,
+ "types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
diff --git a/vite.config.ts b/vite.config.ts
index 6baaeb0..729378a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,4 @@
+/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
@@ -14,4 +15,8 @@ export default defineConfig({
build: {
sourcemap: true,
},
+ test: {
+ globals: true,
+ environment: 'happy-dom',
+ },
})