diff options
| author | Terry Truong <terry06890@gmail.com> | 2023-01-29 12:21:55 +1100 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2023-01-29 12:23:13 +1100 |
| commit | 629b9208503369c3f20ceb59685ef49766344093 (patch) | |
| tree | 87071d862358c56ee38756ab94eb04f9c55fd0dc | |
| parent | 8781fdb2b8c530a6c1531ae9e82221eb062e34fb (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.vue | 168 | ||||
| -rw-r--r-- | src/components/AncestryBar.vue | 21 | ||||
| -rw-r--r-- | src/components/HelpModal.vue | 38 | ||||
| -rw-r--r-- | src/components/SCollapsible.vue | 16 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 55 | ||||
| -rw-r--r-- | src/components/SettingsModal.vue | 26 | ||||
| -rw-r--r-- | src/components/TileInfoModal.vue | 51 | ||||
| -rw-r--r-- | src/components/TolTile.vue | 76 | ||||
| -rw-r--r-- | src/components/TutorialPane.vue | 29 | ||||
| -rw-r--r-- | src/layout.ts | 70 | ||||
| -rw-r--r-- | src/lib.ts | 24 | ||||
| -rw-r--r-- | src/store.ts | 21 | ||||
| -rw-r--r-- | src/tol.ts | 2 | ||||
| -rw-r--r-- | src/util.ts | 16 |
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">• • •</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 @@ -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){ @@ -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)) |
