aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.js1
-rw-r--r--src/App.vue6
-rw-r--r--src/components/BaseLine.vue25
-rw-r--r--src/components/TimeLine.vue382
-rw-r--r--src/lib.ts213
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 ?
diff --git a/src/lib.ts b/src/lib.ts
index 28994f1..9bd23a5 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -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,
};