aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/App.vue9
-rw-r--r--src/components/SButton.vue12
-rw-r--r--src/components/SettingsModal.vue127
-rw-r--r--src/components/TimeLine.vue15
-rw-r--r--src/index.css13
-rw-r--r--src/lib.ts19
-rw-r--r--src/store.ts244
7 files changed, 387 insertions, 52 deletions
diff --git a/src/App.vue b/src/App.vue
index cbd6825..70cd390 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -8,7 +8,7 @@
<icon-button :size="45" :style="buttonStyles">
<help-icon/>
</icon-button>
- <icon-button :size="45" :style="buttonStyles">
+ <icon-button :size="45" :style="buttonStyles" @click="settingsOpen = true" title="Show settings">
<settings-icon/>
</icon-button>
<icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline">
@@ -37,6 +37,9 @@
<transition name="fade">
<info-modal v-if="infoModalData != null" :eventInfo="infoModalData" @close="infoModalData = null"/>
</transition>
+ <transition name="fade">
+ <settings-modal v-if="settingsOpen" @close="settingsOpen = false"/>
+ </transition>
</div>
</template>
@@ -47,6 +50,7 @@ import TimeLine from './components/TimeLine.vue';
import BaseLine from './components/BaseLine.vue';
import InfoModal from './components/InfoModal.vue';
import SearchModal from './components/SearchModal.vue';
+import SettingsModal from './components/SettingsModal.vue';
import IconButton from './components/IconButton.vue';
// Icons
import HelpIcon from './components/icon/HelpIcon.vue';
@@ -314,6 +318,9 @@ function onSearch(event: HistEvent){
timelineTargets.value.splice(timelineIdx, 1, [event, !oldFlag[1]]);
}
+// For settings modal
+const settingsOpen = ref(false);
+
// For resize handling
let lastResizeHdlrTime = 0; // Used to throttle resize handling
let afterResizeHdlr = 0; // Used to trigger handler after ending a run of resize events
diff --git a/src/components/SButton.vue b/src/components/SButton.vue
new file mode 100644
index 0000000..487d6bd
--- /dev/null
+++ b/src/components/SButton.vue
@@ -0,0 +1,12 @@
+<template>
+<button :disabled="disabled" class="block rounded px-4 py-2"
+ :class="{'hover:brightness-125': !disabled, 'brightness-50': disabled}">
+ <slot>?</slot>
+</button>
+</template>
+
+<script setup lang="ts">
+defineProps({
+ disabled: {type: Boolean, default: false},
+});
+</script>
diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue
new file mode 100644
index 0000000..1ccea2a
--- /dev/null
+++ b/src/components/SettingsModal.vue
@@ -0,0 +1,127 @@
+<template>
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
+ <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
+ min-w-[8cm] max-w-[80%] max-h-[80%] overflow-auto" :style="styles">
+ <close-icon @click.stop="onClose" ref="closeRef"
+ class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer" />
+ <h1 class="text-xl md:text-2xl font-bold text-center py-2" :class="borderBClasses">Settings</h1>
+ <div class="pb-2" :class="borderBClasses">
+ <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Categories</h2>
+ <ul class="px-2 grid grid-cols-3">
+ <!-- Row 1 -->
+ <li> <label> <input type="checkbox" v-model="store.ctgs.event"
+ @change="onSettingChg('ctgs.event')"/> Event </label> </li>
+ <li> <label> <input type="checkbox" v-model="store.ctgs.person"
+ @change="onSettingChg('ctgs.person')"/> Person </label> </li>
+ <li> <label> <input type="checkbox" v-model="store.ctgs.work"
+ @change="onSettingChg('ctgs.work')"/> Work </label> </li>
+ <!-- Row 2 -->
+ <li> <label> <input type="checkbox" v-model="store.ctgs.place"
+ @change="onSettingChg('ctgs.place')"/> Place </label> </li>
+ <li> <label> <input type="checkbox" v-model="store.ctgs.organism"
+ @change="onSettingChg('ctgs.organism')"/> Organism </label> </li>
+ <li> <label> <input type="checkbox" v-model="store.ctgs.discovery"
+ @change="onSettingChg('ctgs.discovery')"/> Discovery </label> </li>
+ </ul>
+ </div>
+ <div class="pb-2" :class="borderBClasses">
+ <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Display</h2>
+ <div class="px-2">
+ <label> <input type="checkbox" v-model="store.showEventCounts"
+ @change="onSettingChg('showEventCounts')"/> Show event count indicators </label>
+ </div>
+ <div class="px-2">
+ <label> <input type="checkbox" v-model="store.showMinorTicks"
+ @change="onSettingChg('showMinorTicks')"/> Show minor tick labels </label>
+ </div>
+ </div>
+ <div v-if="store.touchDevice == false" class="pb-2" :class="borderBClasses">
+ <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Input</h2>
+ <div class="px-2">
+ <label> <input type="checkbox" v-model="store.disableShortcuts"
+ @change="onSettingChg('disableShortcuts')"/> Disable keyboard shortcuts </label>
+ </div>
+ <div class="grid grid-cols-[100px_minmax(0,1fr)_65px] gap-1 w-fit mx-auto px-2 md:px-3">
+ <!-- Row 1 -->
+ <label for="scrollRatio" @click="onResetOne('scrollRatio')" :class="rLabelClasses">
+ Scroll ratio
+ </label>
+ <input type="range" min="0.1" max="0.8" step="0.1" v-model.number="store.scrollRatio"
+ @change="onSettingChg('scrollRatio')" name="scrollRatio"/>
+ <div class="my-auto text-right">{{store.scrollRatio}}</div>
+ <!-- Row 2 -->
+ <label for="zoomRatio" @click="onResetOne('zoomRatio')" :class="rLabelClasses">
+ Zoom ratio
+ </label>
+ <input type="range" min="1.2" max="5" step="0.2" v-model.number="store.zoomRatio"
+ @change="onSettingChg('zoomRatio')" name="zoomRatio"/>
+ <div class="my-auto text-right">{{store.zoomRatio}}</div>
+ </div>
+ </div>
+ <s-button class="mx-auto my-2" :style="{color: store.color.text, backgroundColor: store.color.bg}"
+ @click="onReset">
+ Reset
+ </s-button>
+ <transition name="fade">
+ <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndRef"> Saved </div>
+ </transition>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import {ref, computed} from 'vue';
+import SButton from './SButton.vue';
+import CloseIcon from './icon/CloseIcon.vue';
+import {useStore} from '../store';
+
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const closeRef = ref(null as typeof CloseIcon | null);
+const saveIndRef = ref(null as HTMLDivElement | null);
+
+// Global store
+const store = useStore();
+
+// Events
+const emit = defineEmits(['close']);
+
+// Settings change handling
+const saved = ref(false); // Set to true after a setting is saved
+function onSettingChg(option: string){
+ store.save(option);
+ // Make 'Saved' indicator appear/animate
+ if (!saved.value){
+ saved.value = true;
+ } else {
+ let el = saveIndRef.value!;
+ el.classList.remove('animate-flash-yellow');
+ el.offsetWidth; // Triggers reflow
+ el.classList.add('animate-flash-yellow');
+ }
+}
+function onResetOne(option: string){
+ store.resetOne(option);
+ onSettingChg(option);
+}
+function onReset(){
+ store.reset();
+ store.clear();
+ saved.value = false;
+}
+
+// Close handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+
+// Styles and classes
+const styles = computed(() => ({
+ backgroundColor: store.color.bgAlt,
+ borderRadius: store.borderRadius + 'px',
+}));
+const borderBClasses = 'border-b border-stone-400';
+const rLabelClasses = "w-fit hover:cursor-pointer hover:text-yellow-600"; // For reset-upon-click labels
+</script>
diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index e41a729..13589d2 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -38,11 +38,14 @@
:style="tickStyles(tick)" class="animate-fadein"/>
</template>
<!-- Tick labels -->
- <text v-for="tick in ticks" :key="tick.date.toInt()"
- x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle"
- :fill="store.color.textDark" :style="tickLabelStyles(tick)" class="text-sm animate-fadein cursor-default">
- {{tick.date.toDisplayString()}}
- </text>
+ <template v-for="tick in ticks" :key="tick.date.toInt()">
+ <text v-if="tick.major || store.showMinorTicks"
+ x="0" y="0" :text-anchor="vert ? 'start' : 'middle'" dominant-baseline="middle"
+ :fill="store.color.textDark" :style="tickLabelStyles(tick)"
+ class="text-sm animate-fadein cursor-default">
+ {{tick.date.toDisplayString()}}
+ </text>
+ </template>
</svg>
<!-- Events -->
<div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)">
@@ -1222,7 +1225,7 @@ function eventImgStyles(eventId: number){
//backgroundImage: `url(${getImagePath(event.imgId)})`,
backgroundColor: 'black',
backgroundSize: 'cover',
- borderColor: isSearchResult ? 'red' : (event.ctg == 'discovery' ? store.color.alt2 : store.color.altDark),
+ borderColor: isSearchResult ? 'red' : (event.ctg == 'discovery' ? '#2563eb' : store.color.altDark),
borderWidth: '1px',
};
}
diff --git a/src/index.css b/src/index.css
index e16c538..2a7819b 100644
--- a/src/index.css
+++ b/src/index.css
@@ -38,6 +38,19 @@
background-color: transparent;
}
}
+.animate-flash-yellow {
+ animation-name: flash-yellow;
+ animation-duration: 700ms;
+ animation-timing-function: ease-in;
+}
+@keyframes flash-yellow {
+ from {
+ color: #ca8a04;
+ }
+ to {
+ color: inherit;
+ }
+}
/* Other */
@font-face {
diff --git a/src/lib.ts b/src/lib.ts
index d26c8df..3b0bc68 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -5,8 +5,25 @@
import {RBTree} from './rbtree';
export const DEBUG = true;
-export let WRITING_MODE_HORZ = true;
+// For detecting screen size
+export type Breakpoint = 'sm' | 'md' | 'lg';
+export function getBreakpoint(): Breakpoint {
+ const w = window.innerWidth;
+ if (w < 768){
+ return 'sm';
+ } else if (w < 1024){
+ return 'md';
+ } else {
+ return 'lg';
+ }
+}
+// For detecting a touch device
+export function onTouchDevice(){
+ return window.matchMedia('(pointer: coarse)').matches;
+}
+// For detecting writing-mode
// Used with ResizeObserver callbacks, to determine which resized dimensions are width and height
+export let WRITING_MODE_HORZ = true;
if ('writing-mode' in window.getComputedStyle(document.body)){ // Can be null when testing
WRITING_MODE_HORZ = window.getComputedStyle(document.body)['writing-mode' as any].startsWith('horizontal');
}
diff --git a/src/store.ts b/src/store.ts
index 393e4b8..19a184b 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -3,52 +3,208 @@
*/
import {defineStore} from 'pinia';
-import {CalDate} from './lib';
+import {HistDate, CalDate} from './lib';
+import {getBreakpoint, Breakpoint, onTouchDevice} from './lib';
+
+export type StoreState = {
+ // Device info
+ touchDevice: boolean,
+ breakpoint: Breakpoint,
+ // Tick display
+ tickLen: number, //px
+ largeTickLen: number,
+ endTickSz: number // Size for start/end ticks
+ tickLabelHeight: number,
+ minTickSep: number, // Smallest gap between ticks
+ minLastTicks: number // When at smallest scale, don't zoom further into less than this many ticks
+ defaultEndTickOffset: number, // Default fraction of a unit to offset start/end ticks
+ showMinorTicks: boolean,
+ // Mainline and event display
+ mainlineBreadth: number, // Breadth of mainline area (incl ticks and labels)
+ eventImgSz: number, // Width/height of event images
+ eventLabelHeight: number,
+ spacing: number, // Spacing between display edge, events, and mainline area
+ // User input
+ scrollRatio: number, // Fraction of timeline length to move by upon scroll
+ zoomRatio: number, // Ratio of timeline expansion upon zooming out (eg: 1.5)
+ dragInertia: number, // Multiplied by final-drag-speed (pixels-per-sec) to get extra scroll distance
+ disableShortcuts: boolean,
+ // Other feature-specific
+ showEventCounts: boolean,
+ searchSuggLimit: number,
+ ctgs: { // Specifies event categories, and which ones should be visible
+ event: boolean,
+ place: boolean,
+ organism: boolean,
+ person: boolean,
+ work: boolean,
+ discovery: boolean,
+ },
+ // Other
+ initialStartDate: HistDate,
+ initialEndDate: HistDate, // Must be later than initialStartDate
+ color: {
+ text: string, // CSS color
+ textDark: string,
+ bg: string,
+ bgLight: string,
+ bgDark: string,
+ bgLight2: string,
+ bgDark2: string,
+ alt: string,
+ altDark: string,
+ altDark2: string,
+ altBg: string,
+ bgAlt: string,
+ bgAltDark: string,
+ },
+ borderRadius: number, // px
+ transitionDuration: number, // ms
+};
+function getDefaultState(): StoreState {
+ const breakpoint = getBreakpoint();
+ const color = {
+ text: '#fafaf9', // stone-50
+ textDark: '#a8a29e', // stone-400
+ bg: '#292524', // stone-800
+ bgLight: '#44403c', // stone-700
+ bgDark: '#1c1917', // stone-900
+ bgLight2: '#57534e', // stone-600
+ bgDark2: '#0e0c0b', // darker version of stone-900
+ alt: '#fde047', // yellow-300
+ altDark: '#eab308', // yellow-500
+ altDark2: '#ca8a04', // yellow-600
+ altBg: '#6a5e2e',
+ bgAlt: '#f5f5f4', // stone-100
+ bgAltDark: '#d6d3d1', // stone-300
+ };
+ return {
+ // Device info
+ touchDevice: onTouchDevice(),
+ breakpoint: breakpoint,
+ // Tick display
+ tickLen: 16,
+ largeTickLen: 32,
+ endTickSz: 8,
+ tickLabelHeight: 10,
+ minTickSep: 30,
+ minLastTicks: 3,
+ defaultEndTickOffset: 0.5,
+ showMinorTicks: true,
+ // Mainline and event display
+ mainlineBreadth: 80,
+ eventImgSz: 100,
+ eventLabelHeight: 20,
+ spacing: 10,
+ // User input
+ scrollRatio: 0.2,
+ zoomRatio: 1.5,
+ dragInertia: 0.1,
+ disableShortcuts: false,
+ // Other feature-specific
+ showEventCounts: true,
+ searchSuggLimit: 10,
+ ctgs: {
+ event: true,
+ place: true,
+ organism: true,
+ person: true,
+ work: true,
+ discovery: true,
+ },
+ // Other
+ initialStartDate: new CalDate(1900, 1, 1),
+ initialEndDate: new CalDate(2000, 1, 1),
+ color,
+ borderRadius: 5,
+ transitionDuration: 300,
+ };
+}
+
+// Gets 'composite keys' which have the form 'key1' or 'key1.key2' (usable to specify properties of store objects)
+function getCompositeKeys(state: StoreState){
+ const compKeys = [];
+ for (const key of Object.getOwnPropertyNames(state) as (keyof StoreState)[]){
+ if (typeof state[key] != 'object'){
+ compKeys.push(key);
+ } else {
+ for (const subkey of Object.getOwnPropertyNames(state[key])){
+ compKeys.push(`${key}.${subkey}`);
+ }
+ }
+ }
+ return compKeys;
+}
+const STORE_COMP_KEYS = getCompositeKeys(getDefaultState());
+// For getting/setting values in store
+function getStoreVal(state: StoreState, compKey: string): any {
+ if (compKey in state){
+ return state[compKey as keyof StoreState];
+ }
+ const [s1, s2] = compKey.split('.', 2);
+ if (s1 in state){
+ const key1 = s1 as keyof StoreState;
+ if (typeof state[key1] == 'object' && s2 in (state[key1] as any)){
+ return (state[key1] as any)[s2];
+ }
+ }
+ return null;
+}
+function setStoreVal(state: StoreState, compKey: string, val: any): void {
+ if (compKey in state){
+ (state[compKey as keyof StoreState] as any) = val;
+ return;
+ }
+ const [s1, s2] = compKey.split('.', 2);
+ if (s1 in state){
+ const key1 = s1 as keyof StoreState;
+ if (typeof state[key1] == 'object' && s2 in (state[key1] as any)){
+ (state[key1] as any)[s2] = val;
+ return;
+ }
+ }
+}
+// For loading settings into [initial] store state
+function loadFromLocalStorage(state: StoreState){
+ for (const key of STORE_COMP_KEYS){
+ const item = localStorage.getItem(key)
+ if (item != null){
+ setStoreVal(state, key, JSON.parse(item));
+ }
+ }
+}
export const useStore = defineStore('store', {
state: () => {
- const color = { // Note: For scrollbar colors on chrome, edit ./index.css
- text: '#fafaf9', // stone-50
- textDark: '#a8a29e', // stone-400
- bg: '#292524', // stone-800
- bgLight: '#44403c', // stone-700
- bgDark: '#1c1917', // stone-900
- bgLight2: '#57534e', // stone-600
- bgDark2: '#0e0c0b', // darker version of stone-900
- alt: '#fde047', // yellow-300
- altDark: '#eab308', // yellow-500
- altDark2: '#ca8a04', // yellow-600
- altBg: '#6a5e2e',
- alt2: '#2563eb', // sky-600
- bgAlt: '#f5f5f4', // stone-100
- bgAltDark: '#d6d3d1', // stone-300
- };
- return {
- tickLen: 16,
- largeTickLen: 32,
- endTickSz: 8, // Size for start/end ticks
- tickLabelHeight: 10,
- minTickSep: 30, // Smallest px separation between ticks
- minLastTicks: 3, // When at smallest scale, don't zoom further into less than this many ticks
- defaultEndTickOffset: 0.5, // Default fraction of a unit to offset start/end ticks
- //
- mainlineBreadth: 80, // Breadth of mainline area (including ticks and labels)
- eventImgSz: 100, // Width/height of event images
- eventLabelHeight: 20,
- spacing: 10, // Spacing between display edge, events, and mainline area
- //
- scrollRatio: 0.2, // Fraction of timeline length to move by upon scroll
- zoomRatio: 1.5, // Ratio of timeline expansion upon zooming out
- dragInertia: 0.1, // Multiplied by final-drag-speed (pixels-per-sec) to get extra scroll distance
- //
- initialStartDate: new CalDate(1900, 1, 1),
- initialEndDate: new CalDate(2000, 1, 1),
- color,
- showEventCounts: true,
- transitionDuration: 300,
- borderRadius: 5, // px
- searchSuggLimit: 10,
- disableShortcuts: false,
- };
+ const state = getDefaultState();
+ loadFromLocalStorage(state);
+ return state;
+ },
+ actions: {
+ reset(): void {
+ Object.assign(this, getDefaultState());
+ },
+ resetOne(key: string){
+ const val = getStoreVal(this, key);
+ if (val != null){
+ const val2 = getStoreVal(getDefaultState(), key);
+ if (val != val2){
+ setStoreVal(this, key, val2);
+ }
+ }
+ },
+ save(key: string){
+ if (STORE_COMP_KEYS.includes(key)){
+ localStorage.setItem(key, JSON.stringify(getStoreVal(this, key)));
+ }
+ },
+ load(): void {
+ loadFromLocalStorage(this);
+ },
+ clear(): void {
+ for (const key of STORE_COMP_KEYS){
+ localStorage.removeItem(key);
+ }
+ },
},
});