diff options
Diffstat (limited to 'src/App.vue')
| -rw-r--r-- | src/App.vue | 785 |
1 files changed, 404 insertions, 381 deletions
diff --git a/src/App.vue b/src/App.vue index 9abd5ac..14df49f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -38,7 +38,7 @@ function getReverseAction(action: AutoAction): AutoAction | null { return null; } } -// Functions providing default option values +// For options function getDefaultLytOpts(): LayoutOptions { let screenSz = getBreakpoint(); return { @@ -55,7 +55,7 @@ function getDefaultLytOpts(): LayoutOptions { sweepToParent: 'prefer', // 'none' | 'prefer' | 'fallback' }; } -function getDefaultUiOpts(lytOpts: LayoutOptions){ +function getDefaultUiOpts(lytOpts: LayoutOptions): UiOptions { let screenSz = getBreakpoint(); // Reused option values let textColor = '#fafaf9'; @@ -91,6 +91,7 @@ function getDefaultUiOpts(lytOpts: LayoutOptions){ // Timing related clickHoldDuration: 400, // ms transitionDuration: 300, // ms + animationDelay: 100, // ms autoActionDelay: 500, // ms // Other useReducedTree: false, @@ -100,40 +101,43 @@ function getDefaultUiOpts(lytOpts: LayoutOptions){ disabledActions: new Set() as Set<Action>, }; } - -// Initialise tree-of-life data -const initialTolMap: TolMap = new Map(); -initialTolMap.set("", new TolNode()); +const lytOptPrefix = 'LYT '; // Used when saving to localStorage +const uiOptPrefix = 'UI '; export default defineComponent({ data(){ + // 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, // Differs from layoutTree root when expand-to-view is used - layoutMap: initLayoutMap(layoutTree), // Maps names to LayoutNode objects + 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 - // Modals and settings related + // For modals infoModalNodeName: null as string | null, // Name of node to display info for, or null - helpOpen: false, searchOpen: false, settingsOpen: false, - tutorialOpen: !uiOpts.tutorialSkip, - welcomeOpen: !uiOpts.tutorialSkip, - ancestryBarInTransition: false, - tutTriggerAction: null as Action | null, - tutTriggerFlag: false, - tutPaneInTransition: false, + helpOpen: false, // For search and auto-mode modeRunning: false, - lastFocused: null as LayoutNode | null, + 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, // Options lytOpts: lytOpts, uiOpts: uiOpts, @@ -142,14 +146,20 @@ export default defineComponent({ tileAreaDims: [0, 0] as [number, number], lastResizeHdlrTime: 0, // Used to throttle resize handling pendingResizeHdlr: 0, // Set via setTimeout() for a non-initial resize event + // For transitions + ancestryBarInTransition: false, + tutPaneInTransition: false, // Other - justInitialised: false, + justInitialised: false, // Used to skip transition for tile initially loaded from server changedSweepToParent: false, // Set during search animation for efficiency - excessTolNodeThreshold: 1000, // Threshold where excess tolMap entries are removed (done on tile collapse) + excessTolNodeThreshold: 1000, // Threshold where excess tolMap entries get removed }; }, computed: { - // Nodes to show in ancestry-bar, with tol root first + wideArea(): 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; @@ -162,6 +172,7 @@ export default defineComponent({ } return ancestors.reverse(); }, + // Styles buttonStyles(): Record<string,string> { return { color: this.uiOpts.textColor, @@ -170,8 +181,8 @@ export default defineComponent({ }, tutPaneContainerStyles(): Record<string,string> { return { - minHeight: (this.tutorialOpen ? this.uiOpts.tutPaneSz : 0) + 'px', - maxHeight: (this.tutorialOpen ? this.uiOpts.tutPaneSz : 0) + 'px', + minHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px', + maxHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px', transitionDuration: this.uiOpts.transitionDuration + 'ms', transitionProperty: 'max-height, min-height', overflow: 'hidden', @@ -186,9 +197,9 @@ export default defineComponent({ maxHeight: 'none', transitionDuration: this.uiOpts.transitionDuration + 'ms', transitionProperty: '', - overflow: 'hidden' + overflow: 'hidden', }; - if (this.mainAreaDims[0] > this.mainAreaDims[1]){ + if (this.wideArea){ styles.minWidth = ancestryBarBreadth + 'px'; styles.maxWidth = ancestryBarBreadth + 'px'; styles.transitionProperty = 'min-width, max-width'; @@ -202,16 +213,16 @@ export default defineComponent({ }, methods: { // For tile expand/collapse events - onLeafClick(layoutNode: LayoutNode){ + async onLeafClick(layoutNode: LayoutNode): Promise<boolean> { if (this.uiOpts.disabledActions.has('expand')){ - return Promise.resolve(false); + return false; } this.handleActionForTutorial('expand'); this.setLastFocused(null); // If clicking child of overflowing active-root if (this.overflownRoot){ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation - return Promise.resolve(false); + return false; } // Function for expanding tile let doExpansion = () => { @@ -220,16 +231,16 @@ export default defineComponent({ chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg, layoutMap: this.layoutMap }; - let success = tryLayout(this.activeRoot, [0,0], this.tileAreaDims, this.lytOpts, lytFnOpts); + let 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, [0,0], this.tileAreaDims, + success = tryLayout(this.activeRoot, this.tileAreaDims, {...this.lytOpts, layoutType: 'flex-sqr'}, lytFnOpts); if (success){ this.overflownRoot = true; } } - // Check for failure + // if (!success){ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } @@ -238,28 +249,30 @@ export default defineComponent({ // 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 urlPath = '/data/node?name=' + encodeURIComponent(layoutNode.name) - urlPath += this.uiOpts.useReducedTree ? '&tree=reduced' : ''; - return fetch(urlPath) - .then(response => response.json()) - .then(obj => { - Object.getOwnPropertyNames(obj).forEach(key => {this.tolMap.set(key, obj[key])}); - doExpansion(); - }) - .catch(error => { - console.log('ERROR loading tolnode data', error); - }); + let responseObj: {[x: string]: TolNode}; + try { + let urlPath = '/data/node?name=' + encodeURIComponent(layoutNode.name) + urlPath += this.uiOpts.useReducedTree ? '&tree=reduced' : ''; + let response = await fetch(urlPath); + responseObj = await response.json(); + } catch (error){ + console.log('ERROR loading tolnode data', error); + return false; + } + Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])}); + return doExpansion(); } else { - return new Promise((resolve, reject) => resolve(doExpansion())); + return doExpansion(); } }, - onNonleafClick(layoutNode: LayoutNode, skipClean = false){ + async onNonleafClick(layoutNode: LayoutNode, {skipClean = false} = {}): Promise<boolean> { if (this.uiOpts.disabledActions.has('collapse')){ return false; } this.handleActionForTutorial('collapse'); this.setLastFocused(null); - let success = tryLayout(this.activeRoot, [0,0], this.tileAreaDims, this.lytOpts, { + // + let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, { allowCollapse: false, chg: {type: 'collapse', node: layoutNode, tolMap: this.tolMap}, layoutMap: this.layoutMap @@ -267,7 +280,7 @@ export default defineComponent({ if (!success){ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } else { - // Update overflownRoot if root was collapsed + // Update overflownRoot to indicate root was collapsed if (this.overflownRoot){ this.overflownRoot = false; } @@ -288,143 +301,153 @@ export default defineComponent({ return success; }, // For expand-to-view and ancestry-bar events - onLeafClickHeld(layoutNode: LayoutNode){ + async onLeafClickHeld(layoutNode: LayoutNode): Promise<boolean> { if (this.uiOpts.disabledActions.has('expandToView')){ - return; + return false; } this.handleActionForTutorial('expandToView'); this.setLastFocused(null); + // Special case for active root if (layoutNode == this.activeRoot){ this.onLeafClick(layoutNode); - return; + return true; } // Function for expanding tile - let doExpansion = () => { + let doExpansion = async () => { + // Hide ancestors LayoutNode.hideUpward(layoutNode, this.layoutMap); - if (this.detachedAncestors == null){ + if (this.detachedAncestors == null){ // Account for ancestry-bar transition this.ancestryBarInTransition = true; this.relayoutDuringAncestryBarTransition(); } this.activeRoot = layoutNode; - // - return this.updateAreaDims().then(() => { - 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, [0,0], this.tileAreaDims, this.lytOpts, lytFnOpts); - if (!success){ - success = tryLayout(this.activeRoot, [0,0], this.tileAreaDims, - {...this.lytOpts, layoutType: 'flex-sqr'}, lytFnOpts); - if (success){ - this.overflownRoot = true; - } - } - // Check for failure - if (!success){ - layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + // Relayout + await 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: 'flex-sqr'}, lytFnOpts); + if (success){ + this.overflownRoot = true; } - return success; - }); + } + // + if (!success){ + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + } + return success; }; // 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 urlPath = '/data/node?name=' + encodeURIComponent(layoutNode.name) - urlPath += this.uiOpts.useReducedTree ? '&tree=reduced' : ''; - return fetch(urlPath) - .then(response => response.json()) - .then(obj => {Object.getOwnPropertyNames(obj).forEach(key => {this.tolMap.set(key, obj[key])})}) - .then(doExpansion) - .catch(error => { - console.log('ERROR loading tolnode data', error); - }); + let responseObj: {[x: string]: TolNode}; + try { + let urlPath = '/data/node?name=' + encodeURIComponent(layoutNode.name) + urlPath += this.uiOpts.useReducedTree ? '&tree=reduced' : ''; + let response = await fetch(urlPath); + responseObj = await response.json(); + } catch (error){ + console.log('ERROR loading tolnode data', error); + return false; + } + Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])}); + return doExpansion(); } else { return doExpansion(); } }, - onNonleafClickHeld(layoutNode: LayoutNode){ + async onNonleafClickHeld(layoutNode: LayoutNode): Promise<boolean> { if (this.uiOpts.disabledActions.has('expandToView')){ - return; + return false; } this.handleActionForTutorial('expandToView'); this.setLastFocused(null); + // Special case for active root if (layoutNode == this.activeRoot){ console.log('Ignored expand-to-view on active-root node'); - return; + return false; } + // Hide ancestors LayoutNode.hideUpward(layoutNode, this.layoutMap); - if (this.detachedAncestors == null){ + if (this.detachedAncestors == null){ // Account for ancestry-bar transition this.ancestryBarInTransition = true; this.relayoutDuringAncestryBarTransition(); } this.activeRoot = layoutNode; - // - this.updateAreaDims().then(() => this.relayoutWithCollapse()); + // Relayout + await this.updateAreaDims(); + return this.relayoutWithCollapse(); }, - onDetachedAncestorClick(layoutNode: LayoutNode, alsoCollapse = false){ + async onDetachedAncestorClick(layoutNode: LayoutNode, {collapseAndNoRelayout = false} = {}): Promise<boolean> { if (this.uiOpts.disabledActions.has('unhideAncestor')){ - return Promise.resolve(false); + return false; } this.handleActionForTutorial('unhideAncestor'); this.setLastFocused(null); + // Unhide ancestors this.activeRoot = layoutNode; this.overflownRoot = false; - if (layoutNode.parent == null){ + if (layoutNode.parent == null){ // Account for ancestry-bar transition this.ancestryBarInTransition = true; this.relayoutDuringAncestryBarTransition(); } // - if (alsoCollapse){ - this.onNonleafClick(layoutNode, true); + let success: boolean; + if (collapseAndNoRelayout){ + success = await this.onNonleafClick(layoutNode, {skipClean: true}); + } else { + await this.updateAreaDims(); + success = this.relayoutWithCollapse(); } - return this.updateAreaDims().then(() => { - this.relayoutWithCollapse(); - LayoutNode.showDownward(layoutNode); - }); + LayoutNode.showDownward(layoutNode); + return success; }, // For tile-info events - onInfoClick(nodeName: string){ + onInfoClick(nodeName: string): void { this.handleActionForTutorial('tileInfo'); - if (!this.searchOpen){ + if (!this.searchOpen){ // Close an active non-search mode this.resetMode(); } this.infoModalNodeName = nodeName; }, // For search events - onSearchIconClick(){ + onSearchIconClick(): void { this.handleActionForTutorial('search'); - this.resetMode(); - this.searchOpen = true; + if (!this.searchOpen){ + this.resetMode(); + this.searchOpen = true; + } }, - onSearch(name: string){ + onSearch(name: string): void { if (this.modeRunning){ console.log("WARNING: Unexpected search event while search/auto mode is running") return; } this.searchOpen = false; this.modeRunning = true; - if (this.lytOpts.sweepToParent == 'fallback'){ + if (this.lytOpts.sweepToParent == 'fallback'){ // Temporary change for efficiency this.lytOpts.sweepToParent = 'prefer'; this.changedSweepToParent = true; } this.expandToNode(name); }, - expandToNode(name: string){ + async expandToNode(name: string){ if (!this.modeRunning){ return; } - // Check if searched node is displayed + // Check if node is displayed let targetNode = this.layoutMap.get(name); if (targetNode != null && !targetNode.hidden){ this.setLastFocused(targetNode); this.modeRunning = false; - if (this.changedSweepToParent){ - this.lytOpts.sweepToParent = 'fallback'; - this.changedSweepToParent = false; - } + this.afterSearch(); return; } // Get nearest in-layout-tree ancestor @@ -440,11 +463,11 @@ export default defineComponent({ nodeInAncestryBar = nodeInAncestryBar.parent!; } if (!this.uiOpts.searchJumpMode){ - this.onDetachedAncestorClick(nodeInAncestryBar!); + await this.onDetachedAncestorClick(nodeInAncestryBar!); setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); } else{ - this.onDetachedAncestorClick(nodeInAncestryBar, true) - .then(() => this.expandToNode(name)); + await this.onDetachedAncestorClick(nodeInAncestryBar, {collapseAndNoRelayout: true}); + this.expandToNode(name); } return; } @@ -461,46 +484,50 @@ export default defineComponent({ layoutNode.addDescendantChain(nodesToAdd, this.tolMap, this.layoutMap); // Expand-to-view on target-node's parent targetNode = this.layoutMap.get(name); - this.onLeafClickHeld(targetNode!.parent!); - // + await this.onLeafClickHeld(targetNode!.parent!); setTimeout(() => {this.setLastFocused(targetNode!);}, this.uiOpts.transitionDuration); this.modeRunning = false; return; } if (this.overflownRoot){ - this.onLeafClickHeld(layoutNode); + await this.onLeafClickHeld(layoutNode); setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); return; } - this.onLeafClick(layoutNode).then(success => { - 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.modeRunning = false; - return; - } - let ancestorChain = [layoutNode]; - while (layoutNode.parent! != this.activeRoot){ - layoutNode = layoutNode.parent!; - ancestorChain.push(layoutNode); - } - layoutNode = ancestorChain[Math.floor((ancestorChain.length - 1) / 2)] - this.onNonleafClickHeld(layoutNode); + let success = await this.onLeafClick(layoutNode); + 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.modeRunning = false; + 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); + setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration); + }, + afterSearch(): void { + if (this.changedSweepToParent){ + this.lytOpts.sweepToParent = 'fallback'; + this.changedSweepToParent = false; + } }, // For auto-mode events - onPlayIconClick(){ + onAutoIconClick(): void { this.handleActionForTutorial('autoMode'); this.resetMode(); this.modeRunning = true; this.autoAction(); }, - autoAction(){ + async autoAction(){ if (!this.modeRunning){ this.setLastFocused(null); return; @@ -522,39 +549,33 @@ export default defineComponent({ let node: LayoutNode = this.lastFocused; if (node.children.length == 0){ actionWeights = {'move across': 1, 'move up': 2, 'expand': 3}; - // 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; - } } 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 (!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 - } } - if (this.autoPrevAction != null){ // Avoid undoing previous action + // 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; @@ -572,69 +593,72 @@ export default defineComponent({ action = actionList[randWeightedChoice(weightList)!] as AutoAction; } // Perform action - this.autoPrevActionFail = false; - switch (action){ - case 'move across': // Bias towards siblings with higher dCount - let siblings = node.parent!.children.filter(n => n != node); - let siblingWeights = siblings.map(n => n.dCount + 1); - this.setLastFocused(siblings[randWeightedChoice(siblingWeights)!]); - break; - case 'move down': // Bias towards children with higher dCount - let childWeights = node.children.map(n => n.dCount + 1); - this.setLastFocused(node.children[randWeightedChoice(childWeights)!]); - break; - case 'move up': - this.setLastFocused(node.parent!); - break; - case 'expand': - this.onLeafClick(node) - .then(success => this.autoPrevActionFail = !success) - .catch(error => this.autoPrevActionFail = true); - break; - case 'collapse': - this.autoPrevActionFail = !this.onNonleafClick(node); - break; - case 'expandToView': - this.onNonleafClickHeld(node); - break; - case 'unhideAncestor': - this.onDetachedAncestorClick(node.parent!); - break; + this.autoPrevAction = action; + let success = true; + try { + switch (action){ + case 'move across': // Bias towards siblings with higher dCount + let siblings = node.parent!.children.filter(n => n != node); + let siblingWeights = siblings.map(n => n.dCount + 1); + this.setLastFocused(siblings[randWeightedChoice(siblingWeights)!]); + break; + case 'move down': // Bias towards children with higher dCount + let childWeights = node.children.map(n => n.dCount + 1); + this.setLastFocused(node.children[randWeightedChoice(childWeights)!]); + break; + case 'move up': + this.setLastFocused(node.parent!); + break; + case 'expand': + success = await this.onLeafClick(node); + break; + case 'collapse': + success = await this.onNonleafClick(node); + break; + case 'expandToView': + success = await this.onNonleafClickHeld(node); + break; + case 'unhideAncestor': + success = await this.onDetachedAncestorClick(node.parent!); + break; + } + } catch (error) { + this.autoPrevActionFail = true; + return; } + this.autoPrevActionFail = !success; setTimeout(this.autoAction, this.uiOpts.transitionDuration + this.uiOpts.autoActionDelay); - this.autoPrevAction = action; } }, // For settings events - onSettingsIconClick(){ + onSettingsIconClick(): void { this.handleActionForTutorial('settings'); this.resetMode(); this.settingsOpen = true; }, - onTreeChange(){ - // Collapse tree to root + async onTreeChange(){ if (this.activeRoot != this.layoutTree){ - this.onDetachedAncestorClick(this.layoutTree); + // Collapse tree to root + await this.onDetachedAncestorClick(this.layoutTree); } - this.onNonleafClick(this.layoutTree); - // Re-initialise tree - this.initTreeFromServer(); + await this.onNonleafClick(this.layoutTree); + await this.initTreeFromServer(); }, - onSettingsChg(changedLytOpts: Iterable<string>, changedUiOpts: Iterable<string>){ + onSettingsChg(changedLytOpts: Iterable<string>, changedUiOpts: Iterable<string>): void { let changed = false; for (let opt of changedLytOpts){ - localStorage.setItem('lyt ' + opt, String(this.lytOpts[opt as keyof LayoutOptions])); + localStorage.setItem(lytOptPrefix + opt, String(this.lytOpts[opt as keyof LayoutOptions])); changed = true; } for (let opt of changedUiOpts){ - localStorage.setItem('ui ' + opt, String(this.uiOpts[opt])); + localStorage.setItem(uiOptPrefix + opt, String(this.uiOpts[opt])); changed = true; } if (changed){ console.log('Settings saved'); } }, - onResetSettings(){ + onResetSettings(): void { localStorage.clear(); let defaultLytOpts = getDefaultLytOpts(); let defaultUiOpts = getDefaultUiOpts(defaultLytOpts); @@ -647,42 +671,43 @@ export default defineComponent({ this.relayoutWithCollapse(); }, // For help events - onHelpIconClick(){ + onHelpIconClick(): void { this.handleActionForTutorial('help'); this.resetMode(); this.helpOpen = true; }, - // For tutorial events - onStartTutorial(){ - if (this.tutorialOpen == false){ - this.tutorialOpen = true; - // Repeatedly relayout tiles during tutorial-pane transition - this.tutPaneInTransition = true; - this.relayoutDuringTutPaneTransition(); - } - }, - onTutorialClose(){ - this.tutorialOpen = false; - this.welcomeOpen = false; + // For tutorial-pane events + onTutPaneClose(): void { + this.tutPaneOpen = false; + this.tutWelcome = false; this.uiOpts.disabledActions.clear(); - // Repeatedly relayout tiles during tutorial-pane transition + // Account for tutorial-pane transition this.tutPaneInTransition = true; this.relayoutDuringTutPaneTransition(); }, - onTutorialSkip(){ - localStorage.setItem('ui tutorialSkip', String(true)); - }, - onTutStageChg(triggerAction: Action | null){ - this.welcomeOpen = false; + onTutStageChg(triggerAction: Action | null): void { + this.tutWelcome = false; this.tutTriggerAction = triggerAction; }, - handleActionForTutorial(action: Action){ - if (!this.tutorialOpen){ + onTutorialSkip(): void { + // Remember to skip in future sessions + localStorage.setItem(uiOptPrefix + 'tutorialSkip', String(true)); + }, + onStartTutorial(): void { + if (!this.tutPaneOpen){ + this.tutPaneOpen = true; + // Account for tutorial-pane transition + this.tutPaneInTransition = true; + this.relayoutDuringTutPaneTransition(); + } + }, + handleActionForTutorial(action: Action): void { + if (!this.tutPaneOpen){ return; } // Close welcome message on first action - if (this.welcomeOpen){ - this.onTutorialClose(); + if (this.tutWelcome){ + this.onTutPaneClose(); } // Tell TutorialPane if trigger-action was done if (this.tutTriggerAction == action){ @@ -690,57 +715,55 @@ export default defineComponent({ } }, // For other events - onResize(){ + async onResize(){ // Handle event, delaying/ignoring if this was recently done if (this.pendingResizeHdlr == 0){ - const resizeDelay = 100; - let handleResize = () => { - // Update unmodified layout/ui options with defaults + 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)){ - let item = localStorage.getItem('lyt ' + prop); - if (item == null && this.lytOpts[prop] != lytOpts[prop as keyof LayoutOptions]){ - this.lytOpts[prop] = lytOpts[prop as keyof LayoutOptions]; + for (let prop of Object.getOwnPropertyNames(lytOpts) as (keyof LayoutOptions)[]){ + let item = localStorage.getItem(lytOptPrefix + prop); + if (item == null && this.lytOpts[prop] != lytOpts[prop]){ + this.lytOpts[prop] = lytOpts[prop]; } } - for (let prop of Object.getOwnPropertyNames(uiOpts)){ - let item = localStorage.getItem('lyt ' + prop); - if (item == null && this.uiOpts[prop] != uiOpts[prop as keyof typeof uiOpts]){ - console.log("Loaded UI prop " + prop) - this.uiOpts[prop] = uiOpts[prop as keyof typeof uiOpts]; + for (let prop of Object.getOwnPropertyNames(uiOpts) as (keyof UiOptions)[]){ + let item = localStorage.getItem(lytOptPrefix + prop); + if (item == null && this.uiOpts[prop] != uiOpts[prop]){ + this.uiOpts[prop] = uiOpts[prop]; if (prop == 'useReducedTree'){ changedTree = true; } } } - // + // Relayout this.overflownRoot = false; if (!changedTree){ - return this.updateAreaDims().then(() => this.relayoutWithCollapse()); + await this.updateAreaDims(); + this.relayoutWithCollapse(); } else { - return Promise.resolve(this.onTreeChange()); + this.onTreeChange(); } }; + // let currentTime = new Date().getTime(); - if (currentTime - this.lastResizeHdlrTime > resizeDelay){ + if (currentTime - this.lastResizeHdlrTime > this.uiOpts.animationDelay){ this.lastResizeHdlrTime = currentTime; - handleResize().then(() => { - this.lastResizeHdlrTime = new Date().getTime(); - }); + await handleResize(); + this.lastResizeHdlrTime = new Date().getTime(); } else { - let remainingDelay = resizeDelay - (currentTime - this.lastResizeHdlrTime); - this.pendingResizeHdlr = setTimeout(() => { + let remainingDelay = this.uiOpts.animationDelay - (currentTime - this.lastResizeHdlrTime); + this.pendingResizeHdlr = setTimeout(async () => { this.pendingResizeHdlr = 0; - handleResize().then(() => { - this.lastResizeHdlrTime = new Date().getTime(); - }); + await handleResize(); + this.lastResizeHdlrTime = new Date().getTime(); }, remainingDelay); } } }, - onKeyUp(evt: KeyboardEvent){ + onKeyUp(evt: KeyboardEvent): void { if (evt.key == 'Escape'){ this.resetMode(); } else if (evt.key == 'f' && evt.ctrlKey){ @@ -754,158 +777,157 @@ export default defineComponent({ } } } else if (evt.key == 'F' && evt.ctrlKey){ - // If search bar is open, swap search mode + // If search bar is open, switch search mode if (this.searchOpen){ this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode; this.onSettingsChg([], ['searchJumpMode']); } } }, - // Helper methods - initTreeFromServer(){ - let urlPath = '/data/node'; - urlPath += this.uiOpts.useReducedTree ? '?tree=reduced' : ''; - fetch(urlPath) - .then(response => response.json()) - .then(obj => { - // Get root node name - let rootName = null; - let nodeNames = Object.getOwnPropertyNames(obj); - for (let n of nodeNames){ - if (obj[n].parent == null){ - rootName = n; - break; - } - } - if (rootName == null){ - throw new Error('Server response has no root node'); - } - // Initialise tree - this.tolMap.clear(); - nodeNames.forEach(n => {this.tolMap.set(n, obj[n])}); - this.layoutTree = initLayoutTree(this.tolMap, rootName, 0); - this.activeRoot = this.layoutTree; - this.layoutMap = initLayoutMap(this.layoutTree); - this.updateAreaDims().then(() => { - this.relayoutWithCollapse(false); - this.justInitialised = true; - setTimeout(() => {this.justInitialised = false;}, 300); - }); - }) - .catch(error => { - console.log('ERROR loading initial tolnode data', error); - }); - }, - getLytOpts(){ - let opts: {[x: string]: boolean|number|string} = getDefaultLytOpts(); - for (let prop of Object.getOwnPropertyNames(opts)){ - let item = localStorage.getItem('lyt ' + prop); + // For initialisation + async initTreeFromServer(){ + // Query server + let responseObj: {[x: string]: TolNode}; + try { + let urlPath = '/data/node'; + urlPath += this.uiOpts.useReducedTree ? '?tree=reduced' : ''; + let response = await fetch(urlPath); + responseObj = await response.json(); + } catch (error) { + console.log('ERROR: Unable to get tree data', error); + 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 + this.tolMap.clear(); + nodeNames.forEach(n => {this.tolMap.set(n, responseObj[n])}); + this.layoutTree = initLayoutTree(this.tolMap, rootName, 0); + this.activeRoot = this.layoutTree; + this.layoutMap = initLayoutMap(this.layoutTree); + // Relayout + await this.updateAreaDims(); + this.relayoutWithCollapse(false); + // Skip initial transition + this.justInitialised = true; + setTimeout(() => {this.justInitialised = false;}, this.uiOpts.transitionDuration); + }, + getLytOpts(): LayoutOptions { + let opts = getDefaultLytOpts(); + for (let prop of Object.getOwnPropertyNames(opts) as (keyof LayoutOptions)[]){ + let item = localStorage.getItem(lytOptPrefix + prop); if (item != null){ switch (typeof(opts[prop])){ - case 'boolean': opts[prop] = Boolean(item); break; - case 'number': opts[prop] = Number(item); break; - case 'string': opts[prop] = item; break; + 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(){ - let opts: {[x: string]: boolean|number|string|string[]|(string|number)[][]|Set<Action>} = - getDefaultUiOpts(getDefaultLytOpts()); - for (let prop of Object.getOwnPropertyNames(opts)){ - let item = localStorage.getItem('ui ' + prop); + getUiOpts(): UiOptions { + let opts = getDefaultUiOpts(getDefaultLytOpts()); + for (let prop of Object.getOwnPropertyNames(opts) as (keyof UiOptions)[]){ + let item = localStorage.getItem(uiOptPrefix + prop); if (item != null){ switch (typeof(opts[prop])){ - case 'boolean': opts[prop] = item == 'true'; break; - case 'number': opts[prop] = Number(item); break; - case 'string': opts[prop] = item; break; + 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; }, - resetMode(){ - this.infoModalNodeName = null; - this.searchOpen = false; - this.helpOpen = false; - this.settingsOpen = false; - this.modeRunning = false; - this.setLastFocused(null); - if (this.changedSweepToParent){ - this.lytOpts.sweepToParent = 'fallback'; - this.changedSweepToParent = false; - } - }, - setLastFocused(node: LayoutNode | null){ - if (this.lastFocused != null){ - this.lastFocused.hasFocus = false; - } - this.lastFocused = node; - if (node != null){ - node.hasFocus = true; - } - }, - relayoutWithCollapse(secondPass = true){ - if (this.overflownRoot){ - tryLayout(this.activeRoot, [0,0], this.tileAreaDims, - {...this.lytOpts, layoutType: 'flex-sqr'}, {allowCollapse: false, layoutMap: this.layoutMap}); - return; - } - tryLayout(this.activeRoot, [0,0], this.tileAreaDims, this.lytOpts, - {allowCollapse: true, layoutMap: this.layoutMap}); - if (secondPass){ - // Relayout again to allocate remaining tiles 'evenly' - tryLayout(this.activeRoot, [0,0], this.tileAreaDims, this.lytOpts, - {allowCollapse: false, layoutMap: this.layoutMap}); - } - }, - updateAreaDims(){ - let mainAreaEl = this.$refs.mainArea as HTMLElement; - this.mainAreaDims = [mainAreaEl.offsetWidth, mainAreaEl.offsetHeight]; - // Need to wait until ancestor-bar is laid-out before computing tileAreaDims - return this.$nextTick(() => { - let tileAreaEl = this.$refs.tileArea as HTMLElement; - this.tileAreaDims = [tileAreaEl.offsetWidth, tileAreaEl.offsetHeight]; - }); - }, - ancestryBarTransitionEnd(){ - this.ancestryBarInTransition = false; - }, - relayoutDuringAncestryBarTransition(){ - let timerId = setInterval(() => { - this.updateAreaDims().then(() => this.relayoutWithCollapse()); + // For transitions + relayoutDuringAncestryBarTransition(): void { + let timerId = setInterval(async () => { + await this.updateAreaDims(); + this.relayoutWithCollapse(); if (!this.ancestryBarInTransition){ clearTimeout(timerId); } - }, 100); + }, this.uiOpts.animationDelay); setTimeout(() => { if (this.ancestryBarInTransition){ this.ancestryBarInTransition = false; clearTimeout(timerId); console.log('Reached timeout waiting for ancestry-bar transition-end event'); } - }, this.uiOpts.transitionDuration * 3); - }, - tutPaneTransitionEnd(){ - this.tutPaneInTransition = false; + }, this.uiOpts.transitionDuration + 300); }, - relayoutDuringTutPaneTransition(){ - let timerId = setInterval(() => { - this.updateAreaDims().then(() => this.relayoutWithCollapse()); + relayoutDuringTutPaneTransition(): void { + let timerId = setInterval(async () => { + await this.updateAreaDims(); + this.relayoutWithCollapse(); if (!this.tutPaneInTransition){ clearTimeout(timerId); } - }, 100); + }, this.uiOpts.animationDelay); setTimeout(() => { if (this.tutPaneInTransition){ this.tutPaneInTransition = false; clearTimeout(timerId); console.log('Reached timeout waiting for tutorial-pane transition-end event'); } - }, this.uiOpts.transitionDuration * 3); + }, this.uiOpts.transitionDuration + 300); + }, + // For relayout + relayoutWithCollapse(secondPass = true): boolean { + let success; + if (this.overflownRoot){ + success = tryLayout(this.activeRoot, this.tileAreaDims, + {...this.lytOpts, layoutType: 'flex-sqr'}, {allowCollapse: false, layoutMap: this.layoutMap}); + } else { + 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; + }, + async updateAreaDims(){ + let mainAreaEl = this.$refs.mainArea as HTMLElement; + this.mainAreaDims = [mainAreaEl.offsetWidth, mainAreaEl.offsetHeight]; + await this.$nextTick(); // Wait until ancestor-bar is laid-out + let tileAreaEl = this.$refs.tileArea as HTMLElement; + this.tileAreaDims = [tileAreaEl.offsetWidth, tileAreaEl.offsetHeight]; + }, + // Other + resetMode(): void { + this.infoModalNodeName = null; + this.searchOpen = false; + this.afterSearch(); + this.settingsOpen = false; + this.helpOpen = false; + this.modeRunning = false; + this.setLastFocused(null); + }, + setLastFocused(node: LayoutNode | null): void { + if (this.lastFocused != null){ + this.lastFocused.hasFocus = false; + } + this.lastFocused = node; + if (node != null){ + node.hasFocus = true; + } }, }, created(){ @@ -918,9 +940,9 @@ export default defineComponent({ window.removeEventListener('keydown', this.onKeyUp); }, components: { - Tile, AncestryBar, - IconButton, HelpIcon, SettingsIcon, SearchIcon, PlayIcon, - TileInfoModal, HelpModal, SearchModal, SettingsModal, TutorialPane, + Tile, TutorialPane, AncestryBar, + IconButton, SearchIcon, PlayIcon, SettingsIcon, HelpIcon, + TileInfoModal, SearchModal, SettingsModal, HelpModal, }, }); </script> @@ -928,14 +950,15 @@ export default defineComponent({ <template> <div class="absolute left-0 top-0 w-screen h-screen overflow-hidden flex flex-col" :style="{backgroundColor: uiOpts.bgColor}"> + <!-- Title bar --> <div class="flex shadow gap-2 p-2" :style="{backgroundColor: uiOpts.bgColorDark2}"> - <h1 class="my-auto text-2xl" :style="{color: uiOpts.altColor}">Tol Explorer</h1> - <!-- Icons --> + <h1 class="my-auto ml-2 text-3xl" :style="{color: uiOpts.altColor}">Tilo</h1> <div class="mx-auto"/> <!-- Spacer --> + <!-- Icons --> <icon-button v-if="!uiOpts.disabledActions.has('search')" :style="buttonStyles" @click="onSearchIconClick"> <search-icon/> </icon-button> - <icon-button v-if="!uiOpts.disabledActions.has('autoMode')" :style="buttonStyles" @click="onPlayIconClick"> + <icon-button v-if="!uiOpts.disabledActions.has('autoMode')" :style="buttonStyles" @click="onAutoIconClick"> <play-icon/> </icon-button> <icon-button v-if="!uiOpts.disabledActions.has('settings')" :style="buttonStyles" @click="onSettingsIconClick"> @@ -945,19 +968,20 @@ export default defineComponent({ <help-icon/> </icon-button> </div> + <!-- Content area --> <div :style="tutPaneContainerStyles"> <!-- Used to slide-in/out the tutorial pane --> - <transition name="fade" @after-enter="tutPaneTransitionEnd" @after-leave="tutPaneTransitionEnd"> - <tutorial-pane v-if="tutorialOpen" :height="uiOpts.tutPaneSz + 'px'" - :uiOpts="uiOpts" :triggerFlag="tutTriggerFlag" :skipWelcome="!welcomeOpen" - @close="onTutorialClose" @skip="onTutorialSkip" @stage-chg="onTutStageChg"/> + <transition name="fade" @after-enter="tutPaneInTransition = false" @after-leave="tutPaneInTransition = false"> + <tutorial-pane v-if="tutPaneOpen" :height="uiOpts.tutPaneSz + 'px'" + :uiOpts="uiOpts" :triggerFlag="tutTriggerFlag" :skipWelcome="!tutWelcome" + @close="onTutPaneClose" @skip="onTutorialSkip" @stage-chg="onTutStageChg"/> </transition> </div> - <div :class="['flex', mainAreaDims[0] > mainAreaDims[1] ? 'flex-row' : 'flex-col', 'grow', 'min-h-0']" ref="mainArea"> - <div :style="ancestryBarContainerStyles"> - <transition name="fade" @after-enter="ancestryBarTransitionEnd" @after-leave="ancestryBarTransitionEnd"> + <div :class="['flex', wideArea ? 'flex-row' : 'flex-col', 'grow', 'min-h-0']" ref="mainArea"> + <div :style="ancestryBarContainerStyles"> <!-- Used to slide-in/out the ancestry-bar --> + <transition name="fade" + @after-enter="ancestryBarInTransition = false" @after-leave="ancestryBarInTransition = false"> <ancestry-bar v-if="detachedAncestors != null" class="w-full h-full" - :nodes="detachedAncestors" :vert="mainAreaDims[0] > mainAreaDims[1]" - :tolMap="tolMap" :lytOpts="lytOpts" :uiOpts="uiOpts" + :nodes="detachedAncestors" :vert="wideArea" :tolMap="tolMap" :lytOpts="lytOpts" :uiOpts="uiOpts" @ancestor-click="onDetachedAncestorClick" @info-click="onInfoClick"/> </transition> </div> @@ -980,12 +1004,11 @@ export default defineComponent({ @close="infoModalNodeName = null"/> </transition> <transition name="fade"> - <help-modal v-if="helpOpen" :uiOpts="uiOpts" - @close="helpOpen = false" @start-tutorial="onStartTutorial"/> + <help-modal v-if="helpOpen" :uiOpts="uiOpts" @close="helpOpen = false" @start-tutorial="onStartTutorial"/> </transition> <settings-modal v-if="settingsOpen" :lytOpts="lytOpts" :uiOpts="uiOpts" class="z-10" - @close="settingsOpen = false" @settings-chg="onSettingsChg" @reset="onResetSettings" - @layout-setting-chg="relayoutWithCollapse" @tree-chg="onTreeChange"/> + @close="settingsOpen = false" @reset="onResetSettings" + @settings-chg="onSettingsChg" @layout-setting-chg="relayoutWithCollapse" @tree-chg="onTreeChange"/> <!-- Overlay used to prevent interaction and capture clicks --> <div :style="{visibility: modeRunning ? 'visible' : 'hidden'}" class="absolute left-0 top-0 w-full h-full" @click="modeRunning = false"></div> |
