diff options
| -rw-r--r-- | .eslintrc.js | 1 | ||||
| -rw-r--r-- | src/App.vue | 6 | ||||
| -rw-r--r-- | src/components/BaseLine.vue | 25 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 382 | ||||
| -rw-r--r-- | src/lib.ts | 213 |
5 files changed, 476 insertions, 151 deletions
diff --git a/.eslintrc.js b/.eslintrc.js index abbda84..167024a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { ], "rules": { "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-this-alias": "off", "no-constant-condition": "off", } } diff --git a/src/App.vue b/src/App.vue index f32b367..278ef63 100644 --- a/src/App.vue +++ b/src/App.vue @@ -38,7 +38,7 @@ import PlusIcon from './components/icon/PlusIcon.vue'; import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; // Other -import {TimelineRange} from './lib'; +import {HistDate, TimelineRange} from './lib'; import {useStore} from './store'; // Refs @@ -62,11 +62,11 @@ onMounted(updateAreaDims) const timelineRanges: Ref<TimelineRange[]> = ref([]); let nextTimelineId = 1; function addNewTimelineRange(){ - timelineRanges.value.push({id: nextTimelineId, start: -500, end: 500}); + timelineRanges.value.push({id: nextTimelineId, start: new HistDate(1900, 1, 1), end: new HistDate(2000, 1, 1)}); nextTimelineId++; } addNewTimelineRange(); -function onRangeChg(newBounds: [number, number], idx: number){ +function onRangeChg(newBounds: [HistDate, HistDate], idx: number){ let range = timelineRanges.value[idx]; range.start = newBounds[0]; range.end = newBounds[1]; diff --git a/src/components/BaseLine.vue b/src/components/BaseLine.vue index 081225c..ccadb0b 100644 --- a/src/components/BaseLine.vue +++ b/src/components/BaseLine.vue @@ -4,7 +4,7 @@ <div v-for="p in periods" :key="p.label" :style="periodStyles(p)"> <div :style="labelStyles">{{p.label}}</div> </div> - <TransitionGroup name="fade"> + <TransitionGroup name="fade" v-if="mounted"> <div v-for="range in timelineRanges" :key="range.id" class="absolute" :style="spanStyles(range)"> {{range.id}} </div> @@ -32,9 +32,11 @@ const props = defineProps({ // Static time periods type Period = {label: string, len: number}; const periods: Ref<Period[]> = ref([ - {label: 'One', len: 1}, - {label: 'Two', len: 2}, - {label: 'Three', len: 1}, + {label: 'Pre Hadean', len: 8}, + {label: 'Hadean', len: 1}, + {label: 'Archaean', len: 1.5}, + {label: 'Proterozoic', len: 2}, + {label: 'Phanerozoic', len: 0.5}, ]); // For skipping transitions on startup @@ -44,6 +46,13 @@ onMounted(() => setTimeout(() => {skipTransition.value = false}, 100)); // For size tracking (used to prevent time spans shrinking below 1 pixel) 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){ @@ -71,11 +80,13 @@ const labelStyles = computed((): Record<string, string> => ({ function spanStyles(range: TimelineRange){ let styles: Record<string,string>; let availLen = props.vert ? height.value : width.value; - let startFrac = (range.start - MIN_DATE) / (MAX_DATE - MIN_DATE); - let lenFrac = (range.end - range.start) / (MAX_DATE - MIN_DATE); + // Determine positions in full timeline (only considers year values) + let startFrac = (range.start.year - MIN_DATE.year) / (MAX_DATE.year - MIN_DATE.year); + let lenFrac = (range.end.year - range.start.year) / (MAX_DATE.year - MIN_DATE.year); let startPx = Math.max(0, availLen * startFrac); // Prevent negatives due to end-padding let lenPx = Math.min(availLen - startPx, availLen * lenFrac); - lenPx = Math.max(1, lenPx); + lenPx = Math.max(1, lenPx); // Prevent zero length + // if (props.vert){ styles = { top: startPx + 'px', diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue index bf886b1..7ed83d2 100644 --- a/src/components/TimeLine.vue +++ b/src/components/TimeLine.vue @@ -8,23 +8,26 @@ <!-- 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="n in ticks" :key="n"> - <line v-if="n == MIN_DATE || n == MAX_DATE" + <template v-for="date, idx in ticks.dates" :key="date.toInt()"> + <line v-if="date.equals(MIN_DATE, scale) || date.equals(MAX_DATE, scale)" :x1="vert ? -END_TICK_SZ : 0" :y1="vert ? 0 : -END_TICK_SZ" :x2="vert ? END_TICK_SZ : 0" :y2="vert ? 0 : END_TICK_SZ" :stroke="store.color.alt" :stroke-width="`${END_TICK_SZ * 2}px`" - :style="tickStyles(n)" class="animate-fadein"/> - <line v-else + :style="tickStyles(idx)" class="animate-fadein"/> + <line v-else-if="idx >= ticks.vStartIdx && idx <= ticks.vEndIdx" :x1="vert ? -TICK_LEN : 0" :y1="vert ? 0 : -TICK_LEN" :x2="vert ? TICK_LEN : 0" :y2="vert ? 0 : TICK_LEN" - :stroke="store.color.alt" stroke-width="1px" :style="tickStyles(n)" class="animate-fadein"/> + :stroke="store.color.alt" stroke-width="1px" + :style="tickStyles(idx)" class="animate-fadein"/> </template> <!-- Tick labels --> - <text :fill="store.color.textDark" v-for="n in ticks" :key="n" - x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle" - :style="tickLabelStyles(n)" class="text-sm animate-fadein"> - {{Math.floor(n * 10) / 10}} - </text> + <template v-for="date, idx in ticks.dates" :key="date.toInt()"> + <text v-if="idx >= ticks.vStartIdx && idx <= ticks.vEndIdx" :fill="store.color.textDark" + x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle" + :style="tickLabelStyles(idx)" class="text-sm animate-fadein"> + {{date}} + </text> + </template> </svg> <!-- Buttons --> <icon-button :size="30" class="absolute top-2 right-2" @@ -36,13 +39,14 @@ </template> <script setup lang="ts"> -import {ref, onMounted, computed, watch} from 'vue'; +import {ref, onMounted, computed, watch, PropType} from 'vue'; // Components import IconButton from './IconButton.vue'; // Icons import MinusIcon from './icon/MinusIcon.vue'; // Other -import {MIN_DATE, MAX_DATE, SCALES, WRITING_MODE_HORZ} from '../lib'; +import {WRITING_MODE_HORZ, MIN_DATE, MAX_DATE, MONTH_SCALE, DAY_SCALE, SCALES, + HistDate, stepDate, inDateScale} from '../lib'; import {useStore} from '../store'; // Refs @@ -54,8 +58,8 @@ const store = useStore(); // Props + events const props = defineProps({ vert: {type: Boolean, required: true}, - initialStart: {type: Number, required: true}, - initialEnd: {type: Number, required: true}, + initialStart: {type: Object as PropType<HistDate>, required: true}, + initialEnd: {type: Object as PropType<HistDate>, required: true}, }); const emit = defineEmits(['remove', 'range-chg']); @@ -64,6 +68,13 @@ const width = ref(0); const height = ref(0); const availLen = computed(() => props.vert ? height.value : width.value); const prevVert = ref(props.vert); // For skipping transitions on horz/vert swap +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){ @@ -85,102 +96,193 @@ onMounted(() => resizeObserver.observe(rootRef.value as HTMLElement)); // Timeline data const startDate = ref(props.initialStart); // Lowest date on displayed timeline const endDate = ref(props.initialEnd); -let scaleIdx = 0; // Index of current scale in SCALES -const padUnits = computed(() => props.vert ? 0.5 : 1); // Amount of extra scale units to add before/after min/max date +const scaleIdx = ref(0); // Index of current scale in SCALES +const scale = computed(() => SCALES[scaleIdx.value]) // Initialise to smallest usable scale function initScale(){ - let dateLen = endDate.value - startDate.value; - for (let i = 0; i < SCALES.length; i++){ - if (availLen.value * (SCALES[i] / dateLen) > MIN_TICK_SEP){ - scaleIdx = i; + if (startDate.value.year < -4713){ // If a bound is before the Julian period start of 4713 BCE, use a yearly scale + scaleIdx.value = getYearlyScale(startDate.value, endDate.value, availLen.value); + } else { + let dayDiff = startDate.value.getDayDiff(endDate.value); + // Check for day scale usability + if (availLen.value / dayDiff >= MIN_TICK_SEP){ + scaleIdx.value = SCALES.findIndex(s => s == DAY_SCALE); } else { - break; + // Check for month scale usability + let monthDiff = startDate.value.getMonthDiff(endDate.value); + if (availLen.value / monthDiff >= MIN_TICK_SEP){ + scaleIdx.value = SCALES.findIndex(s => s == MONTH_SCALE); + } else { // Use a yearly scale + scaleIdx.value = getYearlyScale(startDate.value, endDate.value, availLen.value); + } } } } +function getYearlyScale(startDate: HistDate, endDate: HistDate, availLen: number){ + // Get the smallest yearly scale that divides a date range, without making ticks too close + let yearDiff = endDate.year - startDate.year; + let idx = 0; + while (SCALES[idx] > yearDiff){ + idx++; + } + while (idx < SCALES.length - 1 && availLen * SCALES[idx + 1] / yearDiff > MIN_TICK_SEP){ + idx++; + } + return idx; +} onMounted(initScale); // Tick data const TICK_LEN = 8; const END_TICK_SZ = 4; // Size for MIN_DATE/MAX_DATE ticks -const MIN_TICK_SEP = 30; // Smallest px separation between ticks +const MIN_TICK_SEP = 5; // Smallest px separation between ticks const MIN_LAST_TICKS = 3; // When at smallest scale, don't zoom further into less than this many ticks -const ticks = computed((): number[] => { // Array of date values for each tick - let dateLen = endDate.value - startDate.value; - let panLen = dateLen * store.scrollRatio; - let zoomLen = dateLen * (store.zoomRatio - 1) / 2; - let scale = SCALES[scaleIdx]; - // Get ticks in new range, and add hidden ticks that might transition in after panning - let tempTicks: number[] = []; - let next = Math.ceil((Math.max(MIN_DATE, startDate.value - panLen) - MIN_DATE) / scale); - let last = Math.floor((Math.min(MAX_DATE, endDate.value + panLen) - MIN_DATE) / scale); - while (next <= last){ - tempTicks.push(MIN_DATE + next * scale); - next++; +function getNumTimeUnits(): number { + if (scale.value == DAY_SCALE){ + return startDate.value.getDayDiff(endDate.value); + } else if (scale.value == MONTH_SCALE){ + return startDate.value.getMonthDiff(endDate.value); + } else { + return Math.floor((endDate.value.year - startDate.value.year) / scale.value); } - // Get hidden ticks that might transition in after zooming - let tempTicks2: number[] = []; - let tempTicks3: number[] = []; - if (scaleIdx > 0){ - scale = SCALES[scaleIdx-1]; - let first = Math.ceil((Math.max(MIN_DATE, startDate.value - zoomLen) - MIN_DATE) / scale); - while (MIN_DATE + first * scale < tempTicks[0]){ - tempTicks2.push(MIN_DATE + first * scale); - first++; +} +const ticks = computed((): {dates: HistDate[], startIdx: number, endIdx: number, + vStartIdx: number, vEndIdx: number} => { + if (!mounted.value){ + return {dates: [], startIdx: 0, endIdx: 0, vStartIdx: 0, vEndIdx: 0}; } - let last = Math.floor((Math.min(MAX_DATE, endDate.value + zoomLen) - MIN_DATE) / scale); - let next = Math.floor((tempTicks[tempTicks.length - 1] - MIN_DATE) / scale) + 1; - while (next <= last){ - tempTicks3.push(MIN_DATE + next * scale); - next++; + // The result holds tick dates, and indexes indicating where the startDate and endDate are + let numUnits = getNumTimeUnits(); + let tempTicks: HistDate[] = []; + let startIdx: number; + let endIdx: number; + let panUnits = Math.floor(numUnits * store.scrollRatio); + // Get hidden preceding ticks + let next: HistDate; + if (MIN_DATE.isEarlier(startDate.value, scale.value)){ + next = startDate.value; + for (let i = 0; i < panUnits; i++){ + next = stepDate(next, scale.value, {forward: false}); + tempTicks.push(next); + if (MIN_DATE.equals(next, scale.value)){ + break; } - } - // Join into tick array - return [...tempTicks2, ...tempTicks, ...tempTicks3]; -}); - -// For panning/zooming -function panTimeline(n: number){ - let dateLen = endDate.value - startDate.value; - let extraLen = padUnits.value * SCALES[scaleIdx] - let paddedMinDate = MIN_DATE - extraLen; - let paddedMaxDate = MAX_DATE + extraLen; - let chg = dateLen * n; - if (startDate.value + chg < paddedMinDate){ - if (startDate.value == paddedMinDate){ - console.log('Reached MIN_DATE limit') - return; } - chg = paddedMinDate - startDate.value; - startDate.value = paddedMinDate; - endDate.value += chg; - } else if (endDate.value + chg > paddedMaxDate){ - if (endDate.value == paddedMaxDate){ - console.log('Reached MAX_DATE limit') - return; + tempTicks.reverse(); } - chg = paddedMaxDate - endDate.value; - endDate.value = paddedMaxDate; - startDate.value += chg; - } else { - startDate.value += chg; - endDate.value += chg; + startIdx = tempTicks.length; + // Get ticks between bounds + next = startDate.value.clone(); + for (let i = 0; i < numUnits + 1; i++){ + tempTicks.push(next); + next = stepDate(next, scale.value); + } + endIdx = tempTicks.length - 1; + // Get hidden following ticks + if (next.isEarlier(MAX_DATE, scale.value)){ + for (let i = 0; i < panUnits; i++){ + next = stepDate(next, scale.value); + tempTicks.push(next) + if (MAX_DATE.equals(next, scale.value)){ + break; + } + } + } + // Get hidden ticks that might transition in after zooming + let tempTicks2: HistDate[] = []; + let tempTicks3: HistDate[] = []; + if (scaleIdx.value > 0 && + availLen.value / (numUnits * store.zoomRatio) < MIN_TICK_SEP){ // If zoom-out would decrease scale + let newNumUnits = Math.floor(numUnits * store.zoomRatio) - numUnits - panUnits * 2; + let zoomedScale = SCALES[scaleIdx.value-1] + let unitsPerZoomedUnit = zoomedScale / scale.value; + let next = tempTicks[0]; + if (MIN_DATE.isEarlier(next, scale.value)){ + for (let i = 0; i < newNumUnits / unitsPerZoomedUnit; i++){ // Get preceding ticks + next = stepDate(next, zoomedScale, {forward: false}); + tempTicks2.push(next); + if (MIN_DATE.equals(next, scale.value)){ + break; + } + } + tempTicks2.reverse(); + } + next = tempTicks[tempTicks.length - 1]; + if (next.isEarlier(MAX_DATE, scale.value)){ + for (let i = 0; i < newNumUnits / unitsPerZoomedUnit; i++){ // Get preceding ticks + next = stepDate(next, zoomedScale); + tempTicks3.push(next); + if (MAX_DATE.equals(next, scale.value)){ + break; + } + } + } + } + // Join into single array + let vStartIdx = startIdx; + while (tempTicks[vStartIdx].isEarlier(MIN_DATE, scale.value)){ + vStartIdx += 1; + } + let vEndIdx = endIdx; + while (MAX_DATE.isEarlier(tempTicks[vEndIdx], scale.value)){ + vEndIdx -= 1; + } + startIdx += tempTicks2.length; + endIdx += tempTicks2.length; + vStartIdx += tempTicks2.length; + vEndIdx += tempTicks2.length; + let dates = [...tempTicks2, ...tempTicks, ...tempTicks3]; + return {dates, startIdx, endIdx, vStartIdx, vEndIdx}; + }); + +// For panning/zooming +function panTimeline(scrollRatio: number): boolean { + let numUnits = getNumTimeUnits(); + let chgUnits = Math.trunc(numUnits * scrollRatio); + if (chgUnits == 0){ + return false; + } + let paddedMinDate = stepDate(MIN_DATE, scale.value, {forward: false}); + let paddedMaxDate = stepDate(MAX_DATE, scale.value); + if (scrollRatio < 0 && startDate.value.equals(paddedMinDate, scale.value)){ + console.log('Reached minimum date limit'); + return true; + } + if (scrollRatio > 0 && endDate.value.equals(paddedMaxDate, scale.value)){ + console.log('Reached maximum date limit'); + return true; + } + while (chgUnits < 0 && paddedMinDate.isEarlier(startDate.value, scale.value)){ + stepDate(startDate.value, scale.value, {forward: false, inplace: true}); + stepDate(endDate.value, scale.value, {forward: false, inplace: true}); + chgUnits += 1; + } + while (chgUnits > 0 && endDate.value.isEarlier(paddedMaxDate, scale.value)){ + stepDate(startDate.value, scale.value, {inplace: true}); + stepDate(endDate.value, scale.value, {inplace: true}); + chgUnits -= 1; } + return true; } -function zoomTimeline(frac: number){ - let oldDateLen = endDate.value - startDate.value; - let newDateLen = oldDateLen * frac; - let extraLen = padUnits.value * SCALES[scaleIdx] - let paddedMinDate = MIN_DATE - extraLen; - let paddedMaxDate = MAX_DATE + extraLen; - // Get new bounds - let newStart: number; - let newEnd: number; +function zoomTimeline(zoomRatio: number){ + let paddedMinDate = stepDate(MIN_DATE, scale.value, {forward: false}); + let paddedMaxDate = stepDate(MAX_DATE, scale.value); + if (zoomRatio > 1 + && startDate.value.equals(paddedMinDate, scale.value) + && endDate.value.equals(paddedMaxDate, scale.value)){ + console.log('Reached upper scale limit'); + return; + } + let numUnits = getNumTimeUnits(); + let newNumUnits = Math.floor(numUnits * zoomRatio); + // Get tentative bound changes + let startChg: number; + let endChg: number; let ptrOffset = props.vert ? pointerY : pointerX; if (ptrOffset == null){ - let lenChg = newDateLen - oldDateLen - newStart = startDate.value - lenChg / 2; - newEnd = endDate.value + lenChg / 2; + let unitChg = Math.abs(newNumUnits - numUnits); + startChg = Math.ceil(unitChg / 2); + endChg = Math.floor(unitChg / 2); } else { // Pointer-centered zoom // Get element-relative ptrOffset let innerOffset = 0; @@ -189,53 +291,60 @@ function zoomTimeline(frac: number){ innerOffset = props.vert ? ptrOffset - rect.top : ptrOffset - rect.left; } // - let zoomCenter = startDate.value + (innerOffset / availLen.value) * oldDateLen; - newStart = zoomCenter - (zoomCenter - startDate.value) * frac; - newEnd = zoomCenter + (endDate.value - zoomCenter) * frac; + let zoomCenter = numUnits * (innerOffset / availLen.value); + startChg = Math.round(Math.abs(zoomCenter * (zoomRatio - 1))); + endChg = Math.abs(newNumUnits - numUnits) - startChg; } - if (newStart < paddedMinDate){ - newEnd += paddedMinDate - newStart; - newStart = paddedMinDate; - if (newEnd > paddedMaxDate){ - if (startDate.value == paddedMinDate && endDate.value == paddedMaxDate){ - console.log('Reached upper scale limit'); - return; - } else { - newEnd = paddedMaxDate; - } + // Get new bounds + let newStart = startDate.value.clone(); + let newEnd = endDate.value.clone(); + if (zoomRatio <= 1){ + stepDate(newStart, scale.value, {inplace: true, count: startChg}); + stepDate(newEnd, scale.value, {forward: false, inplace: true, count: endChg}); + } else { + while (startChg > 0 && paddedMinDate.isEarlier(newStart, scale.value)){ + stepDate(newStart, scale.value, {forward: false, inplace: true}); + startChg -= 1; } - } else if (newEnd > paddedMaxDate){ - newStart -= newEnd - paddedMaxDate; - newEnd = paddedMaxDate; - if (newStart < paddedMinDate){ - if (startDate.value == paddedMinDate && endDate.value == paddedMaxDate){ - console.log('Reached upper scale limit'); - return; - } else { - newStart = paddedMinDate; - } + endChg += startChg; // Transfer excess into end expansion + while (endChg > 0 && newEnd.isEarlier(paddedMaxDate, scale.value)){ + stepDate(newEnd, scale.value, {inplace: true}); + endChg -= 1; + } + while (endChg > 0 && paddedMinDate.isEarlier(newStart, scale.value)){ // Transfer excess into start expansion + stepDate(newStart, scale.value, {forward: false, inplace: true}); + endChg -= 1; } + newNumUnits -= endChg; } // Possibly change the scale - newDateLen = newEnd - newStart; - let tickDiff = availLen.value * (SCALES[scaleIdx] / newDateLen); - if (tickDiff < MIN_TICK_SEP){ - if (scaleIdx == 0){ + let tickDiff = availLen.value / newNumUnits; + if (tickDiff < MIN_TICK_SEP){ // Possibly zoom out + if (scaleIdx.value == 0){ console.log('INFO: Reached zoom out limit'); return; } else { - scaleIdx--; + scaleIdx.value -= 1; } - } else { - if (scaleIdx < SCALES.length - 1){ - if (tickDiff > MIN_TICK_SEP * SCALES[scaleIdx] / SCALES[scaleIdx + 1]){ - scaleIdx++; - } - } else { - if (availLen.value / tickDiff < MIN_LAST_TICKS){ + } else { // Possibly zoom in + if (scaleIdx.value == SCALES.length - 1){ + if (newNumUnits < MIN_LAST_TICKS){ console.log('INFO: Reached zoom in limit'); return; } + } else { + let nextScale = SCALES[scaleIdx.value + 1]; + let zoomedTickDiff: number; + if (nextScale == MONTH_SCALE){ + zoomedTickDiff = tickDiff / 12; + } else if (nextScale == DAY_SCALE){ + zoomedTickDiff = tickDiff / 31; + } else { + zoomedTickDiff = tickDiff / (scale.value / nextScale); + } + if (zoomedTickDiff > MIN_TICK_SEP){ + scaleIdx.value += 1; + } } } // @@ -285,8 +394,10 @@ function onPointerMove(evt: PointerEvent){ if (dragHandler == 0){ dragHandler = setTimeout(() => { if (Math.abs(dragDiff) > 2){ - panTimeline(-dragDiff / availLen.value); - dragDiff = 0; + const moved = panTimeline(-dragDiff / availLen.value); + if (moved){ + dragDiff = 0; + } } dragHandler = 0; }, 50); @@ -362,24 +473,25 @@ const mainlineStyles = computed(() => ({ transitionDuration, transitionTimingFunction, })); -function tickStyles(tick: number){ - let offset = (tick - startDate.value) / (endDate.value - startDate.value) * availLen.value; - let scale = 1; - if (scaleIdx > 0 && tick % SCALES[scaleIdx-1] == 0){ // If the tick exists on the scale directly above this one - scale = 2; +function tickStyles(idx: number){ + let offset = (idx - ticks.value.startIdx) / (ticks.value.endIdx - ticks.value.startIdx) * availLen.value; + let scaleFactor = 1; + if (scaleIdx.value > 0 && + inDateScale(ticks.value.dates[idx], SCALES[scaleIdx.value-1])){ // If tick exists on larger scale + scaleFactor = 2; } return { transform: props.vert ? - `translate(${width.value/2}px, ${offset}px) scale(${scale})` : - `translate(${offset}px, ${height.value/2}px) scale(${scale})`, + `translate(${width.value/2}px, ${offset}px) scale(${scaleFactor})` : + `translate(${offset}px, ${height.value/2}px) scale(${scaleFactor})`, transitionProperty: skipTransition.value ? 'none' : 'transform, opacity', transitionDuration, transitionTimingFunction, opacity: (offset >= 0 && offset <= availLen.value) ? 1 : 0, } } -function tickLabelStyles(tick: number){ - let offset = (tick - startDate.value) / (endDate.value - startDate.value) * availLen.value; +function tickLabelStyles(idx: number){ + let offset = (idx - ticks.value.startIdx) / (ticks.value.endIdx - ticks.value.startIdx) * availLen.value; let labelSz = props.vert ? 10 : 30; return { transform: props.vert ? @@ -2,16 +2,217 @@ * Project-wide globals */ -export const MIN_DATE = -1000; -export const MAX_DATE = 1000; -export const SCALES = [200, 50, 10, 1, 0.2]; // Timeline gets divided into units of SCALES[0], then SCALES[1], etc - +export const DEBUG = true; export const WRITING_MODE_HORZ = window.getComputedStyle(document.body)['writing-mode' as any].startsWith('horizontal'); // Used with ResizeObserver callbacks, to determine which resized dimensions are width and height +// For calendar conversion. Same as in backend/hist_data/cal.py +export function gregorianToJdn(year: number, month: number, day: number): number { + if (year < 0){ + year += 1; + } + const x = Math.trunc((month - 14) / 12); + let jdn = Math.trunc(1461 * (year + 4800 + x) / 4); + jdn += Math.trunc((367 * (month - 2 - 12 * x)) / 12); + jdn -= Math.trunc((3 * Math.trunc((year + 4900 + x) / 100)) / 4); + jdn += day - 32075; + return jdn; +} +export function julianToJdn(year: number, month: number, day: number): number { + if (year < 0){ + year += 1; + } + let jdn = 367 * year; + jdn -= Math.trunc(7 * (year + 5001 + Math.trunc((month - 9) / 7)) / 4); + jdn += Math.trunc(275 * month / 9); + 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; + const g = Math.trunc((e % 1461) / 4); + const h = 5 * g + 2; + const D = Math.trunc((h % 153) / 5) + 1; + const M = (Math.trunc(h / 153) + 2) % 12 + 1; + let Y = Math.trunc(e / 1461) - 4716 + Math.trunc((12 + 2 - M) / 12); + if (Y <= 0){ + Y -= 1; + } + return [Y, M, D]; +} +export function jdnToJulian(jdn: number): [number, number, number] { + const f = jdn + 1401; + const e = 4 * f + 3; + const g = Math.trunc((e % 1461) / 4); + const h = 5 * g + 2; + const D = Math.trunc((h % 153) / 5) + 1; + const M = (Math.trunc(h / 153) + 2) % 12 + 1; + let Y = Math.trunc(e / 1461) - 4716 + Math.trunc((12 + 2 - M) / 12); + if (Y <= 0){ + Y -= 1; + } + 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)); +} + +// For date representation +export class HistDate { + year: number; + month: number; + day: number; + constructor(year: number, month=1, day=1){ + this.year = year; + this.month = month; + this.day = day; + } + equals(other: HistDate, scale=DAY_SCALE){ + if (scale == DAY_SCALE){ + return this.year == other.year && this.month == other.month && this.day == other.day; + } else if (scale == MONTH_SCALE){ + return this.year == other.year && this.month == other.month; + } else { + return Math.floor(this.year / scale) == Math.floor(other.year / scale); + } + } + isEarlier(other: HistDate, scale=DAY_SCALE){ + const yearlyScale = scale != DAY_SCALE && scale != MONTH_SCALE; + const thisYear = yearlyScale ? Math.floor(this.year / scale) : this.year; + const otherYear = yearlyScale ? Math.floor(other.year / scale) : other.year; + if (yearlyScale || thisYear != otherYear){ + return thisYear < otherYear; + } else { + if (scale == MONTH_SCALE || this.month != other.month){ + return this.month < other.month; + } else { + return this.day < other.day; + } + } + } + toInt(){ + return this.day + this.month * 50 + this.year * 1000; + } + toString(){ + return `${this.year}-${this.month}-${this.day}`; + } + getDayDiff(other: HistDate){ + 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 = later.year - earlier.year; + if (yearDiff == 0){ + return later.month - earlier.month; + } else { + return (13 - earlier.month) + (yearDiff * 12) + later.month - 1; + } + } + clone(){ + return new HistDate(this.year, this.month, this.day); + } +} + +// Timeline parameters +const currentDate = new Date(); +export const MIN_DATE = new HistDate(-13.8e9); +export const MAX_DATE = new HistDate(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); +export const MONTH_SCALE = -1; +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 (SCALES[SCALES.length - 1] != DAY_SCALE + || SCALES[SCALES.length - 2] != MONTH_SCALE + || SCALES[SCALES.length - 3] != 1){ + throw new Error('SCALES must end with [1, MONTH_SCALE, DAY_SCALE]'); + } + for (let i = 1; i < SCALES.length - 2; i++){ + if (SCALES[i] <= 0){ + throw new Error('SCALES must only have positive ints before MONTH_SCALE'); + } + if (SCALES[i-1] <= SCALES[i]){ + throw new Error('SCALES must hold decreasing values'); + } + if (SCALES[i-1] % SCALES[i] > 0){ + throw new Error('Each positive int in SCALES must divide the previous int'); + } + } +} +export function stepDate(date: HistDate, scale: number, {forward=true, count=1, inplace=false} = {}): HistDate { + const newDate = inplace ? date : date.clone(); + for (let i = 0; i < count; i++){ + if (scale == DAY_SCALE){ + if (forward && newDate.day < 28){ + newDate.day += 1; + } else if (!forward && newDate.day > 1){ + newDate.day -= 1 + } else { + let jdn = gregorianToJdn(newDate.year, newDate.month, newDate.day) + jdn += forward ? 1 : -1; + const [year, month, day] = jdnToGregorian(jdn); + newDate.year = year; + newDate.month = month; + newDate.day = day; + } + } else if (scale == MONTH_SCALE){ + if (forward){ + if (newDate.month < 12){ + newDate.month += 1; + } else { + newDate.year += 1; + newDate.month = 1; + } + } else { + if (newDate.month > 1){ + newDate.month -= 1; + } else { + newDate.year -= 1; + newDate.month = 12; + } + } + } else { + newDate.year += forward ? scale : -scale; + } + } + return newDate; +} +export function inDateScale(date: HistDate, scale: number): boolean { + if (scale == DAY_SCALE){ + return true; + } else if (scale == MONTH_SCALE){ + return date.day == 1; + } else { + return date.year % scale == 0 && date.month == 1 && date.day == 1; + } +} + +// For sending timeline-bound data to BaseLine export type TimelineRange = { id: number, - start: number, - end: number, + start: HistDate, + end: HistDate, +}; + +export type HistEvent = { + title: string, + start: HistDate, + startUpper: HistDate | null, + end: HistDate, + endUpper: HistDate | null, }; |
