aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2023-01-29 12:21:55 +1100
committerTerry Truong <terry06890@gmail.com>2023-01-29 12:23:13 +1100
commit629b9208503369c3f20ceb59685ef49766344093 (patch)
tree87071d862358c56ee38756ab94eb04f9c55fd0dc
parent8781fdb2b8c530a6c1531ae9e82221eb062e34fb (diff)
Adjust frontend coding style
Add line spacing and section comments Fix 'Last updated' line in help modal being shown despite overflow
-rw-r--r--src/App.vue168
-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
-rw-r--r--src/layout.ts70
-rw-r--r--src/lib.ts24
-rw-r--r--src/store.ts21
-rw-r--r--src/tol.ts2
-rw-r--r--src/util.ts16
14 files changed, 495 insertions, 118 deletions
diff --git a/src/App.vue b/src/App.vue
index ee1380e..7d5768d 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -4,7 +4,8 @@
<!-- Title bar -->
<div class="flex gap-2 p-2" :style="{backgroundColor: store.color.bgDark2, color: store.color.alt}">
<h1 class="my-auto ml-2 text-4xl hover:cursor-pointer" @click="collapseTree" title="Reset tree">Tilo</h1>
- <div class="mx-auto"/> <!-- Spacer -->
+ <!-- Spacer -->
+ <div class="mx-auto"/>
<!-- Icons -->
<icon-button :disabled="isDisabled('help')" :size="45" :style="buttonStyles"
@click="onHelpIconClick" title="Show help info">
@@ -24,6 +25,7 @@
<search-icon/>
</icon-button>
</div>
+
<!-- Content area -->
<div class="grow min-h-0 flex flex-col relative" ref="contentAreaRef">
<div :style="tutPaneContainerStyles" class="z-10"> <!-- Used to slide-in/out the tutorial pane -->
@@ -56,6 +58,7 @@
</icon-button>
</transition>
</div>
+
<!-- Modals -->
<transition name="fade">
<search-modal v-if="searchOpen"
@@ -79,6 +82,7 @@
<transition name="fade">
<loading-modal v-if="loadingMsg != null" :msg="loadingMsg" class="z-10"/>
</transition>
+
<!-- Overlay used to capture clicks during auto mode, etc -->
<div :style="{visibility: modeRunning != null ? 'visible' : 'hidden'}"
class="absolute left-0 top-0 w-full h-full z-20" @click="resetMode"></div>
@@ -87,7 +91,7 @@
<script setup lang="ts">
import {ref, computed, watch, onMounted, onUnmounted, nextTick} from 'vue';
-// Components
+
import TolTile from './components/TolTile.vue';
import TileInfoModal from './components/TileInfoModal.vue';
import SearchModal from './components/SearchModal.vue';
@@ -97,39 +101,40 @@ import AncestryBar from './components/AncestryBar.vue';
import TutorialPane from './components/TutorialPane.vue';
import LoadingModal from './components/LoadingModal.vue';
import IconButton from './components/IconButton.vue';
-// Icons
+
import SearchIcon from './components/icon/SearchIcon.vue';
import PlayIcon from './components/icon/PlayIcon.vue';
import PauseIcon from './components/icon/PauseIcon.vue';
import SettingsIcon from './components/icon/SettingsIcon.vue';
import HelpIcon from './components/icon/HelpIcon.vue';
import EduIcon from './components/icon/EduIcon.vue';
-// Other
- // Note: Import paths lack a .ts or .js because .ts makes vue-tsc complain, and .js makes vite complain
+
+// Note: Import paths lack a .ts or .js because .ts makes vue-tsc complain, and .js makes vite complain
import {TolNode, TolMap} from './tol';
import {LayoutNode, LayoutTreeChg, initLayoutTree, initLayoutMap, tryLayout} from './layout';
import {queryServer, InfoResponse, Action} from './lib';
import {arraySum, randWeightedChoice} from './util';
import {useStore, StoreState} from './store';
-// Constants
const SERVER_WAIT_MSG = 'Loading data';
const PROCESSING_WAIT_MSG = 'Processing';
const EXCESS_TOLNODE_THRESHOLD = 1000; // Threshold where excess tolMap entries get removed
-// Refs
const contentAreaRef = ref(null as HTMLElement | null);
-// Global store
const store = useStore();
-// Tree/layout data
+// ========== Tree/layout data ==========
+
const tolMap = ref(new Map() as TolMap);
tolMap.value.set('', new TolNode())
+
const layoutTree = ref(initLayoutTree(tolMap.value, "", 0));
layoutTree.value.hidden = true;
+
const activeRoot = ref(layoutTree.value); // Root of the displayed subtree
const layoutMap = ref(initLayoutMap(layoutTree.value)); // Maps names to LayoutNodes
+
// Nodes to show in ancestry-bar (ordered from root downwards)
const detachedAncestors = computed((): LayoutNode[] | null => {
if (activeRoot.value == layoutTree.value){
@@ -144,8 +149,10 @@ const detachedAncestors = computed((): LayoutNode[] | null => {
return ancestors.reverse();
});
-// For initialisation
+// ========== For initialisation ==========
+
const justInitialised = ref(false); // Used to skip transition for the tile initially loaded from server
+
async function initTreeFromServer(firstInit = true){
// Get possible target node from URL
let nodeName = (new URL(window.location.href)).searchParams.get('node');
@@ -198,6 +205,7 @@ async function initTreeFromServer(firstInit = true){
updateAreaDims();
relayoutWithCollapse(false);
}
+
async function reInit(){
if (activeRoot.value != layoutTree.value){
// Collapse tree to root
@@ -206,15 +214,19 @@ async function reInit(){
await onNonleafClick(layoutTree.value, null, true);
await initTreeFromServer(false);
}
+
onMounted(() => initTreeFromServer());
-// For layouting
+// ========== For layouting ==========
+
const mainAreaDims = ref([0, 0] as [number, number]);
const tileAreaDims = ref([0, 0] as [number, number]);
const wideMainArea = computed(() => mainAreaDims.value[0] > mainAreaDims.value[1]);
const overflownRoot = ref(false); // Set when displaying a root tile with many children, with overflow
+
function relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean {
let success: boolean;
+
if (overflownRoot.value){
if (keepOverflow){
success = tryLayout(activeRoot.value, tileAreaDims.value,
@@ -223,6 +235,7 @@ function relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean
}
overflownRoot.value = false;
}
+
success = tryLayout(activeRoot.value, tileAreaDims.value, store.lytOpts,
{allowCollapse: true, layoutMap: layoutMap.value});
if (secondPass){
@@ -232,6 +245,7 @@ function relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean
}
return success;
}
+
function updateAreaDims(){
// Set mainAreaDims and tileAreaDims
// Note: Tried setting these by querying tut_pane+ancestry_bar dimensions repeatedly,
@@ -254,9 +268,11 @@ function updateAreaDims(){
tileAreaDims.value = [w, h];
}
-// For resize handling
+// ========== For resize handling ==========
+
let lastResizeHdlrTime = 0; // Used to throttle resize handling
let afterResizeHdlr = 0; // Set via setTimeout() to execute after a run of resize events
+
async function onResize(){
// Handle event if not recently done
let handleResize = async () => {
@@ -277,6 +293,7 @@ async function onResize(){
await handleResize();
lastResizeHdlrTime = new Date().getTime();
}
+
// Also setup a handler to execute after a run of resize events
clearTimeout(afterResizeHdlr);
afterResizeHdlr = setTimeout(async () => {
@@ -288,15 +305,18 @@ async function onResize(){
}
}, 200); // If too small, touch-device detection when swapping to/from mobile-mode gets unreliable
}
+
onMounted(() => window.addEventListener('resize', onResize));
onUnmounted(() => window.removeEventListener('resize', onResize));
-// For tile expand/collapse events
+// ========== For tile expand/collapse events ==========
+
async function onLeafClick(
layoutNode: LayoutNode, onFail: null | (() => void) = null, subAction = false): Promise<boolean> {
if (!subAction && !onActionStart('expand')){
return false;
}
+
// Function for expanding tile
let doExpansion = async () => {
primeLoadInd(PROCESSING_WAIT_MSG);
@@ -306,6 +326,7 @@ async function onLeafClick(
layoutMap: layoutMap.value,
};
let success = tryLayout(activeRoot.value, tileAreaDims.value, store.lytOpts, lytFnOpts);
+
// Handle auto-hide
if (!success && store.autoHide){
while (!success && layoutNode != activeRoot.value){
@@ -322,6 +343,7 @@ async function onLeafClick(
success = tryLayout(activeRoot.value, tileAreaDims.value, store.lytOpts, lytFnOpts);
}
}
+
// If expanding active-root with too many children to fit, allow overflow
if (!success && layoutNode == activeRoot.value){
success = tryLayout(activeRoot.value, tileAreaDims.value,
@@ -330,14 +352,14 @@ async function onLeafClick(
overflownRoot.value = true;
}
}
- //
+
if (!subAction && !success && onFail != null){
onFail(); // Triggers failure animation
}
nextTick(endLoadInd);
return success;
};
- //
+
let success: boolean;
if (overflownRoot.value){ // If clicking child of overflowing active-root
if (!store.autoHide){
@@ -369,11 +391,13 @@ async function onLeafClick(
}
return success;
}
+
async function onNonleafClick(
layoutNode: LayoutNode, onFail: null | (() => void) = null, subAction = false): Promise<boolean> {
if (!subAction && !onActionStart('collapse')){
return false;
}
+
// Relayout
primeLoadInd(PROCESSING_WAIT_MSG);
let success = tryLayout(activeRoot.value, tileAreaDims.value, store.lytOpts, {
@@ -381,10 +405,12 @@ async function onNonleafClick(
chg: {type: 'collapse', node: layoutNode, tolMap: tolMap.value},
layoutMap: layoutMap.value,
});
+
// Update overflownRoot if root was collapsed
if (success && overflownRoot.value){
overflownRoot.value = false;
}
+
if (!subAction){
if (!success){
if (onFail != null){
@@ -410,7 +436,9 @@ async function onNonleafClick(
nextTick(endLoadInd);
return success;
}
-// For expand-to-view and ancestry-bar events
+
+// ========== For expand-to-view and ancestry-bar events ==========
+
async function onLeafClickHeld(
layoutNode: LayoutNode, onFail: null | (() => void) = null, subAction = false): Promise<boolean> {
// Special case for active root
@@ -418,16 +446,19 @@ async function onLeafClickHeld(
console.log('Ignored expand-to-view on active-root node');
return false;
}
- //
+
if (!subAction && !onActionStart('expandToView')){
return false;
}
+
// Function for expanding tile
let doExpansion = async () => {
primeLoadInd(PROCESSING_WAIT_MSG);
+
// Hide ancestors
LayoutNode.hideUpward(layoutNode, layoutMap.value);
activeRoot.value = layoutNode;
+
// Relayout
updateAreaDims();
overflownRoot.value = false;
@@ -437,6 +468,7 @@ async function onLeafClickHeld(
layoutMap: layoutMap.value,
};
let success = tryLayout(activeRoot.value, tileAreaDims.value, store.lytOpts, lytFnOpts);
+
// If expanding active-root with too many children to fit, allow overflow
if (!success){
success = tryLayout(activeRoot.value, tileAreaDims.value,
@@ -445,13 +477,14 @@ async function onLeafClickHeld(
overflownRoot.value = true;
}
}
- //
+
if (!success && !subAction && onFail != null){
onFail(); // Triggers failure animation
}
nextTick(endLoadInd);
return success;
};
+
// Check if data for node-to-expand exists, getting from server if needed
let success: boolean;
let tolNode = tolMap.value.get(layoutNode.name)!;
@@ -472,6 +505,7 @@ async function onLeafClickHeld(
}
return success;
}
+
async function onNonleafClickHeld(
layoutNode: LayoutNode, onFail: null | (() => void) = null, subAction = false): Promise<boolean> {
// Special case for active root
@@ -479,14 +513,16 @@ async function onNonleafClickHeld(
console.log('Ignored expand-to-view on active-root node');
return false;
}
- //
+
if (!subAction && !onActionStart('expandToView')){
return false;
}
primeLoadInd(PROCESSING_WAIT_MSG);
+
// Hide ancestors
LayoutNode.hideUpward(layoutNode, layoutMap.value);
activeRoot.value = layoutNode;
+
// Relayout
updateAreaDims();
let success = relayoutWithCollapse();
@@ -497,15 +533,18 @@ async function onNonleafClickHeld(
nextTick(endLoadInd);
return success;
}
-async function onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false, collapse = false): Promise<boolean> {
+
+async function onDetachedAncestorClick(
+ layoutNode: LayoutNode, subAction = false, collapse = false): Promise<boolean> {
if (!subAction && !onActionStart('unhideAncestor')){
return false;
}
primeLoadInd(PROCESSING_WAIT_MSG);
+
// Unhide ancestors
activeRoot.value = layoutNode;
overflownRoot.value = false;
- //
+
let success: boolean;
updateAreaDims();
if (!collapse){
@@ -520,7 +559,7 @@ async function onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false
success = await onNonleafClick(layoutNode, null, true); // For reducing tile-flashing on-screen
}
LayoutNode.showDownward(layoutNode);
- //
+
if (!subAction){
onActionEnd('unhideAncestor');
}
@@ -528,9 +567,11 @@ async function onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false
return success;
}
-// For tile-info modal
+// ========== For tile-info modal ==========
+
const infoModalNodeName = ref(null as string | null); // Name of node to display info for, or null
const infoModalData = ref(null as InfoResponse | null);
+
async function onInfoClick(nodeName: string){
if (!onActionStart('tileInfo')){
return;
@@ -538,6 +579,7 @@ async function onInfoClick(nodeName: string){
if (!searchOpen.value){ // Close an active non-search mode
resetMode();
}
+
// Query server for tol-node info
let urlParams = new URLSearchParams({type: 'info', name: nodeName, tree: store.tree});
let responseObj: InfoResponse = await loadFromServer(urlParams);
@@ -547,13 +589,16 @@ async function onInfoClick(nodeName: string){
infoModalData.value = responseObj;
}
}
+
function onInfoClose(){
infoModalNodeName.value = null;
onActionEnd('tileInfo');
}
-// For search modal
+// ========== For search modal ==========
+
const searchOpen = ref(false);
+
function onSearchIconClick(){
if (!onActionStart('search')){
return;
@@ -563,6 +608,7 @@ function onSearchIconClick(){
searchOpen.value = true;
}
}
+
function onSearch(name: string){
if (modeRunning.value != null){
console.log('WARNING: Unexpected search event while search/auto mode is running')
@@ -575,10 +621,12 @@ function onSearch(name: string){
}
expandToNode(name);
}
+
async function expandToNode(name: string){
if (modeRunning.value == null){
return;
}
+
// Check if node is displayed
let targetNode = layoutMap.value.get(name);
if (targetNode != null && !targetNode.hidden){
@@ -586,12 +634,14 @@ async function expandToNode(name: string){
onSearchClose();
return;
}
+
// Get nearest in-layout-tree ancestor
let ancestorName = name;
while (layoutMap.value.get(ancestorName) == null){
ancestorName = tolMap.value.get(ancestorName)!.parent!;
}
let layoutNode = layoutMap.value.get(ancestorName)!;
+
// If hidden, expand self/ancestor in ancestry-bar
if (layoutNode.hidden){
let nodeInAncestryBar = layoutNode;
@@ -607,6 +657,7 @@ async function expandToNode(name: string){
}
return;
}
+
// Attempt tile-expand
if (store.searchJumpMode){
// Extend layout tree
@@ -618,6 +669,7 @@ async function expandToNode(name: string){
}
nodesToAdd.reverse();
layoutNode.addDescendantChain(nodesToAdd, tolMap.value, layoutMap.value);
+
// Expand-to-view on target-node's parent
targetNode = layoutMap.value.get(name);
if (targetNode!.parent != activeRoot.value){
@@ -643,6 +695,7 @@ async function expandToNode(name: string){
setTimeout(() => expandToNode(name), store.transitionDuration);
return;
}
+
// Attempt expand-to-view on an ancestor halfway to the active root
if (layoutNode == activeRoot.value){
console.log('Screen too small to expand active root');
@@ -658,17 +711,21 @@ async function expandToNode(name: string){
await onNonleafClickHeld(layoutNode, null, true);
setTimeout(() => expandToNode(name), store.transitionDuration);
}
+
function onSearchClose(){
modeRunning.value = null;
searchOpen.value = false;
onActionEnd('search');
}
+
function onSearchNetWait(){
primeLoadInd(SERVER_WAIT_MSG);
}
-// For auto-mode
+// ========== For auto-mode ==========
+
type AutoAction = 'move across' | 'move down' | 'move up' | Action;
+
function getReverseAction(action: AutoAction): AutoAction | null {
const reversePairs: AutoAction[][] = [
['move down', 'move up'],
@@ -682,8 +739,10 @@ function getReverseAction(action: AutoAction): AutoAction | null {
return null;
}
}
+
const autoPrevAction = ref(null as AutoAction | null); // Used to help prevent action cycles
const autoPrevActionFail = ref(false); // Used to avoid re-trying a failed expand/collapse
+
function onAutoIconClick(){
if (!onActionStart('autoMode')){
return;
@@ -695,6 +754,7 @@ function onAutoIconClick(){
}
autoAction();
}
+
async function autoAction(){
if (modeRunning.value == null){
return;
@@ -722,6 +782,7 @@ async function autoAction(){
'collapse': 1, 'expandToView': 1, 'unhideAncestor': 1
};
}
+
// Zero weights for disallowed actions
if (node == activeRoot.value || node.parent!.children.length == 1){
actionWeights['move across'] = 0;
@@ -744,6 +805,7 @@ async function autoAction(){
if (activeRoot.value.parent == null || node != activeRoot.value){
actionWeights['unhideAncestor'] = 0; // Only expand ancestry-bar if able and activeRoot
}
+
// Avoid undoing previous action
if (autoPrevAction.value != null){
let revAction = getReverseAction(autoPrevAction.value);
@@ -754,6 +816,7 @@ async function autoAction(){
actionWeights[autoPrevAction.value as keyof typeof actionWeights] = 0;
}
}
+
// Choose action
let actionList = Object.getOwnPropertyNames(actionWeights);
let weightList = actionList.map(action => actionWeights[action]);
@@ -762,6 +825,7 @@ async function autoAction(){
} else {
action = actionList[randWeightedChoice(weightList)!] as AutoAction;
}
+
// Perform action
autoPrevAction.value = action;
let success = true;
@@ -803,13 +867,16 @@ async function autoAction(){
setTimeout(autoAction, action == null ? 0 : store.transitionDuration + store.autoActionDelay);
}
}
+
function onAutoClose(){
modeRunning.value = null;
onActionEnd('autoMode');
}
-// For settings modal
+// ========== For settings modal ==========
+
const settingsOpen = ref(false);
+
function onSettingsIconClick(){
if (!onActionStart('settings')){
return;
@@ -817,10 +884,12 @@ function onSettingsIconClick(){
resetMode();
settingsOpen.value = true;
}
+
function onSettingsClose(){
settingsOpen.value = false;
onActionEnd('settings');
}
+
async function onSettingChg(option: keyof StoreState){
store.save(option);
if (option == 'tree'){
@@ -830,6 +899,7 @@ async function onSettingChg(option: keyof StoreState){
relayoutWithCollapse();
}
}
+
function onResetSettings(){
let oldTree = store.tree;
store.reset();
@@ -842,8 +912,10 @@ function onResetSettings(){
}
}
-// For help modal
+// ========== For help modal ==========
+
const helpOpen = ref(false);
+
function onHelpIconClick(){
if (!onActionStart('help')){
return;
@@ -851,17 +923,20 @@ function onHelpIconClick(){
resetMode();
helpOpen.value = true;
}
+
function onHelpClose(){
helpOpen.value = false;
onActionEnd('help');
}
-// For tutorial pane
+// ========== For tutorial pane ==========
+
const tutPaneOpen = ref(!store.tutorialSkip);
const tutWelcome = ref(!store.tutorialSkip);
const tutTriggerAction = ref(null as Action | null); // Used to advance tutorial upon user-actions
const tutTriggerFlag = ref(false);
const actionsDone = ref(new Set() as Set<Action>); // Used to avoid disabling actions the user has already seen
+
function onStartTutorial(){
if (!tutPaneOpen.value){
tutPaneOpen.value = true;
@@ -869,14 +944,17 @@ function onStartTutorial(){
relayoutWithCollapse();
}
}
+
function onTutorialSkip(){
store.tutorialSkip = true;
onSettingChg('tutorialSkip')
}
+
function onTutStageChg(triggerAction: Action | null){
tutWelcome.value = false;
tutTriggerAction.value = triggerAction;
}
+
function onTutPaneClose(){
tutPaneOpen.value = false;
if (tutWelcome.value){
@@ -890,8 +968,10 @@ function onTutPaneClose(){
relayoutWithCollapse(true, true);
}
-// For highlighting a node (after search, auto-mode, or startup)
+// ========== For highlighting a node (after search, auto-mode, or startup) ==========
+
const lastFocused = ref(null as LayoutNode | null); // Used to un-focus
+
function setLastFocused(node: LayoutNode | null){
if (lastFocused.value != null){
lastFocused.value.hasFocus = false;
@@ -902,8 +982,10 @@ function setLastFocused(node: LayoutNode | null){
}
}
-// For general action handling
+// ========== For general action handling ==========
+
const modeRunning = ref(null as null | 'search' | 'autoMode');
+
function resetMode(){
if (infoModalNodeName.value != null){
onInfoClose();
@@ -921,6 +1003,7 @@ function resetMode(){
onHelpClose();
}
}
+
function onActionStart(action: Action): boolean {
if (isDisabled(action)){
return false;
@@ -928,6 +1011,7 @@ function onActionStart(action: Action): boolean {
setLastFocused(null);
return true;
}
+
function onActionEnd(action: Action){
// Update info used by tutorial pane
actionsDone.value.add(action);
@@ -942,20 +1026,24 @@ function onActionEnd(action: Action){
}
}
}
+
function isDisabled(...actions: Action[]): boolean {
let disabledActions = store.disabledActions;
return actions.some(a => disabledActions.has(a));
}
-// For the loading-indicator
+// ========== For the loading-indicator ==========
+
const loadingMsg = ref(null as null | string); // Message to display in loading-indicator
const pendingLoadingRevealHdlr = ref(0); // Used to delay showing the loading-indicator
+
function primeLoadInd(msg: string){ // Sets up a loading message to display after a timeout
clearTimeout(pendingLoadingRevealHdlr.value);
pendingLoadingRevealHdlr.value = setTimeout(() => {
loadingMsg.value = msg;
}, 500);
}
+
function endLoadInd(){ // Cancels or closes a loading message
clearTimeout(pendingLoadingRevealHdlr.value);
pendingLoadingRevealHdlr.value = 0;
@@ -963,6 +1051,7 @@ function endLoadInd(){ // Cancels or closes a loading message
loadingMsg.value = null;
}
}
+
async function loadFromServer(urlParams: URLSearchParams){ // Like queryServer(), but enables the loading indicator
primeLoadInd(SERVER_WAIT_MSG);
let responseObj = await queryServer(urlParams);
@@ -970,7 +1059,8 @@ async function loadFromServer(urlParams: URLSearchParams){ // Like queryServer()
return responseObj;
}
-// For collapsing tree upon clicking 'Tilo'
+// ========== For collapsing tree upon clicking 'Tilo' ==========
+
async function collapseTree(){
if (activeRoot.value != layoutTree.value){
await onDetachedAncestorClick(layoutTree.value, true);
@@ -980,8 +1070,10 @@ async function collapseTree(){
}
}
-// For temporarily changing a sweepToParent setting of 'fallback' to 'prefer', for efficiency
+// ========== For temporarily changing a sweepToParent setting of 'fallback' to 'prefer', for efficiency ==========
+
const changedSweepToParent = ref(false);
+
watch(modeRunning, (newVal) => {
if (newVal != null){
if (store.lytOpts.sweepToParent == 'fallback'){
@@ -996,7 +1088,8 @@ watch(modeRunning, (newVal) => {
}
});
-// For keyboard shortcuts
+// ========== For keyboard shortcuts ==========
+
function onKeyDown(evt: KeyboardEvent){
if (store.disableShortcuts){
return;
@@ -1017,6 +1110,7 @@ function onKeyDown(evt: KeyboardEvent){
}
}
}
+
onMounted(() => {
window.addEventListener('keydown', onKeyDown); // 'keydown' needed to override default CTRL-F
});
@@ -1024,11 +1118,13 @@ onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
});
-// Styles
+// ========== For styling ==========
+
const buttonStyles = computed(() => ({
color: store.color.text,
backgroundColor: store.color.altDark,
}));
+
const tutPaneContainerStyles = computed((): Record<string,string> => {
if (store.breakpoint == 'sm'){
return {
@@ -1049,6 +1145,7 @@ const tutPaneContainerStyles = computed((): Record<string,string> => {
};
}
});
+
const tutPaneStyles = computed((): Record<string,string> => {
if (store.breakpoint == 'sm'){
return {
@@ -1064,6 +1161,7 @@ const tutPaneStyles = computed((): Record<string,string> => {
};
}
});
+
const ancestryBarContainerStyles = computed((): Record<string,string> => {
let ancestryBarBreadth = detachedAncestors.value == null ? 0 : store.ancestryBarBreadth;
let styles = {
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,
diff --git a/src/layout.ts b/src/layout.ts
index 2739037..f588203 100644
--- a/src/layout.ts
+++ b/src/layout.ts
@@ -8,6 +8,8 @@
import {TolMap} from './tol';
import {range, arraySum, linspace, limitVals, updateAscSeq} from './util';
+// ========== General classes/types ==========
+
// Represents a node/tree that holds layout data for a TolNode node/tree
export class LayoutNode {
// TolNode name
@@ -27,6 +29,7 @@ export class LayoutNode {
hidden: boolean; // Used to hide nodes upon an expand-to-view
hiddenWithVisibleTip: boolean;
hasFocus: boolean; // Used by search and auto-mode to mark/flash a tile
+
// Constructor ('parent' are 'depth' are generally initialised later, 'tips' is computed)
constructor(name: string, children: LayoutNode[]){
this.name = name;
@@ -45,6 +48,7 @@ export class LayoutNode {
this.hiddenWithVisibleTip = false;
this.hasFocus = false;
}
+
// Returns a new tree with the same structure and names
// 'chg' is usable to apply a change to the resultant tree
cloneNodeTree(chg?: LayoutTreeChg | null): LayoutNode {
@@ -73,6 +77,7 @@ export class LayoutNode {
newNode.depth = this.depth;
return newNode;
}
+
// Copies layout data to a given LayoutNode tree
// If a target node has more/less children, removes/gives own children
// If 'map' is provided, it is updated to represent node additions/removals
@@ -99,6 +104,7 @@ export class LayoutNode {
}
}
}
+
// Assigns layout data to this single node
assignLayoutData(pos = [0,0] as [number,number], dims = [0,0] as [number,number],
{showHeader = false, sepSweptArea = null as SepSweptArea | null, empSpc = 0} = {}): void {
@@ -108,6 +114,7 @@ export class LayoutNode {
this.sepSweptArea = sepSweptArea;
this.empSpc = empSpc;
}
+
// Given a sequence of child/grandchild/etc names, adds this/the_child's/the_grandchild's/etc children
addDescendantChain(nameChain: string[], tolMap: TolMap, map?: LayoutMap): void {
let layoutNode = this as LayoutNode;
@@ -131,6 +138,7 @@ export class LayoutNode {
layoutNode = childNode;
}
}
+
// Update the 'tips' value of a node and it's ancestors
static updateTips(node: LayoutNode | null, diff: number): void {
while (node != null){
@@ -138,6 +146,7 @@ export class LayoutNode {
node = node.parent;
}
}
+
// Used to hide ancestor/sibling nodes, upon an expand-to-view
static hideUpward(node: LayoutNode, map: LayoutMap): void {
if (node.parent != null){
@@ -154,6 +163,7 @@ export class LayoutNode {
LayoutNode.hideUpward(node.parent, map);
}
}
+
// Used to unhide a node and it's descendants
static showDownward(node: LayoutNode): void {
if (node.hidden){
@@ -163,6 +173,7 @@ export class LayoutNode {
}
}
}
+
// Holds values that affect how layout is done
export type LayoutOptions = {
tileSpacing: number; // Spacing between tiles, in pixels
@@ -178,12 +189,14 @@ export type LayoutOptions = {
sweptNodesPrio: 'linear' | 'sqrt' | 'pow-2/3'; // Specifies allocation of space to swept-vs-remaining nodes
sweepToParent: 'none' | 'prefer' | 'fallback'; // Whether/when to place swept nodes in a parent swept-leaves area
};
+
// Represents a change to a LayoutNode tree
export type LayoutTreeChg = {
type: 'expand' | 'collapse';
node: LayoutNode;
tolMap: TolMap;
}
+
// Used with layout option 'sweepToParent', and represents, for a LayoutNode, a parent area to place leaf nodes in
export class SepSweptArea {
pos: [number, number];
@@ -198,8 +211,11 @@ export class SepSweptArea {
}
}
+// ========== For name-to-node layout maps ==========
+
// Represents a map from TolNode names to nodes in a LayoutNode tree
export type LayoutMap = Map<string, LayoutNode>;
+
// Creates a LayoutMap for a LayoutNode tree
export function initLayoutMap(layoutTree: LayoutNode): LayoutMap {
function helper(node: LayoutNode, map: LayoutMap): void {
@@ -210,17 +226,21 @@ export function initLayoutMap(layoutTree: LayoutNode): LayoutMap {
helper(layoutTree, map);
return map;
}
+
// Adds a node and it's descendants' names to a LayoutMap
function addToLayoutMap(node: LayoutNode, map: LayoutMap): void {
map.set(node.name, node);
node.children.forEach(n => addToLayoutMap(n, map));
}
+
// Removes a node and it's descendants' names from a LayoutMap
function removeFromLayoutMap(node: LayoutNode, map: LayoutMap): void {
map.delete(node.name);
node.children.forEach(n => removeFromLayoutMap(n, map));
}
+// ========== Main layout functions ==========
+
// Creates a LayoutNode representing a TolNode tree, up to a given depth (0 means just the root, -1 means no limit)
export function initLayoutTree(tolMap: TolMap, rootName: string, depth: number): LayoutNode {
function initHelper(tolMap: TolMap, nodeName: string, depthLeft: number, atDepth = 0): LayoutNode {
@@ -243,6 +263,7 @@ export function initLayoutTree(tolMap: TolMap, rootName: string, depth: number):
}
return initHelper(tolMap, rootName, depth);
}
+
// Attempts layout on a LayoutNode tree, for an area with given width+height
// If successful, sets fields of the tree's LayoutNodes, and returns true
// 'allowCollapse' allows the layout algorithm to collapse nodes to avoid layout failure
@@ -273,6 +294,8 @@ export function tryLayout(
return success;
}
+// ========== Specific layout functions ==========
+
// Type for functions called by tryLayout() to attempt layout
// Similar parameters to tryLayout(), with 'showHeader' and 'ownOpts' generally used by other LayoutFns
type LayoutFn = (
@@ -284,6 +307,7 @@ type LayoutFn = (
opts: LayoutOptions,
ownOpts?: any,
) => boolean;
+
// Lays out node as one square, ignoring child nodes // Used for base cases
const oneSqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, opts){
const tileSz = Math.min(dims[0], dims[1], opts.maxTileSz);
@@ -293,11 +317,13 @@ const oneSqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowColla
node.assignLayoutData(pos, [tileSz,tileSz], {showHeader, empSpc: dims[0]*dims[1] - tileSz**2});
return true;
}
+
// Lays out nodes as squares within a grid, with intervening+surrounding spacing
const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, opts){
if (node.children.length == 0){
return oneSqrLayout(node, pos, dims, false, false, opts);
}
+
// Consider area excluding header and top/left spacing
const headerSz = showHeader ? opts.headerSz : 0;
const newPos = [opts.tileSpacing, opts.tileSpacing + headerSz];
@@ -305,6 +331,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
if (newDims[0] * newDims[1] <= 0){
return false;
}
+
// Find number of rows/columns with least empty space
const numChildren = node.children.length;
const areaAR = newDims[0] / newDims[1]; // Aspect ratio
@@ -317,6 +344,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
const gridAR = numCols / numRows;
const usedFrac = // Fraction of area occupied by maximally-fitting grid
areaAR > gridAR ? gridAR / areaAR : areaAR / gridAR;
+
// Get tile edge length
let tileSz = (areaAR > gridAR ? newDims[1] / numRows : newDims[0] / numCols) - opts.tileSpacing;
if (tileSz < opts.minTileSz){
@@ -324,9 +352,11 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
} else if (tileSz > opts.maxTileSz){
tileSz = opts.maxTileSz;
}
+
// Get empty space
const empSpc = (1 - usedFrac) * (newDims[0] * newDims[1]) + // Area outside grid plus ...
(numCols * numRows - numChildren) * (tileSz - opts.tileSpacing)**2; // empty cells within grid
+
// Compare with best-so-far
if (empSpc < lowestEmpSpc){
lowestEmpSpc = empSpc;
@@ -335,6 +365,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
usedTileSz = tileSz;
}
}
+
// Check if unable to find grid
if (lowestEmpSpc == Number.POSITIVE_INFINITY){
if (allowCollapse){
@@ -344,6 +375,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
}
return false;
}
+
// Layout children
for (let i = 0; i < numChildren; i++){
const child = node.children[i];
@@ -364,6 +396,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
return false;
}
}
+
// Create layout
const usedDims: [number, number] = [
usedNumCols * (usedTileSz + opts.tileSpacing) + opts.tileSpacing,
@@ -375,6 +408,7 @@ const sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse
node.assignLayoutData(pos, usedDims, {showHeader, empSpc});
return true;
}
+
// Lays out nodes as rows of rectangles, deferring to sqrLayout() or oneSqrLayout() for simpler cases
//'subLayoutFn' allows other LayoutFns to use this layout, but transfer control back to themselves on recursion
const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, opts,
@@ -385,6 +419,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
} else if (node.children.every(n => n.children.length == 0)){
return sqrLayout(node, pos, dims, showHeader, allowCollapse, opts);
}
+
// Consider area excluding header and top/left spacing
const headerSz = showHeader ? opts.headerSz : 0;
const newPos = [opts.tileSpacing, opts.tileSpacing + headerSz];
@@ -397,6 +432,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
}
return false;
}
+
// Try finding arrangement with low empty space
// Done by searching possible row-groupings, allocating within rows using 'tips' vals, and trimming empty space
const numChildren = node.children.length;
@@ -475,6 +511,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
}
break;
}
+
// Create array-of-arrays representing each rows' cells' 'tips' values
const rowsOfCnts: number[][] = new Array(rowBrks.length);
for (let rowIdx = 0; rowIdx < rowBrks.length; rowIdx++){
@@ -484,6 +521,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
const rowNodeIdxs = range(numNodes).map(i => i + rowBrks![rowIdx]);
rowsOfCnts[rowIdx] = rowNodeIdxs.map(idx => node.children[idx].tips);
}
+
// Get initial cell dims
const cellWs: number[][] = new Array(rowsOfCnts.length);
for (let rowIdx = 0; rowIdx < rowsOfCnts.length; rowIdx++){
@@ -493,6 +531,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
}
const totalTips = arraySum(node.children.map(n => n.tips));
let cellHs = rowsOfCnts.map(rowOfCnts => arraySum(rowOfCnts) / totalTips * newDims[1]);
+
// Check min-tile-size, attempting to reallocate space if needed
for (let rowIdx = 0; rowIdx < rowsOfCnts.length; rowIdx++){
const newWs = limitVals(cellWs[rowIdx], minCellDims[0], Number.POSITIVE_INFINITY);
@@ -505,6 +544,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
if (cellHs == null){
continue RowBrksLoop;
}
+
// Get cell xy-coordinates
const cellXs: number[][] = new Array(rowsOfCnts.length);
for (let rowIdx = 0; rowIdx < rowBrks.length; rowIdx++){
@@ -517,6 +557,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
for (let rowIdx = 1; rowIdx < rowBrks.length; rowIdx++){
cellYs[rowIdx] = cellYs[rowIdx - 1] + cellHs[rowIdx - 1];
}
+
// Determine child layouts, resizing cells to reduce empty space
const tempTree: LayoutNode = node.cloneNodeTree();
let empRight = Number.POSITIVE_INFINITY, empBottom = 0;
@@ -563,10 +604,12 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
empBottom = vertEmp;
}
}
+
// Get empty space
const usedSpc = arraySum(tempTree.children.map(
child => (child.dims[0] + opts.tileSpacing) * (child.dims[1] + opts.tileSpacing) - child.empSpc));
const empSpc = newDims[0] * newDims[1] - usedSpc;
+
// Check with best-so-far
if (empSpc < lowestEmpSpc * opts.rectSensitivity){
lowestEmpSpc = empSpc;
@@ -575,6 +618,7 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
usedEmpBottom = empBottom;
}
}
+
// Check if no found layout
if (usedTree == null){
if (allowCollapse){
@@ -584,12 +628,14 @@ const rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollaps
}
return false;
}
+
// Create layout
usedTree.copyTreeForRender(node);
const usedDims: [number, number] = [dims[0] - usedEmpRight, dims[1] - usedEmpBottom];
node.assignLayoutData(pos, usedDims, {showHeader, empSpc: lowestEmpSpc});
return true;
}
+
// Lays out nodes by pushing leaves to one side, and using rectLayout() for the non-leaves
// With layout option 'sweepToParent', leaves from child nodes may occupy a parent's leaf-section
// 'sepArea' represents a usable leaf-section area from a direct parent,
@@ -599,6 +645,7 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
// Separate leaf and non-leaf nodes
const leaves: LayoutNode[] = [], nonLeaves: LayoutNode[] = [];
node.children.forEach(child => (child.children.length == 0 ? leaves : nonLeaves).push(child));
+
// Check for simpler cases
if (node.children.length == 0){
return oneSqrLayout(node, pos, dims, false, false, opts);
@@ -607,12 +654,14 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
} else if (leaves.length == 0){
return rectLayout(node, pos, dims, showHeader, allowCollapse, opts, {subLayoutFn: sweepLayout});
}
+
// Some variables
const headerSz = showHeader ? opts.headerSz : 0;
let leavesLyt: LayoutNode | null = null, nonLeavesLyt: LayoutNode | null = null, sweptLeft = false;
let sepArea: SepSweptArea | null = null; // Represents leaf-section area provided for a child
const haveParentArea = ownOpts != null && ownOpts.sepArea != null;
let trySweepToParent = haveParentArea && opts.sweepToParent == 'prefer';
+
// Using a loop for conditionally retrying layout
while (true){
if (!trySweepToParent){ // Try laying-out normally
@@ -631,6 +680,7 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
(Math.pow(leaves.length, 2/3) + Math.pow(nonLeavesTiles, 2/3));
break;
}
+
// Attempt leaves layout
const newPos = [0, headerSz];
const newDims: [number,number] = [dims[0], dims[1] - headerSz];
@@ -676,8 +726,10 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
break;
}
}
+
if (leavesSuccess){
leavesLyt.children.forEach(lyt => {lyt.pos[1] += headerSz});
+
// Attempt non-leaves layout
if (sweptLeft){
newPos[0] += leavesLyt.dims[0] - opts.tileSpacing;
@@ -713,11 +765,13 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
nonLeavesSuccess = rectLayout(nonLeavesLyt, [0,0], newDims, false, false, opts, {subLayoutFn:
((n,p,d,h,a,o) => sweepLayout(n,p,d,h,allowCollapse,o,{sepArea:sepArea})) as LayoutFn});
}
+
if (nonLeavesSuccess){
nonLeavesLyt.children.forEach(lyt => {
lyt.pos[0] += newPos[0];
lyt.pos[1] += newPos[1];
});
+
// Create combined layout
let usedDims: [number, number];
if (sweptLeft){
@@ -745,11 +799,13 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
break;
} else { // Try using parent-provided area
const parentArea = ownOpts!.sepArea!;
+
// Attempt leaves layout
sweptLeft = parentArea.sweptLeft;
leavesLyt = new LayoutNode('SWEEP_' + node.name, leaves);
const leavesSuccess = sqrLayout(leavesLyt, [0,0], parentArea.dims, !sweptLeft, false, opts);
let nonLeavesSuccess = true;
+
if (leavesSuccess){
// Attempt non-leaves layout
const newDims: [number,number] = [dims[0], dims[1] - (sweptLeft ? headerSz : 0)];
@@ -777,11 +833,13 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
nonLeavesSuccess = rectLayout(nonLeavesLyt, [0,0], newDims, false, false, opts, {subLayoutFn:
((n,p,d,h,a,o) => sweepLayout(n,p,d,h,allowCollapse,o,{sepArea:sepArea})) as LayoutFn});
}
+
if (nonLeavesSuccess){
// Adjust non-leaf child positions
if (sweptLeft){
nonLeavesLyt.children.forEach(lyt => {lyt.pos[1] += headerSz});
}
+
// Update parentArea to represent space used
parentArea.used = true;
if (sweptLeft){
@@ -804,6 +862,7 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
parentArea.dims[0] += sepArea.dims[0] + opts.tileSpacing;
}
}
+
// Align parentArea size with non-leaves area
if (sweptLeft){
if (parentArea.pos[1] + parentArea.dims[1] > nonLeavesLyt.dims[1] + headerSz){
@@ -818,18 +877,20 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
parentArea.dims[0] = nonLeavesLyt.dims[0] - parentArea.pos[0];
}
}
+
// Adjust area to avoid overlap with non-leaves
if (sweptLeft){
parentArea.dims[0] -= opts.tileSpacing;
} else {
parentArea.dims[1] -= opts.tileSpacing;
}
+
// Move leaves to parent area
leavesLyt.children.map(lyt => {
lyt.pos[0] += parentArea!.pos[0];
lyt.pos[1] += parentArea!.pos[1];
});
- //
+
const usedDims: [number,number] = [nonLeavesLyt.dims[0], nonLeavesLyt.dims[1] + (sweptLeft ? headerSz : 0)];
node.assignLayoutData(pos, usedDims, {showHeader, empSpc: nonLeavesLyt.empSpc, sepSweptArea: parentArea});
return true;
@@ -842,6 +903,7 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
break;
}
}
+
// Handle layout-failure
if (allowCollapse){
node.children = [];
@@ -850,12 +912,14 @@ const sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollap
}
return false;
}
+
// Lays out nodes like sqrLayout(), but may extend past the height limit to fit nodes,
// and does not recurse on child nodes with children
const sqrOverflowLayout: LayoutFn = function(node, pos, dims, showHeader, allowCollapse, opts){
if (node.children.length == 0){
return oneSqrLayout(node, pos, dims, false, false, opts);
}
+
// Consider area excluding header and top/left spacing
const headerSz = showHeader ? opts.headerSz : 0;
const newPos = [opts.tileSpacing, opts.tileSpacing + headerSz];
@@ -863,6 +927,7 @@ const sqrOverflowLayout: LayoutFn = function(node, pos, dims, showHeader, allowC
if (newWidth <= 0){
return false;
}
+
// Find number of rows and columns
const numChildren = node.children.length;
const maxNumCols = Math.floor(newWidth / (opts.minTileSz + opts.tileSpacing));
@@ -877,13 +942,14 @@ const sqrOverflowLayout: LayoutFn = function(node, pos, dims, showHeader, allowC
const numCols = Math.min(numChildren, maxNumCols);
const numRows = Math.ceil(numChildren / numCols);
const tileSz = Math.min(opts.maxTileSz, Math.floor(newWidth / numCols) - opts.tileSpacing);
+
// Layout children
for (let i = 0; i < numChildren; i++){
const childX = newPos[0] + (i % numCols) * (tileSz + opts.tileSpacing);
const childY = newPos[1] + Math.floor(i / numCols) * (tileSz + opts.tileSpacing);
oneSqrLayout(node.children[i], [childX,childY], [tileSz,tileSz], false, false, opts);
}
- //
+
const usedDims: [number, number] = [
numCols * (tileSz + opts.tileSpacing) + opts.tileSpacing,
numRows * (tileSz + opts.tileSpacing) + opts.tileSpacing + headerSz
diff --git a/src/lib.ts b/src/lib.ts
index e262e05..c284de4 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -4,9 +4,11 @@
import {TolNode} from './tol';
-// For server requests
+// ========== For server requests ==========
+
const SERVER_DATA_URL = (new URL(window.location.href)).origin + '/data/'
const SERVER_IMG_PATH = '/tol_data/img/'
+
export async function queryServer(params: URLSearchParams){
// Construct URL
const url = new URL(SERVER_DATA_URL);
@@ -22,26 +24,33 @@ export async function queryServer(params: URLSearchParams){
}
return responseObj;
}
+
export function getImagePath(imgName: string): string {
return SERVER_IMG_PATH + imgName.replaceAll('\'', '\\\'');
}
-// For server search responses
-export type SearchSugg = { // Represents a search-string suggestion
+
+// ========== For server responses (matches backend/tilo.py) ==========
+
+// Represents a search-string suggestion
+export type SearchSugg = {
name: string,
canonicalName: string | null,
pop: number,
};
-export type SearchSuggResponse = { // Holds search suggestions and an indication of if there was more
+
+// Holds search suggestions and an indication of if there was more
+export type SearchSuggResponse = {
suggs: SearchSugg[],
hasMore: boolean,
};
-// For server tile-info responses
+
export type DescInfo = {
text: string,
wikiId: number,
fromRedirect: boolean,
fromDbp: boolean,
};
+
export type ImgInfo = {
id: number,
src: string,
@@ -50,17 +59,20 @@ export type ImgInfo = {
artist: string,
credit: string,
};
+
export type NodeInfo = {
tolNode: TolNode,
descInfo: null | DescInfo,
imgInfo: null | ImgInfo,
};
+
export type InfoResponse = {
nodeInfo: NodeInfo,
subNodesInfo: [] | [NodeInfo | null, NodeInfo | null],
};
-// Used by auto-mode and tutorial-pane
+// ========== Used by auto-mode and tutorial-pane ==========
+
export type Action =
'expand' | 'collapse' | 'expandToView' | 'unhideAncestor' |
'tileInfo' | 'search' | 'autoMode' | 'settings' | 'help';
diff --git a/src/store.ts b/src/store.ts
index 7cc8f55..be50fa2 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -7,6 +7,8 @@ import {Action} from './lib';
import {LayoutOptions} from './layout';
import {getBreakpoint, Breakpoint, getScrollBarWidth, onTouchDevice} from './util';
+// ========== For store state ==========
+
export type StoreState = {
// Device info
touchDevice: boolean,
@@ -61,6 +63,7 @@ export type StoreState = {
disableShortcuts: boolean,
autoHide: boolean, // If true, leaf-click failure results in hiding an ancestor and trying again
};
+
function getDefaultState(): StoreState {
const breakpoint = getBreakpoint();
const scrollGap = getScrollBarWidth();
@@ -80,6 +83,7 @@ function getDefaultState(): StoreState {
altDark: '#65a30d', // lime-600
accent: '#f59e0b', // amber-500
};
+
return {
// Device related
touchDevice: onTouchDevice(),
@@ -129,6 +133,7 @@ function getDefaultState(): StoreState {
autoHide: true,
};
}
+
// Gets 'composite keys' which have the form 'key1' or 'key1.key2' (usable to specify properties of store objects)
function getCompositeKeys(state: StoreState){
const compKeys = [];
@@ -143,8 +148,11 @@ function getCompositeKeys(state: StoreState){
}
return compKeys;
}
+
const STORE_COMP_KEYS = getCompositeKeys(getDefaultState());
-// For getting/setting values in store
+
+// ========== For getting/setting/loading store state ==========
+
function getStoreVal(state: StoreState, compKey: string): any {
if (compKey in state){
return state[compKey as keyof StoreState];
@@ -158,6 +166,7 @@ function getStoreVal(state: StoreState, compKey: string): any {
}
return null;
}
+
function setStoreVal(state: StoreState, compKey: string, val: any): void {
if (compKey in state){
(state[compKey as keyof StoreState] as any) = val;
@@ -172,7 +181,7 @@ function setStoreVal(state: StoreState, compKey: string, val: any): void {
}
}
}
-// For loading settings into [initial] store state
+
function loadFromLocalStorage(state: StoreState){
for (const key of STORE_COMP_KEYS){
const item = localStorage.getItem(key)
@@ -182,16 +191,20 @@ function loadFromLocalStorage(state: StoreState){
}
}
+// ========== Main export ==========
+
export const useStore = defineStore('store', {
state: () => {
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){
@@ -201,19 +214,23 @@ export const useStore = defineStore('store', {
}
}
},
+
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);
}
},
+
softReset(): void { // Like reset(), but keeps saved values
const defaultState = getDefaultState();
for (const key of STORE_COMP_KEYS){
diff --git a/src/tol.ts b/src/tol.ts
index bd299b3..3f7446b 100644
--- a/src/tol.ts
+++ b/src/tol.ts
@@ -13,6 +13,7 @@ export class TolNode {
imgName: null | string |
[string, string] | [null, string] | [string, null]; // Pairs represent compound images
iucn: null | string;
+
constructor(children: string[] = [], parent = null, tips = 0, pSupport = false){
this.otolId = null;
this.children = children;
@@ -24,5 +25,6 @@ export class TolNode {
this.iucn = null;
}
}
+
// Maps TolNode names to TolNode objects
export type TolMap = Map<string, TolNode>;
diff --git a/src/util.ts b/src/util.ts
index a686b70..180c7c2 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -2,8 +2,12 @@
* General utility functions
*/
+// ========== For device detection ==========
+
// For detecting screen size
+
export type Breakpoint = 'sm' | 'md' | 'lg';
+
export function getBreakpoint(): Breakpoint {
const w = window.innerWidth;
if (w < 768){
@@ -14,6 +18,7 @@ export function getBreakpoint(): Breakpoint {
return 'lg';
}
}
+
// For getting scroll-bar width // From stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript
export function getScrollBarWidth(){
// Create hidden outer div
@@ -31,19 +36,24 @@ export function getScrollBarWidth(){
//
return scrollBarWidth;
}
+
// Detects a touch device
export function onTouchDevice(){
return window.matchMedia('(pointer: coarse)').matches;
}
+// ========== Other ==========
+
// Returns [0 ... len]
export function range(len: number): number[] {
return [...Array(len).keys()];
}
+
// Returns sum of array values
export function arraySum(array: number[]): number {
return array.reduce((x,y) => x+y);
}
+
// Returns an array of increasing evenly-spaced numbers from 'start' to 'end', with size 'size'
export function linspace(start: number, end: number, size: number): number[] {
const step = (end - start) / (size - 1);
@@ -53,6 +63,7 @@ export function linspace(start: number, end: number, size: number): number[] {
}
return ar;
}
+
// Returns array copy with vals clipped to within [min,max], redistributing to compensate
// Returns null on failure
export function limitVals(arr: number[], min: number, max: number): number[] | null {
@@ -78,6 +89,7 @@ export function limitVals(arr: number[], min: number, max: number): number[] | n
if (Math.abs(owedChg) < Number.EPSILON){
return vals;
}
+
// Compensate for changes made
const indicesToUpdate = (owedChg > 0) ?
range(vals.length).filter(idx => vals[idx] < max) :
@@ -91,6 +103,7 @@ export function limitVals(arr: number[], min: number, max: number): number[] | n
owedChg = 0;
}
}
+
// Usable to iterate through possible int arrays with ascending values in the range 0 to N, where N < maxLen
// For example, with maxLen 3, passing [0] will update it to [0,1], then [0,2], then [0,1,2]
// Returns false when there is no next array
@@ -114,6 +127,7 @@ export function updateAscSeq(seq: number[], maxLen: number): boolean {
}
}
}
+
// Given a non-empty array of non-negative weights, returns an array index chosen with weighted pseudorandomness
// Returns null if array contains all zeros
export function randWeightedChoice(weights: number[]): number | null {
@@ -131,6 +145,7 @@ export function randWeightedChoice(weights: number[]): number | null {
}
return null;
}
+
// Returns a string with words first-letter capitalised
export function capitalizeWords(str: string){
str = str.replace(/\b\w/g, x => x.toUpperCase()); // '\b' matches word boundary, '\w' is like [a-zA-Z0-9_]
@@ -138,6 +153,7 @@ export function capitalizeWords(str: string){
str = str.replace(/ And\b/, ' and'); // Avoid cases like "frogs and toads" -> "Frogs And Toads"
return str;
}
+
// Used to async-await for until after a timeout
export async function timeout(ms: number){
return new Promise(resolve => setTimeout(resolve, ms))