aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.vue436
-rw-r--r--src/components/AncestryBar.vue64
-rw-r--r--src/components/HelpModal.vue11
-rw-r--r--src/components/SearchModal.vue14
-rw-r--r--src/components/SettingsPane.vue37
-rw-r--r--src/components/Tile.vue360
-rw-r--r--src/components/TileInfoModal.vue13
-rw-r--r--src/layout.ts172
-rw-r--r--src/tol.ts9
-rw-r--r--src/util.ts16
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;
}
diff --git a/src/tol.ts b/src/tol.ts
index 0f05ba9..42605e5 100644
--- a/src/tol.ts
+++ b/src/tol.ts
@@ -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){