diff options
| -rwxr-xr-x | backend/histplorer.py | 2 | ||||
| -rw-r--r-- | package.json | 6 | ||||
| -rw-r--r-- | src/App.vue | 123 | ||||
| -rw-r--r-- | src/components/BaseLine.vue | 4 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 32 | ||||
| -rw-r--r-- | src/lib.ts | 321 | ||||
| -rw-r--r-- | src/rbtree.ts | 4 | ||||
| -rw-r--r-- | tests/lib.test.ts | 161 | ||||
| -rw-r--r-- | tests/rbtree.test.ts | 65 | ||||
| -rw-r--r-- | tsconfig.json | 3 | ||||
| -rw-r--r-- | vite.config.ts | 5 |
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; @@ -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', + }, }) |
