diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.vue | 436 | ||||
| -rw-r--r-- | src/components/AncestryBar.vue | 64 | ||||
| -rw-r--r-- | src/components/HelpModal.vue | 11 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 14 | ||||
| -rw-r--r-- | src/components/SettingsPane.vue | 37 | ||||
| -rw-r--r-- | src/components/Tile.vue | 360 | ||||
| -rw-r--r-- | src/components/TileInfoModal.vue | 13 | ||||
| -rw-r--r-- | src/layout.ts | 172 | ||||
| -rw-r--r-- | src/tol.ts | 9 | ||||
| -rw-r--r-- | src/util.ts | 16 |
10 files changed, 575 insertions, 557 deletions
diff --git a/src/App.vue b/src/App.vue index b28531a..cf25b18 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,114 +1,114 @@ <script lang="ts"> import {defineComponent, PropType} from 'vue'; -// +// Components import Tile from './components/Tile.vue'; import AncestryBar from './components/AncestryBar.vue'; import TileInfoModal from './components/TileInfoModal.vue'; -import SearchModal from './components/SearchModal.vue'; import HelpModal from './components/HelpModal.vue'; +import SearchModal from './components/SearchModal.vue'; import SettingsPane from './components/SettingsPane.vue'; -// +// Icons +import HelpIcon from './components/icon/HelpIcon.vue'; import SearchIcon from './components/icon/SearchIcon.vue'; import PlayIcon from './components/icon/PlayIcon.vue'; -import HelpIcon from './components/icon/HelpIcon.vue'; import SettingsIcon from './components/icon/SettingsIcon.vue'; -// +// Other import {TolNode, TolNodeRaw, tolFromRaw, getTolMap} from './tol'; import {LayoutNode, initLayoutTree, initLayoutMap, tryLayout} from './layout'; import type {LayoutOptions} from './layout'; import {arraySum, randWeightedChoice} from './util'; -// Import paths lack a .ts or .js extension because .ts makes vue-tsc complain, and .js makes vite complain +// Note: Import paths lack a .ts or .js extension because .ts makes vue-tsc complain, and .js makes vite complain + +// Type representing auto-mode actions +type Action = 'move across' | 'move down' | 'move up' | + 'expand' | 'collapse' | 'expand to view' | 'expand ancestry bar'; +// Used in auto-mode to help avoid action cycles +function getReverseAction(action: Action): Action | null { + let reversePairs: Action[][] = [ + ['move down', 'move up'], + ['expand', 'collapse'], + ['expand to view', 'expand ancestry bar'], + ]; + let pair = reversePairs.find(pair => pair.includes(action)); + if (pair != null){ + return pair[0] == action ? pair[1] : pair[0]; + } else { + return null; + } +} -// Obtain tree-of-life data +// Get tree-of-life data import tolRaw from './tolData.json'; -const tol: TolNode = tolFromRaw(tolRaw); +const tol = tolFromRaw(tolRaw); const tolMap = getTolMap(tol); -// Configurable settings +// Configurable options const defaultLytOpts: LayoutOptions = { tileSpacing: 8, //px - headerSz: 20, //px + headerSz: 22, //px minTileSz: 50, //px maxTileSz: 200, //px + // Layout-algorithm related layoutType: 'sweep', //'sqr' | 'rect' | 'sweep' rectMode: 'auto first-row', //'horz' | 'vert' | 'linear' | 'auto' | 'auto first-row' sweepMode: 'left', //'left' | 'top' | 'shorter' | 'auto' sweptNodesPrio: 'pow-2/3', //'linear' | 'sqrt' | 'pow-2/3' - sweepingToParent: true, + sweepToParent: true, }; const defaultUiOpts = { - // For leaf/non_leaf tile and detached-ancestor components + // For tiles borderRadius: 5, //px shadowNormal: '0 0 2px black', shadowHighlight: '0 0 1px 2px greenyellow', shadowFocused: '0 0 1px 2px orange', - // For leaf and detached-ancestor components - imgTilePadding: 4, //px - imgTileFontSz: 15, //px - imgTileColor: '#fafaf9', - expandableImgTileColor: 'greenyellow', //yellow, greenyellow, turquoise, - // For non-leaf tile-group components - nonLeafBgColors: ['#44403c', '#57534e'], //tiles at depth N use the Nth color, repeating from the start as needed - nonLeafHeaderFontSz: 15, //px - nonLeafHeaderColor: '#fafaf9', - nonLeafHeaderBgColor: '#1c1917', - // For tile-info modal + infoIconSz: 18, //px + infoIconMargin: 2, //px + // For leaf tiles + leafTilePadding: 4, //px + leafHeaderFontSz: 15, //px + leafHeaderColor: '#fafaf9', + leafHeaderExColor: 'greenyellow', //yellow, greenyellow, turquoise, + // For non-leaf tiles + nonleafBgColors: ['#44403c', '#57534e'], //tiles at depth N use the Nth color, repeating from the start as needed + nonleafHeaderFontSz: 15, //px + nonleafHeaderColor: '#fafaf9', + nonleafHeaderBgColor: '#1c1917', + // For other components + appBgColor: '#292524', + tileAreaOffset: 5, //px (space between root tile and display boundary) + ancestryBarSz: defaultLytOpts.minTileSz * 2, //px (breadth of ancestry-bar area) + ancestryBarBgColor: '#44403c', + ancestryTileMargin: 5, //px (gap between detached-ancestor tiles) + ancestryBarScrollGap: 10, //px (gap for ancestry-bar scrollbar, used to prevent overlap with tiles) infoModalImgSz: 200, + autoWaitTime: 500, //ms (time to wait between actions (with their transitions)) // Timing related - transitionDuration: 300, //ms + tileChgDuration: 300, //ms (for tile move/expand/collapse) clickHoldDuration: 400, //ms (duration after mousedown when a click-and-hold is recognised) }; -const defaultOwnOptions = { - tileAreaOffset: 5, //px (space between root tile and display boundary) - ancestryBarSz: defaultLytOpts.minTileSz * 2, //px (breadth of ancestry-bar area) -}; - -// Type representing auto-mode actions -type Action = 'move across' | 'move down' | 'move up' | 'expand' | 'collapse' | 'expand to view' | 'expand ancestry bar'; -// Used in auto-mode to help avoid action cycles -function getReverseAction(action: Action): Action | null { - switch (action){ - case 'move across': - return null; - case 'move down': - return 'move up'; - case 'move up': - return 'move down'; - case 'expand': - return 'collapse'; - case 'collapse': - return 'expand'; - case 'expand to view': - return 'expand ancestry bar'; - case 'expand ancestry bar': - return 'expand to view'; - } -} -// Holds a tree structure representing a subtree of 'tol' to be rendered -// Collects events about tile expansion/collapse and window-resize, and initiates relayout of tiles export default defineComponent({ data(){ let layoutTree = initLayoutTree(tol, 0); return { layoutTree: layoutTree, - activeRoot: layoutTree, + activeRoot: layoutTree, // Differs from layoutTree root when expand-to-view is used layoutMap: initLayoutMap(layoutTree), // Maps names to LayoutNode objects tolMap: tolMap, // Maps names to TolNode objects - // - infoModalNode: null as TolNode | null, // Hides/unhides info modal, and provides the node to display + // Modals and settings related + infoModalNode: null as TolNode | null, // Node to display info for, or null + helpOpen: false, searchOpen: false, settingsOpen: false, + // For search and auto-mode + modeRunning: false, lastFocused: null as LayoutNode | null, - animationActive: false, - autoWaitTime: 500, //ms (in auto mode, time to wait after an action ends) - autoPrevAction: null as Action | null, // Used in auto-mode for reducing action cycles - autoPrevActionFail: false, // Used in auto-mode to avoid re-trying a failed expand/collapse - helpOpen: false, + // For auto-mode + autoPrevAction: null as Action | null, // Used to help prevent action cycles + autoPrevActionFail: false, // Used to avoid re-trying a failed expand/collapse // Options lytOpts: {...defaultLytOpts}, uiOpts: {...defaultUiOpts}, - ...defaultOwnOptions, // For window-resize handling width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, @@ -117,9 +117,10 @@ export default defineComponent({ }; }, computed: { - wideArea(): boolean{ + wideArea(): boolean { return this.width >= this.height; }, + // Nodes to show in ancestry-bar, with tol root first detachedAncestors(): LayoutNode[] | null { if (this.activeRoot == this.layoutTree){ return null; @@ -132,166 +133,114 @@ export default defineComponent({ } return ancestors.reverse(); }, + // Placement info for Tile and AncestryBar tileAreaPos(){ - let pos = [this.tileAreaOffset, this.tileAreaOffset] as [number, number]; + let pos = [this.uiOpts.tileAreaOffset, this.uiOpts.tileAreaOffset] as [number, number]; if (this.detachedAncestors != null){ if (this.wideArea){ - pos[0] += this.ancestryBarSz; + pos[0] += this.uiOpts.ancestryBarSz; } else { - pos[1] += this.ancestryBarSz; + pos[1] += this.uiOpts.ancestryBarSz; } } return pos; }, tileAreaDims(){ let dims = [ - this.width - this.tileAreaOffset*2, - this.height - this.tileAreaOffset*2 + this.width - this.uiOpts.tileAreaOffset*2, + this.height - this.uiOpts.tileAreaOffset*2 ] as [number, number]; if (this.detachedAncestors != null){ if (this.wideArea){ - dims[0] -= this.ancestryBarSz; + dims[0] -= this.uiOpts.ancestryBarSz; } else { - dims[1] -= this.ancestryBarSz; + dims[1] -= this.uiOpts.ancestryBarSz; } } return dims; }, ancestryBarDims(): [number, number] { if (this.wideArea){ - return [this.ancestryBarSz, this.height]; + return [this.uiOpts.ancestryBarSz, this.height]; } else { - return [this.width, this.ancestryBarSz]; + return [this.width, this.uiOpts.ancestryBarSz]; } }, - styles(): Record<string,string> { - return { - position: 'absolute', - left: '0', - top: '0', - width: '100vw', // Making this dynamic causes white flashes when resizing - height: '100vh', - backgroundColor: '#292524', - overflow: 'hidden', - }; - }, }, methods: { - onResize(){ - if (!this.resizeThrottled){ - this.width = document.documentElement.clientWidth; - this.height = document.documentElement.clientHeight; - tryLayout(this.activeRoot, this.layoutMap, - this.tileAreaPos, this.tileAreaDims, this.lytOpts, true); - // Prevent re-triggering until after a delay - this.resizeThrottled = true; - setTimeout(() => {this.resizeThrottled = false;}, this.resizeDelay); - } - }, // For tile expand/collapse events - onInnerLeafClicked(layoutNode: LayoutNode){ - let success = tryLayout(this.activeRoot, this.layoutMap, - this.tileAreaPos, this.tileAreaDims, this.lytOpts, false, {type: 'expand', node: layoutNode}); + onLeafClick(layoutNode: LayoutNode){ + let success = tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: false, chg: {type: 'expand', node: layoutNode}, layoutMap: this.layoutMap}); if (!success){ - layoutNode.expandFailFlag = !layoutNode.expandFailFlag; // Triggers failure animation + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } return success; }, - onInnerHeaderClicked(layoutNode: LayoutNode){ - let oldChildren = layoutNode.children; - let success = tryLayout(this.activeRoot, this.layoutMap, - this.tileAreaPos, this.tileAreaDims, this.lytOpts, false, {type: 'collapse', node: layoutNode}); + onNonleafClick(layoutNode: LayoutNode){ + let success = tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: false, chg: {type: 'collapse', node: layoutNode}, layoutMap: this.layoutMap}); if (!success){ - layoutNode.collapseFailFlag = !layoutNode.collapseFailFlag; // Triggers failure animation + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } return success; }, - // For expand-to-view events - onInnerLeafClickHeld(layoutNode: LayoutNode){ + // For expand-to-view and ancestry-bar events + onLeafClickHeld(layoutNode: LayoutNode){ if (layoutNode == this.activeRoot){ - console.log('Ignored expand-to-view on root node'); + console.log('Ignored expand-to-view on active-root node'); return; } LayoutNode.hideUpward(layoutNode); this.activeRoot = layoutNode; - tryLayout(this.activeRoot, this.layoutMap, - this.tileAreaPos, this.tileAreaDims, this.lytOpts, true, {type: 'expand', node: layoutNode}); + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, chg: {type: 'expand', node: layoutNode}, layoutMap: this.layoutMap}); }, - onInnerHeaderClickHeld(layoutNode: LayoutNode){ + onNonleafClickHeld(layoutNode: LayoutNode){ if (layoutNode == this.activeRoot){ console.log('Ignored expand-to-view on active-root node'); return; } LayoutNode.hideUpward(layoutNode); this.activeRoot = layoutNode; - tryLayout(this.activeRoot, this.layoutMap, this.tileAreaPos, this.tileAreaDims, this.lytOpts, true); + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, layoutMap: this.layoutMap, }); }, - onDetachedAncestorClicked(layoutNode: LayoutNode){ + onDetachedAncestorClick(layoutNode: LayoutNode){ LayoutNode.showDownward(layoutNode); this.activeRoot = layoutNode; - tryLayout(this.activeRoot, this.layoutMap, this.tileAreaPos, this.tileAreaDims, this.lytOpts, true); + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, layoutMap: this.layoutMap}); }, - // For info modal events - onInnerInfoIconClicked(node: LayoutNode){ - this.closeModesAndSettings(); + // For tile-info events + onInfoIconClick(node: LayoutNode){ + this.resetMode(); this.infoModalNode = node.tolNode; }, - onInfoModalClose(){ - this.infoModalNode = null; - }, - // - onSettingsIconClick(){ - this.closeModesAndSettings(); - this.settingsOpen = true; - }, - onSettingsClose(){ - this.settingsOpen = false; - }, - onLayoutOptionChange(){ - tryLayout(this.activeRoot, this.layoutMap, this.tileAreaPos, this.tileAreaDims, this.lytOpts, true); + // For help events + onHelpIconClick(){ + this.resetMode(); + this.helpOpen = true; }, - // + // For search events onSearchIconClick(){ - this.closeModesAndSettings(); + this.resetMode(); this.searchOpen = true; }, - onSearchClose(){ - this.searchOpen = false; - }, onSearchNode(tolNode: TolNode){ this.searchOpen = false; - this.animationActive = true; + this.modeRunning = true; this.expandToTolNode(tolNode); }, - // - closeModesAndSettings(){ - this.infoModalNode = null; - this.searchOpen = false; - this.helpOpen = false; - this.settingsOpen = false; - this.animationActive = false; - this.setLastFocused(null); - }, - onKeyUp(evt: KeyboardEvent){ - if (evt.key == 'Escape'){ - this.closeModesAndSettings(); - } else if (evt.key == 'F' && evt.ctrlKey){ - if (!this.searchOpen){ - this.onSearchIconClick(); - } else { - (this.$refs.searchModal as InstanceType<typeof SearchModal>).focusInput(); - } - } - }, expandToTolNode(tolNode: TolNode){ - if (!this.animationActive){ + if (!this.modeRunning){ return; } - // Check if searched node is shown + // Check if searched node is displayed let layoutNode = this.layoutMap.get(tolNode.name); if (layoutNode != null && !layoutNode.hidden){ this.setLastFocused(layoutNode); - this.animationActive = false; + this.modeRunning = false; return; } // Get nearest in-layout-tree ancestor @@ -300,28 +249,27 @@ export default defineComponent({ ancestor = ancestor.parent!; } layoutNode = this.layoutMap.get(ancestor.name)!; - // If hidden, expand ancestor in ancestry-bar + // If hidden, expand self/ancestor in ancestry-bar if (layoutNode.hidden){ - // Get self/ancestor in ancestry-bar while (!this.detachedAncestors!.includes(layoutNode)){ ancestor = ancestor.parent!; layoutNode = this.layoutMap.get(ancestor.name)!; } - this.onDetachedAncestorClicked(layoutNode!); - setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.transitionDuration); + this.onDetachedAncestorClick(layoutNode!); + setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.tileChgDuration); return; } // Attempt tile-expand - let success = this.onInnerLeafClicked(layoutNode); + let success = this.onLeafClick(layoutNode); if (success){ - setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.transitionDuration); + setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.tileChgDuration); return; } // Attempt expand-to-view on ancestor just below activeRoot if (ancestor.name == this.activeRoot.tolNode.name){ console.log('Unable to complete search (not enough room to expand active root)'); - // Happens if screen is very small or node has very many children - this.animationActive = false; + // Note: Only happens if screen is significantly small or node has significantly many children + this.modeRunning = false; return; } while (true){ @@ -331,19 +279,17 @@ export default defineComponent({ ancestor = ancestor.parent!; } layoutNode = this.layoutMap.get(ancestor.name)!; - this.onInnerHeaderClickHeld(layoutNode); - setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.transitionDuration); - }, - onOverlayClick(){ - this.animationActive = false; + this.onNonleafClickHeld(layoutNode); + setTimeout(() => this.expandToTolNode(tolNode), this.uiOpts.tileChgDuration); }, + // For auto-mode events onPlayIconClick(){ - this.closeModesAndSettings(); - this.animationActive = true; + this.resetMode(); + this.modeRunning = true; this.autoAction(); }, autoAction(){ - if (!this.animationActive){ + if (!this.modeRunning){ this.setLastFocused(null); return; } @@ -356,14 +302,14 @@ export default defineComponent({ layoutNode = layoutNode.children[idx!]; } this.setLastFocused(layoutNode); - setTimeout(this.autoAction, this.autoWaitTime); + setTimeout(this.autoAction, this.uiOpts.autoWaitTime); } else { // Determine available actions let action: Action | null; let actionWeights: {[key: string]: number}; // Maps actions to choice weights let node = this.lastFocused; if (node.children.length == 0){ - actionWeights = {'move across': 1, 'move up': 2, 'expand': 4}; + 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; @@ -377,7 +323,7 @@ export default defineComponent({ } else { actionWeights = { 'move across': 1, 'move down': 2, 'move up': 1, - 'collapse': 1, 'expand to view': 0.5, 'expand ancestry bar': 0.5 + 'collapse': 1, 'expand to view': 1, 'expand ancestry bar': 1 }; // Zero weights for disallowed actions if (node == this.activeRoot || node.parent!.children.length == 1){ @@ -416,12 +362,12 @@ export default defineComponent({ // Perform action this.autoPrevActionFail = false; switch (action){ - case 'move across': + 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': + 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; @@ -429,22 +375,63 @@ export default defineComponent({ this.setLastFocused(node.parent!); break; case 'expand': - this.autoPrevActionFail = !this.onInnerLeafClicked(node); + this.autoPrevActionFail = !this.onLeafClick(node); break; case 'collapse': - this.autoPrevActionFail = !this.onInnerHeaderClicked(node); + this.autoPrevActionFail = !this.onNonleafClick(node); break; case 'expand to view': - this.onInnerHeaderClickHeld(node); + this.onNonleafClickHeld(node); break; case 'expand ancestry bar': - this.onDetachedAncestorClicked(node.parent!); + this.onDetachedAncestorClick(node.parent!); break; } - setTimeout(this.autoAction, this.uiOpts.transitionDuration + this.autoWaitTime); + setTimeout(this.autoAction, this.uiOpts.tileChgDuration + this.uiOpts.autoWaitTime); this.autoPrevAction = action; } }, + // For settings events + onSettingsIconClick(){ + this.resetMode(); + this.settingsOpen = true; + }, + onLayoutOptionChange(){ + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, layoutMap: this.layoutMap}); + }, + // For other events + onResize(){ + if (!this.resizeThrottled){ + this.width = document.documentElement.clientWidth; + this.height = document.documentElement.clientHeight; + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, layoutMap: this.layoutMap}); + // Prevent re-triggering until after a delay + this.resizeThrottled = true; + setTimeout(() => {this.resizeThrottled = false;}, this.resizeDelay); + } + }, + onKeyUp(evt: KeyboardEvent){ + if (evt.key == 'Escape'){ + this.resetMode(); + } else if (evt.key == 'F' && evt.ctrlKey){ // On ctrl-shift-f + if (!this.searchOpen){ + this.onSearchIconClick(); + } else { + (this.$refs.searchModal as InstanceType<typeof SearchModal>).focusInput(); + } + } + }, + // Helper methods + resetMode(){ + this.infoModalNode = null; + this.searchOpen = false; + this.helpOpen = false; + this.settingsOpen = false; + this.modeRunning = false; + this.setLastFocused(null); + }, setLastFocused(node: LayoutNode | null){ if (this.lastFocused != null){ this.lastFocused.hasFocus = false; @@ -454,65 +441,64 @@ export default defineComponent({ node.hasFocus = true; } }, - onHelpIconClick(){ - this.closeModesAndSettings(); - this.helpOpen = true; - }, - onHelpModalClose(){ - this.helpOpen = false; - }, }, created(){ window.addEventListener('resize', this.onResize); window.addEventListener('keyup', this.onKeyUp); - tryLayout(this.activeRoot, this.layoutMap, this.tileAreaPos, this.tileAreaDims, this.lytOpts, true); + tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, + {allowCollapse: true, layoutMap: this.layoutMap}); }, unmounted(){ window.removeEventListener('resize', this.onResize); window.removeEventListener('keyup', this.onKeyUp); }, components: { - Tile, AncestryBar, TileInfoModal, SettingsPane, SearchModal, HelpModal, - SearchIcon, PlayIcon, HelpIcon, SettingsIcon, + Tile, AncestryBar, + HelpIcon, SearchIcon, PlayIcon, SettingsIcon, + TileInfoModal, HelpModal, SearchModal, SettingsPane, }, }); </script> <template> -<div :style="styles"> +<div class="absolute left-0 top-0 w-screen h-screen overflow-hidden" :style="{backgroundColor: uiOpts.appBgColor}"> + <!-- Note: Making the above enclosing div's width/height dynamic seems to cause white flashes when resizing --> <tile :layoutNode="layoutTree" :lytOpts="lytOpts" :uiOpts="uiOpts" - @leaf-clicked="onInnerLeafClicked" @header-clicked="onInnerHeaderClicked" - @leaf-click-held="onInnerLeafClickHeld" @header-click-held="onInnerHeaderClickHeld" - @info-icon-clicked="onInnerInfoIconClicked"/> + @leaf-click="onLeafClick" @nonleaf-click="onNonleafClick" + @leaf-click-held="onLeafClickHeld" @nonleaf-click-held="onNonleafClickHeld" + @info-icon-click="onInfoIconClick"/> <ancestry-bar v-if="detachedAncestors != null" :pos="[0,0]" :dims="ancestryBarDims" :nodes="detachedAncestors" :lytOpts="lytOpts" :uiOpts="uiOpts" - @detached-ancestor-clicked="onDetachedAncestorClicked" @info-icon-clicked="onInnerInfoIconClicked"/> + @detached-ancestor-click="onDetachedAncestorClick" @info-icon-click="onInfoIconClick"/> <!-- Icons --> <help-icon @click="onHelpIconClick" - class="absolute bottom-[6px] left-[6px] w-[18px] h-[18px] text-white/40 hover:text-white hover:cursor-pointer"/> + class="absolute bottom-[6px] left-[6px] w-[18px] h-[18px] + text-white/40 hover:text-white hover:cursor-pointer"/> <search-icon @click="onSearchIconClick" - class="absolute bottom-[6px] left-[30px] w-[18px] h-[18px] text-white/40 hover:text-white hover:cursor-pointer"/> + class="absolute bottom-[6px] left-[30px] w-[18px] h-[18px] + text-white/40 hover:text-white hover:cursor-pointer"/> <play-icon @click="onPlayIconClick" - class="absolute bottom-[6px] left-[54px] w-[18px] h-[18px] text-white/40 hover:text-white hover:cursor-pointer"/> + class="absolute bottom-[6px] left-[54px] w-[18px] h-[18px] + text-white/40 hover:text-white hover:cursor-pointer"/> <!-- Modals --> <transition name="fade"> <tile-info-modal v-if="infoModalNode != null" :tolNode="infoModalNode" :uiOpts="uiOpts" - @info-modal-close="onInfoModalClose"/> + @info-modal-close="infoModalNode = null"/> </transition> <transition name="fade"> - <search-modal v-if="searchOpen" :layoutTree="layoutTree" :tolMap="tolMap" :uiOpts="uiOpts" - @search-close="onSearchClose" @search-node="onSearchNode" ref="searchModal"/> + <search-modal v-if="searchOpen" :tolMap="tolMap" :uiOpts="uiOpts" + @search-close="searchOpen = false" @search-node="onSearchNode" ref="searchModal"/> </transition> <transition name="fade"> - <help-modal v-if="helpOpen" :uiOpts="uiOpts" @help-modal-close="onHelpModalClose"/> + <help-modal v-if="helpOpen" :uiOpts="uiOpts" @help-modal-close="helpOpen = false"/> </transition> <!-- Settings --> <transition name="slide-bottom-right"> <settings-pane v-if="settingsOpen" :lytOpts="lytOpts" :uiOpts="uiOpts" - @settings-close="onSettingsClose" @layout-option-change="onLayoutOptionChange"/> - <!-- outer div prevents transition interference with inner rotate --> + @settings-close="settingsOpen = false" @layout-option-change="onLayoutOptionChange"/> <div v-else class="absolute bottom-0 right-0 w-[100px] h-[100px] invisible"> + <!-- Note: Above enclosing div prevents transition interference with inner rotate --> <div class="absolute bottom-[-50px] right-[-50px] w-[100px] h-[100px] visible -rotate-45 bg-black text-white hover:cursor-pointer" @click="onSettingsIconClick"> <settings-icon class="w-6 h-6 mx-auto mt-2"/> @@ -520,46 +506,12 @@ export default defineComponent({ </div> </transition> <!-- Overlay used to prevent interaction and capture clicks --> - <div :style="{visibility: animationActive ? 'visible' : 'hidden'}" - class="absolute left-0 top-0 w-full h-full" @click="onOverlayClick"></div> + <div :style="{visibility: modeRunning ? 'visible' : 'hidden'}" + class="absolute left-0 top-0 w-full h-full" @click="modeRunning = false"></div> </div> </template> <style> -.animate-expand-shrink { - animation-name: expand-shrink; - animation-duration: 300ms; - animation-iteration-count: 1; - animation-timing-function: ease-in-out; -} -@keyframes expand-shrink { - from { - transform: scale(1, 1); - } - 50% { - transform: scale(1.1, 1.1); - } - to { - transform: scale(1, 1); - } -} -.animate-shrink-expand { - animation-name: shrink-expand; - animation-duration: 300ms; - animation-iteration-count: 1; - animation-timing-function: ease-in-out; -} -@keyframes shrink-expand { - from { - transform: translate3d(0,0,0) scale(1, 1); - } - 50% { - transform: translate3d(0,0,0) scale(0.9, 0.9); - } - to { - transform: translate3d(0,0,0) scale(1, 1); - } -} .fade-enter-from, .fade-leave-to { opacity: 0; } diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue index f7ce232..6d6ae3c 100644 --- a/src/components/AncestryBar.vue +++ b/src/components/AncestryBar.vue @@ -1,83 +1,87 @@ <script lang="ts"> import {defineComponent, PropType} from 'vue'; +import Tile from './Tile.vue' import {LayoutNode} from '../layout'; import type {LayoutOptions} from '../layout'; -import Tile from './Tile.vue' +// Displays a sequence of nodes, representing ancestors from a tree-of-life root to a currently-active root export default defineComponent({ props: { + // For absolute positioning pos: {type: Array as unknown as PropType<[number,number]>, required: true}, dims: {type: Array as unknown as PropType<[number,number]>, required: true}, + // The ancestors to display nodes: {type: Array as PropType<LayoutNode[]>, required: true}, + // Options lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, uiOpts: {type: Object, required: true}, }, - data(){ - return { - tileMargin: 5, //px (gap between detached-ancestor tiles) - scrollBarOffset: 10, //px (gap for scrollbar, used to prevent overlap with tiles) - }; - }, computed: { wideArea(){ return this.dims[0] >= this.dims[1]; }, tileSz(){ - return (this.wideArea ? this.dims[1] : this.dims[0]) - (this.tileMargin * 2) - this.scrollBarOffset; + return (this.wideArea ? this.dims[1] : this.dims[0]) - + (this.uiOpts.ancestryTileMargin * 2) - this.uiOpts.ancestryBarScrollGap; }, - usedNodes(){ + usedNodes(){ // Childless versions of 'nodes' used to parameterise <tile> return this.nodes.map(n => { let newNode = new LayoutNode(n.tolNode, []); newNode.dims = [this.tileSz, this.tileSz]; return newNode; }); }, - hasOverflow(){ - let len = this.tileMargin + (this.tileSz + this.tileMargin) * this.nodes.length; + overflowing(){ + let len = this.uiOpts.ancestryTileMargin + + (this.tileSz + this.uiOpts.ancestryTileMargin) * this.nodes.length; return len > (this.wideArea ? this.dims[0] : this.dims[1]); }, + width(){ + return this.dims[0] + (this.wideArea || this.overflowing ? 0 : -this.uiOpts.ancestryBarScrollGap); + }, + height(){ + return this.dims[1] + (!this.wideArea || this.overflowing ? 0 : -this.uiOpts.ancestryBarScrollGap); + }, styles(): Record<string,string> { return { position: 'absolute', left: this.pos[0] + 'px', top: this.pos[1] + 'px', - width: (this.dims[0] + (this.wideArea || this.hasOverflow ? 0 : -this.scrollBarOffset)) + 'px', - height: (this.dims[1] + (!this.wideArea || this.hasOverflow ? 0 : -this.scrollBarOffset)) + 'px', + width: this.width + 'px', + height: this.height + 'px', overflowX: this.wideArea ? 'auto' : 'hidden', overflowY: this.wideArea ? 'hidden' : 'auto', // Extra padding for scrollbar inclusion - paddingRight: (this.hasOverflow && !this.wideArea ? this.scrollBarOffset : 0) + 'px', - paddingBottom: (this.hasOverflow && this.wideArea ? this.scrollBarOffset : 0) + 'px', + paddingRight: (this.overflowing && !this.wideArea ? this.uiOpts.ancestryBarScrollGap : 0) + 'px', + paddingBottom: (this.overflowing && this.wideArea ? this.uiOpts.ancestryBarScrollGap : 0) + 'px', // For child layout display: 'flex', flexDirection: this.wideArea ? 'row' : 'column', - gap: this.tileMargin + 'px', - padding: this.tileMargin + 'px', - // - backgroundColor: '#44403c', + gap: this.uiOpts.ancestryTileMargin + 'px', + padding: this.uiOpts.ancestryTileMargin + 'px', + // Other + backgroundColor: this.uiOpts.ancestryBarBgColor, boxShadow: this.uiOpts.shadowNormal, }; }, }, methods: { - onClick(node: LayoutNode){ - this.$emit('detached-ancestor-clicked', node); + onTileClick(node: LayoutNode){ + this.$emit('detached-ancestor-click', node); }, - onInnerInfoIconClicked(data: LayoutNode){ - this.$emit('info-icon-clicked', data); + onInfoIconClick(data: LayoutNode){ + this.$emit('info-icon-click', data); } }, - components: { - Tile, - }, - emits: ['detached-ancestor-clicked', 'info-icon-clicked'], + components: {Tile, }, + emits: ['detached-ancestor-click', 'info-icon-click', ], }); </script> <template> <div :style="styles"> - <tile v-for="(node, idx) in usedNodes" :key="node.tolNode.name" :layoutNode="node" - :nonAbsPos="true" :lytOpts="lytOpts" :uiOpts="uiOpts" - @leaf-clicked="onClick(nodes[idx])" @info-icon-clicked="onInnerInfoIconClicked"/> + <tile v-for="(node, idx) in usedNodes" :key="node.tolNode.name" class="shrink-0" + :layoutNode="node" :nonAbsPos="true" :lytOpts="lytOpts" :uiOpts="uiOpts" + @leaf-click="onTileClick(nodes[idx])" @info-icon-click="onInfoIconClick"/> </div> </template> diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index 30b1b21..539d3dc 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -2,27 +2,28 @@ import {defineComponent, PropType} from 'vue'; import CloseIcon from './icon/CloseIcon.vue'; +// Displays help information export default defineComponent({ props: { uiOpts: {type: Object, required: true}, }, methods: { - closeClicked(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon.$el as HTMLElement).contains(evt.target as HTMLElement)){ + onCloseClick(evt: Event){ + if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ this.$emit('help-modal-close'); } }, }, components: {CloseIcon, }, - emits: ['help-modal-close'], + emits: ['help-modal-close', ], }); </script> <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="closeClicked"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onCloseClick"> <div class="absolute left-1/2 -translate-x-1/2 w-4/5 top-1/2 -translate-y-1/2 p-4 bg-stone-50 rounded-md shadow shadow-black"> - <close-icon @click.stop="closeClicked" ref="closeIcon" + <close-icon @click.stop="onCloseClick" ref="closeIcon" class="block absolute top-2 right-2 w-6 h-6 hover:cursor-pointer"/> <h1 class="text-center text-xl font-bold mb-2">Help Info</h1> <hr class="mb-4 border-stone-400"/> diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue index 369b632..91f06ae 100644 --- a/src/components/SearchModal.vue +++ b/src/components/SearchModal.vue @@ -4,15 +4,17 @@ import SearchIcon from './icon/SearchIcon.vue'; import {TolNode} from '../tol'; import {LayoutNode} from '../layout'; +// Displays a search box, and sends search requests export default defineComponent({ props: { - layoutTree: {type: Object as PropType<LayoutNode>, required: true}, + // Map from tree-of-life node names to TolNode objects tolMap: {type: Object as PropType<Map<string,TolNode>>, required: true}, + // Options uiOpts: {type: Object, required: true}, }, methods: { - closeClicked(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon.$el as HTMLElement).contains(evt.target as HTMLElement)){ + onCloseClick(evt: Event){ + if (evt.target == this.$el || (this.$refs.searchInput as typeof SearchIcon).$el.contains(evt.target)){ this.$emit('search-close'); } }, @@ -37,16 +39,16 @@ export default defineComponent({ (this.$refs.searchInput as HTMLInputElement).focus(); }, components: {SearchIcon, }, - emits: ['search-node', 'search-close'] + emits: ['search-node', 'search-close', ], }); </script> <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="closeClicked"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onCloseClick"> <div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 p-3 bg-stone-50 rounded-md shadow shadow-black flex gap-1"> <input type="text" class="block border" - @keyup.enter="onSearchEnter" @keyup.esc="closeClicked" ref="searchInput"/> + @keyup.enter="onSearchEnter" @keyup.esc="onCloseClick" ref="searchInput"/> <search-icon @click.stop="onSearchEnter" class="block w-6 h-6 ml-1 hover:cursor-pointer hover:bg-stone-200" /> </div> diff --git a/src/components/SettingsPane.vue b/src/components/SettingsPane.vue index c9f1833..990d1f7 100644 --- a/src/components/SettingsPane.vue +++ b/src/components/SettingsPane.vue @@ -3,18 +3,19 @@ import {defineComponent, PropType} from 'vue'; import CloseIcon from './icon/CloseIcon.vue'; import type {LayoutOptions} from '../layout'; +// Displays configurable options, and sends option-change requests export default defineComponent({ props: { lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, uiOpts: {type: Object, required: true}, }, methods: { - closeClicked(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon.$el as HTMLElement).contains(evt.target as HTMLElement)){ + onCloseClick(evt: Event){ + if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ this.$emit('settings-close'); } }, - onLayoutOptChg(){ + onLytOptChg(){ this.$emit('layout-option-change'); }, onMinTileSzChg(){ @@ -23,7 +24,7 @@ export default defineComponent({ if (Number(minInput.value) > Number(maxInput.value)){ this.lytOpts.maxTileSz = this.lytOpts.minTileSz; } - this.onLayoutOptChg(); + this.onLytOptChg(); }, onMaxTileSzChg(){ let minInput = this.$refs.minTileSzInput as HTMLInputElement; @@ -31,7 +32,7 @@ export default defineComponent({ if (Number(maxInput.value) < Number(minInput.value)){ this.lytOpts.minTileSz = this.lytOpts.maxTileSz; } - this.onLayoutOptChg(); + this.onLytOptChg(); }, }, components: {CloseIcon, }, @@ -41,13 +42,13 @@ export default defineComponent({ <template> <div class="absolute bottom-4 right-4 min-w-[5cm] p-3 bg-stone-50 visible rounded-md shadow shadow-black"> - <close-icon @click="closeClicked" ref="closeIcon" + <close-icon @click="onCloseClick" ref="closeIcon" class="block absolute top-2 right-2 w-6 h-6 hover:cursor-pointer" /> <h1 class="text-xl font-bold mb-2">Settings</h1> <hr class="border-stone-400"/> <div> <label>Tile Spacing <input type="range" min="0" max="20" class="mx-2 w-[3cm]" - v-model.number="lytOpts.tileSpacing" @input="onLayoutOptChg"/></label> + v-model.number="lytOpts.tileSpacing" @input="onLytOptChg"/></label> </div> <hr class="border-stone-400"/> <div> @@ -70,22 +71,22 @@ export default defineComponent({ <ul> <li> <label> <input type="radio" v-model="lytOpts.layoutType" value="sqr" - @change="onLayoutOptChg"/> Squares </label> + @change="onLytOptChg"/> Squares </label> </li> <li> <label> <input type="radio" v-model="lytOpts.layoutType" value="rect" - @change="onLayoutOptChg"/> Rectangles </label> + @change="onLytOptChg"/> Rectangles </label> </li> <li> <label> <input type="radio" v-model="lytOpts.layoutType" value="sweep" - @change="onLayoutOptChg"/> Sweep to side </label> + @change="onLytOptChg"/> Sweep to side </label> </li> </ul> </div> <hr class="border-stone-400"/> <div> - <label> <input type="checkbox" v-model="lytOpts.sweepingToParent" - @change="onLayoutOptChg"/> Sweep to parent</label> + <label> <input type="checkbox" v-model="lytOpts.sweepToParent" + @change="onLytOptChg"/> Sweep to parent</label> </div> <hr class="border-stone-400"/> <div> @@ -93,26 +94,26 @@ export default defineComponent({ <ul> <li> <label> <input type="radio" v-model="lytOpts.sweepMode" value="left" - @change="onLayoutOptChg"/> To left </label> + @change="onLytOptChg"/> To left </label> </li> <li> <label> <input type="radio" v-model="lytOpts.sweepMode" value="top" - @change="onLayoutOptChg"/> To top </label> + @change="onLytOptChg"/> To top </label> </li> <li> <label> <input type="radio" v-model="lytOpts.sweepMode" value="shorter" - @change="onLayoutOptChg"/> To shorter </label> + @change="onLytOptChg"/> To shorter </label> </li> <li> <label> <input type="radio" v-model="lytOpts.sweepMode" value="auto" - @change="onLayoutOptChg"/> Auto </label> + @change="onLytOptChg"/> Auto </label> </li> </ul> </div> <hr class="border-stone-400"/> <div> - <label>Animation Speed <input type="range" min="0" max="1000" class="mx-2 w-[3cm]" - v-model.number="uiOpts.transitionDuration"/></label> + <label>Animation Duration <input type="range" min="0" max="1000" class="mx-2 w-[3cm]" + v-model.number="uiOpts.tileChgDuration"/></label> </div> </div> </template> diff --git a/src/components/Tile.vue b/src/components/Tile.vue index 9e10615..a17869b 100644 --- a/src/components/Tile.vue +++ b/src/components/Tile.vue @@ -4,166 +4,191 @@ import InfoIcon from './icon/InfoIcon.vue'; import {LayoutNode} from '../layout'; import type {LayoutOptions} from '../layout'; -// Component holds a tree-node structure representing a tile or tile-group to be rendered +// Displays one, or a hierarchy of, tree-of-life nodes, as a 'tile' export default defineComponent({ props: { + // A LayoutNode representing a laid-out tree-of-life node to display layoutNode: {type: Object as PropType<LayoutNode>, required: true}, + // Options lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, uiOpts: {type: Object, required: true}, - nonAbsPos: {type: Boolean, default: false}, // Don't use absolute positioning (only applies for leaf nodes) + // For a leaf node, prevents usage of absolute positioning (used by AncestryBar) + nonAbsPos: {type: Boolean, default: false}, }, data(){ return { - highlight: false, - clickHoldTimer: 0, // Used to recognise a click-and-hold event - animating: false, // Used to prevent content overlap and overflow during transitions - } + highlight: false, // Used to draw a colored outline on mouse hover, etc + inTransition: false, // Used to prevent content overlap and overflow during transitions + clickHoldTimer: 0, // Used to recognise click-and-hold events + }; }, computed: { - isLeaf(){ + // Basic abbreviations + isLeaf(): boolean { return this.layoutNode.children.length == 0; }, - isExpandable(){ - return this.layoutNode.tolNode.children.length > this.layoutNode.children.length; + isExpandableLeaf(): boolean { + return this.isLeaf && this.layoutNode.tolNode.children.length > 0; }, - showHeader(){ - return (this.layoutNode.showHeader && !this.layoutNode.sepSweptArea) || - (this.layoutNode.sepSweptArea && this.layoutNode.sepSweptArea.sweptLeft); + showNonleafHeader(): boolean { + return (this.layoutNode.showHeader && this.layoutNode.sepSweptArea == null) || + (this.layoutNode.sepSweptArea != null && this.layoutNode.sepSweptArea.sweptLeft); }, - nonLeafBgColor(){ - let colorArray = this.uiOpts.nonLeafBgColors; + // Style related + nonleafBgColor(): string { + let colorArray = this.uiOpts.nonleafBgColors; return colorArray[this.layoutNode.depth % colorArray.length]; }, - tileStyles(): Record<string,string> { + boxShadow(): string { + if (this.highlight){ + return this.uiOpts.shadowHighlight; + } else if (this.layoutNode.hasFocus && !this.inTransition){ + return this.uiOpts.shadowFocused; + } else { + return this.uiOpts.shadowNormal; + } + }, + 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', + visibility: 'visible', + }; + if (this.layoutNode.hidden){ + layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0'; + layoutStyles.visibility = 'hidden'; + } + if (this.nonAbsPos){ + layoutStyles.position = 'static'; + } return { - // Places div using layoutNode, with centering if root - position: this.nonAbsPos && this.isLeaf ? 'static' : 'absolute', - left: (this.layoutNode.hidden ? 0 : this.layoutNode.pos[0]) + 'px', - top: (this.layoutNode.hidden ? 0 : this.layoutNode.pos[1]) + 'px', - width: (this.layoutNode.hidden ? 0 : this.layoutNode.dims[0]) + 'px', - height: (this.layoutNode.hidden ? 0 : this.layoutNode.dims[1]) + 'px', - visibility: this.layoutNode.hidden ? 'hidden' : 'visible', - // Other bindings - transitionDuration: this.uiOpts.transitionDuration + 'ms', - zIndex: this.animating ? '1' : '0', - overflow: this.animating && !this.isLeaf ? 'hidden' : 'visible', - // Static styles + ...layoutStyles, + // Transition related + transitionDuration: this.uiOpts.tileChgDuration + 'ms', transitionProperty: 'left, top, width, height, visibility', transitionTimingFunction: 'ease-out', + zIndex: this.inTransition ? '1' : '0', + overflow: this.inTransition && !this.isLeaf ? 'hidden' : 'visible', // CSS variables - '--nonLeafBgColor': this.nonLeafBgColor, + '--nonleafBgColor': this.nonleafBgColor, '--tileSpacing': this.lytOpts.tileSpacing + 'px', }; }, leafStyles(): Record<string,string> { return { - width: '100%', - height: '100%', - // Image + // Image (and scrims) backgroundImage: - 'linear-gradient(to bottom, rgba(0,0,0,0.4), rgba(0,0,0,0) 40%, rgba(0,0,0,0) 60%, rgba(0,0,0,0.4) 100%),' + + 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.4) 100%),' + 'url(\'/img/' + this.layoutNode.tolNode.name.replaceAll('\'', '\\\'') + '.png\')', backgroundSize: 'cover', - // Child layout - display: 'flex', - flexDirection: 'column', // Other borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.highlight ? this.uiOpts.shadowHighlight : - (this.layoutNode.hasFocus ? this.uiOpts.shadowFocused : this.uiOpts.shadowNormal), + boxShadow: this.boxShadow, }; }, leafHeaderStyles(): Record<string,string> { return { - height: (this.uiOpts.imgTileFontSz + this.uiOpts.imgTilePadding * 2) + 'px', - lineHeight: this.uiOpts.imgTileFontSz + 'px', - fontSize: this.uiOpts.imgTileFontSz + 'px', - padding: this.uiOpts.imgTilePadding + 'px', - color: this.isExpandable ? this.uiOpts.expandableImgTileColor : this.uiOpts.imgTileColor, + height: (this.uiOpts.leafHeaderFontSz + this.uiOpts.leafTilePadding * 2) + 'px', + padding: this.uiOpts.leafTilePadding + 'px', + lineHeight: this.uiOpts.leafHeaderFontSz + 'px', + fontSize: this.uiOpts.leafHeaderFontSz + 'px', + color: !this.isExpandableLeaf ? this.uiOpts.leafHeaderColor : this.uiOpts.leafHeaderExColor, // For ellipsis overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }; }, - nonLeafStyles(): Record<string,string> { - let temp = { - width: '100%', - height: '100%', - backgroundColor: this.nonLeafBgColor, - borderRadius: this.uiOpts.borderRadius + 'px', - boxShadow: this.animating ? 'none' : (this.highlight ? this.uiOpts.shadowHighlight : - (this.layoutNode.hasFocus ? this.uiOpts.shadowFocused : this.uiOpts.shadowNormal)), - }; + nonleafStyles(): Record<string,string> { + let borderR = this.uiOpts.borderRadius + 'px'; if (this.layoutNode.sepSweptArea != null){ - let r = this.uiOpts.borderRadius + 'px'; - temp = this.layoutNode.sepSweptArea.sweptLeft ? - {...temp, borderRadius: `${r} ${r} ${r} 0`} : - {...temp, borderRadius: `${r} 0 ${r} ${r}`}; + borderR = this.layoutNode.sepSweptArea.sweptLeft ? + `${borderR} ${borderR} ${borderR} 0` : + `${borderR} 0 ${borderR} ${borderR}`; } - return temp; + return { + backgroundColor: this.nonleafBgColor, + borderRadius: borderR, + boxShadow: this.boxShadow, + }; }, - nonLeafHeaderStyles(): Record<string,string> { - let r = this.uiOpts.borderRadius + 'px'; + nonleafHeaderStyles(): Record<string,string> { + let borderR = this.uiOpts.borderRadius + 'px'; + borderR = `${borderR} ${borderR} 0 0`; return { height: this.lytOpts.headerSz + 'px', + borderRadius: borderR, + backgroundColor: this.uiOpts.nonleafHeaderBgColor, + }; + }, + nonleafHeaderTextStyles(): Record<string,string> { + return { lineHeight: this.lytOpts.headerSz + 'px', - fontSize: this.uiOpts.nonLeafHeaderFontSz + 'px', + fontSize: this.uiOpts.nonleafHeaderFontSz + 'px', textAlign: 'center', - color: this.uiOpts.nonLeafHeaderColor, - backgroundColor: this.uiOpts.nonLeafHeaderBgColor, - borderRadius: `${r} ${r} 0 0`, + color: this.uiOpts.nonleafHeaderColor, // For ellipsis overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }; }, + infoIconStyles(): Record<string,string> { + let size = this.uiOpts.infoIconSz + 'px'; + return { + width: size, + height: size, + minWidth: size, + minHeight: size, + margin: this.uiOpts.infoIconMargin + 'px', + }; + }, sepSweptAreaStyles(): Record<string,string> { - let commonStyles = { + let borderR = this.uiOpts.borderRadius + 'px'; + let styles = { position: 'absolute', - backgroundColor: this.nonLeafBgColor, - boxShadow: this.animating ? 'none' : (this.highlight ? this.uiOpts.shadowHighlight : - (this.layoutNode.hasFocus ? this.uiOpts.shadowFocused : this.uiOpts.shadowNormal)), - transitionDuration: this.uiOpts.transitionDuration + 'ms', - transitionProperty: 'left, top, width, height', + backgroundColor: this.nonleafBgColor, + boxShadow: this.boxShadow, + transitionDuration: this.uiOpts.tileChgDuration + 'ms', + transitionProperty: 'left, top, width, height, visibility', transitionTimingFunction: 'ease-out', }; let area = this.layoutNode.sepSweptArea; - if (this.layoutNode.hidden || area == null){ + 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 { - ...commonStyles, + ...styles, visibility: 'hidden', left: '0', top: this.lytOpts.headerSz + 'px', width: '0', height: '0', - }; - } else { - let r = this.uiOpts.borderRadius + 'px'; - return { - ...commonStyles, - left: area.pos[0] + 'px', - top: area.pos[1] + 'px', - width: area.dims[0] + 'px', - height: area.dims[1] + 'px', - borderRadius: area.sweptLeft ? `${r} 0 0 ${r}` : `${r} ${r} 0 0`, + borderRadius: borderR, }; } }, - collapseFailFlag(){ - return this.layoutNode.collapseFailFlag; - }, - expandFailFlag(){ - return this.layoutNode.expandFailFlag; + // Other + failFlag(){ + return this.layoutNode.failFlag; }, }, watch: { - expandFailFlag(newVal){ - this.triggerAnimation('animate-expand-shrink'); - }, - collapseFailFlag(newVal){ - this.triggerAnimation('animate-shrink-expand'); + failFlag(newVal){ + this.triggerAnimation(this.isLeaf ? 'animate-expand-shrink' : 'animate-shrink-expand'); }, }, methods: { @@ -184,115 +209,98 @@ export default defineComponent({ } }, onClick(){ - if (this.isLeaf && !this.isExpandable){ + if (this.isLeaf && !this.isExpandableLeaf){ console.log('Ignored click on non-expandable node'); return; } this.prepForTransition(); - if (this.isLeaf){ - this.$emit('leaf-clicked', this.layoutNode); - } else { - this.$emit('header-clicked', this.layoutNode); - } + this.$emit(this.isLeaf ? 'leaf-click' : 'nonleaf-click', this.layoutNode); }, onClickHold(){ - if (this.isLeaf && !this.isExpandable){ + if (this.isLeaf && !this.isExpandableLeaf){ console.log('Ignored click-hold on non-expandable node'); return; } this.prepForTransition(); - if (this.isLeaf){ - this.$emit('leaf-click-held', this.layoutNode); - } else { - this.$emit('header-click-held', this.layoutNode); - } + this.$emit(this.isLeaf ? 'leaf-click-held' : 'nonleaf-click-held', this.layoutNode); }, prepForTransition(){ - this.animating = true; - setTimeout(() => {this.animating = false}, this.uiOpts.transitionDuration); + this.inTransition = true; + setTimeout(() => {this.inTransition = false}, this.uiOpts.tileChgDuration); }, - onInfoClick(evt: Event){ - this.$emit('info-icon-clicked', this.layoutNode); + onInfoIconClick(evt: Event){ + this.$emit('info-icon-click', this.layoutNode); }, - // For coloured outlines on hover + // Mouse hover handling onMouseEnter(evt: Event){ - if (!this.isLeaf || this.isExpandable){ + if ((!this.isLeaf || this.isExpandableLeaf) && !this.inTransition){ this.highlight = true; } }, onMouseLeave(evt: Event){ - if (!this.isLeaf || this.isExpandable){ - this.highlight = false; - } + this.highlight = false; }, // Child event propagation - onInnerLeafClicked(data: LayoutNode){ - this.$emit('leaf-clicked', data); + onInnerLeafClick(node: LayoutNode){ + this.$emit('leaf-click', node); }, - onInnerHeaderClicked(data: LayoutNode){ - this.$emit('header-clicked', data); + onInnerNonleafClick(node: LayoutNode){ + this.$emit('nonleaf-click', node); }, - onInnerLeafClickHeld(data: LayoutNode){ - this.$emit('leaf-click-held', data); + onInnerLeafClickHeld(node: LayoutNode){ + this.$emit('leaf-click-held', node); }, - onInnerHeaderClickHeld(data: LayoutNode){ - this.$emit('header-click-held', data); + onInnerNonleafClickHeld(node: LayoutNode){ + this.$emit('nonleaf-click-held', node); }, - onInnerInfoIconClicked(data: LayoutNode){ - this.$emit('info-icon-clicked', data); + onInnerInfoIconClick(node: LayoutNode){ + this.$emit('info-icon-click', node); }, - // - triggerAnimation(animationClass: string){ - this.$el.classList.remove(animationClass); + // Other + triggerAnimation(animation: string){ + this.$el.classList.remove(animation); this.$el.offsetWidth; // Triggers reflow - this.$el.classList.add(animationClass); + this.$el.classList.add(animation); }, }, - name: 'tile', // Need this to use self in template + name: 'tile', // Note: Need this to use self in template components: {InfoIcon, }, - emits: ['leaf-clicked', 'header-clicked', 'leaf-click-held', 'header-click-held', 'info-icon-clicked'], + emits: ['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-icon-click', ], }); </script> <template> -<div :style="tileStyles"> - <div v-if="isLeaf" :style="leafStyles" :class="isExpandable ? ['hover:cursor-pointer'] : []" - @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" - @mousedown="onMouseDown" @mouseup="onMouseUp"> +<div :style="styles"> <!-- Enclosing div needed for size transitions --> + <div v-if="isLeaf" :style="leafStyles" + class="w-full h-full flex flex-col overflow-hidden" :class="{'hover:cursor-pointer': isExpandableLeaf}" + @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp"> <h1 :style="leafHeaderStyles">{{layoutNode.tolNode.name}}</h1> - <info-icon - class="w-[18px] h-[18px] mt-auto mb-[2px] mr-[2px] self-end - text-white/30 hover:text-white hover:cursor-pointer" - @click.stop="onInfoClick" @mousedown.stop @mouseup.stop/> + <info-icon :style="[infoIconStyles, {marginTop: 'auto'}]" + class="self-end text-white/10 hover:text-white hover:cursor-pointer" + @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/> </div> - <div v-else :style="nonLeafStyles" ref="nonLeaf"> - <div v-if="showHeader" :style="nonLeafHeaderStyles" class="flex hover:cursor-pointer" - @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" - @mousedown="onMouseDown" @mouseup="onMouseUp"> - <h1 class="grow">{{layoutNode.tolNode.name}}</h1> - <info-icon - class="w-[18px] h-[18px] mr-[2px] - text-white/20 hover:text-white hover:cursor-pointer" - @click.stop="onInfoClick" @mousedown.stop @mouseup.stop/> + <div v-else :style="nonleafStyles" class="w-full h-full" ref="nonleaf"> + <div v-if="showNonleafHeader" :style="nonleafHeaderStyles" class="flex hover:cursor-pointer" + @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp"> + <h1 :style="nonleafHeaderTextStyles" class="grow">{{layoutNode.tolNode.name}}</h1> + <info-icon :style="infoIconStyles" class="text-white/10 hover:text-white hover:cursor-pointer" + @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/> </div> <div :style="sepSweptAreaStyles" ref="sepSweptArea" :class="layoutNode?.sepSweptArea?.sweptLeft ? 'hide-right-edge' : 'hide-top-edge'"> <div v-if="layoutNode?.sepSweptArea?.sweptLeft === false" - :style="nonLeafHeaderStyles" class="flex hover:cursor-pointer" - @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" - @mousedown="onMouseDown" @mouseup="onMouseUp"> - <h1 class="grow">{{layoutNode.tolNode.name}}</h1> - <info-icon - class="w-[18px] h-[18px] mr-[2px] - text-white/20 hover:text-white hover:cursor-pointer" - @click.stop="onInfoClick" @mousedown.stop @mouseup.stop/> + :style="nonleafHeaderStyles" class="flex hover:cursor-pointer" + @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp"> + <h1 :style="nonleafHeaderTextStyles" class="grow">{{layoutNode.tolNode.name}}</h1> + <info-icon :style="infoIconStyles" class="text-white/10 hover:text-white hover:cursor-pointer" + @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/> </div> </div> - <tile v-for="child in layoutNode.children" :key="child.tolNode.name" :layoutNode="child" - :lytOpts="lytOpts" :uiOpts="uiOpts" - @leaf-clicked="onInnerLeafClicked" @header-clicked="onInnerHeaderClicked" - @leaf-click-held="onInnerLeafClickHeld" @header-click-held="onInnerHeaderClickHeld" - @info-icon-clicked="onInnerInfoIconClicked"/> + <tile v-for="child in layoutNode.children" :key="child.tolNode.name" + :layoutNode="child" :lytOpts="lytOpts" :uiOpts="uiOpts" + @leaf-click="onInnerLeafClick" @nonleaf-click="onInnerNonleafClick" + @leaf-click-held="onInnerLeafClickHeld" @nonleaf-click-held="onInnerNonleafClickHeld" + @info-icon-click="onInnerInfoIconClick"/> </div> </div> </template> @@ -301,7 +309,7 @@ export default defineComponent({ .hide-right-edge::before { content: ''; position: absolute; - background-color: var(--nonLeafBgColor); + background-color: var(--nonleafBgColor); right: calc(0px - var(--tileSpacing)); bottom: 0; width: var(--tileSpacing); @@ -310,10 +318,44 @@ export default defineComponent({ .hide-top-edge::before { content: ''; position: absolute; - background-color: var(--nonLeafBgColor); + background-color: var(--nonleafBgColor); bottom: calc(0px - var(--tileSpacing)); right: 0; width: calc(100% + var(--tileSpacing)); height: var(--tileSpacing); } +.animate-expand-shrink { + animation-name: expand-shrink; + animation-duration: 300ms; + animation-iteration-count: 1; + animation-timing-function: ease-in-out; +} +@keyframes expand-shrink { + from { + transform: scale(1, 1); + } + 50% { + transform: scale(1.1, 1.1); + } + to { + transform: scale(1, 1); + } +} +.animate-shrink-expand { + animation-name: shrink-expand; + animation-duration: 300ms; + animation-iteration-count: 1; + animation-timing-function: ease-in-out; +} +@keyframes shrink-expand { + from { + transform: translate3d(0,0,0) scale(1, 1); + } + 50% { + transform: translate3d(0,0,0) scale(0.9, 0.9); + } + to { + transform: translate3d(0,0,0) scale(1, 1); + } +} </style> diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue index cfa1a10..7549375 100644 --- a/src/components/TileInfoModal.vue +++ b/src/components/TileInfoModal.vue @@ -3,6 +3,7 @@ import {defineComponent, PropType} from 'vue'; import CloseIcon from './icon/CloseIcon.vue'; import {TolNode} from '../tol'; +// Displays information about a tree-of-life node export default defineComponent({ props: { tolNode: {type: Object as PropType<TolNode>, required: true}, @@ -16,26 +17,26 @@ export default defineComponent({ height: this.uiOpts.infoModalImgSz + 'px', backgroundSize: 'cover', borderRadius: this.uiOpts.borderRadius + 'px', - } + }; }, }, methods: { - closeClicked(evt: Event){ - if (evt.target == this.$el || (this.$refs.closeIcon.$el as HTMLElement).contains(evt.target as HTMLElement)){ + onCloseClick(evt: Event){ + if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){ this.$emit('info-modal-close'); } }, }, components: {CloseIcon, }, - emits: ['info-modal-close'], + emits: ['info-modal-close', ], }); </script> <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="closeClicked"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onCloseClick"> <div class="absolute left-1/2 -translate-x-1/2 w-4/5 top-1/2 -translate-y-1/2 p-4 bg-stone-50 rounded-md shadow shadow-black"> - <close-icon @click.stop="closeClicked" ref="closeIcon" + <close-icon @click.stop="onCloseClick" ref="closeIcon" class="block absolute top-2 right-2 w-6 h-6 hover:cursor-pointer"/> <h1 class="text-center text-xl font-bold mb-2">{{tolNode.name}}</h1> <hr class="mb-4 border-stone-400"/> diff --git a/src/layout.ts b/src/layout.ts index 711f20a..d863fa0 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -1,52 +1,53 @@ /* - * Contains classes used for representing tile-based layouts of tree-of-life data. + * Contains classes for representing tile-based layouts of tree-of-life data. * - * Generally, given a TolNode tree T,initLayoutTree() produces a tree structure representing a subtree of T, - * which is passed to tryLayout(), which alters data fields to represent a tile-based layout. - * The tree structure consists of LayoutNode objects, each of which holds placement info for a linked TolNode. + * Generally, given a TolNode tree T, initLayoutTree() produces a + * subtree-analagous LayoutNode tree, for which tryLayout() can attempt to + * find a tile-based layout, filling in node fields to represent placement. */ import {TolNode} from './tol'; import {range, arraySum, limitVals, updateAscSeq} from './util'; -// Represents a node/tree, and holds layout data for a TolNode node/tree +// Represents a node/tree that holds layout data for a TolNode node/tree export class LayoutNode { tolNode: TolNode; + // Tree-structure related children: LayoutNode[]; parent: LayoutNode | null; - // Used for rendering a corresponding tile + dCount: number; // Number of descendant leaf nodes + depth: number; // Number of ancestor nodes + // Layout data pos: [number, number]; dims: [number, number]; showHeader: boolean; - sepSweptArea: SepSweptArea | null; - hidden: boolean; - hasFocus: boolean; - collapseFailFlag: boolean; // Used to trigger failure animations - expandFailFlag: boolean; // Used to trigger failure animations - // Used for layout heuristics and info display - dCount: number; // Number of descendant leaf nodes - depth: number; // Number of ancestor nodes - empSpc: number; // Amount of unused space (in pixels) - // Creates object with given fields ('parent' are 'depth' are generally initialised later, 'dCount' is computed) + sepSweptArea: SepSweptArea | null; // Used with layout option 'sweepToParent' + empSpc: number; // Amount of unused layout space (in pixels) + // Other + hidden: boolean; // Used to hide nodes upon an expand-to-view + hasFocus: boolean; // Used by search and auto-mode to highlight a tile + failFlag: boolean; // Used to trigger failure animations + // Constructor ('parent' are 'depth' are generally initialised later, 'dCount' is computed) constructor(tolNode: TolNode, children: LayoutNode[]){ this.tolNode = tolNode; this.children = children; this.parent = null; + this.dCount = children.length == 0 ? 1 : arraySum(children.map(n => n.dCount)); + this.depth = 0; + // this.pos = [0,0]; this.dims = [0,0]; this.showHeader = false; this.sepSweptArea = null; + this.empSpc = 0; + // this.hidden = false; this.hasFocus = false; - this.collapseFailFlag = false; - this.expandFailFlag = false; - this.dCount = children.length == 0 ? 1 : arraySum(children.map(n => n.dCount)); - this.depth = 0; - this.empSpc = 0; + this.failFlag = false; } - // Creates new node tree with the same structure (fields like 'pos' are set to defaults) + // Returns a new tree with the same structure and TolNode linkage // 'chg' is usable to apply a change to the resultant tree - cloneNodeTree(chg?: LayoutTreeChg){ + cloneNodeTree(chg?: LayoutTreeChg | null): LayoutNode { let newNode: LayoutNode; if (chg != null && this == chg.node){ switch (chg.type){ @@ -70,15 +71,16 @@ export class LayoutNode { newNode.depth = this.depth; return newNode; } - // Copies render-relevant data to a given LayoutNode tree + // Copies layout data to a given LayoutNode tree // If a target node has more/less children, removes/gives own children - copyTreeForRender(target: LayoutNode, map?: LayoutMap): void { + // If 'map' is provided, it is updated to represent node additions/removals + copyTreeForRender(target: LayoutNode, map?: LayoutMap | null): void { target.pos = this.pos; target.dims = this.dims; target.showHeader = this.showHeader; target.sepSweptArea = this.sepSweptArea; target.dCount = this.dCount; // Copied for structural-consistency - target.empSpc = this.empSpc; // Currently redundant, but maintains data-consistency + target.empSpc = this.empSpc; // Note: Currently redundant, but maintains data-consistency // Handle children if (this.children.length == target.children.length){ this.children.forEach((n,i) => n.copyTreeForRender(target.children[i], map)); @@ -95,9 +97,9 @@ export class LayoutNode { } } } - // Assigns render-relevant data to this single node - assignLayoutData(pos=[0,0] as [number,number], dims=[0,0] as [number,number], - {showHeader=false, sepSweptArea=null as SepSweptArea|null, empSpc=0} = {}){ + // Assigns layout data to this single node + assignLayoutData(pos = [0,0] as [number,number], dims = [0,0] as [number,number], + {showHeader = false, sepSweptArea = null as SepSweptArea | null, empSpc = 0} = {}): void { this.pos = [...pos]; this.dims = [...dims]; this.showHeader = showHeader; @@ -111,19 +113,19 @@ export class LayoutNode { node = node.parent; } } - // Used to hide/show parent nodes upon expand-to-view - static hideUpward(node: LayoutNode){ + // These are used to hide/show parent nodes upon an expand-to-view + static hideUpward(node: LayoutNode): void { if (node.parent != null){ node.parent.hidden = true; node.parent.children.filter(n => n != node).forEach(n => LayoutNode.hideDownward(n)); LayoutNode.hideUpward(node.parent); } } - static hideDownward(node: LayoutNode){ + static hideDownward(node: LayoutNode): void { node.hidden = true; node.children.forEach(n => LayoutNode.hideDownward(n)); } - static showDownward(node: LayoutNode){ + static showDownward(node: LayoutNode): void { if (node.hidden){ node.hidden = false; node.children.forEach(n => LayoutNode.showDownward(n)); @@ -132,22 +134,24 @@ export class LayoutNode { } // Contains settings that affect how layout is done export type LayoutOptions = { - tileSpacing: number; // Spacing between tiles, in pixels (ignoring borders) + tileSpacing: number; // Spacing between tiles, in pixels headerSz: number; - minTileSz: number; // Minimum size of a tile edge, in pixels (ignoring borders) + minTileSz: number; // Minimum size of a tile edge, in pixels maxTileSz: number; + // Layout-algorithm related layoutType: 'sqr' | 'rect' | 'sweep'; // The LayoutFn function to use rectMode: 'horz' | 'vert' | 'linear' | 'auto' | 'auto first-row'; - // Layout in 1 row, 1 column, 1 row or column, or multiple rows (with/without first-row-heuristic) + // Rect-layout in 1 row, 1 column, 1 row or column, or multiple rows (optionally with first-row-heuristic) sweepMode: 'left' | 'top' | 'shorter' | 'auto'; // Sweep to left, top, shorter-side, or to minimise empty space sweptNodesPrio: 'linear' | 'sqrt' | 'pow-2/3'; // Specifies allocation of space to swept-vs-remaining nodes - sweepingToParent: boolean; // Allow swept nodes to occupy empty space in a parent's swept-leaves area + sweepToParent: boolean; // Allow swept nodes to occupy empty space in a parent's swept-leaves area }; +// Represents a change to a LayoutNode tree export type LayoutTreeChg = { type: 'expand' | 'collapse'; node: LayoutNode; } -// Used with layout option 'sweepingToParent', and represents, for a LayoutNode, a parent area to place leaf nodes in +// Used with layout option 'sweepToParent', and represents, for a LayoutNode, a parent area to place leaf nodes in export class SepSweptArea { pos: [number, number]; dims: [number, number]; @@ -161,8 +165,29 @@ export class SepSweptArea { return new SepSweptArea([...this.pos], [...this.dims], this.sweptLeft); } } -// + +// Represents a map from TolNode names to nodes in a LayoutNode tree export type LayoutMap = Map<string, LayoutNode>; +// Creates a LayoutMap for a given tree +export function initLayoutMap(layoutTree: LayoutNode): LayoutMap { + function helper(node: LayoutNode, map: LayoutMap): void { + map.set(node.tolNode.name, node); + node.children.forEach(n => helper(n, map)); + } + let map = new Map(); + helper(layoutTree, map); + return map; +} +// Adds a node and it's descendants' names to a LayoutMap +function addToLayoutMap(node: LayoutNode, map: LayoutMap): void { + map.set(node.tolNode.name, node); + node.children.forEach(n => addToLayoutMap(n, map)); +} +// Removes a node and it's descendants' names from a LayoutMap +function removeFromLayoutMap(node: LayoutNode, map: LayoutMap): void { + map.delete(node.tolNode.name); + node.children.forEach(n => removeFromLayoutMap(n, map)); +} // Creates a LayoutNode representing a TolNode tree, up to a given depth (0 means just the root) export function initLayoutTree(tol: TolNode, depth: number): LayoutNode { @@ -180,28 +205,14 @@ export function initLayoutTree(tol: TolNode, depth: number): LayoutNode { } return initHelper(tol, depth); } -export function initLayoutMap(node: LayoutNode): LayoutMap { - function helper(node: LayoutNode, map: LayoutMap){ - map.set(node.tolNode.name, node); - node.children.forEach(n => helper(n, map)); - } - let map = new Map(); - helper(node, map); - return map; -} -function removeFromLayoutMap(node: LayoutNode, map: LayoutMap){ - map.delete(node.tolNode.name); - node.children.forEach(n => removeFromLayoutMap(n, map)); -} -function addToLayoutMap(node: LayoutNode, map: LayoutMap){ - map.set(node.tolNode.name, node); - node.children.forEach(n => addToLayoutMap(n, map)); -} // Attempts layout on a LayoutNode's corresponding TolNode tree, for an area with given xy-position and width+height // 'allowCollapse' allows the layout algorithm to collapse nodes to avoid layout failure // 'chg' allows for performing layout after expanding/collapsing a node -export function tryLayout(layoutTree: LayoutNode, layoutMap: LayoutMap, pos: [number,number], dims: [number,number], - options: LayoutOptions, allowCollapse: boolean = false, chg?: LayoutTreeChg){ +// 'layoutMap' provides a LayoutMap to update with added/removed children +export function tryLayout( + layoutTree: LayoutNode, pos: [number,number], dims: [number,number], options: LayoutOptions, + {allowCollapse = false, chg = null as LayoutTreeChg | null, layoutMap = null as LayoutMap | null} = {} + ): boolean { // Create a new LayoutNode tree, in case of layout failure let tempTree = layoutTree.cloneNodeTree(chg); let success: boolean; @@ -214,14 +225,14 @@ export function tryLayout(layoutTree: LayoutNode, layoutMap: LayoutMap, pos: [nu // Center in layout area tempTree.pos[0] += (dims[0] - tempTree.dims[0]) / 2; tempTree.pos[1] += (dims[1] - tempTree.dims[1]) / 2; - // Apply to active LayoutNode tree + // Copy to given LayoutNode tree tempTree.copyTreeForRender(layoutTree, layoutMap); } return success; } -// Type for functions called by tryLayout() to perform layout -// Given a LayoutNode tree, determines and records a new layout by setting fields of nodes in the tree +// Type for functions called by tryLayout() to attempt layout +// Takes similar parameters to tryLayout(), with 'showHeader' and 'ownOpts' generally used by other LayoutFns // Returns a boolean indicating success type LayoutFn = ( node: LayoutNode, @@ -280,6 +291,7 @@ let sqrLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, usedTileSz = tileSz; } } + // Check if unable to find grid if (lowestEmpSpc == Number.POSITIVE_INFINITY){ if (allowCollapse){ node.children = []; @@ -341,14 +353,15 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, let numChildren = node.children.length; let rowBrks: number[] = []; // Will hold indices for nodes at which each row starts let lowestEmpSpc = Number.POSITIVE_INFINITY; - let usedTree: LayoutNode | null = null, usedEmpRight = 0, usedEmpBottom = 0; + let usedTree: LayoutNode | null = null; // Best-so-far layout + let usedEmpRight = 0, usedEmpBottom = 0; // usedTree's empty-space at-right-of-all-rows and below-last-row const minCellDims = [ // Can situationally assume non-leaf children opts.minTileSz + opts.tileSpacing + (opts.layoutType == 'sweep' ? opts.tileSpacing*2 : 0), opts.minTileSz + opts.tileSpacing + (opts.layoutType == 'sweep' ? opts.tileSpacing*2 + opts.headerSz : 0) ]; - rowBrksLoop: + RowBrksLoop: while (true){ // Update rowBrks or exit loop switch (opts.rectMode){ @@ -356,14 +369,14 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, if (rowBrks.length == 0){ rowBrks = [0]; } else { - break rowBrksLoop; + break RowBrksLoop; } break; case 'vert': if (rowBrks.length == 0){ rowBrks = range(numChildren); } else { - break rowBrksLoop; + break RowBrksLoop; } break; case 'linear': @@ -372,7 +385,7 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, } else if (rowBrks.length == numChildren){ rowBrks = range(numChildren); } else { - break rowBrksLoop; + break RowBrksLoop; } break; case 'auto': @@ -381,7 +394,7 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, } else { let updated = updateAscSeq(rowBrks, numChildren); if (!updated){ - break rowBrksLoop; + break RowBrksLoop; } } break; @@ -392,7 +405,7 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, // Get next possible first row let idxFirstRowLastEl = (rowBrks.length == 1 ? numChildren : rowBrks[1]) - 1; if (idxFirstRowLastEl == 0){ - break rowBrksLoop; + break RowBrksLoop; } rowBrks = [0]; rowBrks.push(idxFirstRowLastEl); @@ -435,13 +448,13 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, for (let rowIdx = 0; rowIdx < rowsOfCnts.length; rowIdx++){ let newWs = limitVals(cellWs[rowIdx], minCellDims[0], Number.POSITIVE_INFINITY); if (newWs == null){ - continue rowBrksLoop; + continue RowBrksLoop; } cellWs[rowIdx] = newWs; } cellHs = limitVals(cellHs, minCellDims[1], Number.POSITIVE_INFINITY)!; if (cellHs == null){ - continue rowBrksLoop; + continue RowBrksLoop; } // Get cell xy-coordinates let cellXs: number[][] = new Array(rowsOfCnts.length); @@ -477,7 +490,7 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, success = layoutFn(child, childPos, childDims, true, allowCollapse, opts); } if (!success){ - continue rowBrksLoop; + continue RowBrksLoop; } // Remove horizontal empty space by trimming cell and moving/expanding any next cell let horzEmp = childDims[0] - child.dims[0]; @@ -513,7 +526,8 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, usedEmpBottom = empBottom; } } - if (usedTree == null){ // If no found layout + // Check if no found layout + if (usedTree == null){ if (allowCollapse){ node.children = []; LayoutNode.updateDCounts(node, 1 - node.dCount); @@ -528,9 +542,9 @@ let rectLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, return true; } // Lays out nodes by pushing leaves to one side, and using rectLayout() for the non-leaves -// With layout option 'sweepingToParent', leaves from child nodes may occupy a parent's leaf-section -//'sepArea' represents a usable leaf-section area from a direct parent, - //and is altered to represent the area used, which the parent can use for reducing empty space +// With layout option 'sweepToParent', leaves from child nodes may occupy a parent's leaf-section +// 'sepArea' represents a usable leaf-section area from a direct parent, + //and is changed to represent the area used, with changes visibile to the parent for reducing empty space let sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse, opts, ownOpts?: {sepArea?: SepSweptArea}){ // Separate leaf and non-leaf nodes @@ -549,13 +563,13 @@ let sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse let leavesLyt: LayoutNode | null = null, nonLeavesLyt: LayoutNode | null = null, sweptLeft = false; let sepArea: SepSweptArea | null = null, sepAreaUsed = false; // Represents leaf-section area provided for a child // Try using parent-provided area - let parentArea = (opts.sweepingToParent && ownOpts) ? ownOpts.sepArea : null; // Represents area provided by parent + let parentArea = (opts.sweepToParent && ownOpts) ? ownOpts.sepArea : null; // Represents area provided by parent let usingParentArea = false; if (parentArea != null){ // Attempt leaves layout sweptLeft = parentArea.sweptLeft; leavesLyt = new LayoutNode(new TolNode('SWEEP_' + node.tolNode.name), leaves); - // Not updating child nodes to point to tempTree as a parent seems acceptable here + // Note: Intentionally neglecting to update child nodes' 'parent' or 'depth' fields here let leavesSuccess = sqrLayout(leavesLyt, [0,0], parentArea.dims, !sweptLeft, false, opts); if (leavesSuccess){ // Move leaves to parent area @@ -720,7 +734,7 @@ let sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse if (sweptLeft){ sepAreaLen = newDims[1] - leavesLyt.dims[1] - opts.tileSpacing; sepArea = new SepSweptArea( - [-leavesLyt.dims[0] + opts.tileSpacing, leavesLyt.dims[1] - opts.tileSpacing], //Relative to child + [-leavesLyt.dims[0] + opts.tileSpacing, leavesLyt.dims[1] - opts.tileSpacing], // Relative to child [leavesLyt.dims[0], sepAreaLen], sweptLeft ); @@ -752,7 +766,7 @@ let sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse lyt.pos[1] += newPos[1]; }); } - // Combine layouts + // Create combined layout if (leavesLyt == null || nonLeavesLyt == null){ //hint for typescript return false; } @@ -1,5 +1,5 @@ /* - * Contains classes used for representing tree-of-life data. + * Provides classes for representing and working with tree-of-life data. */ // Represents a tree-of-life node/tree @@ -13,7 +13,7 @@ export class TolNode { this.parent = parent; } } -// Represents a tree-of-life node obtained from tol.json +// Represents a tree-of-life node obtained from tolData.json export class TolNodeRaw { name: string; children?: TolNodeRaw[]; @@ -29,15 +29,14 @@ export function tolFromRaw(node: TolNodeRaw): TolNode { if (node.children == null){ tolNode.children = []; } else { - tolNode.children = Array(node.children.length); - node.children.forEach((child, idx) => {tolNode.children[idx] = helper(child, tolNode)}); + tolNode.children = node.children.map(child => helper(child, tolNode)); } tolNode.parent = parent; return tolNode; } return helper(node, null); } -// Returns a mapping from TolNode names to TolNodes in a given tree +// Returns a map from TolNode names to TolNodes in a given tree export function getTolMap(tolTree: TolNode): Map<string, TolNode> { function helper(node: TolNode, map: Map<string, TolNode>){ map.set(node.name, node); diff --git a/src/util.ts b/src/util.ts index 79c2b5c..be31102 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,17 +1,18 @@ /* - * Contains commonly-used utility functions. + * Contains utility functions. */ // Returns [0 ... len] -export function range(len: number){ +export function range(len: number): number[] { return [...Array(len).keys()]; } // Returns sum of array values -export function arraySum(array: number[]){ +export function arraySum(array: number[]): number { return array.reduce((x,y) => x+y); } -// Returns array copy with vals clipped to within [min,max], redistributing to compensate (returns null on failure) -export function limitVals(arr: number[], min: number, max: number){ +// Returns array copy with vals clipped to within [min,max], redistributing to compensate +// Returns null on failure +export function limitVals(arr: number[], min: number, max: number): number[] | null { let vals = [...arr]; let clipped = new Array(vals.length).fill(false); let owedChg = 0; // Stores total change made after clipping values @@ -48,8 +49,9 @@ export function limitVals(arr: number[], min: number, max: number){ } } // Usable to iterate through possible int arrays with ascending values in the range 0 to maxLen-1, starting with [0] - // eg: With maxLen 3, updates [0] to [0,1], then to [0,2], then [0,1,2], then null -export function updateAscSeq(seq: number[], maxLen: number){ + // eg: With maxLen 3, updates [0] to [0,1], then to [0,2], then [0,1,2] +// Returns false when there is no next array +export function updateAscSeq(seq: number[], maxLen: number): boolean { // Try increasing last element, then preceding elements, then extending the array let i = seq.length - 1; while (true){ |
