diff options
| author | Terry Truong <terry06890@gmail.com> | 2022-09-13 19:59:06 +1000 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2022-09-13 20:00:17 +1000 |
| commit | 23b5cc80ba02936659564dd03b173d3214ce5978 (patch) | |
| tree | cdf6a183d1a0bfcb45a924585b764c723dd67b55 | |
| parent | e382d4173c990a49a9ef3db1b3681763a3e2e908 (diff) | |
Use Vue Composition API and ESLint
27 files changed, 2253 insertions, 2410 deletions
diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..eb86580 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:vue/vue3-essential", + "plugin:@typescript-eslint/recommended" + ], + "overrides": [ + ], + "parser": "vue-eslint-parser", + "parserOptions": { + "parser": "@typescript-eslint/parser", + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "vue", + "@typescript-eslint" + ], + "rules": { + "@typescript-eslint/no-non-null-assertion": "off" + } +} diff --git a/package.json b/package.json index ad0739f..773d30a 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,17 @@ "vue": "^3.2.25" }, "devDependencies": { - "@vitejs/plugin-vue": "^2.2.0", + "@typescript-eslint/eslint-plugin": "^5.36.2", + "@typescript-eslint/parser": "^5.36.2", + "@vitejs/plugin-vue": "^3.1.0", "autoprefixer": "^10.4.2", + "eslint": "^8.23.1", + "eslint-plugin-vue": "^9.4.0", "postcss": "^8.4.7", "smartcrop-cli": "^2.0.3", "tailwindcss": "^3.0.23", "typescript": "^4.6.2", - "vite": "^2.8.0", - "vue-tsc": "^0.32.1" + "vite": "^3.1.0", + "vue-tsc": "^0.40.13" } } diff --git a/src/App.vue b/src/App.vue index c0e7f9c..6ed2423 100644 --- a/src/App.vue +++ b/src/App.vue @@ -25,7 +25,7 @@ </icon-button> </div> <!-- Content area --> - <div class="grow min-h-0 flex flex-col relative" ref="contentArea"> + <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 --> <transition name="fade"> <tutorial-pane v-if="tutPaneOpen" :style="tutPaneStyles" @@ -62,7 +62,7 @@ <search-modal v-if="searchOpen" :tolMap="tolMap" :lytMap="layoutMap" :activeRoot="activeRoot" :lytOpts="lytOpts" :uiOpts="uiOpts" @close="onSearchClose" @search="onSearch" @info-click="onInfoClick" @setting-chg="onSettingChg" - @net-wait="onSearchNetWait" @net-get="endLoadInd" class="z-10" ref="searchModal"/> + @net-wait="onSearchNetWait" @net-get="endLoadInd" class="z-10"/> </transition> <transition name="fade"> <tile-info-modal v-if="infoModalNodeName != null && infoModalData != null" @@ -86,8 +86,8 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<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'; @@ -112,1034 +112,1021 @@ import {LayoutNode, LayoutOptions, LayoutTreeChg, initLayoutTree, initLayoutMap, tryLayout} from './layout'; import {queryServer, InfoResponse, Action, UiOptions, getDefaultLytOpts, getDefaultUiOpts, OptionType} from './lib'; -import {arraySum, randWeightedChoice, timeout} from './util'; +import {arraySum, randWeightedChoice} from './util'; // Constants const SERVER_WAIT_MSG = 'Loading data'; const PROCESSING_WAIT_MSG = 'Processing'; -// Type representing auto-mode actions -type AutoAction = 'move across' | 'move down' | 'move up' | Action; -// Function used in auto-mode to reduce action cycles -function getReverseAction(action: AutoAction): AutoAction | null { - const reversePairs: AutoAction[][] = [ - ['move down', 'move up'], - ['expand', 'collapse'], - ['expandToView', 'unhideAncestor'], - ]; - let pair = reversePairs.find(pair => pair.includes(action)); - if (pair != null){ - return pair[0] == action ? pair[1] : pair[0]; - } else { +const EXCESS_TOLNODE_THRESHOLD = 1000; // Threshold where excess tolMap entries get removed + +// Refs +const contentAreaRef = ref(null as HTMLElement | null); + +// Get/load option values +function getLytOpts(): LayoutOptions { + let opts = getDefaultLytOpts(); + for (let prop of Object.getOwnPropertyNames(opts) as (keyof LayoutOptions)[]){ + let item = localStorage.getItem('LYT ' + prop); + if (item != null){ + switch (typeof(opts[prop])){ + case 'boolean': (opts[prop] as unknown as boolean) = Boolean(item); break; + case 'number': (opts[prop] as unknown as number) = Number(item); break; + case 'string': (opts[prop] as unknown as string) = item; break; + default: console.log(`WARNING: Found saved layout setting "${prop}" with unexpected type`); + } + } + } + return opts; +} +function getUiOpts(): UiOptions { + let opts = getDefaultUiOpts(getDefaultLytOpts()); + for (let prop of Object.getOwnPropertyNames(opts) as (keyof UiOptions)[]){ + let item = localStorage.getItem('UI ' + prop); + if (item != null){ + switch (typeof(opts[prop])){ + case 'boolean': (opts[prop] as unknown as boolean) = (item == 'true'); break; + case 'number': (opts[prop] as unknown as number) = Number(item); break; + case 'string': (opts[prop] as unknown as string) = item; break; + default: console.log(`WARNING: Found saved UI setting "${prop}" with unexpected type`); + } + } + } + return opts; +} +const lytOpts = ref(getLytOpts()); +const uiOpts = ref(getUiOpts()); + +// 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){ return null; } + let ancestors = []; + let node = activeRoot.value.parent; + while (node != null){ + ancestors.push(node); + node = node.parent; + } + return ancestors.reverse(); +}); + +// 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'); + // Query server + let urlParams = new URLSearchParams({type: 'node', tree: uiOpts.value.tree}); + if (nodeName != null && firstInit){ + urlParams.append('name', nodeName); + urlParams.append('toroot', '1'); + } + let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams); + if (responseObj == null){ + return; + } + // Get root node name + let rootName = null; + let nodeNames = Object.getOwnPropertyNames(responseObj); + for (let n of nodeNames){ + if (responseObj[n].parent == null){ + rootName = n; + break; + } + } + if (rootName == null){ + console.log('ERROR: Server response has no root node'); + return; + } + // Initialise tree + tolMap.value.clear(); + nodeNames.forEach(n => {tolMap.value.set(n, responseObj[n])}); + if (nodeName == null){ + layoutTree.value = initLayoutTree(tolMap.value, rootName, 0); + layoutMap.value = initLayoutMap(layoutTree.value); + activeRoot.value = layoutTree.value; + } else { + layoutTree.value = initLayoutTree(tolMap.value, rootName, -1); + layoutMap.value = initLayoutMap(layoutTree.value); + // Set active root + let targetNode = layoutMap.value.get(nodeName)!; + let newRoot = targetNode.parent == null ? targetNode : targetNode.parent; + LayoutNode.hideUpward(newRoot, layoutMap.value); + activeRoot.value = newRoot; + setTimeout(() => setLastFocused(targetNode!), uiOpts.value.transitionDuration); + } + // Skip initial transition + if (firstInit){ + justInitialised.value = true; + setTimeout(() => {justInitialised.value = false}, uiOpts.value.transitionDuration); + } + // Relayout + updateAreaDims(); + relayoutWithCollapse(false); } +async function reInit(){ + if (activeRoot.value != layoutTree.value){ + // Collapse tree to root + await onDetachedAncestorClick(layoutTree.value, true); + } + await onNonleafClick(layoutTree.value, true); + await initTreeFromServer(false); +} +onMounted(() => initTreeFromServer()); -export default defineComponent({ - data(){ - // Create initial tree-of-life data - let initialTolMap: TolMap = new Map(); - initialTolMap.set("", new TolNode()); - let layoutTree = initLayoutTree(initialTolMap, "", 0); - layoutTree.hidden = true; - // Get/load option values - let lytOpts = this.getLytOpts(); - let uiOpts = this.getUiOpts(); - // - return { - // Tree/layout data - tolMap: initialTolMap, - layoutTree: layoutTree, - activeRoot: layoutTree, // Root of the displayed subtree - layoutMap: initLayoutMap(layoutTree), // Maps names to LayoutNodes - overflownRoot: false, // Set when displaying a root tile with many children, with overflow - // For modals - infoModalNodeName: null as string | null, // Name of node to display info for, or null - infoModalData: null as InfoResponse | null, - searchOpen: false, - settingsOpen: false, - helpOpen: false, - loadingMsg: null as null | string, // Message to display in loading-indicator - // For search and auto-mode - modeRunning: null as null | 'search' | 'autoMode', - lastFocused: null as LayoutNode | null, // Used to un-focus - // For auto-mode - autoPrevAction: null as AutoAction | null, // Used to help prevent action cycles - autoPrevActionFail: false, // Used to avoid re-trying a failed expand/collapse - // For tutorial pane - tutPaneOpen: !uiOpts.tutorialSkip, - tutWelcome: !uiOpts.tutorialSkip, - tutTriggerAction: null as Action | null, // Used to advance tutorial upon user-actions - tutTriggerFlag: false, - actionsDone: new Set() as Set<Action>, // Used to avoid disabling actions the user has already seen - // Options - lytOpts: lytOpts, - uiOpts: uiOpts, - // For layout and resize-handling - mainAreaDims: [0, 0] as [number, number], - tileAreaDims: [0, 0] as [number, number], - lastResizeHdlrTime: 0, // Used to throttle resize handling - afterResizeHdlr: 0, // Set via setTimeout() to execute after a run of resize events - // Other - justInitialised: false, // Used to skip transition for the tile initially loaded from server - pendingLoadingRevealHdlr: 0, // Used to delay showing the loading-indicator - changedSweepToParent: false, // Set during search animation for efficiency - excessTolNodeThreshold: 1000, // Threshold where excess tolMap entries get removed - }; - }, - computed: { - wideMainArea(): boolean { - return this.mainAreaDims[0] > this.mainAreaDims[1]; - }, - // Nodes to show in ancestry-bar (ordered from root downwards) - detachedAncestors(): LayoutNode[] | null { - if (this.activeRoot == this.layoutTree){ - return null; - } - let ancestors = []; - let node = this.activeRoot.parent; - while (node != null){ - ancestors.push(node); - node = node.parent; - } - return ancestors.reverse(); - }, - // Styles - buttonStyles(): Record<string,string> { - return { - color: this.uiOpts.textColor, - backgroundColor: this.uiOpts.altColorDark, - }; - }, - tutPaneContainerStyles(): Record<string,string> { - if (this.uiOpts.breakpoint == 'sm'){ - return { - minHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px', - maxHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px', - transitionProperty: 'max-height, min-height', - transitionDuration: this.uiOpts.transitionDuration + 'ms', - overflow: 'hidden', - }; - } else { - return { - position: 'absolute', - bottom: '0.5cm', - right: '0.5cm', - visibility: this.tutPaneOpen ? 'visible' : 'hidden', - transitionProperty: 'visibility', - transitionDuration: this.uiOpts.transitionDuration + 'ms', - }; - } - }, - tutPaneStyles(): Record<string,string> { - if (this.uiOpts.breakpoint == 'sm'){ - return { - height: this.uiOpts.tutPaneSz + 'px', - } - } else { - return { - height: this.uiOpts.tutPaneSz + 'px', - minWidth: '10cm', - maxWidth: '10cm', - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: '0 0 3px black', - }; - } - }, - ancestryBarContainerStyles(): Record<string,string> { - let ancestryBarBreadth = this.detachedAncestors == null ? 0 : this.uiOpts.ancestryBarBreadth; - let styles = { - minWidth: 'auto', - maxWidth: 'none', - minHeight: 'auto', - maxHeight: 'none', - transitionDuration: this.uiOpts.transitionDuration + 'ms', - transitionProperty: '', - overflow: 'hidden', - }; - if (this.wideMainArea){ - styles.minWidth = ancestryBarBreadth + 'px'; - styles.maxWidth = ancestryBarBreadth + 'px'; - styles.transitionProperty = 'min-width, max-width'; - } else { - styles.minHeight = ancestryBarBreadth + 'px'; - styles.maxHeight = ancestryBarBreadth + 'px'; - styles.transitionProperty = 'min-height, max-height'; - } - return styles; - }, - }, - methods: { - // For tile expand/collapse events - async onLeafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> { - if (!subAction && !this.onActionStart('expand')){ - return false; - } - // Function for expanding tile - let doExpansion = async () => { - this.primeLoadInd(PROCESSING_WAIT_MSG); - let lytFnOpts = { - allowCollapse: false, - chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg, - layoutMap: this.layoutMap - }; - let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, lytFnOpts); - // Handle auto-hide - if (!success && this.uiOpts.autoHide){ - while (!success && layoutNode != this.activeRoot){ - let node = layoutNode; - while (node.parent != this.activeRoot){ - node = node.parent!; - } - // Hide ancestor - // Note: Not using onNonleafClickHeld() here to avoid a relayoutWithCollapse() - LayoutNode.hideUpward(node, this.layoutMap); - this.activeRoot = node; - // Try relayout - this.updateAreaDims(); - success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, lytFnOpts); - } - } - // If expanding active-root with too many children to fit, allow overflow - if (!success && layoutNode == this.activeRoot){ - success = tryLayout(this.activeRoot, this.tileAreaDims, - {...this.lytOpts, layoutType: 'sqr-overflow'}, lytFnOpts); - if (success){ - this.overflownRoot = true; - } - } - // - if (!subAction && !success){ - layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation - } - this.$nextTick(this.endLoadInd); - return success; - }; - // - let success: boolean; - if (this.overflownRoot){ // If clicking child of overflowing active-root - if (!this.uiOpts.autoHide){ - if (!subAction){ - layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation - } - success = false; - } else { - success = await this.onLeafClickHeld(layoutNode); - } - } else { - // Check if data for node-to-expand exists, getting from server if needed - let tolNode = this.tolMap.get(layoutNode.name)!; - if (!this.tolMap.has(tolNode.children[0])){ - let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: this.uiOpts.tree}); - let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams); - if (responseObj == null){ - success = false; - } else { - Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])}); - success = await doExpansion(); - } - } else { - success = await doExpansion(); - } - } - if (!subAction){ - this.onActionEnd('expand'); - } +// 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, + {...lytOpts.value, layoutType: 'sqr-overflow'}, {layoutMap: layoutMap.value}); return success; - }, - async onNonleafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> { - if (!subAction && !this.onActionStart('collapse')){ - return false; - } - // Relayout - this.primeLoadInd(PROCESSING_WAIT_MSG); - let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, { - allowCollapse: false, - chg: {type: 'collapse', node: layoutNode, tolMap: this.tolMap}, - layoutMap: this.layoutMap - }); - // Update overflownRoot if root was collapsed - if (success && this.overflownRoot){ - this.overflownRoot = false; - } - if (!subAction){ - if (!success){ - layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation - } else { - // Possibly clear out excess nodes when a threshold is reached - let numNodes = this.tolMap.size; - let extraNodes = numNodes - this.layoutMap.size; - if (extraNodes > this.excessTolNodeThreshold){ - for (let n of this.tolMap.keys()){ - if (!this.layoutMap.has(n)){ - this.tolMap.delete(n) - } - } - console.log(`Cleaned up tolMap (removed ${numNodes - this.tolMap.size} out of ${numNodes})`); - } + } + overflownRoot.value = false; + } + success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, + {allowCollapse: true, layoutMap: layoutMap.value}); + if (secondPass){ + // Relayout again, which can help allocate remaining tiles 'evenly' + success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, + {allowCollapse: false, layoutMap: layoutMap.value}); + } + return success; +} +function updateAreaDims(){ + // Set mainAreaDims and tileAreaDims + // Note: Tried setting these by querying tut_pane+ancestry_bar dimensions repeatedly, + // throughout their transitions, relayouting each time, but this makes the tile movements jerky + let contentAreaEl = contentAreaRef.value!; + let w = contentAreaEl.offsetWidth, h = contentAreaEl.offsetHeight; + if (tutPaneOpen.value && uiOpts.value.breakpoint == 'sm'){ + h -= uiOpts.value.tutPaneSz; + } + mainAreaDims.value = [w, h]; + if (detachedAncestors.value != null){ + if (w > h){ + w -= uiOpts.value.ancestryBarBreadth; + } else { + h -= uiOpts.value.ancestryBarBreadth; + } + } + w -= lytOpts.value.tileSpacing * 2; + h -= lytOpts.value.tileSpacing * 2; + tileAreaDims.value = [w, h]; +} + +// 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 () => { + // Update layout/ui options with defaults, excluding user-modified ones + let lytOpts2 = getDefaultLytOpts(); + let uiOpts2 = getDefaultUiOpts(lytOpts2); + let changedTree = false; + for (let prop of Object.getOwnPropertyNames(lytOpts2) as (keyof LayoutOptions)[]){ + let item = localStorage.getItem('LYT ' + prop); + if (item == null && lytOpts.value[prop] != lytOpts2[prop]){ + (lytOpts.value[prop] as any) = lytOpts2[prop]; + } + } + for (let prop of Object.getOwnPropertyNames(uiOpts2) as (keyof UiOptions)[]){ + let item = localStorage.getItem('UI ' + prop); + //Note: Using JSON.stringify here to roughly deep-compare values + if (item == null && JSON.stringify(uiOpts.value[prop]) != JSON.stringify(uiOpts2[prop])){ + (uiOpts.value[prop] as any) = uiOpts2[prop]; + if (prop == 'tree'){ + changedTree = true; } } - if (!subAction){ - this.onActionEnd('collapse'); - } - this.$nextTick(this.endLoadInd); - return success; - }, - // For expand-to-view and ancestry-bar events - async onLeafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> { - // Special case for active root - if (layoutNode == this.activeRoot){ - console.log('Ignored expand-to-view on active-root node'); - return false; - } - // - if (!subAction && !this.onActionStart('expandToView')){ - return false; - } - // Function for expanding tile - let doExpansion = async () => { - this.primeLoadInd(PROCESSING_WAIT_MSG); - // Hide ancestors - LayoutNode.hideUpward(layoutNode, this.layoutMap); - this.activeRoot = layoutNode; - // Relayout - this.updateAreaDims(); - this.overflownRoot = false; - let lytFnOpts = { - allowCollapse: false, - chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg, - layoutMap: this.layoutMap - }; - let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, lytFnOpts); - // If expanding active-root with too many children to fit, allow overflow - if (!success){ - success = tryLayout(this.activeRoot, this.tileAreaDims, - {...this.lytOpts, layoutType: 'sqr-overflow'}, lytFnOpts); - if (success){ - this.overflownRoot = true; - } - } - // - if (!success && !subAction){ - layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation - } - this.$nextTick(this.endLoadInd); - return success; - }; - // Check if data for node-to-expand exists, getting from server if needed - let success: boolean; - let tolNode = this.tolMap.get(layoutNode.name)!; - if (!this.tolMap.has(tolNode.children[0])){ - let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: this.uiOpts.tree}); - let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams); - if (responseObj == null){ - success = false; - } else { - Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])}); - success = await doExpansion(); + } + // Relayout + if (!changedTree){ + updateAreaDims(); + relayoutWithCollapse(); + } else { + reInit(); + } + }; + let currentTime = new Date().getTime(); + if (currentTime - lastResizeHdlrTime > uiOpts.value.transitionDuration){ + lastResizeHdlrTime = currentTime; + await handleResize(); + lastResizeHdlrTime = new Date().getTime(); + } + // Also setup a handler to execute after a run of resize events + clearTimeout(afterResizeHdlr); + afterResizeHdlr = setTimeout(async () => { + afterResizeHdlr = 0; + await handleResize(); + lastResizeHdlrTime = new Date().getTime(); + }, 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 +async function onLeafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> { + if (!subAction && !onActionStart('expand')){ + return false; + } + // Function for expanding tile + let doExpansion = async () => { + primeLoadInd(PROCESSING_WAIT_MSG); + let lytFnOpts = { + allowCollapse: false, + chg: {type: 'expand', node: layoutNode, tolMap: tolMap.value} as LayoutTreeChg, + layoutMap: layoutMap.value, + }; + let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts); + // Handle auto-hide + if (!success && uiOpts.value.autoHide){ + while (!success && layoutNode != activeRoot.value){ + let node = layoutNode; + while (node.parent != activeRoot.value){ + node = node.parent!; } - } else { - success = await doExpansion(); - } - if (!subAction){ - this.onActionEnd('expandToView'); - } - return success; - }, - async onNonleafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> { - // Special case for active root - if (layoutNode == this.activeRoot){ - console.log('Ignored expand-to-view on active-root node'); - return false; - } - // - if (!subAction && !this.onActionStart('expandToView')){ - return false; + // Hide ancestor + // Note: Not using onNonleafClickHeld() here to avoid a relayoutWithCollapse() + LayoutNode.hideUpward(node, layoutMap.value); + activeRoot.value = node; + // Try relayout + updateAreaDims(); + success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts); + } + } + // If expanding active-root with too many children to fit, allow overflow + if (!success && layoutNode == activeRoot.value){ + success = tryLayout(activeRoot.value, tileAreaDims.value, + {...lytOpts.value, layoutType: 'sqr-overflow'}, lytFnOpts); + if (success){ + overflownRoot.value = true; } - this.primeLoadInd(PROCESSING_WAIT_MSG); - // Hide ancestors - LayoutNode.hideUpward(layoutNode, this.layoutMap); - this.activeRoot = layoutNode; - // Relayout - this.updateAreaDims(); - let success = this.relayoutWithCollapse(); - // + } + // + if (!subAction && !success){ + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + } + nextTick(endLoadInd); + return success; + }; + // + let success: boolean; + if (overflownRoot.value){ // If clicking child of overflowing active-root + if (!uiOpts.value.autoHide){ if (!subAction){ - this.onActionEnd('expandToView'); - } - this.$nextTick(this.endLoadInd); - return success; - }, - async onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false, collapse = false): Promise<boolean> { - if (!subAction && !this.onActionStart('unhideAncestor')){ - return false; + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } - this.primeLoadInd(PROCESSING_WAIT_MSG); - // Unhide ancestors - this.activeRoot = layoutNode; - this.overflownRoot = false; - // - let success: boolean; - this.updateAreaDims(); - if (!collapse){ - // Relayout, attempting to have the ancestor expanded - this.relayoutWithCollapse(false); - if (layoutNode.children.length > 0){ - success = this.relayoutWithCollapse(false); // Second pass for regularity - } else { - success = await this.onLeafClick(layoutNode, true); - } + success = false; + } else { + success = await onLeafClickHeld(layoutNode); + } + } else { + // Check if data for node-to-expand exists, getting from server if needed + let tolNode = tolMap.value.get(layoutNode.name)!; + if (!tolMap.value.has(tolNode.children[0])){ + let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: uiOpts.value.tree}); + let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams); + if (responseObj == null){ + success = false; } else { - success = await this.onNonleafClick(layoutNode, true); // For reducing tile-flashing on-screen - } - LayoutNode.showDownward(layoutNode); - // - if (!subAction){ - this.onActionEnd('unhideAncestor'); - } - this.$nextTick(this.endLoadInd); - return success; - }, - // For tile-info events - async onInfoClick(nodeName: string){ - if (!this.onActionStart('tileInfo')){ - return; - } - if (!this.searchOpen){ // Close an active non-search mode - this.resetMode(); - } - // Query server for tol-node info - let urlParams = new URLSearchParams({type: 'info', name: nodeName, tree: this.uiOpts.tree}); - let responseObj: InfoResponse = await this.loadFromServer(urlParams); - if (responseObj != null){ - // Set fields from response - this.infoModalNodeName = nodeName; - this.infoModalData = responseObj; - } - }, - onInfoClose(){ - this.infoModalNodeName = null; - this.onActionEnd('tileInfo'); - }, - // For search events - onSearchIconClick(){ - if (!this.onActionStart('search')){ - return; - } - if (!this.searchOpen){ - this.resetMode(); - this.searchOpen = true; - } - }, - onSearch(name: string){ - if (this.modeRunning != null){ - console.log('WARNING: Unexpected search event while search/auto mode is running') - return; - } - this.searchOpen = false; - this.modeRunning = 'search'; - if (this.tutWelcome){ // Don't keep welcome message up during an initial search - this.onActionEnd('search'); - } - this.expandToNode(name); - }, - async expandToNode(name: string){ - if (this.modeRunning == null){ - return; - } - // Check if node is displayed - let targetNode = this.layoutMap.get(name); - if (targetNode != null && !targetNode.hidden){ - this.setLastFocused(targetNode); - this.onSearchClose(); - return; - } - // Get nearest in-layout-tree ancestor - let ancestorName = name; - while (this.layoutMap.get(ancestorName) == null){ - ancestorName = this.tolMap.get(ancestorName)!.parent!; - } - let layoutNode = this.layoutMap.get(ancestorName)!; - // If hidden, expand self/ancestor in ancestry-bar - if (layoutNode.hidden){ - let nodeInAncestryBar = layoutNode; - while (!this.detachedAncestors!.includes(nodeInAncestryBar)){ - nodeInAncestryBar = nodeInAncestryBar.parent!; - } - if (!this.uiOpts.searchJumpMode){ - await this.onDetachedAncestorClick(nodeInAncestryBar!, true); - setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); - } else{ - await this.onDetachedAncestorClick(nodeInAncestryBar!, true, true); - this.expandToNode(name); - } - return; - } - // Attempt tile-expand - if (this.uiOpts.searchJumpMode){ - // Extend layout tree - let tolNode = this.tolMap.get(name)!; - let nodesToAdd = [name] as string[]; - while (tolNode.parent != layoutNode.name){ - nodesToAdd.push(tolNode.parent!); - tolNode = this.tolMap.get(tolNode.parent!)!; - } - nodesToAdd.reverse(); - layoutNode.addDescendantChain(nodesToAdd, this.tolMap, this.layoutMap); - // Expand-to-view on target-node's parent - targetNode = this.layoutMap.get(name); - if (targetNode!.parent != this.activeRoot){ - // Hide ancestors - LayoutNode.hideUpward(targetNode!.parent!, this.layoutMap); - this.activeRoot = targetNode!.parent!; - this.updateAreaDims(); - await this.onNonleafClick(this.activeRoot, true); - await this.onLeafClick(this.activeRoot, true); - } else { - await this.onLeafClick(this.activeRoot, true); - } - setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); - return; - } - if (this.overflownRoot){ - await this.onLeafClickHeld(layoutNode, true); - setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); - return; - } - let success = await this.onLeafClick(layoutNode, true); - if (success){ - setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); - return; - } - // Attempt expand-to-view on an ancestor halfway to the active root - if (layoutNode == this.activeRoot){ - console.log('Screen too small to expand active root'); - this.onSearchClose(); - return; - } - let ancestorChain = [layoutNode]; - while (layoutNode.parent! != this.activeRoot){ - layoutNode = layoutNode.parent!; - ancestorChain.push(layoutNode); - } - layoutNode = ancestorChain[Math.floor((ancestorChain.length - 1) / 2)] - await this.onNonleafClickHeld(layoutNode, true); - setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); - }, - onSearchClose(){ - this.modeRunning = null; - this.searchOpen = false; - this.onActionEnd('search'); - }, - onSearchNetWait(){ - this.primeLoadInd(SERVER_WAIT_MSG); - }, - // For auto-mode events - onAutoIconClick(){ - if (!this.onActionStart('autoMode')){ - return; - } - this.resetMode(); - this.modeRunning = 'autoMode'; - if (this.tutWelcome){ // Don't keep welcome message up during an initial auto-mode - this.onActionEnd('autoMode'); - } - this.autoAction(); - }, - async autoAction(){ - if (this.modeRunning == null){ - return; + Object.getOwnPropertyNames(responseObj).forEach(n => {tolMap.value.set(n, responseObj[n])}); + success = await doExpansion(); } - if (this.lastFocused == null){ - // Pick random leaf LayoutNode - let layoutNode = this.activeRoot; - while (layoutNode.children.length > 0){ - let childWeights = layoutNode.children.map(n => n.tips); - let idx = randWeightedChoice(childWeights); - layoutNode = layoutNode.children[idx!]; - } - this.setLastFocused(layoutNode); - setTimeout(this.autoAction, this.uiOpts.autoActionDelay); - } else { - // Determine available actions - let action: AutoAction | null; - let actionWeights: {[key: string]: number}; // Maps actions to choice weights - let node: LayoutNode = this.lastFocused; - if (node.children.length == 0){ - actionWeights = {'move across': 1, 'move up': 2, 'expand': 3}; - } else { - actionWeights = { - 'move across': 1, 'move down': 2, 'move up': 1, - 'collapse': 1, 'expandToView': 1, 'unhideAncestor': 1 - }; - } - // Zero weights for disallowed actions - if (node == this.activeRoot || node.parent!.children.length == 1){ - actionWeights['move across'] = 0; - } - if (node == this.activeRoot){ - actionWeights['move up'] = 0; - } - if (this.tolMap.get(node.name)!.children.length == 0 || this.overflownRoot){ - actionWeights['expand'] = 0; - } - if (!node.children.every(n => n.children.length == 0)){ - actionWeights['collapse'] = 0; // Only collapse if all children are leaves - } - if (node.parent != this.activeRoot){ - actionWeights['expandToView'] = 0; // Only expand-to-view if direct child of activeRoot - } - if (this.activeRoot.parent == null || node != this.activeRoot){ - actionWeights['unhideAncestor'] = 0; // Only expand ancestry-bar if able and activeRoot - } - // Avoid undoing previous action - if (this.autoPrevAction != null){ - let revAction = getReverseAction(this.autoPrevAction); - if (revAction != null && revAction in actionWeights){ - actionWeights[revAction as keyof typeof actionWeights] = 0; - } - if (this.autoPrevActionFail){ - actionWeights[this.autoPrevAction as keyof typeof actionWeights] = 0; - } - } - // Choose action - let actionList = Object.getOwnPropertyNames(actionWeights); - let weightList = actionList.map(action => actionWeights[action]); - if (arraySum(weightList) == 0){ - action = null; - } else { - action = actionList[randWeightedChoice(weightList)!] as AutoAction; - } - // Perform action - this.autoPrevAction = action; - let success = true; - try { - switch (action){ - case 'move across': // Bias towards siblings with higher tips - let siblings = node.parent!.children.filter(n => n != node); - let siblingWeights = siblings.map(n => n.tips + 1); - this.setLastFocused(siblings[randWeightedChoice(siblingWeights)!]); - break; - case 'move down': // Bias towards children with higher tips - let childWeights = node.children.map(n => n.tips + 1); - this.setLastFocused(node.children[randWeightedChoice(childWeights)!]); - break; - case 'move up': - this.setLastFocused(node.parent!); - break; - case 'expand': - success = await this.onLeafClick(node, true); - break; - case 'collapse': - success = await this.onNonleafClick(node, true); - break; - case 'expandToView': - success = await this.onNonleafClickHeld(node, true); - break; - case 'unhideAncestor': - success = await this.onDetachedAncestorClick(node.parent!, true); - break; + } else { + success = await doExpansion(); + } + } + if (!subAction){ + onActionEnd('expand'); + } + return success; +} +async function onNonleafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> { + if (!subAction && !onActionStart('collapse')){ + return false; + } + // Relayout + primeLoadInd(PROCESSING_WAIT_MSG); + let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, { + allowCollapse: false, + 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){ + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + } else { + // Possibly clear out excess nodes when a threshold is reached + let numNodes = tolMap.value.size; + let extraNodes = numNodes - layoutMap.value.size; + if (extraNodes > EXCESS_TOLNODE_THRESHOLD.value){ + for (let n of tolMap.value.keys()){ + if (!layoutMap.value.has(n)){ + tolMap.value.delete(n) } - } catch (error) { - this.autoPrevActionFail = true; - this.onAutoClose(); - return; } - this.autoPrevActionFail = !success; - setTimeout(this.autoAction, this.uiOpts.transitionDuration + this.uiOpts.autoActionDelay); - } - }, - onAutoClose(){ - this.modeRunning = null; - this.onActionEnd('autoMode'); - }, - // For settings events - onSettingsIconClick(){ - if (!this.onActionStart('settings')){ - return; - } - this.resetMode(); - this.settingsOpen = true; - }, - async onSettingChg(optionType: OptionType, option: string, - {relayout = false, reinit = false} = {}){ - // Save setting - if (optionType == 'LYT'){ - localStorage.setItem(`${optionType} ${option}`, - String(this.lytOpts[option as keyof LayoutOptions])); - } else if (optionType == 'UI') { - localStorage.setItem(`${optionType} ${option}`, - String(this.uiOpts[option as keyof UiOptions])); - } - // Possibly relayout/reinitialise - if (reinit){ - this.reInit(); - } else if (relayout){ - this.relayoutWithCollapse(); - } - }, - onResetSettings(reinit: boolean){ - localStorage.clear(); - if (reinit){ - this.reInit(); - } else { - this.relayoutWithCollapse(); - } - }, - onSettingsClose(){ - this.settingsOpen = false; - this.onActionEnd('settings'); - }, - // For help events - onHelpIconClick(){ - if (!this.onActionStart('help')){ - return; - } - this.resetMode(); - this.helpOpen = true; - }, - onHelpClose(){ - this.helpOpen = false; - this.onActionEnd('help'); - }, - // For tutorial-pane events - onStartTutorial(){ - if (!this.tutPaneOpen){ - this.tutPaneOpen = true; - this.updateAreaDims(); - this.relayoutWithCollapse(); - } - }, - onTutorialSkip(){ - this.uiOpts.tutorialSkip = true; - this.onSettingChg('UI', 'tutorialSkip'); - }, - onTutStageChg(triggerAction: Action | null){ - this.tutWelcome = false; - this.tutTriggerAction = triggerAction; - }, - onTutPaneClose(){ - this.tutPaneOpen = false; - if (this.tutWelcome){ - this.tutWelcome = false; - } else if (this.uiOpts.tutorialSkip == false){ - this.uiOpts.tutorialSkip = true; - this.onSettingChg('UI', 'tutorialSkip'); + console.log(`Cleaned up tolMap (removed ${numNodes - tolMap.value.size} out of ${numNodes})`); } - this.uiOpts.disabledActions.clear(); - this.updateAreaDims(); - this.relayoutWithCollapse(true, true); - }, - // For general action handling - onActionStart(action: Action): boolean { - if (this.isDisabled(action)){ - return false; - } - this.setLastFocused(null); - return true; - }, - onActionEnd(action: Action){ - // Update info used by tutorial pane - this.actionsDone.add(action); - if (this.tutPaneOpen){ - // Close welcome message on first action - if (this.tutWelcome){ - this.onTutPaneClose(); - } - // Tell TutorialPane if trigger-action was done - if (this.tutTriggerAction == action){ - this.tutTriggerFlag = !this.tutTriggerFlag; - } - } - }, - isDisabled(...actions: Action[]): boolean { - let disabledActions = this.uiOpts.disabledActions; - return actions.some(a => disabledActions.has(a)); - }, - resetMode(){ - if (this.infoModalNodeName != null){ - this.onInfoClose(); - } - if (this.searchOpen || this.modeRunning == 'search'){ - this.onSearchClose(); - } - if (this.modeRunning == 'autoMode'){ - this.onAutoClose(); - } - if (this.settingsOpen){ - this.onSettingsClose(); - } - if (this.helpOpen){ - this.onHelpClose(); + } + } + if (!subAction){ + onActionEnd('collapse'); + } + nextTick(endLoadInd); + return success; +} +// For expand-to-view and ancestry-bar events +async function onLeafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> { + // Special case for active root + if (layoutNode == activeRoot.value){ + 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; + let lytFnOpts = { + allowCollapse: false, + chg: {type: 'expand', node: layoutNode, tolMap: tolMap.value} as LayoutTreeChg, + layoutMap: layoutMap.value, + }; + let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts); + // If expanding active-root with too many children to fit, allow overflow + if (!success){ + success = tryLayout(activeRoot.value, tileAreaDims.value, + {...lytOpts.value, layoutType: 'sqr-overflow'}, lytFnOpts); + if (success){ + overflownRoot.value = true; } - }, - // For other events - async onResize(){ - // Handle event if not recently done - let handleResize = async () => { - // Update layout/ui options with defaults, excluding user-modified ones - let lytOpts = getDefaultLytOpts(); - let uiOpts = getDefaultUiOpts(lytOpts); - let changedTree = false; - for (let prop of Object.getOwnPropertyNames(lytOpts) as (keyof LayoutOptions)[]){ - let item = localStorage.getItem('LYT ' + prop); - if (item == null && this.lytOpts[prop] != lytOpts[prop]){ - this.lytOpts[prop] = lytOpts[prop]; - } - } - for (let prop of Object.getOwnPropertyNames(uiOpts) as (keyof UiOptions)[]){ - let item = localStorage.getItem('UI ' + prop); - //Note: Using JSON.stringify here to roughly deep-compare values - if (item == null && JSON.stringify(this.uiOpts[prop]) != JSON.stringify(uiOpts[prop])){ - this.uiOpts[prop] = uiOpts[prop]; - if (prop == 'tree'){ - changedTree = true; - } - } - } - // Relayout - if (!changedTree){ - this.updateAreaDims(); - this.relayoutWithCollapse(); - } else { - this.reInit(); - } + } + // + if (!success && !subAction){ + layoutNode.failFlag = !layoutNode.failFlag; // 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)!; + if (!tolMap.value.has(tolNode.children[0])){ + let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: uiOpts.value.tree}); + let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams); + if (responseObj == null){ + success = false; + } else { + Object.getOwnPropertyNames(responseObj).forEach(n => {tolMap.value.set(n, responseObj[n])}); + success = await doExpansion(); + } + } else { + success = await doExpansion(); + } + if (!subAction){ + onActionEnd('expandToView'); + } + return success; +} +async function onNonleafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> { + // Special case for active root + if (layoutNode == activeRoot.value){ + 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(); + // + if (!subAction){ + onActionEnd('expandToView'); + } + nextTick(endLoadInd); + return success; +} +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){ + // Relayout, attempting to have the ancestor expanded + relayoutWithCollapse(false); + if (layoutNode.children.length > 0){ + success = relayoutWithCollapse(false); // Second pass for regularity + } else { + success = await onLeafClick(layoutNode, true); + } + } else { + success = await onNonleafClick(layoutNode, true); // For reducing tile-flashing on-screen + } + LayoutNode.showDownward(layoutNode); + // + if (!subAction){ + onActionEnd('unhideAncestor'); + } + nextTick(endLoadInd); + return success; +} + +// For tile-info modal/events +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; + } + 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: uiOpts.value.tree}); + let responseObj: InfoResponse = await loadFromServer(urlParams); + if (responseObj != null){ + // Set fields from response + infoModalNodeName.value = nodeName; + infoModalData.value = responseObj; + } +} +function onInfoClose(){ + infoModalNodeName.value = null; + onActionEnd('tileInfo'); +} + +// For search modal/events +const searchOpen = ref(false); +function onSearchIconClick(){ + if (!onActionStart('search')){ + return; + } + if (!searchOpen.value){ + resetMode(); + searchOpen.value = true; + } +} +function onSearch(name: string){ + if (modeRunning.value != null){ + console.log('WARNING: Unexpected search event while search/auto mode is running') + return; + } + searchOpen.value = false; + modeRunning.value = 'search'; + if (tutWelcome.value){ // Don't keep welcome message up during an initial search + onActionEnd('search'); + } + 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){ + setLastFocused(targetNode); + 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; + while (!detachedAncestors.value!.includes(nodeInAncestryBar)){ + nodeInAncestryBar = nodeInAncestryBar.parent!; + } + if (!uiOpts.value.searchJumpMode){ + await onDetachedAncestorClick(nodeInAncestryBar!, true); + setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration); + } else{ + await onDetachedAncestorClick(nodeInAncestryBar!, true, true); + expandToNode(name); + } + return; + } + // Attempt tile-expand + if (uiOpts.value.searchJumpMode){ + // Extend layout tree + let tolNode = tolMap.value.get(name)!; + let nodesToAdd = [name] as string[]; + while (tolNode.parent != layoutNode.name){ + nodesToAdd.push(tolNode.parent!); + tolNode = tolMap.value.get(tolNode.parent!)!; + } + 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){ + // Hide ancestors + LayoutNode.hideUpward(targetNode!.parent!, layoutMap.value); + activeRoot.value = targetNode!.parent!; + updateAreaDims(); + await onNonleafClick(activeRoot.value, true); + await onLeafClick(activeRoot.value, true); + } else { + await onLeafClick(activeRoot.value, true); + } + setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration); + return; + } + if (overflownRoot.value){ + await onLeafClickHeld(layoutNode, true); + setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration); + return; + } + let success = await onLeafClick(layoutNode, true); + if (success){ + setTimeout(() => expandToNode(name), uiOpts.value.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'); + onSearchClose(); + return; + } + let ancestorChain = [layoutNode]; + while (layoutNode.parent! != activeRoot.value){ + layoutNode = layoutNode.parent!; + ancestorChain.push(layoutNode); + } + layoutNode = ancestorChain[Math.floor((ancestorChain.length - 1) / 2)] + await onNonleafClickHeld(layoutNode, true); + setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration); +} +function onSearchClose(){ + modeRunning.value = null; + searchOpen.value = false; + onActionEnd('search'); +} +function onSearchNetWait(){ + primeLoadInd(SERVER_WAIT_MSG); +} + +// 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'], + ['expand', 'collapse'], + ['expandToView', 'unhideAncestor'], + ]; + let pair = reversePairs.find(pair => pair.includes(action)); + if (pair != null){ + return pair[0] == action ? pair[1] : pair[0]; + } else { + 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; + } + resetMode(); + modeRunning.value = 'autoMode'; + if (tutWelcome.value){ // Don't keep welcome message up during an initial auto-mode + onActionEnd('autoMode'); + } + autoAction(); +} +async function autoAction(){ + if (modeRunning.value == null){ + return; + } + if (lastFocused.value == null){ + // Pick random leaf LayoutNode + let layoutNode = activeRoot.value; + while (layoutNode.children.length > 0){ + let childWeights = layoutNode.children.map(n => n.tips); + let idx = randWeightedChoice(childWeights); + layoutNode = layoutNode.children[idx!]; + } + setLastFocused(layoutNode); + setTimeout(autoAction, uiOpts.value.autoActionDelay); + } else { + // Determine available actions + let action: AutoAction | null; + let actionWeights: {[key: string]: number}; // Maps actions to choice weights + let node: LayoutNode = lastFocused.value; + if (node.children.length == 0){ + actionWeights = {'move across': 1, 'move up': 2, 'expand': 3}; + } else { + actionWeights = { + 'move across': 1, 'move down': 2, 'move up': 1, + 'collapse': 1, 'expandToView': 1, 'unhideAncestor': 1 }; - let currentTime = new Date().getTime(); - if (currentTime - this.lastResizeHdlrTime > this.uiOpts.transitionDuration){ - this.lastResizeHdlrTime = currentTime; - await handleResize(); - this.lastResizeHdlrTime = new Date().getTime(); - } - // Also setup a handler to execute after a run of resize events - clearTimeout(this.afterResizeHdlr); - this.afterResizeHdlr = setTimeout(async () => { - this.afterResizeHdlr = 0; - await handleResize(); - this.lastResizeHdlrTime = new Date().getTime(); - }, 200); // If too small, touch-device detection when swapping to/from mobile-mode gets unreliable - }, - onKeyUp(evt: KeyboardEvent){ - if (this.uiOpts.disableShortcuts){ - return; - } - if (evt.key == 'Escape'){ - this.resetMode(); - } else if (evt.key == 'f' && evt.ctrlKey){ - evt.preventDefault(); - // Open/focus search bar - if (!this.searchOpen){ - this.onSearchIconClick(); - } else { - (this.$refs.searchModal as InstanceType<typeof SearchModal>).focusInput(); - } - } else if (evt.key == 'F' && evt.ctrlKey){ - // If search bar is open, switch search mode - if (this.searchOpen){ - this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode; - this.onSettingChg('UI', 'searchJumpMode'); - } - } - }, - // For the loading-indicator - primeLoadInd(msg: string){ // Sets up a loading message to display after a timeout - clearTimeout(this.pendingLoadingRevealHdlr); - this.pendingLoadingRevealHdlr = setTimeout(() => { - this.loadingMsg = msg; - }, 500); - }, - endLoadInd(){ // Cancels or closes a loading message - clearTimeout(this.pendingLoadingRevealHdlr); - this.pendingLoadingRevealHdlr = 0; - if (this.loadingMsg != null){ - this.loadingMsg = null; - } - }, - async loadFromServer(urlParams: URLSearchParams){ // Like queryServer(), but enables the loading indicator - this.primeLoadInd(SERVER_WAIT_MSG); - let responseObj = await queryServer(urlParams); - this.endLoadInd(); - return responseObj; - }, - // For initialisation - async initTreeFromServer(firstInit = true){ - // Get possible target node from URL - let nodeName = (new URL(window.location.href)).searchParams.get('node'); - // Query server - let urlParams = new URLSearchParams({type: 'node', tree: this.uiOpts.tree}); - if (nodeName != null && firstInit){ - urlParams.append('name', nodeName); - urlParams.append('toroot', '1'); - } - let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams); - if (responseObj == null){ - return; - } - // Get root node name - let rootName = null; - let nodeNames = Object.getOwnPropertyNames(responseObj); - for (let n of nodeNames){ - if (responseObj[n].parent == null){ - rootName = n; + } + // Zero weights for disallowed actions + if (node == activeRoot.value || node.parent!.children.length == 1){ + actionWeights['move across'] = 0; + } + if (node == activeRoot.value){ + actionWeights['move up'] = 0; + } + if (tolMap.value.get(node.name)!.children.length == 0 || overflownRoot.value){ + actionWeights['expand'] = 0; + } + if (!node.children.every(n => n.children.length == 0)){ + actionWeights['collapse'] = 0; // Only collapse if all children are leaves + } + if (node.parent != activeRoot.value){ + actionWeights['expandToView'] = 0; // Only expand-to-view if direct child of activeRoot + } + 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); + if (revAction != null && revAction in actionWeights){ + actionWeights[revAction as keyof typeof actionWeights] = 0; + } + if (autoPrevActionFail.value){ + actionWeights[autoPrevAction.value as keyof typeof actionWeights] = 0; + } + } + // Choose action + let actionList = Object.getOwnPropertyNames(actionWeights); + let weightList = actionList.map(action => actionWeights[action]); + if (arraySum(weightList) == 0){ + action = null; + } else { + action = actionList[randWeightedChoice(weightList)!] as AutoAction; + } + // Perform action + autoPrevAction.value = action; + let success = true; + try { + switch (action){ + case 'move across': { // Bias towards siblings with higher tips + let siblings = node.parent!.children.filter(n => n != node); + let siblingWeights = siblings.map(n => n.tips + 1); + setLastFocused(siblings[randWeightedChoice(siblingWeights)!]); break; } - } - if (rootName == null){ - console.log('ERROR: Server response has no root node'); - return; - } - // Initialise tree - this.tolMap.clear(); - nodeNames.forEach(n => {this.tolMap.set(n, responseObj[n])}); - if (nodeName == null){ - this.layoutTree = initLayoutTree(this.tolMap, rootName, 0); - this.layoutMap = initLayoutMap(this.layoutTree); - this.activeRoot = this.layoutTree; - } else { - this.layoutTree = initLayoutTree(this.tolMap, rootName, -1); - this.layoutMap = initLayoutMap(this.layoutTree); - // Set active root - let targetNode = this.layoutMap.get(nodeName)!; - let newRoot = targetNode.parent == null ? targetNode : targetNode.parent; - LayoutNode.hideUpward(newRoot, this.layoutMap); - this.activeRoot = newRoot; - setTimeout(() => this.setLastFocused(targetNode!), this.uiOpts.transitionDuration); - } - // Skip initial transition - if (firstInit){ - this.justInitialised = true; - setTimeout(() => {this.justInitialised = false}, this.uiOpts.transitionDuration); - } - // Relayout - this.updateAreaDims(); - this.relayoutWithCollapse(false); - }, - async reInit(){ - if (this.activeRoot != this.layoutTree){ - // Collapse tree to root - await this.onDetachedAncestorClick(this.layoutTree, true); - } - await this.onNonleafClick(this.layoutTree, true); - await this.initTreeFromServer(false); - }, - getLytOpts(): LayoutOptions { - let opts = getDefaultLytOpts(); - for (let prop of Object.getOwnPropertyNames(opts) as (keyof LayoutOptions)[]){ - let item = localStorage.getItem('LYT ' + prop); - if (item != null){ - switch (typeof(opts[prop])){ - case 'boolean': (opts[prop] as unknown as boolean) = Boolean(item); break; - case 'number': (opts[prop] as unknown as number) = Number(item); break; - case 'string': (opts[prop] as unknown as string) = item; break; - default: console.log(`WARNING: Found saved layout setting "${prop}" with unexpected type`); - } - } - } - return opts; - }, - getUiOpts(): UiOptions { - let opts = getDefaultUiOpts(getDefaultLytOpts()); - for (let prop of Object.getOwnPropertyNames(opts) as (keyof UiOptions)[]){ - let item = localStorage.getItem('UI ' + prop); - if (item != null){ - switch (typeof(opts[prop])){ - case 'boolean': (opts[prop] as unknown as boolean) = (item == 'true'); break; - case 'number': (opts[prop] as unknown as number) = Number(item); break; - case 'string': (opts[prop] as unknown as string) = item; break; - default: console.log(`WARNING: Found saved UI setting "${prop}" with unexpected type`); - } - } - } - return opts; - }, - // For relayout - relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean { - let success: boolean; - if (this.overflownRoot){ - if (keepOverflow){ - success = tryLayout(this.activeRoot, this.tileAreaDims, - {...this.lytOpts, layoutType: 'sqr-overflow'}, {layoutMap: this.layoutMap}); - return success; - } - this.overflownRoot = false; - } - success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, - {allowCollapse: true, layoutMap: this.layoutMap}); - if (secondPass){ - // Relayout again, which can help allocate remaining tiles 'evenly' - success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, - {allowCollapse: false, layoutMap: this.layoutMap}); - } - return success; - }, - updateAreaDims(){ - // Set mainAreaDims and tileAreaDims - // Note: Tried setting these by querying tut_pane+ancestry_bar dimensions repeatedly, - // throughout their transitions, relayouting each time, but this makes the tile movements jerky - let contentAreaEl = this.$refs.contentArea as HTMLElement; - let w = contentAreaEl.offsetWidth, h = contentAreaEl.offsetHeight; - if (this.tutPaneOpen && this.uiOpts.breakpoint == 'sm'){ - h -= this.uiOpts.tutPaneSz; - } - this.mainAreaDims = [w, h]; - if (this.detachedAncestors != null){ - if (w > h){ - w -= this.uiOpts.ancestryBarBreadth; - } else { - h -= this.uiOpts.ancestryBarBreadth; - } - } - w -= this.lytOpts.tileSpacing * 2; - h -= this.lytOpts.tileSpacing * 2; - this.tileAreaDims = [w, h]; - }, - // Other - setLastFocused(node: LayoutNode | null){ - if (this.lastFocused != null){ - this.lastFocused.hasFocus = false; - } - this.lastFocused = node; - if (node != null){ - node.hasFocus = true; - } - }, - async collapseTree(){ - if (this.activeRoot != this.layoutTree){ - await this.onDetachedAncestorClick(this.layoutTree, true); - } - if (this.layoutTree.children.length > 0){ - await this.onNonleafClick(this.layoutTree); - } - }, - }, - watch: { - modeRunning(newVal, oldVal){ - // For sweepToParent setting 'fallback', temporarily change to 'prefer' for efficiency - if (newVal != null){ - if (this.lytOpts.sweepToParent == 'fallback'){ - this.lytOpts.sweepToParent = 'prefer'; - this.changedSweepToParent = true; - } - } else { - if (this.changedSweepToParent){ - this.lytOpts.sweepToParent = 'fallback'; - this.changedSweepToParent = false; + case 'move down': { // Bias towards children with higher tips + let childWeights = node.children.map(n => n.tips + 1); + setLastFocused(node.children[randWeightedChoice(childWeights)!]); + break; } + case 'move up': + setLastFocused(node.parent!); + break; + case 'expand': + success = await onLeafClick(node, true); + break; + case 'collapse': + success = await onNonleafClick(node, true); + break; + case 'expandToView': + success = await onNonleafClickHeld(node, true); + break; + case 'unhideAncestor': + success = await onDetachedAncestorClick(node.parent!, true); + break; } - }, - }, - mounted(){ - window.addEventListener('resize', this.onResize); - window.addEventListener('keydown', this.onKeyUp); - this.initTreeFromServer(); - }, - unmounted(){ - window.removeEventListener('resize', this.onResize); - window.removeEventListener('keydown', this.onKeyUp); - }, - components: { - TolTile, TutorialPane, AncestryBar, - IconButton, SearchIcon, PlayIcon, PauseIcon, SettingsIcon, HelpIcon, EduIcon, - TileInfoModal, SearchModal, SettingsModal, HelpModal, LoadingModal, - }, + } catch (error) { + autoPrevActionFail.value = true; + onAutoClose(); + return; + } + autoPrevActionFail.value = !success; + setTimeout(autoAction, uiOpts.value.transitionDuration + uiOpts.value.autoActionDelay); + } +} +function onAutoClose(){ + modeRunning.value = null; + onActionEnd('autoMode'); +} + +// For settings modal/events +const settingsOpen = ref(false); +function onSettingsIconClick(){ + if (!onActionStart('settings')){ + return; + } + resetMode(); + settingsOpen.value = true; +} +function onSettingsClose(){ + settingsOpen.value = false; + onActionEnd('settings'); +} +async function onSettingChg(optionType: OptionType, option: string, {relayout = false, reinit = false} = {}){ + // Save setting + if (optionType == 'LYT'){ + localStorage.setItem(`${optionType} ${option}`, + String(lytOpts.value[option as keyof LayoutOptions])); + } else if (optionType == 'UI') { + localStorage.setItem(`${optionType} ${option}`, + String(uiOpts.value[option as keyof UiOptions])); + } + // Possibly relayout/reinitialise + if (reinit){ + reInit(); + } else if (relayout){ + relayoutWithCollapse(); + } +} +function onResetSettings(reinit: boolean){ + localStorage.clear(); + if (reinit){ + reInit(); + } else { + relayoutWithCollapse(); + } +} + +// For help modal/events +const helpOpen = ref(false); +function onHelpIconClick(){ + if (!onActionStart('help')){ + return; + } + resetMode(); + helpOpen.value = true; +} +function onHelpClose(){ + helpOpen.value = false; + onActionEnd('help'); +} + +// For tutorial pane/events +const tutPaneOpen = ref(!uiOpts.value.tutorialSkip); +const tutWelcome = ref(!uiOpts.value.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 +// For tutorial-pane events +function onStartTutorial(){ + if (!tutPaneOpen.value){ + tutPaneOpen.value = true; + updateAreaDims(); + relayoutWithCollapse(); + } +} +function onTutorialSkip(){ + uiOpts.value.tutorialSkip = true; + onSettingChg('UI', 'tutorialSkip'); +} +function onTutStageChg(triggerAction: Action | null){ + tutWelcome.value = false; + tutTriggerAction.value = triggerAction; +} +function onTutPaneClose(){ + tutPaneOpen.value = false; + if (tutWelcome.value){ + tutWelcome.value = false; + } else if (uiOpts.value.tutorialSkip == false){ + uiOpts.value.tutorialSkip = true; + onSettingChg('UI', 'tutorialSkip'); + } + uiOpts.value.disabledActions.clear(); + updateAreaDims(); + relayoutWithCollapse(true, true); +} + +// 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; + } + lastFocused.value = node; + if (node != null){ + node.hasFocus = true; + } +} + +// For general action handling +const modeRunning = ref(null as null | 'search' | 'autoMode'); +function resetMode(){ + if (infoModalNodeName.value != null){ + onInfoClose(); + } + if (searchOpen.value || modeRunning.value == 'search'){ + onSearchClose(); + } + if (modeRunning.value == 'autoMode'){ + onAutoClose(); + } + if (settingsOpen.value){ + onSettingsClose(); + } + if (helpOpen.value){ + onHelpClose(); + } +} +function onActionStart(action: Action): boolean { + if (isDisabled(action)){ + return false; + } + setLastFocused(null); + return true; +} +function onActionEnd(action: Action){ + // Update info used by tutorial pane + actionsDone.value.add(action); + if (tutPaneOpen.value){ + // Close welcome message on first action + if (tutWelcome.value){ + onTutPaneClose(); + } + // Tell TutorialPane if trigger-action was done + if (tutTriggerAction.value == action){ + tutTriggerFlag.value = !tutTriggerFlag.value; + } + } +} +function isDisabled(...actions: Action[]): boolean { + let disabledActions = uiOpts.value.disabledActions; + return actions.some(a => disabledActions.has(a)); +} + +// 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; + if (loadingMsg.value != null){ + loadingMsg.value = null; + } +} +async function loadFromServer(urlParams: URLSearchParams){ // Like queryServer(), but enables the loading indicator + primeLoadInd(SERVER_WAIT_MSG); + let responseObj = await queryServer(urlParams); + endLoadInd(); + return responseObj; +} + +// For collapsing tree upon clicking 'Tilo' +async function collapseTree(){ + if (activeRoot.value != layoutTree.value){ + await onDetachedAncestorClick(layoutTree.value, true); + } + if (layoutTree.value.children.length > 0){ + await onNonleafClick(layoutTree.value); + } +} + +// For temporarily changing a sweepToParent setting of 'fallback' to 'prefer', for efficiency +const changedSweepToParent = ref(false); +watch(modeRunning, (newVal) => { + if (newVal != null){ + if (lytOpts.value.sweepToParent == 'fallback'){ + lytOpts.value.sweepToParent = 'prefer'; + changedSweepToParent.value = true; + } + } else { + if (changedSweepToParent.value){ + lytOpts.value.sweepToParent = 'fallback'; + changedSweepToParent.value = false; + } + } +}); + +// For keyboard shortcuts +function onKeyDown(evt: KeyboardEvent){ + if (uiOpts.value.disableShortcuts){ + return; + } + if (evt.key == 'Escape'){ + resetMode(); + } else if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + // Open/focus search bar + if (!searchOpen.value){ + onSearchIconClick(); + } + } else if (evt.key == 'F' && evt.ctrlKey){ + // If search bar is open, switch search mode + if (searchOpen.value){ + uiOpts.value.searchJumpMode = !uiOpts.value.searchJumpMode; + onSettingChg('UI', 'searchJumpMode'); + } + } +} +onMounted(() => { + window.addEventListener('keydown', onKeyDown); // 'keydown' needed to override default CTRL-F +}); +onUnmounted(() => { + window.removeEventListener('keydown', onKeyDown); +}); + +// Styles +const buttonStyles = computed(() => ({ + color: uiOpts.value.textColor, + backgroundColor: uiOpts.value.altColorDark, +})); +const tutPaneContainerStyles = computed((): Record<string,string> => { + if (uiOpts.value.breakpoint == 'sm'){ + return { + minHeight: (tutPaneOpen.value ? uiOpts.value.tutPaneSz : 0) + 'px', + maxHeight: (tutPaneOpen.value ? uiOpts.value.tutPaneSz : 0) + 'px', + transitionProperty: 'max-height, min-height', + transitionDuration: uiOpts.value.transitionDuration + 'ms', + overflow: 'hidden', + }; + } else { + return { + position: 'absolute', + bottom: '0.5cm', + right: '0.5cm', + visibility: tutPaneOpen.value ? 'visible' : 'hidden', + transitionProperty: 'visibility', + transitionDuration: uiOpts.value.transitionDuration + 'ms', + }; + } +}); +const tutPaneStyles = computed((): Record<string,string> => { + if (uiOpts.value.breakpoint == 'sm'){ + return { + height: uiOpts.value.tutPaneSz + 'px', + } + } else { + return { + height: uiOpts.value.tutPaneSz + 'px', + minWidth: '10cm', + maxWidth: '10cm', + borderRadius: uiOpts.value.borderRadius + 'px', + boxShadow: '0 0 3px black', + }; + } +}); +const ancestryBarContainerStyles = computed((): Record<string,string> => { + let ancestryBarBreadth = detachedAncestors.value == null ? 0 : uiOpts.value.ancestryBarBreadth; + let styles = { + minWidth: 'auto', + maxWidth: 'none', + minHeight: 'auto', + maxHeight: 'none', + transitionDuration: uiOpts.value.transitionDuration + 'ms', + transitionProperty: '', + overflow: 'hidden', + }; + if (wideMainArea.value){ + styles.minWidth = ancestryBarBreadth + 'px'; + styles.maxWidth = ancestryBarBreadth + 'px'; + styles.transitionProperty = 'min-width, max-width'; + } else { + styles.minHeight = ancestryBarBreadth + 'px'; + styles.maxHeight = ancestryBarBreadth + 'px'; + styles.transitionProperty = 'min-height, max-height'; + } + return styles; }); </script> diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue index 3cfd116..1b4ee81 100644 --- a/src/components/AncestryBar.vue +++ b/src/components/AncestryBar.vue @@ -1,92 +1,86 @@ <template> -<div :style="styles" @wheel.stop="onWheelEvt"> +<div :style="styles" @wheel.stop="onWheelEvt" ref="rootRef"> <tol-tile v-for="(node, idx) in dummyNodes" :key="node.name" class="shrink-0" :layoutNode="node" :tolMap="tolMap" :nonAbsPos="true" :lytOpts="lytOpts" :uiOpts="uiOpts" @leaf-click="onTileClick(nodes[idx])" @info-click="onInfoIconClick"/> </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, watch, onMounted, nextTick, PropType} from 'vue'; import TolTile from './TolTile.vue'; import {TolMap} from '../tol'; import {LayoutNode, LayoutOptions} from '../layout'; import {UiOptions} from '../lib'; -export default defineComponent({ - props: { - nodes: {type: Array as PropType<LayoutNode[]>, required: true}, - vert: {type: Boolean, default: false}, - breadth: {type: Number, required: true}, - // Other - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - tolMap: {type: Object as PropType<TolMap>, required: true}, - }, - computed: { - imgSz(){ - return this.breadth - this.lytOpts.tileSpacing - this.uiOpts.scrollGap; - // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage - }, - dummyNodes(){ // Childless versions of 'nodes' used to parameterise <tol-tile>s - return this.nodes.map(n => { - let newNode = new LayoutNode(n.name, []); - newNode.dims = [this.imgSz, this.imgSz]; - return newNode; - }); - }, - styles(): Record<string,string> { - return { - // For child layout - display: 'flex', - flexDirection: this.vert ? 'column' : 'row', - alignItems: 'center', - gap: this.lytOpts.tileSpacing + 'px', - padding: this.lytOpts.tileSpacing + 'px', - overflowX: this.vert ? 'hidden' : 'auto', - overflowY: this.vert ? 'auto' : 'hidden', - // Other - backgroundColor: this.uiOpts.ancestryBarBgColor, - boxShadow: this.uiOpts.shadowNormal, - }; - }, - }, - methods: { - // Click events - onTileClick(node: LayoutNode){ - this.$emit('ancestor-click', node); - }, - onInfoIconClick(data: string){ - this.$emit('info-click', data); - }, - // For converting vertical scrolling to horizontal - onWheelEvt(evt: WheelEvent){ - if (!this.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){ - this.$el.scrollLeft -= (evt.deltaY > 0 ? -30 : 30); - } - }, - // Other - scrollToEnd(){ - if (this.vert){ - this.$el.scrollTop = this.$el.scrollHeight; - } else { - this.$el.scrollLeft = this.$el.scrollWidth; - } - }, - }, - watch: { - // For scrolling-to-end upon node/screen changes - nodes(){ - this.$nextTick(() => this.scrollToEnd()); - }, - vert(){ - this.$nextTick(() => this.scrollToEnd()); - }, - }, - mounted(){ - this.scrollToEnd(); - }, - components: {TolTile, }, - emits: ['ancestor-click', 'info-click', ], +// Refs +const rootRef = ref(null as HTMLDivElement | null); + +// Props + events +const props = defineProps({ + nodes: {type: Array as PropType<LayoutNode[]>, required: true}, + vert: {type: Boolean, default: false}, + breadth: {type: Number, required: true}, + // + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, + tolMap: {type: Object as PropType<TolMap>, required: true}, }); +const emit = defineEmits(['ancestor-click', 'info-click']); + +// Computed prop data for display +const imgSz = computed(() => + props.breadth - props.lytOpts.tileSpacing - props.uiOpts.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 +function onTileClick(node: LayoutNode){ + emit('ancestor-click', node); +} +function onInfoIconClick(data: string){ + emit('info-click', data); +} + +// 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 (props.vert){ + el.scrollTop = el.scrollHeight; + } else { + el.scrollLeft = el.scrollWidth; + } +} +watch(props.nodes, () => { + nextTick(() => scrollToEnd()); +}); +watch(() => props.vert, () => { + nextTick(() => scrollToEnd()); +}); +onMounted(() => scrollToEnd()); + +// Styles +const styles = computed(() => ({ + // For child layout + display: 'flex', + flexDirection: props.vert ? 'column' : 'row', + alignItems: 'center', + gap: props.lytOpts.tileSpacing + 'px', + padding: props.lytOpts.tileSpacing + 'px', + overflowX: props.vert ? 'hidden' : 'auto', + overflowY: props.vert ? 'auto' : 'hidden', + // Other + backgroundColor: props.uiOpts.ancestryBarBgColor, + boxShadow: props.uiOpts.shadowNormal, +})); </script> diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index 733810b..c403e53 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -1,9 +1,9 @@ <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose"> +<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose" ref="rootRef"> <!-- Outer div is slightly less dark to make scrollbar more distinguishable --> <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-[90%] max-w-[16cm] max-h-[80%] overflow-auto" :style="styles"> - <close-icon @click.stop="onClose" ref="closeIcon" + <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 font-bold pt-2 pb-1">Help</h1> <div class="flex flex-col gap-2 p-2"> @@ -84,7 +84,7 @@ </ul> </p> <br/> - <p> + <div> There are many other methods of visualisation. Examples include <a href="https://itol.embl.de/" :style="aStyles">iTOL</a> and <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a> @@ -104,7 +104,7 @@ </div> </div> </div> - </p> + </div> <br/> <h1 class="text-lg font-bold">Settings</h1> <ul class="list-disc pl-4"> @@ -231,7 +231,7 @@ <a href="https://tree.opentreeoflife.org" :style="aStyles">Open Tree of Life</a>, in <a href="https://tree.opentreeoflife.org/about/synthesis-release" :style="aStyles">synthesis release</a> - version 13.4, accessed 23/04/2022. The data is licensed under + version 13.4, accessed 23/04/2022. The data is licensed under <a href="https://creativecommons.org/publicdomain/zero/1.0/" :style="aStyles">CC0</a>. </li> <li> @@ -296,7 +296,7 @@ Thanks to <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a> for having <a href="https://github.com/OneZoom/OZtree/tree/main/OZprivate/ServerScripts/TaxonMappingAndPopularity" :style="aStyles">code</a> - that automates node mapping. + that automates taxon ID mapping </li> <li> Thanks to @@ -426,67 +426,51 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, PropType} 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 ExternalLinkIcon from './icon/ExternalLinkIcon.vue'; import {UiOptions} from '../lib'; -export default defineComponent({ - props: { - tutOpen: {type: Boolean, default: false}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - computed: { - styles(): Record<string,string> { - return { - backgroundColor: this.uiOpts.bgColorAlt, - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.uiOpts.shadowNormal, - }; - }, - scClasses(): string { - return 'border border-stone-400 rounded'; - }, - scSummaryClasses(): string { - return "relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2"; - }, - downIconClasses(): string { - return 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300'; - }, - downIconExpandedClasses(): string { - return this.downIconClasses + ' -rotate-90'; - }, - contentClasses(): string { - return 'py-2 px-2 text-sm md:text-base'; - }, - aStyles(): Record<string,string> { - return { - color: this.uiOpts.altColorDark, - }; - }, - linkIconClasses(): string { - return 'inline-block w-3 h-3 ml-1'; - }, - touchDevice(): boolean { - return this.uiOpts.touchDevice; - }, - }, - methods: { - onClose(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ - this.$emit('close'); - } - }, - onStartTutorial(){ - this.$emit('start-tutorial'); - this.$emit('close'); - }, - }, - components: {SButton, SCollapsible, CloseIcon, DownIcon, ExternalLinkIcon, }, - emits: ['close', 'start-tutorial', ], +// Refs +const rootRef = ref(null as HTMLDivElement | null) +const closeRef = ref(null as typeof CloseIcon | null); + +// Props + events +const props = defineProps({ + tutOpen: {type: Boolean, default: false}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, }); +const touchDevice = computed(() => props.uiOpts.touchDevice) +const emit = defineEmits(['close', 'start-tutorial']); + +// 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 +const styles = computed(() => ({ + backgroundColor: props.uiOpts.bgColorAlt, + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: props.uiOpts.shadowNormal, +})); +const aStyles = computed(() => ({ + color: props.uiOpts.altColorDark, +})); + +// 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'; +const downIconExpandedClasses = computed(() => downIconClasses + ' -rotate-90'); +const contentClasses = 'py-2 px-2 text-sm md:text-base'; </script> diff --git a/src/components/IconButton.vue b/src/components/IconButton.vue index 5684fb0..9357e97 100644 --- a/src/components/IconButton.vue +++ b/src/components/IconButton.vue @@ -5,24 +5,19 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {computed} from 'vue'; -export default defineComponent({ - props: { - size: {type: Number, default: 36}, - disabled: {type: Boolean, default: false}, - }, - computed: { - styles(): Record<string,string> { - return { - minWidth: this.size + 'px', - maxWidth: this.size + 'px', - minHeight: this.size + 'px', - maxHeight: this.size + 'px', - padding: (this.size / 5) + 'px', - }; - }, - }, +const props = defineProps({ + size: {type: Number, default: 36}, + disabled: {type: Boolean, default: false}, }); + +const styles = computed(() => ({ + minWidth: props.size + 'px', + maxWidth: props.size + 'px', + minHeight: props.size + 'px', + maxHeight: props.size + 'px', + padding: (props.size / 5) + 'px', +})); </script> diff --git a/src/components/LoadingModal.vue b/src/components/LoadingModal.vue index ee8d699..abd405c 100644 --- a/src/components/LoadingModal.vue +++ b/src/components/LoadingModal.vue @@ -8,26 +8,20 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {computed, PropType} from 'vue'; import LoaderIcon from './icon/LoaderIcon.vue'; import {UiOptions} from '../lib'; -export default defineComponent({ - props: { - msg: {type: String, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - computed: { - styles(): Record<string,string> { - return { - color: this.uiOpts.textColor, - backgroundColor: this.uiOpts.bgColorDark2, - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.uiOpts.shadowNormal, - }; - }, - }, - components: {LoaderIcon, }, +const props = defineProps({ + msg: {type: String, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, }); + +const styles = computed(() => ({ + color: props.uiOpts.textColor, + backgroundColor: props.uiOpts.bgColorDark2, + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: props.uiOpts.shadowNormal, +})); </script> diff --git a/src/components/SButton.vue b/src/components/SButton.vue index 508f8e6..884fa30 100644 --- a/src/components/SButton.vue +++ b/src/components/SButton.vue @@ -5,12 +5,8 @@ </button> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; - -export default defineComponent({ - props: { - disabled: {type: Boolean, default: false}, - }, +<script setup lang="ts"> +const props = defineProps({ + disabled: {type: Boolean, default: false}, }); </script> diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue index 5b49c8c..39b4283 100644 --- a/src/components/SCollapsible.vue +++ b/src/components/SCollapsible.vue @@ -11,62 +11,51 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, watch} from 'vue'; -export default defineComponent({ - props: { - modelValue: {type: Boolean, default: false}, // For using v-model on the component - }, - data(){ - return { - open: false, - }; - }, - computed: { - styles(): Record<string,string> { - return { - overflow: this.open ? 'visible' : 'hidden', - }; - }, - contentStyles(): Record<string,string> { - return { - overflow: 'hidden', - opacity: this.open ? '1' : '0', - transitionProperty: 'max-height, opacity', - transitionDuration: '300ms', - transitionTimingFunction: 'ease-in-out', - }; - }, - }, - methods: { - onClick(evt: Event){ - this.open = !this.open; - this.$emit('update:modelValue', this.open); - if (this.open){ - this.$emit('open'); - } - }, - onEnter(el: HTMLDivElement){ - el.style.maxHeight = el.scrollHeight + 'px'; - }, - onAfterEnter(el: HTMLDivElement){ - el.style.maxHeight = 'none'; - // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short - }, - onBeforeLeave(el: HTMLDivElement){ - el.style.maxHeight = el.scrollHeight + 'px'; - el.offsetWidth; // Triggers reflow - }, - onLeave(el: HTMLDivElement){ - el.style.maxHeight = '0'; - }, - }, - watch: { - modelValue(newVal, oldVal){ - this.open = newVal; - }, - }, - emits: ['update:modelValue', 'open', ], +// 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 +const open = ref(false); +watch(() => props.modelValue, (newVal) => {open.value = newVal}) +function onClick(){ + open.value = !open.value; + emit('update:modelValue', open.value); + if (open.value){ + emit('open'); + } +} + +// Styles +const styles = computed(() => ({ + overflow: open.value ? 'visible' : 'hidden', +})); +const contentStyles = computed(() => ({ + overflow: 'hidden', + opacity: open.value ? '1' : '0', + transitionProperty: 'max-height, opacity', + transitionDuration: '300ms', + transitionTimingFunction: 'ease-in-out', +})); + +// 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'; +} </script> diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue index 7406634..a035cac 100644 --- a/src/components/SearchModal.vue +++ b/src/components/SearchModal.vue @@ -1,8 +1,8 @@ <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose"> +<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 type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="searchInput" + <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"/> <div class="p-1 hover:cursor-pointer"> @@ -32,225 +32,226 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, onMounted, onUnmounted, PropType} from 'vue'; import SearchIcon from './icon/SearchIcon.vue'; -import LogInIcon from './icon/LogInIcon.vue'; import InfoIcon from './icon/InfoIcon.vue'; import {TolNode, TolMap} from '../tol'; import {LayoutNode, LayoutMap, LayoutOptions} from '../layout'; import {queryServer, SearchSugg, SearchSuggResponse, UiOptions} from '../lib'; -export default defineComponent({ - props: { - 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 - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - data(){ - return { - // Search-suggestion data - searchSuggs: [] as SearchSugg[], - searchHadMoreSuggs: false, - suggsInput: '', // The input that resulted in the current suggestions (used to highlight matching text) - // For search-suggestion requests - lastSuggReqTime: 0, // Set when a search-suggestions request is initiated - pendingSuggReqParams: null as null | URLSearchParams, - // Used by a search-suggestion requester to request with the latest user input - pendingDelayedSuggReq: 0, // Set via setTimeout() for a non-initial search-suggestions request - pendingSuggInput: '', // Used to remember what input triggered a suggestions request - // Other - focusedSuggIdx: null as null | number, // Index of a search-suggestion selected using the arrow keys - }; - }, - computed: { - styles(): Record<string,string> { - let br = this.uiOpts.borderRadius; - return { - backgroundColor: this.uiOpts.bgColorAlt, - borderRadius: (this.searchSuggs.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, - boxShadow: this.uiOpts.shadowNormal, - }; - }, - suggContainerStyles(): Record<string,string> { - let br = this.uiOpts.borderRadius; - return { - backgroundColor: this.uiOpts.bgColorAlt, - color: this.uiOpts.textColorAlt, - borderRadius: `0 0 ${br}px ${br}px`, - }; - }, - animateLabelStyles(): Record<string,string> { - return { - position: 'absolute', - top: -this.lytOpts.headerSz - 2 + 'px', - right: '0', - height: this.lytOpts.headerSz + 'px', - color: this.uiOpts.textColor, - }; - }, - suggDisplayStrings(): [string, string, string, string][] { - let result: [string, string, string, string][] = []; - let input = this.suggsInput.toLowerCase(); - // For each SearchSugg - for (let sugg of this.searchSuggs){ - let idx = sugg.name.indexOf(input); - // Split suggestion text into parts before/within/after an input match - let strings: [string, string, string, string]; - if (idx != -1){ - strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), '']; - } else { - strings = [input, '', '', '']; - } - // Indicate any distinct canonical-name - if (sugg.canonicalName != null){ - strings[3] = ` (aka ${sugg.canonicalName})`; - } - // - result.push(strings); - } - return result; - }, - }, - methods: { - // Search-suggestion events - async onInput(){ - let input = this.$refs.searchInput as HTMLInputElement; - // Check for empty input - if (input.value.length == 0){ - this.searchSuggs = []; - this.searchHadMoreSuggs = false; - this.focusedSuggIdx = null; - return; - } - // Get URL params to use for querying search-suggestions - let urlParams = new URLSearchParams({ - type: 'sugg', - name: input.value, - limit: String(this.uiOpts.searchSuggLimit), - tree: this.uiOpts.tree, - }); - // Query server, delaying/skipping if a request was recently sent - this.pendingSuggReqParams = urlParams; - this.pendingSuggInput = input.value; - let doReq = async () => { - let suggInput = this.pendingSuggInput; - let responseObj: SearchSuggResponse = - await queryServer(this.pendingSuggReqParams!); - if (responseObj == null){ - return; - } - this.searchSuggs = responseObj.suggs; - this.searchHadMoreSuggs = responseObj.hasMore; - this.suggsInput = suggInput; - // Auto-select first result if present - if (this.searchSuggs.length > 0){ - this.focusedSuggIdx = 0; - } else { - this.focusedSuggIdx = null; - } - }; - let currentTime = new Date().getTime(); - if (this.lastSuggReqTime == 0){ - this.lastSuggReqTime = currentTime; - await doReq(); - if (this.lastSuggReqTime == currentTime){ - this.lastSuggReqTime = 0; - } - } else if (this.pendingDelayedSuggReq == 0){ - this.lastSuggReqTime = currentTime; - this.pendingDelayedSuggReq = setTimeout(async () => { - this.pendingDelayedSuggReq = 0; - await doReq(); - if (this.lastSuggReqTime == currentTime){ - this.lastSuggReqTime = 0; - } - }, 300); - } - }, - onInfoIconClick(nodeName: string){ - this.$emit('info-click', nodeName); - }, - onDownKey(){ - if (this.focusedSuggIdx != null){ - this.focusedSuggIdx = (this.focusedSuggIdx + 1) % this.searchSuggs.length; - } - }, - onUpKey(){ - if (this.focusedSuggIdx != null){ - this.focusedSuggIdx = (this.focusedSuggIdx - 1 + this.searchSuggs.length) % this.searchSuggs.length; - // The addition after '-1' is to avoid becoming negative - } - }, - // Search events - onSearch(){ - if (this.focusedSuggIdx == null){ - let input = (this.$refs.searchInput as HTMLInputElement).value.toLowerCase(); - this.resolveSearch(input) - } else { - let sugg = this.searchSuggs[this.focusedSuggIdx] - this.resolveSearch(sugg.canonicalName || sugg.name); - } - }, - async resolveSearch(tolNodeName: string){ - if (tolNodeName == ''){ - return; - } - // Check if the node data is already here - if (this.lytMap.has(tolNodeName)){ - this.$emit('search', tolNodeName); - return; - } - // Ask server for nodes in parent-chain, updates tolMap, then emits search event - let urlParams = new URLSearchParams({ - type: 'node', - name: tolNodeName, - toroot: '1', - excl: this.activeRoot.name, - tree: this.uiOpts.tree, - }); - this.$emit('net-wait'); // Allows the parent component to show a loading-indicator - let responseObj: {[x: string]: TolNode} = await queryServer(urlParams); - this.$emit('net-get'); - if (responseObj == null){ - return; - } - let keys = Object.getOwnPropertyNames(responseObj); - if (keys.length > 0){ - keys.forEach(key => { - if (!this.tolMap.has(key)){ - this.tolMap.set(key, responseObj[key]) - } - }); - this.$emit('search', tolNodeName); - } else { - // Trigger failure animation - let input = this.$refs.searchInput as HTMLInputElement; - input.classList.remove('animate-red-then-fade'); - input.offsetWidth; // Triggers reflow - input.classList.add('animate-red-then-fade'); +// Refs +const rootRef = ref(null as HTMLDivElement | null); +const inputRef = ref(null as HTMLInputElement | null); + +// 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 + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, +}); +const emit = defineEmits(['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get']); + +// Search-suggestion data +const searchSuggs = ref([] as SearchSugg[]); +const searchHadMoreSuggs = ref(false); +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 + let strings: [string, string, string, string]; + if (idx != -1){ + strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), '']; + } 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 +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 = []; + searchHadMoreSuggs.value = false; + focusedSuggIdx.value = null; + return; + } + // Get URL params to use for querying search-suggestions + let urlParams = new URLSearchParams({ + type: 'sugg', + name: input.value, + limit: String(props.uiOpts.searchSuggLimit), + tree: props.uiOpts.tree, + }); + // Query server, delaying/skipping if a request was recently sent + pendingSuggReqParams.value = urlParams; + pendingSuggInput.value = input.value; + let doReq = async () => { + let suggInput = pendingSuggInput.value; + let responseObj: SearchSuggResponse = + await queryServer(pendingSuggReqParams.value!); + if (responseObj == null){ + return; + } + 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; + } else { + focusedSuggIdx.value = null; + } + }; + let currentTime = new Date().getTime(); + if (lastSuggReqTime.value == 0){ + lastSuggReqTime.value = currentTime; + await doReq(); + if (lastSuggReqTime.value == currentTime){ + lastSuggReqTime.value = 0; + } + } else if (pendingDelayedSuggReq.value == 0){ + lastSuggReqTime.value = currentTime; + pendingDelayedSuggReq.value = setTimeout(async () => { + pendingDelayedSuggReq.value = 0; + await doReq(); + if (lastSuggReqTime.value == currentTime){ + lastSuggReqTime.value = 0; } - }, - // Other - onSearchModeChg(){ - this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode; - this.$emit('setting-chg', 'searchJumpMode'); - }, - onClose(evt: Event){ - if (evt.target == this.$el){ - this.$emit('close'); + }, 300); + } +} + +// For search events +function onSearch(){ + if (focusedSuggIdx.value == null){ + let input = inputRef.value!.value.toLowerCase(); + resolveSearch(input) + } else { + let sugg = searchSuggs.value[focusedSuggIdx.value] + 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', + name: tolNodeName, + toroot: '1', + excl: props.activeRoot.name, + tree: props.uiOpts.tree, + }); + emit('net-wait'); // Allows the parent component to show a loading-indicator + let responseObj: {[x: string]: TolNode} = await queryServer(urlParams); + emit('net-get'); + if (responseObj == null){ + return; + } + let keys = Object.getOwnPropertyNames(responseObj); + if (keys.length > 0){ + keys.forEach(key => { + if (!props.tolMap.has(key)){ + props.tolMap.set(key, responseObj[key]) } - }, - focusInput(){ // Used from external component - (this.$refs.searchInput as HTMLInputElement).focus(); - }, - }, - mounted(){ - (this.$refs.searchInput as HTMLInputElement).focus(); - }, - components: {SearchIcon, InfoIcon, LogInIcon, }, - emits: ['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get', ], + }); + emit('search', tolNodeName); + } else { + // Trigger failure animation + let input = inputRef.value!; + input.classList.remove('animate-red-then-fade'); + input.offsetWidth; // Triggers reflow + input.classList.add('animate-red-then-fade'); + } +} + +// 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 +function onKeyDown(evt: KeyboardEvent){ + if (props.uiOpts.disableShortcuts){ + return; + } + if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + inputRef.value!.focus(); + } +} +onMounted(() => window.addEventListener('keydown', onKeyDown)) +onUnmounted(() => window.removeEventListener('keydown', onKeyDown)) + +// Focus input on mount +onMounted(() => inputRef.value!.focus()) + +// Styles +const styles = computed((): Record<string,string> => { + let br = props.uiOpts.borderRadius; + return { + backgroundColor: props.uiOpts.bgColorAlt, + borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, + boxShadow: props.uiOpts.shadowNormal, + }; +}); +const suggContainerStyles = computed((): Record<string,string> => { + let br = props.uiOpts.borderRadius; + return { + backgroundColor: props.uiOpts.bgColorAlt, + color: props.uiOpts.textColorAlt, + borderRadius: `0 0 ${br}px ${br}px`, + }; }); +const animateLabelStyles = computed(() => ({ + position: 'absolute', + top: -props.lytOpts.headerSz - 2 + 'px', + right: '0', + height: props.lytOpts.headerSz + 'px', + color: props.uiOpts.textColor, +})); </script> diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue index 4f5f05e..df8444f 100644 --- a/src/components/SettingsModal.vue +++ b/src/components/SettingsModal.vue @@ -1,8 +1,8 @@ <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef"> <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 min-w-[8cm] max-w-[80%] max-h-[80%] overflow-auto" :style="styles"> - <close-icon @click.stop="onClose" ref="closeIcon" + <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"> @@ -56,7 +56,7 @@ <input type="range" min="15" :max="uiOpts.breakpoint == 'sm' ? 150 : 200" v-model.number="lytOpts.minTileSz" @input="onSettingChgThrottled('LYT', 'minTileSz')" @change="onSettingChg('LYT', 'minTileSz')" - name="minTileSizeInput" ref="minTileSzInput"/> + name="minTileSizeInput" ref="minTileSzRef"/> <div class="my-auto text-right">{{lytOpts.minTileSz}} px</div> <!-- Row 2 --> <label for="maxTileSizeInput" @click="onReset('LYT', 'maxTileSz')" :class="rLabelClasses"> @@ -64,7 +64,7 @@ </label> <input type="range" min="15" max="400" v-model.number="lytOpts.maxTileSz" @input="onSettingChgThrottled('LYT', 'maxTileSz')" @change="onSettingChg('LYT', 'maxTileSz')" - name="maxTileSizeInput" ref="maxTileSzInput"/> + name="maxTileSizeInput" ref="maxTileSzRef"/> <div class="my-auto text-right">{{lytOpts.maxTileSz}} px</div> <!-- Row 3 --> <label for="tileSpacingInput" @click="onReset('LYT', 'tileSpacing')" :class="rLabelClasses"> @@ -107,129 +107,124 @@ Reset </s-button> <transition name="fade"> - <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndicator"> Saved </div> + <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndRef"> Saved </div> </transition> </div> </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, watch, PropType} from 'vue'; import SButton from './SButton.vue'; import CloseIcon from './icon/CloseIcon.vue'; import {UiOptions, OptionType, getDefaultLytOpts, getDefaultUiOpts} from '../lib'; import {LayoutOptions} from '../layout'; -export default defineComponent({ - props: { - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - data(){ - return { - sweepLeaves: this.lytOpts.layoutType == 'sweep', - // For making only two of 'layoutType's values available for user selection - saved: false, // Set to true after a setting is saved - settingChgTimeout: 0, // Use to throttle some setting-change handling - }; - }, - computed: { - styles(): Record<string,string> { - return { - backgroundColor: this.uiOpts.bgColorAlt, - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.uiOpts.shadowNormal, - }; - }, - borderBClasses(): string { - return 'border-b border-stone-400'; - }, - rLabelClasses(): string { // For reset-upon-click labels - return "w-fit hover:cursor-pointer hover:text-lime-600"; - }, - }, - methods: { - onClose(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ - this.$emit('close'); - } - }, - onSettingChg(optionType: OptionType, option: string){ - // Maintain min/max-tile-size consistency - if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){ - let minInput = this.$refs.minTileSzInput as HTMLInputElement; - let maxInput = this.$refs.maxTileSzInput as HTMLInputElement; - if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){ - this.lytOpts.maxTileSz = this.lytOpts.minTileSz; - this.$emit('setting-chg', 'LYT', 'maxTileSz'); - } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){ - this.lytOpts.minTileSz = this.lytOpts.maxTileSz; - this.$emit('setting-chg', 'LYT', 'minTileSz'); - } - } - // Notify parent component - this.$emit('setting-chg', optionType, option, - {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'}); - // Possibly make saved-indicator appear/animate - if (!this.saved){ - this.saved = true; - } else { - let el = this.$refs.saveIndicator as HTMLDivElement; - el.classList.remove('animate-flash-green'); - el.offsetWidth; // Triggers reflow - el.classList.add('animate-flash-green'); - } - }, - onSettingChgThrottled(optionType: OptionType, option: string){ - if (this.settingChgTimeout == 0){ - this.settingChgTimeout = setTimeout(() => { - this.settingChgTimeout = 0; - this.onSettingChg(optionType, option); - }, this.uiOpts.animationDelay); - } - }, - onReset(optionType: OptionType, option: string){ - // Restore the setting's default - let defaultLytOpts = getDefaultLytOpts(); - let defaultUiOpts = getDefaultUiOpts(defaultLytOpts); - if (optionType == 'LYT'){ - let lytOpt = option as keyof LayoutOptions; - if (this.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){ - return; - } - (this.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt]; - if (option == 'layoutType'){ - this.sweepLeaves = this.lytOpts.layoutType == 'sweep'; - } - } else { - let uiOpt = option as keyof UiOptions; - if (this.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){ - return; - } - (this.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt]; - } - // Notify parent component - this.onSettingChg(optionType, option); - }, - onResetAll(){ - // Restore default options - let defaultLytOpts = getDefaultLytOpts(); - let defaultUiOpts = getDefaultUiOpts(defaultLytOpts); - let needReinit = this.uiOpts.tree != defaultUiOpts.tree; - Object.assign(this.lytOpts, defaultLytOpts); - Object.assign(this.uiOpts, defaultUiOpts); - // Notify parent component - this.$emit('reset', needReinit); - // Clear saved-indicator - this.saved = false; - }, - }, - watch: { - sweepLeaves(newVal: boolean, oldVal: boolean){ - this.lytOpts.layoutType = newVal ? 'sweep' : 'rect'; - }, - }, - components: {SButton, CloseIcon, }, - emits: ['close', 'setting-chg', 'reset', ], +// 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); + +// Props + events +const props = defineProps({ + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, }); +const emit = defineEmits(['close', 'setting-chg', 'reset', ]); + +// For settings +const sweepLeaves = ref(props.lytOpts.layoutType == 'sweep'); + // For making only two of 'layoutType's values available for user selection) +watch(sweepLeaves, (newVal) => {props.lytOpts.layoutType = newVal ? 'sweep' : 'rect'}) + +// Settings change handling +const saved = ref(false); // Set to true after a setting is saved +const settingChgTimeout = ref(0); // Used to throttle some setting-change handling +function onSettingChg(optionType: OptionType, option: string){ + // Maintain min/max-tile-size consistency + if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){ + let minInput = minTileSzRef.value!; + let maxInput = maxTileSzRef.value!; + if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){ + props.lytOpts.maxTileSz = props.lytOpts.minTileSz; + emit('setting-chg', 'LYT', 'maxTileSz'); + } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){ + props.lytOpts.minTileSz = props.lytOpts.maxTileSz; + emit('setting-chg', 'LYT', 'minTileSz'); + } + } + // Notify parent component + emit('setting-chg', optionType, option, + {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'}); + // Possibly make saved-indicator appear/animate + if (!saved.value){ + saved.value = true; + } else { + let el = saveIndRef.value!; + el.classList.remove('animate-flash-green'); + el.offsetWidth; // Triggers reflow + el.classList.add('animate-flash-green'); + } +} +function onSettingChgThrottled(optionType: OptionType, option: string){ + if (settingChgTimeout.value == 0){ + settingChgTimeout.value = setTimeout(() => { + settingChgTimeout.value = 0; + onSettingChg(optionType, option); + }, props.uiOpts.animationDelay); + } +} +function onReset(optionType: OptionType, option: string){ + // Restore the setting's default + let defaultLytOpts = getDefaultLytOpts(); + let defaultUiOpts = getDefaultUiOpts(defaultLytOpts); + if (optionType == 'LYT'){ + let lytOpt = option as keyof LayoutOptions; + if (props.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){ + return; + } + (props.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt]; + if (option == 'layoutType'){ + sweepLeaves.value = props.lytOpts.layoutType == 'sweep'; + } + } else { + let uiOpt = option as keyof UiOptions; + if (props.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){ + return; + } + (props.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt]; + } + // Notify parent component + onSettingChg(optionType, option); +} +function onResetAll(){ + // Restore default options + let defaultLytOpts = getDefaultLytOpts(); + let defaultUiOpts = getDefaultUiOpts(defaultLytOpts); + let needReinit = props.uiOpts.tree != defaultUiOpts.tree; + Object.assign(props.lytOpts, defaultLytOpts); + Object.assign(props.uiOpts, defaultUiOpts); + // Notify parent component + emit('reset', needReinit); + // Clear saved-indicator + saved.value = false; +} + +// Close handling +function onClose(evt: Event){ + if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ + emit('close'); + } +} + +// Styles and classes +const styles = computed(() => ({ + backgroundColor: props.uiOpts.bgColorAlt, + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: props.uiOpts.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 39e2b5b..fc09d86 100644 --- a/src/components/TileInfoModal.vue +++ b/src/components/TileInfoModal.vue @@ -1,9 +1,9 @@ <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef"> <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 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-icon @click.stop="onClose" ref="closeIcon" + <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"/> <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" @@ -23,7 +23,7 @@ <div v-if="tolNode.iucn != null"> <a href="https://en.wikipedia.org/wiki/Endangered_species_(IUCN_status)" target="_blank" title="IUCN Conservation Status">IUCN</a>: - <span :style="iucnStyles">{{getDisplayIucn(tolNode.iucn)}}</span> + <span :style="iucnStyles(tolNode.iucn)">{{getDisplayIucn(tolNode.iucn)}}</span> </div> <div> <a :href="'https://tree.opentreeoflife.org/opentree/argus/opentree13.4@' + tolNode.otolId" @@ -111,176 +111,174 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<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, TolMap} from '../tol'; -import {LayoutNode, LayoutOptions} from '../layout'; -import {getImagePath, DescInfo, ImgInfo, NodeInfo, InfoResponse, UiOptions} from '../lib'; +import {TolNode} from '../tol'; +import {LayoutOptions} from '../layout'; +import {getImagePath, DescInfo, ImgInfo, InfoResponse, UiOptions} from '../lib'; import {capitalizeWords} from '../util'; -export default defineComponent({ - props: { - // Node data to display - nodeName: {type: String, required: true}, - infoResponse: {type: Object as PropType<InfoResponse>, required: true}, - // Options - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - data(){ - return { - linkCopied: false, // Used to temporarily show a 'link copied' label - }; - }, - computed: { - tolNode(): TolNode { - return this.infoResponse.nodeInfo.tolNode; - }, - nodes(): (TolNode | null)[] { - if (this.infoResponse.subNodesInfo.length == 0){ - return [this.tolNode]; - } else { - return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null); - } - }, - imgInfos(): (ImgInfo | null)[] { - if (this.infoResponse.subNodesInfo.length == 0){ - return [this.infoResponse.nodeInfo.imgInfo]; - } else { - return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null); - } - }, - descInfos(): (DescInfo | null)[] { - if (this.infoResponse.subNodesInfo.length == 0){ - return [this.infoResponse.nodeInfo.descInfo]; - } else { - return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null); - } - }, - subNames(): [string, string] | null { - const regex = /\[(.+) \+ (.+)\]/; - let results = regex.exec(this.nodeName); - return results == null ? null : [results[1], results[2]]; - }, - styles(): Record<string,string> { - return { - backgroundColor: this.uiOpts.bgColorAlt, - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.uiOpts.shadowNormal, - overflow: 'visible auto', - }; - }, - iucnStyles(): Record<string,string> { - let col = 'currentcolor'; - switch (this.tolNode.iucn){ - case 'least concern': col = 'green'; break; - case 'near threatened': col = 'limegreen'; break; - case 'vulnerable': col = 'goldenrod'; break; - case 'endangered': col = 'darkorange'; break; - case 'critically endangered': col = 'red'; break; - case 'extinct in the wild': - case 'extinct species': col = 'gray'; break; - } - return { - color: col, - }; - }, - linkCopyLabelStyles(): Record<string,string> { - return { - color: this.uiOpts.textColor, - backgroundColor: this.uiOpts.bgColor, - borderRadius: this.uiOpts.borderRadius + 'px', - }; - }, - }, - methods: { - getDisplayName(name: string, tolNode: TolNode | null): string { - if (tolNode == null || tolNode.commonName == null){ - return capitalizeWords(name); - } else { - return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`; - } - }, - getDisplayIucn(iucn: string){ - switch (this.tolNode.iucn){ - case 'least concern': return 'LC'; - case 'near threatened': return 'NT'; - case 'vulnerable': return 'VN'; - case 'endangered': return 'EN'; - case 'critically endangered': return 'CR'; - case 'extinct in the wild': return 'EX'; - case 'extinct species': return 'ES'; - case 'data deficient': return 'DD'; +// Refs +const rootRef = ref(null as HTMLDivElement | null); +const closeRef = ref(null as typeof CloseIcon | null); + +// Props + events +const props = defineProps({ + // Node data to display + nodeName: {type: String, required: true}, + infoResponse: {type: Object as PropType<InfoResponse>, required: true}, + // Options + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, +}); +const emit = defineEmits(['close']); + +// InfoResponse computed data +const tolNode = computed(() => props.infoResponse.nodeInfo.tolNode); +const nodes = computed((): (TolNode | null)[] => { + if (props.infoResponse.subNodesInfo.length == 0){ + return [tolNode.value]; + } else { + 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]; + } else { + 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]; + } else { + 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 +function getDisplayName(name: string, tolNode: TolNode | null): string { + if (tolNode == null || tolNode.commonName == null){ + return capitalizeWords(name); + } else { + return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`; + } +} +function getDisplayIucn(iucn: string){ + switch (iucn){ + case 'least concern': return 'LC'; + case 'near threatened': return 'NT'; + case 'vulnerable': return 'VN'; + case 'endangered': return 'EN'; + case 'critically endangered': return 'CR'; + case 'extinct in the wild': return 'EX'; + case 'extinct species': return 'ES'; + case 'data deficient': return 'DD'; + } +} +function licenseToUrl(license: string){ + license = license.toLowerCase().replaceAll('-', ' '); + if (license == 'cc0'){ + return 'https://creativecommons.org/publicdomain/zero/1.0/'; + } else if (license == 'cc publicdomain'){ + return 'https://creativecommons.org/licenses/publicdomain/'; + } else { + const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/; + let results = regex.exec(license); + if (results != null){ + let url = 'https://creativecommons.org/licenses/by'; + if (results[1] != null){ + url += '-nc'; } - }, - getImgStyles(tolNode: TolNode | null): Record<string,string> { - let imgName = null; - if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case - imgName = tolNode.imgName; + if (results[2] != null){ + url += '-sa'; } - return { - width: '200px', - height: '200px', - backgroundImage: imgName != null ? - `url('${getImagePath(imgName as string)}')` : - 'none', - backgroundColor: this.uiOpts.bgColorDark, - backgroundSize: 'cover', - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.uiOpts.shadowNormal, - }; - }, - licenseToUrl(license: string){ - license = license.toLowerCase().replaceAll('-', ' '); - if (license == 'cc0'){ - return 'https://creativecommons.org/publicdomain/zero/1.0/'; - } else if (license == 'cc publicdomain'){ - return 'https://creativecommons.org/licenses/publicdomain/'; + if (results[4] != null){ + url += '/' + results[4]; } else { - const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/; - let results = regex.exec(license); - if (results != null){ - let url = 'https://creativecommons.org/licenses/by'; - if (results[1] != null){ - url += '-nc'; - } - if (results[2] != null){ - url += '-sa'; - } - if (results[4] != null){ - url += '/' + results[4]; - } else { - url += '/4.0'; - } - if (results[5] != null){ - url += '/' + results[5].substring(1); - } - return url; - } - return "[INVALID LICENSE]"; + url += '/4.0'; } - }, - onClose(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ - this.$emit('close'); + if (results[5] != null){ + url += '/' + results[5].substring(1); } - }, - onLinkIconClick(evt: Event){ - // Copy link to clipboard - let url = new URL(window.location.href); - url.search = (new URLSearchParams({node: this.nodeName})).toString(); - navigator.clipboard.writeText(url.toString()); - // Show visual indicator - this.linkCopied = true; - setTimeout(() => {this.linkCopied = false}, 1500); - }, - }, - components: {SCollapsible, CloseIcon, ExternalLinkIcon, DownIcon, LinkIcon, }, - emits: ['close', ], -}); + return url; + } + return "[INVALID LICENSE]"; + } +} + +// Close handling +function onClose(evt: Event){ + if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){ + emit('close'); + } +} + +// 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); + url.search = (new URLSearchParams({node: props.nodeName})).toString(); + navigator.clipboard.writeText(url.toString()); + // Show visual indicator + linkCopied.value = true; + setTimeout(() => {linkCopied.value = false}, 1500); +} + +// Styles +const styles = computed(() => ({ + backgroundColor: props.uiOpts.bgColorAlt, + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: props.uiOpts.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 + imgName = tolNode.imgName; + } + return { + width: '200px', + height: '200px', + backgroundImage: imgName != null ? + `url('${getImagePath(imgName as string)}')` : + 'none', + backgroundColor: props.uiOpts.bgColorDark, + backgroundSize: 'cover', + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: props.uiOpts.shadowNormal, + }; +} +function iucnStyles(iucn: string): Record<string,string>{ + let col = 'currentcolor'; + switch (iucn){ + case 'least concern': col = 'green'; break; + case 'near threatened': col = 'limegreen'; break; + case 'vulnerable': col = 'goldenrod'; break; + case 'endangered': col = 'darkorange'; break; + case 'critically endangered': col = 'red'; break; + case 'extinct in the wild': + case 'extinct species': col = 'gray'; break; + } + return { + color: col, + }; +} +const linkCopyLabelStyles = computed(() => ({ + color: props.uiOpts.textColor, + backgroundColor: props.uiOpts.bgColor, + borderRadius: props.uiOpts.borderRadius + 'px', +})); </script> diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue index afb6616..d3b3539 100644 --- a/src/components/TolTile.vue +++ b/src/components/TolTile.vue @@ -1,5 +1,5 @@ <template> -<div :style="styles" @scroll="onScroll"> +<div :style="styles" @scroll="onScroll" ref="rootRef"> <div v-if="isLeaf" :class="[hasOneImage ? 'flex' : 'grid', {'hover:cursor-pointer': isExpandableLeaf}]" class="w-full h-full flex-col grid-cols-1" :style="leafStyles" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp"> @@ -48,512 +48,470 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, watch, PropType} from 'vue'; import InfoIcon from './icon/InfoIcon.vue'; -import {TolNode, TolMap} from '../tol'; +import {TolMap} from '../tol'; import {LayoutNode, LayoutOptions} from '../layout'; import {getImagePath, UiOptions} from '../lib'; import {capitalizeWords} from '../util'; -const scrimGradient = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)'; +const SCRIM_GRADIENT = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)'; -export default defineComponent({ - props: { - layoutNode: {type: Object as PropType<LayoutNode>, required: true}, - tolMap: {type: Object as PropType<TolMap>, required: true}, - // Options - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - // Other - skipTransition: {type: Boolean, default: false}, - nonAbsPos: {type: Boolean, default: false}, - // For a leaf node, prevents usage of absolute positioning (used by AncestryBar) - overflownDim: {type: Number, default: 0}, - // For a non-leaf node, display with overflow within area of this height - }, - data(){ +// Refs +const rootRef = ref(null as HTMLDivElement | null); + +// Props + events +const props = defineProps({ + layoutNode: {type: Object as PropType<LayoutNode>, required: true}, + tolMap: {type: Object as PropType<TolMap>, required: true}, + // Options + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, + // Other + skipTransition: {type: Boolean, default: false}, + nonAbsPos: {type: Boolean, default: false}, + // For a leaf node, prevents usage of absolute positioning (used by AncestryBar) + 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 +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; + // If not displaying with overflow, return 'visible' layoutNode children + if (!isOverflownRoot.value){ + return children.filter(n => !n.hidden || n.hiddenWithVisibleTip); + } + // Otherwise, return children within/near non-overflowing region + let firstIdx = children.length - 1; + for (let i = 0; i < children.length; i++){ + if (children[i].pos[1] + children[i].dims[1] >= scrollOffset.value){ + firstIdx = i; + break; + } + } + let lastIdx = children.length; + for (let i = firstIdx + 1; i < children.length; i++){ + if (children[i].pos[1] > scrollOffset.value + props.overflownDim){ + lastIdx = i; + break; + } + } + return children.slice(firstIdx, lastIdx); +}); +const isLeaf = computed(() => props.layoutNode.children.length == 0); +const isExpandableLeaf = computed(() => isLeaf.value && tolNode.value.children.length > 0); +const showNonleafHeader = computed(() => + (props.layoutNode.showHeader && props.layoutNode.sepSweptArea == null) || + (props.layoutNode.sepSweptArea != null && props.layoutNode.sepSweptArea.sweptLeft) +); +const displayName = computed((): string => { + let newName = capitalizeWords(tolNode.value.commonName || props.layoutNode.name); + if (!tolNode.value.pSupport && tolNode.value.parent != null){ + newName += '*'; + } + return newName; +}); +const hasOneImage = computed(() => !Array.isArray(tolNode.value.imgName)); +const isOverflownRoot = computed(() => + props.overflownDim > 0 && !props.layoutNode.hidden && props.layoutNode.children.length > 0 +); +const hasFocusedChild = computed(() => props.layoutNode.children.some(n => n.hasFocus)); +const infoIconDisabled = computed(() => !props.uiOpts.disabledActions.has('tileInfo')); + +// Click/hold handling +const clickHoldTimer = ref(0); // Used to recognise click-and-hold events +function onMouseDown(): void { + highlight.value = false; + if (!props.uiOpts.touchDevice){ + // Wait for a mouseup or click-hold + clearTimeout(clickHoldTimer.value); + clickHoldTimer.value = setTimeout(() => { + clickHoldTimer.value = 0; + onClickHold(); + }, props.uiOpts.clickHoldDuration); + } else { + // Wait for or recognise a double-click + if (clickHoldTimer.value == 0){ + clickHoldTimer.value = setTimeout(() => { + clickHoldTimer.value = 0; + onClick(); + }, props.uiOpts.clickHoldDuration); + } else { + clearTimeout(clickHoldTimer.value) + clickHoldTimer.value = 0; + onDblClick(); + } + } +} +function onMouseUp(): void { + if (!props.uiOpts.touchDevice){ + if (clickHoldTimer.value > 0){ + clearTimeout(clickHoldTimer.value); + clickHoldTimer.value = 0; + onClick(); + } + } +} + +// 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'); + return; + } + wasClicked.value = true; + emit(isLeaf.value ? 'leaf-click' : 'nonleaf-click', props.layoutNode); +} +function onClickHold(): void { + if (isLeaf.value && !isExpandableLeaf.value){ + console.log('Ignored click-hold on non-expandable node'); + return; + } + emit(isLeaf.value ? 'leaf-click-held' : 'nonleaf-click-held', props.layoutNode); +} +function onDblClick(): void { + onClickHold(); +} +function onInfoIconClick(): void { + emit('info-click', props.layoutNode.name); +} +// Child click-action propagation +function onInnerLeafClick(node: LayoutNode): void { + wasClicked.value = true; + emit('leaf-click', node); +} +function onInnerNonleafClick(node: LayoutNode): void { + wasClicked.value = true; + emit('nonleaf-click', node); +} +function onInnerLeafClickHeld(node: LayoutNode): void { + emit('leaf-click-held', node); +} +function onInnerNonleafClickHeld(node: LayoutNode): void { + emit('nonleaf-click-held', node); +} +function onInnerInfoIconClick(nodeName: string): void { + emit('info-click', nodeName); +} + +// 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 +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(() => { + scrollOffset.value = rootRef.value!.scrollTop; + pendingScrollHdlr.value = 0; + }, props.uiOpts.animationDelay); + } +} +// Scroll to focused child if overflownRoot +watch(hasFocusedChild, (newVal: boolean) => { + if (newVal && isOverflownRoot.value){ + let focusedChild = props.layoutNode.children.find(n => n.hasFocus)! + let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + props.lytOpts.tileSpacing; + let scrollTop = Math.max(0, bottomY - (props.overflownDim / 2)); // No need to manually cap at max + rootRef.value!.scrollTop = scrollTop; + } +}); + +// 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; + wasClicked.value = false; + 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]; + if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){ + inTransition.value = true; + setTimeout(onTransitionEnd, props.uiOpts.transitionDuration); + } +}); +watch(() => props.layoutNode.dims, (newVal: [number, number], oldVal: [number, number]) => { + let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1]; + if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){ + inTransition.value = true; + setTimeout(onTransitionEnd, props.uiOpts.transitionDuration); + } +}); + +// For externally triggering fail animations (used by search and auto-mode) +function triggerAnimation(animation: string){ + let el = rootRef.value!; + el.classList.remove(animation); + el.offsetWidth; // Triggers reflow + el.classList.add(animation); +} +watch(() => props.layoutNode.failFlag, () => + triggerAnimation(isLeaf.value ? 'animate-expand-shrink' : 'animate-shrink-expand') +); + +// 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; + setTimeout(() => {inFlash.value = false;}, props.uiOpts.transitionDuration); + } +}); + +// 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; + setTimeout(() => {justUnhidden.value = false}, props.uiOpts.transitionDuration + 100); + } +}); + +// Styles + classes +const nonleafBgColor = computed(() => { + let colorArray = props.uiOpts.nonleafBgColors; + return colorArray[props.layoutNode.depth % colorArray.length]; +}); +const boxShadow = computed((): string => { + if (highlight.value){ + return props.uiOpts.shadowHovered; + } else if (props.layoutNode.hasFocus && !inTransition.value){ + return props.uiOpts.shadowFocused; + } else { + return props.uiOpts.shadowNormal; + } +}); +const fontSz = computed((): number => { + // These values are a compromise between dynamic font size and code simplicity + if (props.layoutNode.dims[0] >= 150){ + return props.lytOpts.headerSz * 0.8; + } else if (props.layoutNode.dims[0] >= 80){ + return props.lytOpts.headerSz * 0.7; + } else { + return props.lytOpts.headerSz * 0.6; + } +}); +// +const styles = computed((): Record<string,string> => { + let layoutStyles = { + position: 'absolute', + left: props.layoutNode.pos[0] + 'px', + top: props.layoutNode.pos[1] + 'px', + width: props.layoutNode.dims[0] + 'px', + height: props.layoutNode.dims[1] + 'px', + borderRadius: props.uiOpts.borderRadius + 'px', + boxShadow: boxShadow.value, + visibility: 'visible', + // Transition related + transitionDuration: (props.skipTransition ? 0 : props.uiOpts.transitionDuration) + 'ms', + transitionProperty: 'left, top, width, height, visibility', + transitionTimingFunction: 'ease-out', + zIndex: inTransition.value && wasClicked.value ? '1' : '0', + overflow: (inTransition.value && !isLeaf.value && !hasExpanded.value && !justUnhidden.value) ? + 'hidden' : 'visible', + // CSS variables + '--nonleafBgColor': nonleafBgColor.value, + '--tileSpacing': props.lytOpts.tileSpacing + 'px', + }; + if (!isLeaf.value){ + let borderR = props.uiOpts.borderRadius + 'px'; + if (props.layoutNode.sepSweptArea != null){ + borderR = props.layoutNode.sepSweptArea.sweptLeft ? + `${borderR} ${borderR} ${borderR} 0` : + `${borderR} 0 ${borderR} ${borderR}`; + } + layoutStyles.borderRadius = borderR; + } + if (isOverflownRoot.value){ + layoutStyles.width = (props.layoutNode.dims[0] + props.uiOpts.scrollGap) + 'px'; + layoutStyles.height = props.overflownDim + 'px'; + layoutStyles.overflow = 'hidden scroll'; + } + if (props.layoutNode.hidden){ + layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0'; + layoutStyles.visibility = 'hidden'; + } + if (props.nonAbsPos){ + layoutStyles.position = 'static'; + } + return layoutStyles; +}); +const leafStyles = computed((): Record<string,string> => { + let styles: Record<string,string> = { + borderRadius: 'inherit', + }; + if (hasOneImage.value){ + styles = { + ...styles, + backgroundImage: tolNode.value.imgName != null ? + `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName as string)}')` : + 'none', + backgroundColor: props.uiOpts.bgColorDark, + backgroundSize: 'cover', + }; + } + return styles; +}); +const leafHeaderStyles = computed((): Record<string,string> => { + let numChildren = tolNode.value.children.length; + let textColor = props.uiOpts.textColor; + for (let [threshold, color] of props.uiOpts.childQtyColors){ + if (numChildren >= threshold){ + textColor = color; + } else { + break; + } + } + return { + lineHeight: (fontSz.value * 1.3) + 'px', + fontSize: fontSz.value + 'px', + paddingLeft: (fontSz.value * 0.2) + 'px', + color: textColor, + // For ellipsis + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }; +}); +function leafSubImgStyles(idx: number): Record<string,string> { + let [w, h] = props.layoutNode.dims; + return { + width: '100%', + height: '100%', + // Image (and scrims) + backgroundImage: (tolNode.value.imgName![idx]! != null) ? + `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName![idx]! as string)}')` : + 'none', + backgroundColor: props.uiOpts.bgColorDark, + backgroundSize: '125%', + borderRadius: 'inherit', + clipPath: (idx == 0) ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)', + 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%', + height: '100%', + backgroundColor: nonleafBgColor.value, + borderRadius: 'inherit', + }; + if (isOverflownRoot.value){ + styles.width = props.layoutNode.dims[0] + 'px'; + styles.height = props.layoutNode.dims[1] + 'px'; + } + return styles; +}); +const nonleafHeaderStyles = computed((): Record<string,string> => { + let styles: Record<string,string> = { + position: 'static', + height: props.lytOpts.headerSz + 'px', + borderTopLeftRadius: 'inherit', + borderTopRightRadius: 'inherit', + backgroundColor: props.uiOpts.nonleafHeaderColor, + }; + if (isOverflownRoot.value){ + styles = { + ...styles, + position: 'sticky', + top: '0', + left: '0', + borderTopRightRadius: '0', + zIndex: '1', + boxShadow: props.uiOpts.shadowNormal, + }; + } + return styles; +}); +const nonleafHeaderTextStyles = computed(() => ({ + lineHeight: (fontSz.value * 1.3) + 'px', + fontSize: fontSz.value + 'px', + paddingLeft: (fontSz.value * 0.2) + 'px', + textAlign: 'center', + color: props.uiOpts.textColor, + // For ellipsis + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +})); +const sepSweptAreaStyles = computed((): Record<string,string> => { + let borderR = props.uiOpts.borderRadius + 'px'; + let styles = { + position: 'absolute', + backgroundColor: nonleafBgColor.value, + boxShadow: boxShadow.value, + transitionDuration: props.uiOpts.transitionDuration + 'ms', + transitionProperty: 'left, top, width, height, visibility', + transitionTimingFunction: 'ease-out', + }; + let area = props.layoutNode.sepSweptArea; + if (!props.layoutNode.hidden && area != null){ + return { + ...styles, + visibility: 'visible', + left: area.pos[0] + 'px', + top: area.pos[1] + 'px', + width: area.dims[0] + 'px', + height: area.dims[1] + 'px', + borderRadius: area.sweptLeft ? + `${borderR} 0 0 ${borderR}` : + `${borderR} ${borderR} 0 0`, + }; + } else { return { - // Mouse-event related - clickHoldTimer: 0, // Used to recognise click-and-hold events - highlight: false, // Used to draw a colored outline on mouse hover - // Scroll-during-overflow related - scrollOffset: 0, // Used to track scroll offset when displaying with overflow - pendingScrollHdlr: 0, // Used for throttling updating of scrollOffset - // Transition related - inTransition: false, // Used to avoid content overlap and overflow during 'user-perceivable' transitions - wasClicked: false, // Used to increase z-index during transition after this tile (or a child) is clicked - hasExpanded: 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 - justUnhidden: false, // Used to allow overflow temporarily after being unhidden - // Other - inFlash: false, // Used to 'flash' the tile when focused + ...styles, + visibility: 'hidden', + left: '0', + top: props.lytOpts.headerSz + 'px', + width: '0', + height: '0', + borderRadius: borderR, }; - }, - computed: { - tolNode(): TolNode { - return this.tolMap.get(this.layoutNode.name)!; - }, - visibleChildren(): LayoutNode[] { // Used to reduce slowdown from rendering many nodes - let children = this.layoutNode.children; - // If not displaying with overflow, return 'visible' layoutNode children - if (!this.isOverflownRoot){ - return children.filter(n => !n.hidden || n.hiddenWithVisibleTip); - } - // Otherwise, return children within/near non-overflowing region - let firstIdx = children.length - 1; - for (let i = 0; i < children.length; i++){ - if (children[i].pos[1] + children[i].dims[1] >= this.scrollOffset){ - firstIdx = i; - break; - } - } - let lastIdx = children.length; - for (let i = firstIdx + 1; i < children.length; i++){ - if (children[i].pos[1] > this.scrollOffset + this.overflownDim){ - lastIdx = i; - break; - } - } - return children.slice(firstIdx, lastIdx); - }, - // Convenience abbreviations - isLeaf(): boolean { - return this.layoutNode.children.length == 0; - }, - isExpandableLeaf(): boolean { - return this.isLeaf && this.tolNode.children.length > 0; - }, - showNonleafHeader(): boolean { - return (this.layoutNode.showHeader && this.layoutNode.sepSweptArea == null) || - (this.layoutNode.sepSweptArea != null && this.layoutNode.sepSweptArea.sweptLeft); - }, - displayName(): string { - let newName = capitalizeWords(this.tolNode.commonName || this.layoutNode.name); - if (!this.tolNode.pSupport && this.tolNode.parent != null){ - newName += '*'; - } - return newName; - }, - hasOneImage(): boolean { - return !Array.isArray(this.tolNode.imgName); - }, - isOverflownRoot(): boolean { - return this.overflownDim > 0 && !this.layoutNode.hidden && this.layoutNode.children.length > 0; - }, - hasFocusedChild(): boolean { - return this.layoutNode.children.some(n => n.hasFocus); - }, - infoIconDisabled(): boolean { - return !this.uiOpts.disabledActions.has('tileInfo'); - }, - // For styling - nonleafBgColor(): string { - let colorArray = this.uiOpts.nonleafBgColors; - return colorArray[this.layoutNode.depth % colorArray.length]; - }, - boxShadow(): string { - if (this.highlight){ - return this.uiOpts.shadowHovered; - } else if (this.layoutNode.hasFocus && !this.inTransition){ - return this.uiOpts.shadowFocused; - } else { - return this.uiOpts.shadowNormal; - } - }, - fontSz(): number { - // These values are a compromise between dynamic font size and code simplicity - if (this.layoutNode.dims[0] >= 150){ - return this.lytOpts.headerSz * 0.8; - } else if (this.layoutNode.dims[0] >= 80){ - return this.lytOpts.headerSz * 0.7; - } else { - return this.lytOpts.headerSz * 0.6; - } - }, - styles(): Record<string,string> { - let layoutStyles = { - position: 'absolute', - left: this.layoutNode.pos[0] + 'px', - top: this.layoutNode.pos[1] + 'px', - width: this.layoutNode.dims[0] + 'px', - height: this.layoutNode.dims[1] + 'px', - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.boxShadow, - visibility: 'visible', - // Transition related - transitionDuration: (this.skipTransition ? 0 : this.uiOpts.transitionDuration) + 'ms', - transitionProperty: 'left, top, width, height, visibility', - transitionTimingFunction: 'ease-out', - zIndex: this.inTransition && this.wasClicked ? '1' : '0', - overflow: (this.inTransition && !this.isLeaf && !this.hasExpanded && !this.justUnhidden) ? - 'hidden' : 'visible', - // CSS variables - '--nonleafBgColor': this.nonleafBgColor, - '--tileSpacing': this.lytOpts.tileSpacing + 'px', - }; - if (!this.isLeaf){ - let borderR = this.uiOpts.borderRadius + 'px'; - if (this.layoutNode.sepSweptArea != null){ - borderR = this.layoutNode.sepSweptArea.sweptLeft ? - `${borderR} ${borderR} ${borderR} 0` : - `${borderR} 0 ${borderR} ${borderR}`; - } - layoutStyles.borderRadius = borderR; - } - if (this.isOverflownRoot){ - layoutStyles.width = (this.layoutNode.dims[0] + this.uiOpts.scrollGap) + 'px'; - layoutStyles.height = this.overflownDim + 'px'; - layoutStyles.overflow = 'hidden scroll'; - } - if (this.layoutNode.hidden){ - layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0'; - layoutStyles.visibility = 'hidden'; - } - if (this.nonAbsPos){ - layoutStyles.position = 'static'; - } - return layoutStyles; - }, - leafStyles(): Record<string,string> { - let styles: Record<string,string> = { - borderRadius: 'inherit', - }; - if (this.hasOneImage){ - styles = { - ...styles, - backgroundImage: this.tolNode.imgName != null ? - `${scrimGradient},url('${getImagePath(this.tolNode.imgName as string)}')` : - 'none', - backgroundColor: this.uiOpts.bgColorDark, - backgroundSize: 'cover', - }; - } - return styles; - }, - leafHeaderStyles(): Record<string,string> { - let numChildren = this.tolNode.children.length; - let textColor = this.uiOpts.textColor; - for (let [threshold, color] of this.uiOpts.childQtyColors){ - if (numChildren >= threshold){ - textColor = color; - } else { - break; - } - } - return { - lineHeight: (this.fontSz * 1.3) + 'px', - fontSize: this.fontSz + 'px', - paddingLeft: (this.fontSz * 0.2) + 'px', - color: textColor, - // For ellipsis - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }; - }, - leafFirstImgStyles(): Record<string,string> { - return this.leafSubImgStyles(0); - }, - leafSecondImgStyles(): Record<string,string> { - return this.leafSubImgStyles(1); - }, - nonleafStyles(): Record<string,string> { - let styles = { - width: '100%', - height: '100%', - backgroundColor: this.nonleafBgColor, - borderRadius: 'inherit', - }; - if (this.isOverflownRoot){ - styles.width = this.layoutNode.dims[0] + 'px'; - styles.height = this.layoutNode.dims[1] + 'px'; - } - return styles; - }, - nonleafHeaderStyles(): Record<string,string> { - let styles: Record<string,string> = { - position: 'static', - height: this.lytOpts.headerSz + 'px', - borderTopLeftRadius: 'inherit', - borderTopRightRadius: 'inherit', - backgroundColor: this.uiOpts.nonleafHeaderColor, - }; - if (this.isOverflownRoot){ - styles = { - ...styles, - position: 'sticky', - top: '0', - left: '0', - borderTopRightRadius: '0', - zIndex: '1', - boxShadow: this.uiOpts.shadowNormal, - }; - } - return styles; - }, - nonleafHeaderTextStyles(): Record<string,string> { - return { - lineHeight: (this.fontSz * 1.3) + 'px', - fontSize: this.fontSz + 'px', - paddingLeft: (this.fontSz * 0.2) + 'px', - textAlign: 'center', - color: this.uiOpts.textColor, - // For ellipsis - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }; - }, - sepSweptAreaStyles(): Record<string,string> { - let borderR = this.uiOpts.borderRadius + 'px'; - let styles = { - position: 'absolute', - backgroundColor: this.nonleafBgColor, - boxShadow: this.boxShadow, - transitionDuration: this.uiOpts.transitionDuration + 'ms', - transitionProperty: 'left, top, width, height, visibility', - transitionTimingFunction: 'ease-out', - }; - let area = this.layoutNode.sepSweptArea; - if (!this.layoutNode.hidden && area != null){ - return { - ...styles, - visibility: 'visible', - left: area.pos[0] + 'px', - top: area.pos[1] + 'px', - width: area.dims[0] + 'px', - height: area.dims[1] + 'px', - borderRadius: area.sweptLeft ? - `${borderR} 0 0 ${borderR}` : - `${borderR} ${borderR} 0 0`, - }; - } else { - return { - ...styles, - visibility: 'hidden', - left: '0', - top: this.lytOpts.headerSz + 'px', - width: '0', - height: '0', - borderRadius: borderR, - }; - } - }, - sepSweptAreaHideEdgeClass(): string { - if (this.layoutNode.sepSweptArea == null){ - return ''; - } else if (this.layoutNode.sepSweptArea.sweptLeft){ - return 'hide-right-edge'; - } else { - return 'hide-top-edge'; - } - }, - infoIconStyles(): Record<string,string> { - let size = (this.lytOpts.headerSz * 0.85); - let marginSz = (this.lytOpts.headerSz - size); - return { - width: size + 'px', - height: size + 'px', - minWidth: size + 'px', - minHeight: size + 'px', - margin: this.isLeaf ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`, - }; - }, - infoIconClasses(): string { - return 'text-white/30 hover:text-white hover:cursor-pointer'; - }, - // For watching layoutNode data - pos(){ - return this.layoutNode.pos; - }, - dims(){ - return this.layoutNode.dims; - }, - hidden(){ - return this.layoutNode.hidden; - }, - hasFocus(){ - return this.layoutNode.hasFocus; - }, - failFlag(){ - return this.layoutNode.failFlag; - }, - }, - methods: { - // Click handling - onMouseDown(): void { - this.highlight = false; - if (!this.uiOpts.touchDevice){ - // Wait for a mouseup or click-hold - clearTimeout(this.clickHoldTimer); - this.clickHoldTimer = setTimeout(() => { - this.clickHoldTimer = 0; - this.onClickHold(); - }, this.uiOpts.clickHoldDuration); - } else { - // Wait for or recognise a double-click - if (this.clickHoldTimer == 0){ - this.clickHoldTimer = setTimeout(() => { - this.clickHoldTimer = 0; - this.onClick(); - }, this.uiOpts.clickHoldDuration); - } else { - clearTimeout(this.clickHoldTimer) - this.clickHoldTimer = 0; - this.onDblClick(); - } - } - }, - onMouseUp(): void { - if (!this.uiOpts.touchDevice){ - if (this.clickHoldTimer > 0){ - clearTimeout(this.clickHoldTimer); - this.clickHoldTimer = 0; - this.onClick(); - } - } - }, - onClick(): void { - if (this.isLeaf && !this.isExpandableLeaf){ - console.log('Ignored click on non-expandable node'); - return; - } - this.wasClicked = true; - this.$emit(this.isLeaf ? 'leaf-click' : 'nonleaf-click', this.layoutNode); - }, - onClickHold(): void { - if (this.isLeaf && !this.isExpandableLeaf){ - console.log('Ignored click-hold on non-expandable node'); - return; - } - this.$emit(this.isLeaf ? 'leaf-click-held' : 'nonleaf-click-held', this.layoutNode); - }, - onDblClick(): void { - this.onClickHold(); - }, - onInfoIconClick(evt: Event): void { - this.$emit('info-click', this.layoutNode.name); - }, - // Mouse-hover handling - onMouseEnter(evt: Event): void { - if ((!this.isLeaf || this.isExpandableLeaf) && !this.inTransition){ - this.highlight = true; - } - }, - onMouseLeave(evt: Event): void { - this.highlight = false; - }, - // Child event propagation - onInnerLeafClick(node: LayoutNode): void { - this.wasClicked = true; - this.$emit('leaf-click', node); - }, - onInnerNonleafClick(node: LayoutNode): void { - this.wasClicked = true; - this.$emit('nonleaf-click', node); - }, - onInnerLeafClickHeld(node: LayoutNode): void { - this.$emit('leaf-click-held', node); - }, - onInnerNonleafClickHeld(node: LayoutNode): void { - this.$emit('nonleaf-click-held', node); - }, - onInnerInfoIconClick(nodeName: string): void { - this.$emit('info-click', nodeName); - }, - onScroll(evt: Event): void { - if (this.pendingScrollHdlr == 0){ - this.pendingScrollHdlr = setTimeout(() => { - this.scrollOffset = this.$el.scrollTop; - this.pendingScrollHdlr = 0; - }, this.uiOpts.animationDelay); - } - }, - // Other - leafSubImgStyles(idx: number): Record<string,string> { - let [w, h] = this.layoutNode.dims; - return { - width: '100%', - height: '100%', - // Image (and scrims) - backgroundImage: (this.tolNode.imgName![idx]! != null) ? - `${scrimGradient},url('${getImagePath(this.tolNode.imgName![idx]! as string)}')` : - 'none', - backgroundColor: this.uiOpts.bgColorDark, - backgroundSize: '125%', - borderRadius: 'inherit', - clipPath: idx == 0 ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)', - backgroundPosition: idx == 0 ? `${-w/4}px ${-h/4}px` : '0px 0px', - }; - }, - onTransitionEnd(evt: Event){ - if (this.inTransition){ - this.inTransition = false; - this.wasClicked = false; - this.hasExpanded = this.layoutNode.children.length > 0; - } - }, - triggerAnimation(animation: string){ - this.$el.classList.remove(animation); - this.$el.offsetWidth; // Triggers reflow - this.$el.classList.add(animation); - }, - }, - watch: { - // For setting transition state (allows external triggering, like via search and auto-mode) - pos: { - handler(newVal: [number, number], oldVal: [number, number]){ - let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1]; - if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){ - this.inTransition = true; - setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration); - } - }, - deep: true, - }, - dims: { - handler(newVal: [number, number], oldVal: [number, number]){ - let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1]; - if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){ - this.inTransition = true; - setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration); - } - }, - deep: true, - }, - // For externally triggering fail animations (used by search and auto-mode) - failFlag(){ - this.triggerAnimation(this.isLeaf ? 'animate-expand-shrink' : 'animate-shrink-expand'); - }, - // Scroll to focused child if overflownRoot - hasFocusedChild(newVal: boolean, oldVal: boolean){ - if (newVal && this.isOverflownRoot){ - let focusedChild = this.layoutNode.children.find(n => n.hasFocus)! - let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + this.lytOpts.tileSpacing; - let scrollTop = Math.max(0, bottomY - (this.overflownDim / 2)); // No need to manually cap at max - this.$el.scrollTop = scrollTop; - } - }, - // Allow overflow temporarily after being unhidden - hidden(newVal: boolean, oldVal: boolean){ - if (oldVal && !newVal){ - this.justUnhidden = true; - setTimeout(() => {this.justUnhidden = false;}, this.uiOpts.transitionDuration + 100); - } - }, - // Used to 'flash' the tile when focused - hasFocus(newVal: boolean, oldVal: boolean){ - if (newVal != oldVal && newVal){ - this.inFlash = true; - setTimeout(() => {this.inFlash = false;}, this.uiOpts.transitionDuration); - } - }, - }, - name: 'tol-tile', // Note: Need this to use self in template - components: {InfoIcon, }, - emits: ['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click', ], + } +}); +const sepSweptAreaHideEdgeClass = computed((): string => { + if (props.layoutNode.sepSweptArea == null){ + return ''; + } else if (props.layoutNode.sepSweptArea.sweptLeft){ + return 'hide-right-edge'; + } else { + return 'hide-top-edge'; + } +}); +const infoIconStyles = computed((): Record<string,string> => { + let size = (props.lytOpts.headerSz * 0.85); + let marginSz = (props.lytOpts.headerSz - size); + return { + width: size + 'px', + height: size + 'px', + minWidth: size + 'px', + minHeight: size + 'px', + 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> <style scoped> diff --git a/src/components/TutorialPane.vue b/src/components/TutorialPane.vue index 2803498..4c24bae 100644 --- a/src/components/TutorialPane.vue +++ b/src/components/TutorialPane.vue @@ -2,7 +2,7 @@ <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"/> <h1 class="text-center text-lg font-bold pt-3 pb-2"> - {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${lastStage})`}} + {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${LAST_STAGE})`}} </h1> <transition name="fade" mode="out-in"> <div v-if="stage == 0" :style="contentStyles"> @@ -58,119 +58,106 @@ Prev </s-button> <s-button :class="{invisible: !hidNextPrevOnce && stage == 1}" - @click="stage != lastStage ? onNextClick() : onClose()" :style="buttonStyles"> - {{stage != lastStage ? 'Next' : 'Finish'}} + @click="stage != LAST_STAGE ? onNextClick() : onClose()" :style="buttonStyles"> + {{stage != LAST_STAGE ? 'Next' : 'Finish'}} </s-button> </template> </div> </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<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, UiOptions} from '../lib'; -export default defineComponent({ - props: { - actionsDone: {type: Object as PropType<Set<Action>>, required: true}, - // Used to avoid disabling actions already done - triggerFlag: {type: Boolean, required: true}, - // Used to indicate that a tutorial-requested 'trigger' action has been done - skipWelcome: {type: Boolean, default: false}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - data(){ - return { - stage: 0, // Indicates the current step of the tutorial (stage 0 is the welcome message) - lastStage: 9, - disabledOnce: false, // Set to true after disabling features at stage 1 - stageActions: [ - // 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[], - hidNextPrevOnce: false, // Used to hide prev/next buttons when initially at stage 1 - }; - }, - computed: { - styles(): Record<string,string> { - return { - backgroundColor: this.uiOpts.bgColorDark, - color: this.uiOpts.textColor, - }; - }, - contentStyles(): Record<string,string> { - return { - padding: '0 0.5cm', - overflow: 'auto', - textAlign: 'center', - }; - }, - buttonStyles(): Record<string,string> { - return { - color: this.uiOpts.textColor, - backgroundColor: this.uiOpts.bgColor, - }; - }, - touchDevice(): boolean { - return this.uiOpts.touchDevice; - }, - }, - methods: { - onStartTutorial(){ - this.stage = 1; - }, - onSkipTutorial(){ - this.$emit('skip'); - this.$emit('close'); - }, - onPrevClick(){ - this.stage = Math.max(1, this.stage - 1); - }, - onNextClick(){ - this.stage = Math.min(this.stage + 1, this.lastStage); - }, - onClose(){ - this.$emit('close'); - }, - }, - watch: { - stage(newVal, oldVal){ - // If starting tutorial, disable 'all' actions - if (newVal == 1 && !this.disabledOnce){ - for (let action of this.stageActions){ - if (action != null && !this.actionsDone.has(action)){ - this.uiOpts.disabledActions.add(action); - } - } - this.disabledOnce = true; - } - // Enable action for this stage - this.uiOpts.disabledActions.delete(this.stageActions[this.stage - 1]); - // Notify of new trigger-action - this.$emit('stage-chg', this.stageActions[this.stage - 1]); - // After stage 1, show prev/next buttons - if (newVal == 2){ - this.hidNextPrevOnce = true; - } - }, - // Called when a trigger-action is done, and advances to the next stage - triggerFlag(){ - if (this.stage < this.lastStage){ - this.onNextClick(); - } else { - this.onClose(); +// Props + events +const props = defineProps({ + actionsDone: {type: Object as PropType<Set<Action>>, required: true}, + // Used to avoid disabling actions already done + triggerFlag: {type: Boolean, required: true}, + // Used to indicate that a tutorial-requested 'trigger' action has been done + skipWelcome: {type: Boolean, default: false}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, +}); +const touchDevice = computed(() => props.uiOpts.touchDevice); +const emit = defineEmits(['close', 'stage-chg', 'skip']); + +// 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 +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){ + for (let action of STAGE_ACTIONS){ + if (action != null && !props.actionsDone.has(action)){ + props.uiOpts.disabledActions.add(action); } - }, - }, - created(){ - if (this.skipWelcome){ - this.stage += 1; } - }, - components: {CloseIcon, SButton, }, - emits: ['close', 'stage-chg', 'skip', ], + disabledOnce = true; + } + // Enable action for this stage + props.uiOpts.disabledActions.delete(STAGE_ACTIONS[stage.value - 1]); + // Notify of new trigger-action + emit('stage-chg', STAGE_ACTIONS[stage.value - 1]); + // After stage 1, show prev/next buttons + if (stage.value == 2){ + hidNextPrevOnce.value = true; + } +} +onMounted(() => { + if (props.skipWelcome){ + onStageChange(); + } +}) +watch(stage, onStageChange); +watch(() => props.triggerFlag, () => { + if (stage.value < LAST_STAGE){ + onNextClick(); + } else { + onClose(); + } }); + +// Styles +const styles = computed(() => ({ + backgroundColor: props.uiOpts.bgColorDark, + color: props.uiOpts.textColor, +})); +const contentStyles = { + padding: '0 0.5cm', + overflow: 'auto', + textAlign: 'center', +}; +const buttonStyles = computed(() => ({ + color: props.uiOpts.textColor, + backgroundColor: props.uiOpts.bgColor, +})); </script> diff --git a/src/components/icon/CloseIcon.vue b/src/components/icon/CloseIcon.vue index 7dceef9..a62b08a 100644 --- a/src/components/icon/CloseIcon.vue +++ b/src/components/icon/CloseIcon.vue @@ -5,8 +5,3 @@ <line x1="6" y1="6" x2="18" y2="18"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/DownIcon.vue b/src/components/icon/DownIcon.vue index dc954d1..f7a5835 100644 --- a/src/components/icon/DownIcon.vue +++ b/src/components/icon/DownIcon.vue @@ -4,8 +4,3 @@ <polyline points="6 9 12 15 18 9"></polyline> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/EduIcon.vue b/src/components/icon/EduIcon.vue index e46f2a6..a7d0405 100644 --- a/src/components/icon/EduIcon.vue +++ b/src/components/icon/EduIcon.vue @@ -6,8 +6,3 @@ d="M112 240v128l144 80 144-80V240M480 368V192M256 320v128"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/ExternalLinkIcon.vue b/src/components/icon/ExternalLinkIcon.vue index b7cb286..f672f3a 100644 --- a/src/components/icon/ExternalLinkIcon.vue +++ b/src/components/icon/ExternalLinkIcon.vue @@ -6,8 +6,3 @@ <line x1="10" y1="14" x2="21" y2="3"></line> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/HelpIcon.vue b/src/components/icon/HelpIcon.vue index 8486686..a61553a 100644 --- a/src/components/icon/HelpIcon.vue +++ b/src/components/icon/HelpIcon.vue @@ -5,8 +5,3 @@ <circle cx="248" cy="430" r="32" fill="currentColor"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue index 0f390cf..47a14cd 100644 --- a/src/components/icon/InfoIcon.vue +++ b/src/components/icon/InfoIcon.vue @@ -6,8 +6,3 @@ <line x1="12" y1="8" x2="12.01" y2="8"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/LinkIcon.vue b/src/components/icon/LinkIcon.vue index 49996b7..2e43324 100644 --- a/src/components/icon/LinkIcon.vue +++ b/src/components/icon/LinkIcon.vue @@ -5,8 +5,3 @@ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/LoaderIcon.vue b/src/components/icon/LoaderIcon.vue index cd5093b..c2a0369 100644 --- a/src/components/icon/LoaderIcon.vue +++ b/src/components/icon/LoaderIcon.vue @@ -11,8 +11,3 @@ <line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/LogInIcon.vue b/src/components/icon/LogInIcon.vue index b91550b..97ab871 100644 --- a/src/components/icon/LogInIcon.vue +++ b/src/components/icon/LogInIcon.vue @@ -6,8 +6,3 @@ <line x1="15" y1="12" x2="3" y2="12"></line> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/PauseIcon.vue b/src/components/icon/PauseIcon.vue index dc768a2..be13f5f 100644 --- a/src/components/icon/PauseIcon.vue +++ b/src/components/icon/PauseIcon.vue @@ -5,8 +5,3 @@ <rect x="14" y="4" width="4" height="16"></rect> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/PlayIcon.vue b/src/components/icon/PlayIcon.vue index 7e5d823..4295328 100644 --- a/src/components/icon/PlayIcon.vue +++ b/src/components/icon/PlayIcon.vue @@ -4,8 +4,3 @@ <polygon points="5 3 19 12 5 21 5 3"></polygon> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/SearchIcon.vue b/src/components/icon/SearchIcon.vue index 40b8226..4a25aea 100644 --- a/src/components/icon/SearchIcon.vue +++ b/src/components/icon/SearchIcon.vue @@ -5,8 +5,3 @@ <line x1="21" y1="21" x2="16.65" y2="16.65"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> diff --git a/src/components/icon/SettingsIcon.vue b/src/components/icon/SettingsIcon.vue index bea2a3f..e8738a9 100644 --- a/src/components/icon/SettingsIcon.vue +++ b/src/components/icon/SettingsIcon.vue @@ -15,8 +15,3 @@ 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/> </svg> </template> - -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; -export default defineComponent({}); -</script> |
