diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.vue | 9 | ||||
| -rw-r--r-- | src/components/SButton.vue | 12 | ||||
| -rw-r--r-- | src/components/SettingsModal.vue | 127 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 15 | ||||
| -rw-r--r-- | src/index.css | 13 | ||||
| -rw-r--r-- | src/lib.ts | 19 | ||||
| -rw-r--r-- | src/store.ts | 244 |
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 { @@ -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); + } + }, }, }); |
