diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/AncestryBar.vue | 21 | ||||
| -rw-r--r-- | src/components/HelpModal.vue | 38 | ||||
| -rw-r--r-- | src/components/SCollapsible.vue | 16 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 55 | ||||
| -rw-r--r-- | src/components/SettingsModal.vue | 26 | ||||
| -rw-r--r-- | src/components/TileInfoModal.vue | 51 | ||||
| -rw-r--r-- | src/components/TolTile.vue | 76 | ||||
| -rw-r--r-- | src/components/TutorialPane.vue | 29 |
8 files changed, 239 insertions, 73 deletions
diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue index 8eabf22..762fa99 100644 --- a/src/components/AncestryBar.vue +++ b/src/components/AncestryBar.vue @@ -8,51 +8,56 @@ <script setup lang="ts"> import {ref, computed, watch, onMounted, nextTick, PropType} from 'vue'; + import TolTile from './TolTile.vue'; import {TolMap} from '../tol'; import {LayoutNode} from '../layout'; import {useStore} from '../store'; -// Refs const rootRef = ref(null as HTMLDivElement | null); -// Global store const store = useStore(); -// Props + events const props = defineProps({ nodes: {type: Array as PropType<LayoutNode[]>, required: true}, vert: {type: Boolean, default: false}, breadth: {type: Number, required: true}, tolMap: {type: Object as PropType<TolMap>, required: true}, }); + const emit = defineEmits(['ancestor-click', 'info-click']); -// Computed prop data for display +// ========== Computed prop data for display ========== + const imgSz = computed(() => props.breadth - store.lytOpts.tileSpacing - store.scrollGap // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage ); + const dummyNodes = computed(() => props.nodes.map(n => { let newNode = new LayoutNode(n.name, []); newNode.dims = [imgSz.value, imgSz.value]; return newNode; })); -// Click handling +// ========== Click handling ========== + function onTileClick(node: LayoutNode){ emit('ancestor-click', node); } + function onInfoIconClick(data: string){ emit('info-click', data); } -// Scroll handling +// ========== Scroll handling ========== + function onWheelEvt(evt: WheelEvent){ // For converting vertical scrolling to horizontal if (!props.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){ rootRef.value!.scrollLeft -= (evt.deltaY > 0 ? -30 : 30); } } + function scrollToEnd(){ let el = rootRef.value; if (el != null){ @@ -63,6 +68,7 @@ function scrollToEnd(){ } } } + watch(props.nodes, () => { nextTick(() => scrollToEnd()); }); @@ -71,7 +77,8 @@ watch(() => props.vert, () => { }); onMounted(() => scrollToEnd()); -// Styles +// ========== For styling ========== + const styles = computed(() => ({ // For child layout display: 'flex', diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index 5ebc36e..b6fb4a9 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -5,7 +5,9 @@ w-[90%] max-w-[16cm] 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-center text-xl sm:text-2xl font-bold pt-2 pb-1">Help</h1> + <div class="flex flex-col gap-2 p-2"> <s-collapsible :class="scClasses"> <template #summary="slotProps"> @@ -44,6 +46,7 @@ </div> </template> </s-collapsible> + <s-collapsible :class="scClasses"> <template #summary="slotProps"> <div :class="scSummaryClasses"> @@ -209,6 +212,7 @@ </div> </template> </s-collapsible> + <s-collapsible :class="scClasses"> <template #summary="slotProps"> <div :class="scSummaryClasses"> @@ -312,6 +316,7 @@ </div> </template> </s-collapsible> + <s-collapsible :class="scClasses"> <template #summary="slotProps"> <div :class="scSummaryClasses"> @@ -416,61 +421,66 @@ </template> </s-collapsible> </div> - <s-button class="mx-auto mb-2" :style="{color: store.color.text, backgroundColor: store.color.bg}" - :disabled="tutOpen" @click.stop="onStartTutorial"> - Start Tutorial - </s-button> - <p class="absolute text-xs md:text-sm text-stone-500 right-2 bottom-2"> - Last updated 28/01/23 - </p> + + <div class="relative"> + <s-button class="mx-auto mb-2" :style="{color: store.color.text, backgroundColor: store.color.bg}" + :disabled="tutOpen" @click.stop="onStartTutorial"> + Start Tutorial + </s-button> + <p class="absolute text-xs md:text-sm text-stone-500 right-2 bottom-0"> + Last updated 29/01/23 + </p> + </div> </div> </div> </template> <script setup lang="ts"> import {ref, computed} from 'vue'; + import SButton from './SButton.vue'; import SCollapsible from './SCollapsible.vue'; import CloseIcon from './icon/CloseIcon.vue'; import DownIcon from './icon/DownIcon.vue'; import {useStore} from '../store'; -// Refs const rootRef = ref(null as HTMLDivElement | null) const closeRef = ref(null as typeof CloseIcon | null); -// Global store const store = useStore(); +const touchDevice = computed(() => store.touchDevice) -// Props + events defineProps({ tutOpen: {type: Boolean, default: false}, }); -const touchDevice = computed(() => store.touchDevice) + const emit = defineEmits(['close', 'start-tutorial']); -// Event handlers +// ========== Event handlers ========== + function onClose(evt: Event){ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ emit('close'); } } + function onStartTutorial(){ emit('start-tutorial'); emit('close'); } -// Styles +// ========== For styling ========== + const styles = computed(() => ({ backgroundColor: store.color.bgAlt, borderRadius: store.borderRadius + 'px', boxShadow: store.shadowNormal, })); + const aStyles = computed(() => ({ color: store.color.altDark, })); -// Classes const scClasses = 'border border-stone-400 rounded'; const scSummaryClasses = 'relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2'; const downIconClasses = 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300'; diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue index 39b4283..4676fda 100644 --- a/src/components/SCollapsible.vue +++ b/src/components/SCollapsible.vue @@ -14,15 +14,17 @@ <script setup lang="ts"> import {ref, computed, watch} from 'vue'; -// Props + events const props = defineProps({ modelValue: {type: Boolean, default: false}, // For using v-model on the component }); + const emit = defineEmits(['update:modelValue', 'open']); -// For open status +// ========== For open status ========== + const open = ref(false); watch(() => props.modelValue, (newVal) => {open.value = newVal}) + function onClick(){ open.value = !open.value; emit('update:modelValue', open.value); @@ -31,10 +33,12 @@ function onClick(){ } } -// Styles +// ========== For styles ========== + const styles = computed(() => ({ overflow: open.value ? 'visible' : 'hidden', })); + const contentStyles = computed(() => ({ overflow: 'hidden', opacity: open.value ? '1' : '0', @@ -43,18 +47,22 @@ const contentStyles = computed(() => ({ transitionTimingFunction: 'ease-in-out', })); -// Open/close transitions +// ========== Open/close transitions ========== + function onEnter(el: HTMLDivElement){ el.style.maxHeight = el.scrollHeight + 'px'; } + function onAfterEnter(el: HTMLDivElement){ el.style.maxHeight = 'none'; // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short } + function onBeforeLeave(el: HTMLDivElement){ el.style.maxHeight = el.scrollHeight + 'px'; el.offsetWidth; // Triggers reflow } + function onLeave(el: HTMLDivElement){ el.style.maxHeight = '0'; } diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue index 1818529..607587f 100644 --- a/src/components/SearchModal.vue +++ b/src/components/SearchModal.vue @@ -2,12 +2,17 @@ <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/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex" :style="styles"> + <!-- Input field --> <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef" @keyup.enter="onSearch" @keyup.esc="onClose" @input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/> + + <!-- Search button --> <div class="p-1 hover:cursor-pointer"> <search-icon @click.stop="onSearch" class="w-8 h-8"/> </div> + + <!-- Search suggestions --> <div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles"> <div v-for="(sugg, idx) of searchSuggs" :key="sugg.name + '|' + sugg.canonicalName" :style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}" @@ -24,6 +29,8 @@ </div> <div v-if="searchHadMoreSuggs" class="text-center">• • •</div> </div> + + <!-- Options --> <label :style="animateLabelStyles" class="flex gap-1"> <input type="checkbox" v-model="store.searchJumpMode" @change="emit('setting-chg', 'searchJumpMode')"/> <div class="text-sm">Jump to result</div> @@ -34,6 +41,7 @@ <script setup lang="ts"> import {ref, computed, onMounted, onUnmounted, PropType} from 'vue'; + import SearchIcon from './icon/SearchIcon.vue'; import InfoIcon from './icon/InfoIcon.vue'; import {TolNode, TolMap} from '../tol'; @@ -41,28 +49,28 @@ import {LayoutNode, LayoutMap} from '../layout'; import {queryServer, SearchSugg, SearchSuggResponse} from '../lib'; import {useStore} from '../store'; -// Refs const rootRef = ref(null as HTMLDivElement | null); const inputRef = ref(null as HTMLInputElement | null); -// Global store const store = useStore(); -// Props + events const props = defineProps({ lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added }); + const emit = defineEmits(['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get']); -// Search-suggestion data +// ========== Search-suggestion data ========== + const searchSuggs = ref([] as SearchSugg[]); const searchHadMoreSuggs = ref(false); +const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text) + const suggDisplayStrings = computed((): [string, string, string, string][] => { let result: [string, string, string, string][] = []; let input = suggsInput.value.toLowerCase(); - // For each SearchSugg for (let sugg of searchSuggs.value){ let idx = sugg.name.indexOf(input); // Split suggestion text into parts before/within/after an input match @@ -72,26 +80,30 @@ const suggDisplayStrings = computed((): [string, string, string, string][] => { } else { strings = [input, '', '', '']; } + // Indicate any distinct canonical-name if (sugg.canonicalName != null){ strings[3] = ` (aka ${sugg.canonicalName})`; } - // + result.push(strings); } return result; }); -const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text) + const focusedSuggIdx = ref(null as null | number); // Index of a search-suggestion selected using the arrow keys -// For search-suggestion requests +// ========== For search-suggestion requests ========== + const lastSuggReqTime = ref(0); // Set when a search-suggestions request is initiated const pendingSuggReqParams = ref(null as null | URLSearchParams); // Used by a search-suggestion requester to request with the latest user input const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for a non-initial search-suggestions request const pendingSuggInput = ref(''); // Used to remember what input triggered a suggestions request + async function onInput(){ let input = inputRef.value!; + // Check for empty input if (input.value.length == 0){ searchSuggs.value = []; @@ -99,6 +111,7 @@ async function onInput(){ focusedSuggIdx.value = null; return; } + // Get URL params to use for querying search-suggestions let urlParams = new URLSearchParams({ type: 'sugg', @@ -106,6 +119,7 @@ async function onInput(){ limit: String(store.searchSuggLimit), tree: store.tree, }); + // Query server, delaying/skipping if a request was recently sent pendingSuggReqParams.value = urlParams; pendingSuggInput.value = input.value; @@ -119,6 +133,7 @@ async function onInput(){ searchSuggs.value = responseObj.suggs; searchHadMoreSuggs.value = responseObj.hasMore; suggsInput.value = suggInput; + // Auto-select first result if present if (searchSuggs.value.length > 0){ focusedSuggIdx.value = 0; @@ -145,7 +160,8 @@ async function onInput(){ } } -// For search events +// ========== For search events ========== + function onSearch(){ if (focusedSuggIdx.value == null){ let input = inputRef.value!.value.toLowerCase(); @@ -155,15 +171,18 @@ function onSearch(){ resolveSearch(sugg.canonicalName || sugg.name); } } + async function resolveSearch(tolNodeName: string){ if (tolNodeName == ''){ return; } + // Check if the node data is already here if (props.lytMap.has(tolNodeName)){ emit('search', tolNodeName); return; } + // Ask server for nodes in parent-chain, updates tolMap, then emits search event let urlParams = new URLSearchParams({ type: 'node', @@ -195,28 +214,33 @@ async function resolveSearch(tolNodeName: string){ } } -// More event handling +// ========== More event handling ========== + function onClose(evt: Event){ if (evt.target == rootRef.value){ emit('close'); } } + function onDownKey(){ if (focusedSuggIdx.value != null){ focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length; } } + function onUpKey(){ if (focusedSuggIdx.value != null){ focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length; // The addition after '-1' is to avoid becoming negative } } + function onInfoIconClick(nodeName: string){ emit('info-click', nodeName); } -// For keyboard shortcuts +// ========== For keyboard shortcuts ========== + function onKeyDown(evt: KeyboardEvent){ if (store.disableShortcuts){ return; @@ -226,13 +250,16 @@ function onKeyDown(evt: KeyboardEvent){ inputRef.value!.focus(); } } + onMounted(() => window.addEventListener('keydown', onKeyDown)) onUnmounted(() => window.removeEventListener('keydown', onKeyDown)) -// Focus input on mount +// ========== Focus input on mount ========== + onMounted(() => inputRef.value!.focus()) -// Styles +// ========== For styling ========== + const styles = computed((): Record<string,string> => { let br = store.borderRadius; return { @@ -241,6 +268,7 @@ const styles = computed((): Record<string,string> => { boxShadow: store.shadowNormal, }; }); + const suggContainerStyles = computed((): Record<string,string> => { let br = store.borderRadius; return { @@ -249,6 +277,7 @@ const suggContainerStyles = computed((): Record<string,string> => { borderRadius: `0 0 ${br}px ${br}px`, }; }); + const animateLabelStyles = computed(() => ({ position: 'absolute', top: -store.lytOpts.headerSz - 2 + 'px', diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue index a55dc41..95721e2 100644 --- a/src/components/SettingsModal.vue +++ b/src/components/SettingsModal.vue @@ -5,6 +5,7 @@ <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">Timing</h2> <div class="grid grid-cols-[130px_minmax(0,1fr)_65px] gap-1 px-2 md:px-3"> @@ -24,6 +25,7 @@ <div class="my-auto text-right">{{store.autoActionDelay}} ms</div> </div> </div> + <div class="pb-2" :class="borderBClasses"> <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Layout</h2> <div class="flex gap-2 justify-around px-2 pb-1"> @@ -76,6 +78,7 @@ <div class="my-auto text-right">{{store.lytOpts.tileSpacing}} px</div> </div> </div> + <div class="pb-2 px-2 md:px-3" :class="borderBClasses"> <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 -mb-2 ">Other</h2> <div> @@ -102,10 +105,12 @@ @change="onSettingChg('disableShortcuts')"/> Disable keyboard shortcuts </label> </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> @@ -117,28 +122,27 @@ import {ref, computed, watch} from 'vue'; import SButton from './SButton.vue'; import CloseIcon from './icon/CloseIcon.vue'; -import {useStore, StoreState} from '../store'; +import {useStore} from '../store'; -// Refs const rootRef = ref(null as HTMLDivElement | null); const closeRef = ref(null as typeof CloseIcon | null); const minTileSzRef = ref(null as HTMLInputElement | null); const maxTileSzRef = ref(null as HTMLInputElement | null); const saveIndRef = ref(null as HTMLDivElement | null); -// Global store const store = useStore(); -// Events const emit = defineEmits(['close', 'setting-chg', 'reset']); // For making only two of 'layoutType's values available for user selection) const sweepLeaves = ref(store.lytOpts.layoutType == 'sweep'); watch(sweepLeaves, (newVal) => {store.lytOpts.layoutType = newVal ? 'sweep' : 'rect'}) -// Settings change handling +// ========== Settings change handling ========== + const saved = ref(false); // Set to true after a setting is saved let settingChgTimeout = 0; // Used to throttle some setting-change handling + function onSettingChg(option: string){ // Maintain min/max-tile-size consistency if (option == 'lytOpts.minTileSz' || option == 'lytOpts.maxTileSz'){ @@ -154,8 +158,10 @@ function onSettingChg(option: string){ } } } + // Notify parent (might need to relayout) emit('setting-chg', option); + // Possibly make saved-indicator appear/animate if (!saved.value){ saved.value = true; @@ -166,6 +172,7 @@ function onSettingChg(option: string){ el.classList.add('animate-flash-green'); } } + function onSettingChgThrottled(option: string){ if (settingChgTimeout == 0){ settingChgTimeout = setTimeout(() => { @@ -174,6 +181,7 @@ function onSettingChgThrottled(option: string){ }, store.animationDelay); } } + function onResetOne(option: string){ store.resetOne(option); if (option == 'lytOpts.layoutType'){ @@ -181,24 +189,28 @@ function onResetOne(option: string){ } onSettingChg(option); } + function onReset(){ emit('reset'); // Notify parent (might need to relayout) saved.value = false; // Clear saved-indicator } -// Close handling +// ========== Close handling ========== + function onClose(evt: Event){ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ emit('close'); } } -// Styles and classes +// ========== For styling ========== + const styles = computed(() => ({ backgroundColor: store.color.bgAlt, borderRadius: store.borderRadius + 'px', boxShadow: store.shadowNormal, })); + const borderBClasses = 'border-b border-stone-400'; const rLabelClasses = "w-fit hover:cursor-pointer hover:text-lime-600"; // For reset-upon-click labels </script> diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue index 52dd1b2..ead1417 100644 --- a/src/components/TileInfoModal.vue +++ b/src/components/TileInfoModal.vue @@ -3,8 +3,11 @@ <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 max-w-[80%] w-2/3 min-w-[8cm] md:w-[14cm] lg:w-[16cm] max-h-[80%]" :style="styles"> <div class="pb-1 md:pb-2"> + <!-- Close button --> <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"/> + + <!-- Copy-link button --> <div class="absolute top-1 left-1 md:top-2 md:left-2 flex items-center"> <a :href="'/?node=' + encodeURIComponent(nodeName)" class="block w-8 h-8 p-[2px] hover:cursor-pointer" @click.prevent="onLinkIconClick" title="Copy link to this node"> @@ -14,27 +17,37 @@ <div v-if="linkCopied" class="text-sm p-1 ml-2" :style="linkCopyLabelStyles">Link Copied</div> </transition> </div> + + <!-- Title --> <h1 class="text-center text-xl font-bold pt-2 pb-1 mx-10 md:text-2xl md:pt-3 md:pb-1"> {{getDisplayName(nodeName, tolNode)}} </h1> + + <!-- Node data --> <div class="flex justify-evenly text-sm md:text-base"> <div><span class="font-bold">Children:</span> {{(tolNode.children.length).toLocaleString()}}</div> + <div><span class="font-bold">Tips:</span> {{(tolNode.tips).toLocaleString()}}</div> + <div v-if="tolNode.iucn != null"> <a href="https://en.wikipedia.org/wiki/Endangered_species_(IUCN_status)" target="_blank" title="IUCN Conservation Status" class="font-bold">IUCN: </a> <span :style="iucnStyles(tolNode.iucn)">{{getDisplayIucn(tolNode.iucn)}}</span> </div> + <div> <a :href="'https://tree.opentreeoflife.org/opentree/argus/opentree13.4@' + tolNode.otolId" target="_blank" title="Look up in Open Tree of Life" class="font-bold">OTOL <external-link-icon class="inline-block w-3 h-3"/></a> </div> </div> + <div v-if="nodes.length > 1" class="text-center text-sm px-2"> <div> (This is a compound node. The details below describe two descendants) </div> </div> </div> + + <!-- Main content --> <div v-for="(node, idx) in nodes" :key="node == null ? -1 : node.otolId!" class="border-t border-stone-400 p-2 md:p-3 clear-both"> <h1 v-if="nodes.length > 1" class="text-center font-bold mb-1"> @@ -45,10 +58,13 @@ </div> <div v-else> <div v-if="imgInfos[idx] != null" class="mt-1 mr-2 md:mb-2 md:mr-4 md:float-left"> + <!-- Image --> <a :href="imgInfos[idx]!.url != '' ? imgInfos[idx]!.url : 'javascript:;'" :target="imgInfos[idx]!.url != '' ? '_blank' : ''" class="block w-fit mx-auto"> <div :style="getImgStyles(node)"/> </a> + + <!-- Image Source --> <s-collapsible class="text-sm text-center w-fit max-w-full md:max-w-[200px] mx-auto"> <template v-slot:summary="slotProps"> <div class="py-1 hover:underline"> @@ -95,6 +111,8 @@ </template> </s-collapsible> </div> + + <!-- Description --> <div v-if="descInfos[idx]! != null"> <div>{{descInfos[idx]!.text}}</div> <div class="text-sm text-stone-600 text-right"> @@ -115,32 +133,34 @@ <script setup lang="ts"> import {ref, computed, PropType} from 'vue'; + import SCollapsible from './SCollapsible.vue'; import CloseIcon from './icon/CloseIcon.vue'; import ExternalLinkIcon from './icon/ExternalLinkIcon.vue'; import DownIcon from './icon/DownIcon.vue'; import LinkIcon from './icon/LinkIcon.vue'; + import {TolNode} from '../tol'; import {getImagePath, DescInfo, ImgInfo, InfoResponse} from '../lib'; import {capitalizeWords} from '../util'; import {useStore} from '../store'; -// Refs const rootRef = ref(null as HTMLDivElement | null); const closeRef = ref(null as typeof CloseIcon | null); -// Global store const store = useStore(); -// Props + events const props = defineProps({ nodeName: {type: String, required: true}, infoResponse: {type: Object as PropType<InfoResponse>, required: true}, }); + const emit = defineEmits(['close']); -// InfoResponse computed data +// ========== InfoResponse computed data ========== + const tolNode = computed(() => props.infoResponse.nodeInfo.tolNode); + const nodes = computed((): (TolNode | null)[] => { if (props.infoResponse.subNodesInfo.length == 0){ return [tolNode.value]; @@ -148,6 +168,7 @@ const nodes = computed((): (TolNode | null)[] => { return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null); } }); + const imgInfos = computed((): (ImgInfo | null)[] => { if (props.infoResponse.subNodesInfo.length == 0){ return [props.infoResponse.nodeInfo.imgInfo]; @@ -155,6 +176,7 @@ const imgInfos = computed((): (ImgInfo | null)[] => { return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null); } }); + const descInfos = computed((): (DescInfo | null)[] => { if (props.infoResponse.subNodesInfo.length == 0){ return [props.infoResponse.nodeInfo.descInfo]; @@ -162,13 +184,15 @@ const descInfos = computed((): (DescInfo | null)[] => { return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null); } }); + const subNames = computed((): [string, string] | null => { const regex = /\[(.+) \+ (.+)\]/; let results = regex.exec(props.nodeName); return results == null ? null : [results[1], results[2]]; }); -// InfoResponse data converters +// ========== InfoResponse data converters ========== + function getDisplayName(name: string, tolNode: TolNode | null): string { if (tolNode == null || tolNode.commonName == null){ return capitalizeWords(name); @@ -176,6 +200,7 @@ function getDisplayName(name: string, tolNode: TolNode | null): string { return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`; } } + function getDisplayIucn(iucn: string){ switch (iucn){ case 'least concern': return 'LC'; @@ -188,6 +213,7 @@ function getDisplayIucn(iucn: string){ case 'data deficient': return 'DD'; } } + function licenseToUrl(license: string){ license = license.toLowerCase().replaceAll('-', ' '); if (license == 'cc0'){ @@ -219,15 +245,18 @@ function licenseToUrl(license: string){ } } -// Close handling +// ========== Close handling ========== + function onClose(evt: Event){ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ emit('close'); } } -// Copy-link handling +// ========== Copy-link handling ========== + const linkCopied = ref(false); // Used to temporarily show a 'link copied' label + function onLinkIconClick(){ // Copy link to clipboard let url = new URL(window.location.href); @@ -238,13 +267,15 @@ function onLinkIconClick(){ setTimeout(() => {linkCopied.value = false}, 1500); } -// Styles +// ========== For styling ========== + const styles = computed(() => ({ backgroundColor: store.color.bgAlt, borderRadius: store.borderRadius + 'px', boxShadow: store.shadowNormal, overflow: 'visible auto', })); + function getImgStyles(tolNode: TolNode | null): Record<string,string> { let imgName = null; if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case @@ -262,15 +293,18 @@ function getImgStyles(tolNode: TolNode | null): Record<string,string> { boxShadow: store.shadowNormal, }; } + const sourceLabelStyles = computed((): Record<string,string> => { return { color: store.color.textDark, fontWeight: 'bold', }; }); + const aStyles = computed((): Record<string,string> => ({ color: store.color.alt, })); + function iucnStyles(iucn: string): Record<string,string>{ let col = 'currentcolor'; switch (iucn){ @@ -286,6 +320,7 @@ function iucnStyles(iucn: string): Record<string,string>{ color: col, }; } + const linkCopyLabelStyles = computed(() => ({ color: store.color.text, backgroundColor: store.color.bg, diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue index 99aa4e1..7f036f3 100644 --- a/src/components/TolTile.vue +++ b/src/components/TolTile.vue @@ -17,6 +17,7 @@ @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/> </template> </div> + <div v-else :style="nonleafStyles"> <div v-if="showNonleafHeader" :style="nonleafHeaderStyles" class="flex hover:cursor-pointer" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp"> @@ -24,6 +25,7 @@ <info-icon v-if="infoIconDisabled" :style="infoIconStyles" :class="infoIconClasses" @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/> </div> + <div :style="sepSweptAreaStyles" :class="sepSweptAreaHideEdgeClass"> <div v-if="layoutNode.sepSweptArea?.sweptLeft === false" :style="nonleafHeaderStyles" class="flex hover:cursor-pointer" @@ -36,12 +38,14 @@ <div v-if="inFlash" class="absolute w-full h-full top-0 left-0 rounded-[inherit] bg-amber-500/70 z-20"/> </transition> </div> + <tol-tile v-for="child in visibleChildren" :key="child.name" :layoutNode="child" :tolMap="tolMap" :overflownDim="overflownDim" @leaf-click="onInnerLeafClick" @nonleaf-click="onInnerNonleafClick" @leaf-click-held="onInnerLeafClickHeld" @nonleaf-click-held="onInnerNonleafClickHeld" @info-click="onInnerInfoIconClick"/> </div> + <transition name="fadeout"> <div v-if="inFlash" :style="{top: scrollOffset + 'px'}" class="absolute w-full h-full left-0 rounded-[inherit] bg-amber-500/70"/> @@ -51,6 +55,7 @@ <script setup lang="ts"> import {ref, computed, watch, PropType} from 'vue'; + import InfoIcon from './icon/InfoIcon.vue'; import {TolMap} from '../tol'; import {LayoutNode} from '../layout'; @@ -60,13 +65,10 @@ import {useStore} from '../store'; const SCRIM_GRADIENT = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)'; -// Refs const rootRef = ref(null as HTMLDivElement | null); -// Global store const store = useStore(); -// Props + events const props = defineProps({ layoutNode: {type: Object as PropType<LayoutNode>, required: true}, tolMap: {type: Object as PropType<TolMap>, required: true}, @@ -77,9 +79,11 @@ const props = defineProps({ overflownDim: {type: Number, default: 0}, // For a non-leaf node, display with overflow within area of this height }); + const emit = defineEmits(['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click']); -// Data computed from props +// ========== Data computed from props ========== + const tolNode = computed(() => props.tolMap.get(props.layoutNode.name)!); const visibleChildren = computed((): LayoutNode[] => { // Used to reduce slowdown from rendering many nodes let children = props.layoutNode.children; @@ -124,8 +128,10 @@ const isOverflownRoot = computed(() => const hasFocusedChild = computed(() => props.layoutNode.children.some(n => n.hasFocus)); const infoIconDisabled = computed(() => !store.disabledActions.has('tileInfo')); -// Click/hold handling +// ========== Click/hold handling ========== + const clickHoldTimer = ref(0); // Used to recognise click-and-hold events + function onMouseDown(): void { highlight.value = false; if (!store.touchDevice){ @@ -149,6 +155,7 @@ function onMouseDown(): void { } } } + function onMouseUp(): void { if (!store.touchDevice){ if (clickHoldTimer.value > 0){ @@ -159,8 +166,10 @@ function onMouseUp(): void { } } -// Click-action handling +// ========== Click-action handling ========== + const wasClicked = ref(false); // Used to increase z-index during transition after this tile (or a child) is clicked + function onClick(): void { if (isLeaf.value && !isExpandableLeaf.value){ console.log('Ignored click on non-expandable node'); @@ -173,6 +182,7 @@ function onClick(): void { emit('nonleaf-click', props.layoutNode, onCollapseFail); } } + function onClickHold(): void { if (isLeaf.value && !isExpandableLeaf.value){ console.log('Ignored click-hold on non-expandable node'); @@ -184,45 +194,58 @@ function onClickHold(): void { emit('nonleaf-click-held', props.layoutNode, onCollapseFail); } } + function onDblClick(): void { onClickHold(); } + function onInfoIconClick(): void { emit('info-click', props.layoutNode.name); } -// Child click-action propagation + +// ========== Child click-action propagation ========== + function onInnerLeafClick(node: LayoutNode, onFail: () => void): void { wasClicked.value = true; emit('leaf-click', node, onFail); } + function onInnerNonleafClick(node: LayoutNode, onFail: () => void): void { wasClicked.value = true; emit('nonleaf-click', node, onFail); } + function onInnerLeafClickHeld(node: LayoutNode, onFail: () => void): void { emit('leaf-click-held', node, onFail); } + function onInnerNonleafClickHeld(node: LayoutNode, onFail: () => void): void { emit('nonleaf-click-held', node, onFail); } + function onInnerInfoIconClick(nodeName: string): void { emit('info-click', nodeName); } -// Mouse-hover handling +// ========== Mouse-hover handling ========== + const highlight = ref(false); // Used to draw a colored outline on mouse hover + function onMouseEnter(): void { if ((!isLeaf.value || isExpandableLeaf.value) && !inTransition.value){ highlight.value = true; } } + function onMouseLeave(): void { highlight.value = false; } -// Scrolling if overflownRoot +// ========== Scrolling if overflownRoot ========== + const scrollOffset = ref(0); // Used to track scroll offset when displaying with overflow const pendingScrollHdlr = ref(0); // Used for throttling updating of scrollOffset + function onScroll(): void { if (pendingScrollHdlr.value == 0){ pendingScrollHdlr.value = setTimeout(() => { @@ -233,9 +256,11 @@ function onScroll(): void { }, store.animationDelay); } } + // Without this, sometimes, if auto-mode enters an overflowing node, scrolls down, collapses, then stops, // and the node is then manually expanded, the scroll will be 0, and some nodes will be hidden watch(isLeaf, onScroll); + // Scroll to focused child if overflownRoot watch(hasFocusedChild, (newVal: boolean) => { if (newVal && isOverflownRoot.value){ @@ -246,10 +271,12 @@ watch(hasFocusedChild, (newVal: boolean) => { } }); -// Transition related +// ========== Transition related ========== + const inTransition = ref(false); // Used to avoid content overlap and overflow during 'user-perceivable' transitions const hasExpanded = ref(false); // Set to true after an expansion transition ends, and false upon collapse // Used to hide overflow on tile expansion, but not hide a sepSweptArea on subsequent transitions + function onTransitionEnd(){ if (inTransition.value){ inTransition.value = false; @@ -257,6 +284,7 @@ function onTransitionEnd(){ hasExpanded.value = props.layoutNode.children.length > 0; } } + // For setting transition state (allows external triggering, like via search and auto-mode) watch(() => props.layoutNode.pos, (newVal: [number, number], oldVal: [number, number]) => { let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1]; @@ -280,15 +308,19 @@ function triggerAnimation(animation: string){ el.offsetWidth; // Triggers reflow el.classList.add(animation); } + function onExpandFail(){ triggerAnimation('animate-expand-shrink'); } + function onCollapseFail(){ triggerAnimation('animate-shrink-expand'); } -// For 'flashing' the tile when focused +// ========== For 'flashing' the tile when focused ========== + const inFlash = ref(false); // Used to 'flash' the tile when focused + watch(() => props.layoutNode.hasFocus, (newVal: boolean, oldVal: boolean) => { if (newVal != oldVal && newVal){ inFlash.value = true; @@ -296,8 +328,10 @@ watch(() => props.layoutNode.hasFocus, (newVal: boolean, oldVal: boolean) => { } }); -// For temporarily enabling overflow after being unhidden +// ========== For temporarily enabling overflow after being unhidden ========== + const justUnhidden = ref(false); // Used to allow overflow temporarily after being unhidden + watch(() => props.layoutNode.hidden, (newVal: boolean, oldVal: boolean) => { if (oldVal && !newVal){ justUnhidden.value = true; @@ -305,11 +339,13 @@ watch(() => props.layoutNode.hidden, (newVal: boolean, oldVal: boolean) => { } }); -// Styles + classes +// ========== For styling ========== + const nonleafBgColor = computed(() => { let colorArray = store.nonleafBgColors; return colorArray[props.layoutNode.depth % colorArray.length]; }); + const boxShadow = computed((): string => { if (highlight.value){ return store.shadowHovered; @@ -319,6 +355,7 @@ const boxShadow = computed((): string => { return store.shadowNormal; } }); + const fontSz = computed((): number => { // These values are a compromise between dynamic font size and code simplicity if (props.layoutNode.dims[0] >= 150){ @@ -329,7 +366,7 @@ const fontSz = computed((): number => { return store.lytOpts.headerSz * 0.6; } }); -// + const styles = computed((): Record<string,string> => { let layoutStyles = { position: 'absolute', @@ -374,6 +411,7 @@ const styles = computed((): Record<string,string> => { } return layoutStyles; }); + const leafStyles = computed((): Record<string,string> => { let styles: Record<string,string> = { borderRadius: 'inherit', @@ -390,6 +428,7 @@ const leafStyles = computed((): Record<string,string> => { } return styles; }); + const leafHeaderStyles = computed((): Record<string,string> => { let numChildren = tolNode.value.children.length; let textColor = store.color.text; @@ -411,6 +450,7 @@ const leafHeaderStyles = computed((): Record<string,string> => { whiteSpace: 'nowrap', }; }); + function leafSubImgStyles(idx: number): Record<string,string> { let [w, h] = props.layoutNode.dims; return { @@ -427,8 +467,10 @@ function leafSubImgStyles(idx: number): Record<string,string> { backgroundPosition: (idx == 0) ? `${-w/4}px ${-h/4}px` : '0px 0px', }; } + const leafFirstImgStyles = computed(() => leafSubImgStyles(0)); const leafSecondImgStyles = computed(() => leafSubImgStyles(1)); + const nonleafStyles = computed((): Record<string,string> => { let styles = { width: '100%', @@ -442,6 +484,7 @@ const nonleafStyles = computed((): Record<string,string> => { } return styles; }); + const nonleafHeaderStyles = computed((): Record<string,string> => { let styles: Record<string,string> = { position: 'static', @@ -463,6 +506,7 @@ const nonleafHeaderStyles = computed((): Record<string,string> => { } return styles; }); + const nonleafHeaderTextStyles = computed(() => ({ lineHeight: (fontSz.value * 1.3) + 'px', fontSize: fontSz.value + 'px', @@ -474,6 +518,7 @@ const nonleafHeaderTextStyles = computed(() => ({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', })); + const sepSweptAreaStyles = computed((): Record<string,string> => { let borderR = store.borderRadius + 'px'; let styles = { @@ -509,6 +554,7 @@ const sepSweptAreaStyles = computed((): Record<string,string> => { }; } }); + const sepSweptAreaHideEdgeClass = computed((): string => { if (props.layoutNode.sepSweptArea == null){ return ''; @@ -518,6 +564,7 @@ const sepSweptAreaHideEdgeClass = computed((): string => { return 'hide-top-edge'; } }); + const infoIconStyles = computed((): Record<string,string> => { let size = (store.lytOpts.headerSz * 0.85); let marginSz = (store.lytOpts.headerSz - size); @@ -529,6 +576,7 @@ const infoIconStyles = computed((): Record<string,string> => { margin: isLeaf.value ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`, }; }); + const infoIconClasses = 'text-white/30 hover:text-white hover:cursor-pointer'; </script> diff --git a/src/components/TutorialPane.vue b/src/components/TutorialPane.vue index 3ccbc46..b5134ca 100644 --- a/src/components/TutorialPane.vue +++ b/src/components/TutorialPane.vue @@ -1,9 +1,12 @@ <template> <div :style="styles" class="relative flex flex-col justify-between"> <close-icon @click.stop="onClose" class="absolute top-2 right-2 w-8 h-8 hover:cursor-pointer"/> + <!-- Heading --> <h1 class="text-center text-lg font-bold pt-3 pb-2"> {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${LAST_STAGE})`}} </h1> + + <!-- Text content --> <transition name="fade" mode="out-in"> <div v-if="stage == 0" :style="contentStyles"> This is a visual explorer for the biological Tree of Life. @@ -46,6 +49,7 @@ And finally, {{touchDevice ? 'tap' : 'click'}} the help icon for more information </div> </transition> + <!-- Buttons --> <div class="w-full my-2 flex justify-evenly"> <template v-if="stage == 0"> @@ -68,15 +72,15 @@ <script setup lang="ts"> import {ref, computed, watch, onMounted, PropType} from 'vue'; + import SButton from './SButton.vue'; import CloseIcon from './icon/CloseIcon.vue'; import {Action} from '../lib'; import {useStore} from '../store'; -// Global store const store = useStore(); +const touchDevice = computed(() => store.touchDevice); -// Props + events const props = defineProps({ actionsDone: {type: Object as PropType<Set<Action>>, required: true}, // Used to avoid disabling actions already done @@ -84,38 +88,47 @@ const props = defineProps({ // Used to indicate that a tutorial-requested 'trigger' action has been done skipWelcome: {type: Boolean, default: false}, }); -const touchDevice = computed(() => store.touchDevice); + const emit = defineEmits(['close', 'stage-chg', 'skip']); -// For tutorial stage +// ========== For tutorial stage ========== + const stage = ref(props.skipWelcome ? 1 : 0); // Indicates the current step of the tutorial (stage 0 is the welcome message) + const LAST_STAGE = 9; const STAGE_ACTIONS = [ // Specifies, for stages 1+, what action to enable (can repeat an action to enable nothing new) 'expand', 'collapse', 'expandToView', 'unhideAncestor', 'tileInfo', 'search', 'autoMode', 'settings', 'help', ] as Action[]; + let disabledOnce = false; // Set to true after disabling features at stage 1 const hidNextPrevOnce = ref(false); // Used to hide prev/next buttons when initially at stage 1 -// For stage changes +// ========== For stage changes ========== + function onStartTutorial(){ stage.value = 1; } + function onSkipTutorial(){ emit('skip'); emit('close'); } + function onPrevClick(){ stage.value = Math.max(1, stage.value - 1); } + function onNextClick(){ stage.value = Math.min(stage.value + 1, LAST_STAGE); } + function onClose(){ emit('close'); } + function onStageChange(){ // If starting tutorial, disable 'all' actions if (stage.value == 1 && !disabledOnce){ @@ -135,6 +148,7 @@ function onStageChange(){ hidNextPrevOnce.value = true; } } + onMounted(() => { if (props.skipWelcome){ onStageChange(); @@ -149,16 +163,19 @@ watch(() => props.triggerFlag, () => { } }); -// Styles +// ========== For styling ========== + const styles = computed(() => ({ backgroundColor: store.color.bgDark, color: store.color.text, })); + const contentStyles = { padding: '0 0.5cm', overflow: 'auto', textAlign: 'center', }; + const buttonStyles = computed(() => ({ color: store.color.text, backgroundColor: store.color.bg, |
