aboutsummaryrefslogtreecommitdiff
path: root/src/components/TolTile.vue
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/TolTile.vue')
-rw-r--r--src/components/TolTile.vue579
1 files changed, 579 insertions, 0 deletions
diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue
new file mode 100644
index 0000000..afb6616
--- /dev/null
+++ b/src/components/TolTile.vue
@@ -0,0 +1,579 @@
+<template>
+<div :style="styles" @scroll="onScroll">
+ <div v-if="isLeaf" :class="[hasOneImage ? 'flex' : 'grid', {'hover:cursor-pointer': isExpandableLeaf}]"
+ class="w-full h-full flex-col grid-cols-1" :style="leafStyles"
+ @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp">
+ <template v-if="hasOneImage">
+ <h1 :style="leafHeaderStyles">{{displayName}}</h1>
+ <info-icon v-if="infoIconDisabled" :style="infoIconStyles" :class="infoIconClasses"
+ @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/>
+ </template>
+ <template v-else>
+ <div :style="leafFirstImgStyles" class="col-start-1 row-start-1"></div>
+ <div :style="leafSecondImgStyles" class="col-start-1 row-start-1"></div>
+ <h1 :style="leafHeaderStyles" class="col-start-1 row-start-1 z-10">{{displayName}}</h1>
+ <info-icon v-if="infoIconDisabled" class="col-start-1 row-start-1 z-10"
+ :style="infoIconStyles" :class="infoIconClasses"
+ @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/>
+ </template>
+ </div>
+ <div v-else :style="nonleafStyles">
+ <div v-if="showNonleafHeader" :style="nonleafHeaderStyles" class="flex hover:cursor-pointer"
+ @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp">
+ <h1 :style="nonleafHeaderTextStyles" class="grow">{{displayName}}</h1>
+ <info-icon v-if="infoIconDisabled" :style="infoIconStyles" :class="infoIconClasses"
+ @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/>
+ </div>
+ <div :style="sepSweptAreaStyles" :class="sepSweptAreaHideEdgeClass">
+ <div v-if="layoutNode.sepSweptArea?.sweptLeft === false"
+ :style="nonleafHeaderStyles" class="flex hover:cursor-pointer"
+ @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp">
+ <h1 :style="nonleafHeaderTextStyles" class="grow">{{displayName}}</h1>
+ <info-icon v-if="infoIconDisabled" :style="infoIconStyles" :class="infoIconClasses"
+ @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/>
+ </div>
+ <transition name="fadein">
+ <div v-if="inFlash" class="absolute w-full h-full top-0 left-0 rounded-[inherit] bg-amber-500/70 z-20"/>
+ </transition>
+ </div>
+ <tol-tile v-for="child in visibleChildren" :key="child.name"
+ :layoutNode="child" :tolMap="tolMap" :lytOpts="lytOpts" :uiOpts="uiOpts" :overflownDim="overflownDim"
+ @leaf-click="onInnerLeafClick" @nonleaf-click="onInnerNonleafClick"
+ @leaf-click-held="onInnerLeafClickHeld" @nonleaf-click-held="onInnerNonleafClickHeld"
+ @info-click="onInnerInfoIconClick"/>
+ </div>
+ <transition name="fadein">
+ <div v-if="inFlash" class="absolute w-full h-full top-0 left-0 rounded-[inherit] bg-amber-500/70"/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import {defineComponent, PropType} from 'vue';
+import InfoIcon from './icon/InfoIcon.vue';
+import {TolNode, TolMap} from '../tol';
+import {LayoutNode, LayoutOptions} from '../layout';
+import {getImagePath, UiOptions} from '../lib';
+import {capitalizeWords} from '../util';
+
+const scrimGradient = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)';
+
+export default defineComponent({
+ props: {
+ layoutNode: {type: Object as PropType<LayoutNode>, required: true},
+ tolMap: {type: Object as PropType<TolMap>, required: true},
+ // Options
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+ // Other
+ skipTransition: {type: Boolean, default: false},
+ nonAbsPos: {type: Boolean, default: false},
+ // For a leaf node, prevents usage of absolute positioning (used by AncestryBar)
+ overflownDim: {type: Number, default: 0},
+ // For a non-leaf node, display with overflow within area of this height
+ },
+ data(){
+ return {
+ // Mouse-event related
+ clickHoldTimer: 0, // Used to recognise click-and-hold events
+ highlight: false, // Used to draw a colored outline on mouse hover
+ // Scroll-during-overflow related
+ scrollOffset: 0, // Used to track scroll offset when displaying with overflow
+ pendingScrollHdlr: 0, // Used for throttling updating of scrollOffset
+ // Transition related
+ inTransition: false, // Used to avoid content overlap and overflow during 'user-perceivable' transitions
+ wasClicked: false, // Used to increase z-index during transition after this tile (or a child) is clicked
+ hasExpanded: false, // Set to true after an expansion transition ends, and false upon collapse
+ // Used to hide overflow on tile expansion, but not hide a sepSweptArea on subsequent transitions
+ justUnhidden: false, // Used to allow overflow temporarily after being unhidden
+ // Other
+ inFlash: false, // Used to 'flash' the tile when focused
+ };
+ },
+ computed: {
+ tolNode(): TolNode {
+ return this.tolMap.get(this.layoutNode.name)!;
+ },
+ visibleChildren(): LayoutNode[] { // Used to reduce slowdown from rendering many nodes
+ let children = this.layoutNode.children;
+ // If not displaying with overflow, return 'visible' layoutNode children
+ if (!this.isOverflownRoot){
+ return children.filter(n => !n.hidden || n.hiddenWithVisibleTip);
+ }
+ // Otherwise, return children within/near non-overflowing region
+ let firstIdx = children.length - 1;
+ for (let i = 0; i < children.length; i++){
+ if (children[i].pos[1] + children[i].dims[1] >= this.scrollOffset){
+ firstIdx = i;
+ break;
+ }
+ }
+ let lastIdx = children.length;
+ for (let i = firstIdx + 1; i < children.length; i++){
+ if (children[i].pos[1] > this.scrollOffset + this.overflownDim){
+ lastIdx = i;
+ break;
+ }
+ }
+ return children.slice(firstIdx, lastIdx);
+ },
+ // Convenience abbreviations
+ isLeaf(): boolean {
+ return this.layoutNode.children.length == 0;
+ },
+ isExpandableLeaf(): boolean {
+ return this.isLeaf && this.tolNode.children.length > 0;
+ },
+ showNonleafHeader(): boolean {
+ return (this.layoutNode.showHeader && this.layoutNode.sepSweptArea == null) ||
+ (this.layoutNode.sepSweptArea != null && this.layoutNode.sepSweptArea.sweptLeft);
+ },
+ displayName(): string {
+ let newName = capitalizeWords(this.tolNode.commonName || this.layoutNode.name);
+ if (!this.tolNode.pSupport && this.tolNode.parent != null){
+ newName += '*';
+ }
+ return newName;
+ },
+ hasOneImage(): boolean {
+ return !Array.isArray(this.tolNode.imgName);
+ },
+ isOverflownRoot(): boolean {
+ return this.overflownDim > 0 && !this.layoutNode.hidden && this.layoutNode.children.length > 0;
+ },
+ hasFocusedChild(): boolean {
+ return this.layoutNode.children.some(n => n.hasFocus);
+ },
+ infoIconDisabled(): boolean {
+ return !this.uiOpts.disabledActions.has('tileInfo');
+ },
+ // For styling
+ nonleafBgColor(): string {
+ let colorArray = this.uiOpts.nonleafBgColors;
+ return colorArray[this.layoutNode.depth % colorArray.length];
+ },
+ boxShadow(): string {
+ if (this.highlight){
+ return this.uiOpts.shadowHovered;
+ } else if (this.layoutNode.hasFocus && !this.inTransition){
+ return this.uiOpts.shadowFocused;
+ } else {
+ return this.uiOpts.shadowNormal;
+ }
+ },
+ fontSz(): number {
+ // These values are a compromise between dynamic font size and code simplicity
+ if (this.layoutNode.dims[0] >= 150){
+ return this.lytOpts.headerSz * 0.8;
+ } else if (this.layoutNode.dims[0] >= 80){
+ return this.lytOpts.headerSz * 0.7;
+ } else {
+ return this.lytOpts.headerSz * 0.6;
+ }
+ },
+ styles(): Record<string,string> {
+ let layoutStyles = {
+ position: 'absolute',
+ left: this.layoutNode.pos[0] + 'px',
+ top: this.layoutNode.pos[1] + 'px',
+ width: this.layoutNode.dims[0] + 'px',
+ height: this.layoutNode.dims[1] + 'px',
+ borderRadius: this.uiOpts.borderRadius + 'px',
+ boxShadow: this.boxShadow,
+ visibility: 'visible',
+ // Transition related
+ transitionDuration: (this.skipTransition ? 0 : this.uiOpts.transitionDuration) + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ zIndex: this.inTransition && this.wasClicked ? '1' : '0',
+ overflow: (this.inTransition && !this.isLeaf && !this.hasExpanded && !this.justUnhidden) ?
+ 'hidden' : 'visible',
+ // CSS variables
+ '--nonleafBgColor': this.nonleafBgColor,
+ '--tileSpacing': this.lytOpts.tileSpacing + 'px',
+ };
+ if (!this.isLeaf){
+ let borderR = this.uiOpts.borderRadius + 'px';
+ if (this.layoutNode.sepSweptArea != null){
+ borderR = this.layoutNode.sepSweptArea.sweptLeft ?
+ `${borderR} ${borderR} ${borderR} 0` :
+ `${borderR} 0 ${borderR} ${borderR}`;
+ }
+ layoutStyles.borderRadius = borderR;
+ }
+ if (this.isOverflownRoot){
+ layoutStyles.width = (this.layoutNode.dims[0] + this.uiOpts.scrollGap) + 'px';
+ layoutStyles.height = this.overflownDim + 'px';
+ layoutStyles.overflow = 'hidden scroll';
+ }
+ if (this.layoutNode.hidden){
+ layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0';
+ layoutStyles.visibility = 'hidden';
+ }
+ if (this.nonAbsPos){
+ layoutStyles.position = 'static';
+ }
+ return layoutStyles;
+ },
+ leafStyles(): Record<string,string> {
+ let styles: Record<string,string> = {
+ borderRadius: 'inherit',
+ };
+ if (this.hasOneImage){
+ styles = {
+ ...styles,
+ backgroundImage: this.tolNode.imgName != null ?
+ `${scrimGradient},url('${getImagePath(this.tolNode.imgName as string)}')` :
+ 'none',
+ backgroundColor: this.uiOpts.bgColorDark,
+ backgroundSize: 'cover',
+ };
+ }
+ return styles;
+ },
+ leafHeaderStyles(): Record<string,string> {
+ let numChildren = this.tolNode.children.length;
+ let textColor = this.uiOpts.textColor;
+ for (let [threshold, color] of this.uiOpts.childQtyColors){
+ if (numChildren >= threshold){
+ textColor = color;
+ } else {
+ break;
+ }
+ }
+ return {
+ lineHeight: (this.fontSz * 1.3) + 'px',
+ fontSize: this.fontSz + 'px',
+ paddingLeft: (this.fontSz * 0.2) + 'px',
+ color: textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ };
+ },
+ leafFirstImgStyles(): Record<string,string> {
+ return this.leafSubImgStyles(0);
+ },
+ leafSecondImgStyles(): Record<string,string> {
+ return this.leafSubImgStyles(1);
+ },
+ nonleafStyles(): Record<string,string> {
+ let styles = {
+ width: '100%',
+ height: '100%',
+ backgroundColor: this.nonleafBgColor,
+ borderRadius: 'inherit',
+ };
+ if (this.isOverflownRoot){
+ styles.width = this.layoutNode.dims[0] + 'px';
+ styles.height = this.layoutNode.dims[1] + 'px';
+ }
+ return styles;
+ },
+ nonleafHeaderStyles(): Record<string,string> {
+ let styles: Record<string,string> = {
+ position: 'static',
+ height: this.lytOpts.headerSz + 'px',
+ borderTopLeftRadius: 'inherit',
+ borderTopRightRadius: 'inherit',
+ backgroundColor: this.uiOpts.nonleafHeaderColor,
+ };
+ if (this.isOverflownRoot){
+ styles = {
+ ...styles,
+ position: 'sticky',
+ top: '0',
+ left: '0',
+ borderTopRightRadius: '0',
+ zIndex: '1',
+ boxShadow: this.uiOpts.shadowNormal,
+ };
+ }
+ return styles;
+ },
+ nonleafHeaderTextStyles(): Record<string,string> {
+ return {
+ lineHeight: (this.fontSz * 1.3) + 'px',
+ fontSize: this.fontSz + 'px',
+ paddingLeft: (this.fontSz * 0.2) + 'px',
+ textAlign: 'center',
+ color: this.uiOpts.textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ };
+ },
+ sepSweptAreaStyles(): Record<string,string> {
+ let borderR = this.uiOpts.borderRadius + 'px';
+ let styles = {
+ position: 'absolute',
+ backgroundColor: this.nonleafBgColor,
+ boxShadow: this.boxShadow,
+ transitionDuration: this.uiOpts.transitionDuration + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ };
+ let area = this.layoutNode.sepSweptArea;
+ if (!this.layoutNode.hidden && area != null){
+ return {
+ ...styles,
+ visibility: 'visible',
+ left: area.pos[0] + 'px',
+ top: area.pos[1] + 'px',
+ width: area.dims[0] + 'px',
+ height: area.dims[1] + 'px',
+ borderRadius: area.sweptLeft ?
+ `${borderR} 0 0 ${borderR}` :
+ `${borderR} ${borderR} 0 0`,
+ };
+ } else {
+ return {
+ ...styles,
+ visibility: 'hidden',
+ left: '0',
+ top: this.lytOpts.headerSz + 'px',
+ width: '0',
+ height: '0',
+ borderRadius: borderR,
+ };
+ }
+ },
+ sepSweptAreaHideEdgeClass(): string {
+ if (this.layoutNode.sepSweptArea == null){
+ return '';
+ } else if (this.layoutNode.sepSweptArea.sweptLeft){
+ return 'hide-right-edge';
+ } else {
+ return 'hide-top-edge';
+ }
+ },
+ infoIconStyles(): Record<string,string> {
+ let size = (this.lytOpts.headerSz * 0.85);
+ let marginSz = (this.lytOpts.headerSz - size);
+ return {
+ width: size + 'px',
+ height: size + 'px',
+ minWidth: size + 'px',
+ minHeight: size + 'px',
+ margin: this.isLeaf ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`,
+ };
+ },
+ infoIconClasses(): string {
+ return 'text-white/30 hover:text-white hover:cursor-pointer';
+ },
+ // For watching layoutNode data
+ pos(){
+ return this.layoutNode.pos;
+ },
+ dims(){
+ return this.layoutNode.dims;
+ },
+ hidden(){
+ return this.layoutNode.hidden;
+ },
+ hasFocus(){
+ return this.layoutNode.hasFocus;
+ },
+ failFlag(){
+ return this.layoutNode.failFlag;
+ },
+ },
+ methods: {
+ // Click handling
+ onMouseDown(): void {
+ this.highlight = false;
+ if (!this.uiOpts.touchDevice){
+ // Wait for a mouseup or click-hold
+ clearTimeout(this.clickHoldTimer);
+ this.clickHoldTimer = setTimeout(() => {
+ this.clickHoldTimer = 0;
+ this.onClickHold();
+ }, this.uiOpts.clickHoldDuration);
+ } else {
+ // Wait for or recognise a double-click
+ if (this.clickHoldTimer == 0){
+ this.clickHoldTimer = setTimeout(() => {
+ this.clickHoldTimer = 0;
+ this.onClick();
+ }, this.uiOpts.clickHoldDuration);
+ } else {
+ clearTimeout(this.clickHoldTimer)
+ this.clickHoldTimer = 0;
+ this.onDblClick();
+ }
+ }
+ },
+ onMouseUp(): void {
+ if (!this.uiOpts.touchDevice){
+ if (this.clickHoldTimer > 0){
+ clearTimeout(this.clickHoldTimer);
+ this.clickHoldTimer = 0;
+ this.onClick();
+ }
+ }
+ },
+ onClick(): void {
+ if (this.isLeaf && !this.isExpandableLeaf){
+ console.log('Ignored click on non-expandable node');
+ return;
+ }
+ this.wasClicked = true;
+ this.$emit(this.isLeaf ? 'leaf-click' : 'nonleaf-click', this.layoutNode);
+ },
+ onClickHold(): void {
+ if (this.isLeaf && !this.isExpandableLeaf){
+ console.log('Ignored click-hold on non-expandable node');
+ return;
+ }
+ this.$emit(this.isLeaf ? 'leaf-click-held' : 'nonleaf-click-held', this.layoutNode);
+ },
+ onDblClick(): void {
+ this.onClickHold();
+ },
+ onInfoIconClick(evt: Event): void {
+ this.$emit('info-click', this.layoutNode.name);
+ },
+ // Mouse-hover handling
+ onMouseEnter(evt: Event): void {
+ if ((!this.isLeaf || this.isExpandableLeaf) && !this.inTransition){
+ this.highlight = true;
+ }
+ },
+ onMouseLeave(evt: Event): void {
+ this.highlight = false;
+ },
+ // Child event propagation
+ onInnerLeafClick(node: LayoutNode): void {
+ this.wasClicked = true;
+ this.$emit('leaf-click', node);
+ },
+ onInnerNonleafClick(node: LayoutNode): void {
+ this.wasClicked = true;
+ this.$emit('nonleaf-click', node);
+ },
+ onInnerLeafClickHeld(node: LayoutNode): void {
+ this.$emit('leaf-click-held', node);
+ },
+ onInnerNonleafClickHeld(node: LayoutNode): void {
+ this.$emit('nonleaf-click-held', node);
+ },
+ onInnerInfoIconClick(nodeName: string): void {
+ this.$emit('info-click', nodeName);
+ },
+ onScroll(evt: Event): void {
+ if (this.pendingScrollHdlr == 0){
+ this.pendingScrollHdlr = setTimeout(() => {
+ this.scrollOffset = this.$el.scrollTop;
+ this.pendingScrollHdlr = 0;
+ }, this.uiOpts.animationDelay);
+ }
+ },
+ // Other
+ leafSubImgStyles(idx: number): Record<string,string> {
+ let [w, h] = this.layoutNode.dims;
+ return {
+ width: '100%',
+ height: '100%',
+ // Image (and scrims)
+ backgroundImage: (this.tolNode.imgName![idx]! != null) ?
+ `${scrimGradient},url('${getImagePath(this.tolNode.imgName![idx]! as string)}')` :
+ 'none',
+ backgroundColor: this.uiOpts.bgColorDark,
+ backgroundSize: '125%',
+ borderRadius: 'inherit',
+ clipPath: idx == 0 ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)',
+ backgroundPosition: idx == 0 ? `${-w/4}px ${-h/4}px` : '0px 0px',
+ };
+ },
+ onTransitionEnd(evt: Event){
+ if (this.inTransition){
+ this.inTransition = false;
+ this.wasClicked = false;
+ this.hasExpanded = this.layoutNode.children.length > 0;
+ }
+ },
+ triggerAnimation(animation: string){
+ this.$el.classList.remove(animation);
+ this.$el.offsetWidth; // Triggers reflow
+ this.$el.classList.add(animation);
+ },
+ },
+ watch: {
+ // For setting transition state (allows external triggering, like via search and auto-mode)
+ pos: {
+ handler(newVal: [number, number], oldVal: [number, number]){
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){
+ this.inTransition = true;
+ setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration);
+ }
+ },
+ deep: true,
+ },
+ dims: {
+ handler(newVal: [number, number], oldVal: [number, number]){
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){
+ this.inTransition = true;
+ setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration);
+ }
+ },
+ deep: true,
+ },
+ // For externally triggering fail animations (used by search and auto-mode)
+ failFlag(){
+ this.triggerAnimation(this.isLeaf ? 'animate-expand-shrink' : 'animate-shrink-expand');
+ },
+ // Scroll to focused child if overflownRoot
+ hasFocusedChild(newVal: boolean, oldVal: boolean){
+ if (newVal && this.isOverflownRoot){
+ let focusedChild = this.layoutNode.children.find(n => n.hasFocus)!
+ let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + this.lytOpts.tileSpacing;
+ let scrollTop = Math.max(0, bottomY - (this.overflownDim / 2)); // No need to manually cap at max
+ this.$el.scrollTop = scrollTop;
+ }
+ },
+ // Allow overflow temporarily after being unhidden
+ hidden(newVal: boolean, oldVal: boolean){
+ if (oldVal && !newVal){
+ this.justUnhidden = true;
+ setTimeout(() => {this.justUnhidden = false;}, this.uiOpts.transitionDuration + 100);
+ }
+ },
+ // Used to 'flash' the tile when focused
+ hasFocus(newVal: boolean, oldVal: boolean){
+ if (newVal != oldVal && newVal){
+ this.inFlash = true;
+ setTimeout(() => {this.inFlash = false;}, this.uiOpts.transitionDuration);
+ }
+ },
+ },
+ name: 'tol-tile', // Note: Need this to use self in template
+ components: {InfoIcon, },
+ emits: ['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click', ],
+});
+</script>
+
+<style scoped>
+/* For making a parent-swept-area div look continuous with the tile div */
+.hide-right-edge::before {
+ content: '';
+ position: absolute;
+ background-color: var(--nonleafBgColor);
+ right: calc(0px - var(--tileSpacing));
+ bottom: 0;
+ width: var(--tileSpacing);
+ height: calc(100% + var(--tileSpacing));
+}
+.hide-top-edge::before {
+ content: '';
+ position: absolute;
+ background-color: var(--nonleafBgColor);
+ bottom: calc(0px - var(--tileSpacing));
+ right: 0;
+ width: calc(100% + var(--tileSpacing));
+ height: var(--tileSpacing);
+}
+</style>