diff options
Diffstat (limited to 'src/components')
| -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 |
6 files changed, 275 insertions, 224 deletions
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"/> |
