aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AncestryBar.vue21
-rw-r--r--src/components/HelpModal.vue38
-rw-r--r--src/components/SCollapsible.vue16
-rw-r--r--src/components/SearchModal.vue55
-rw-r--r--src/components/SettingsModal.vue26
-rw-r--r--src/components/TileInfoModal.vue51
-rw-r--r--src/components/TolTile.vue76
-rw-r--r--src/components/TutorialPane.vue29
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">&#x2022; &#x2022; &#x2022;</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,