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.vue954
1 files changed, 456 insertions, 498 deletions
diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue
index afb6616..d3b3539 100644
--- a/src/components/TolTile.vue
+++ b/src/components/TolTile.vue
@@ -1,5 +1,5 @@
<template>
-<div :style="styles" @scroll="onScroll">
+<div :style="styles" @scroll="onScroll" ref="rootRef">
<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">
@@ -48,512 +48,470 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, PropType} from 'vue';
import InfoIcon from './icon/InfoIcon.vue';
-import {TolNode, TolMap} from '../tol';
+import {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%)';
+const SCRIM_GRADIENT = '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(){
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ 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
+});
+const emit = defineEmits(['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click']);
+
+// Data computed from props
+const tolNode = computed(() => props.tolMap.get(props.layoutNode.name)!);
+const visibleChildren = computed((): LayoutNode[] => { // Used to reduce slowdown from rendering many nodes
+ let children = props.layoutNode.children;
+ // If not displaying with overflow, return 'visible' layoutNode children
+ if (!isOverflownRoot.value){
+ 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] >= scrollOffset.value){
+ firstIdx = i;
+ break;
+ }
+ }
+ let lastIdx = children.length;
+ for (let i = firstIdx + 1; i < children.length; i++){
+ if (children[i].pos[1] > scrollOffset.value + props.overflownDim){
+ lastIdx = i;
+ break;
+ }
+ }
+ return children.slice(firstIdx, lastIdx);
+});
+const isLeaf = computed(() => props.layoutNode.children.length == 0);
+const isExpandableLeaf = computed(() => isLeaf.value && tolNode.value.children.length > 0);
+const showNonleafHeader = computed(() =>
+ (props.layoutNode.showHeader && props.layoutNode.sepSweptArea == null) ||
+ (props.layoutNode.sepSweptArea != null && props.layoutNode.sepSweptArea.sweptLeft)
+);
+const displayName = computed((): string => {
+ let newName = capitalizeWords(tolNode.value.commonName || props.layoutNode.name);
+ if (!tolNode.value.pSupport && tolNode.value.parent != null){
+ newName += '*';
+ }
+ return newName;
+});
+const hasOneImage = computed(() => !Array.isArray(tolNode.value.imgName));
+const isOverflownRoot = computed(() =>
+ props.overflownDim > 0 && !props.layoutNode.hidden && props.layoutNode.children.length > 0
+);
+const hasFocusedChild = computed(() => props.layoutNode.children.some(n => n.hasFocus));
+const infoIconDisabled = computed(() => !props.uiOpts.disabledActions.has('tileInfo'));
+
+// Click/hold handling
+const clickHoldTimer = ref(0); // Used to recognise click-and-hold events
+function onMouseDown(): void {
+ highlight.value = false;
+ if (!props.uiOpts.touchDevice){
+ // Wait for a mouseup or click-hold
+ clearTimeout(clickHoldTimer.value);
+ clickHoldTimer.value = setTimeout(() => {
+ clickHoldTimer.value = 0;
+ onClickHold();
+ }, props.uiOpts.clickHoldDuration);
+ } else {
+ // Wait for or recognise a double-click
+ if (clickHoldTimer.value == 0){
+ clickHoldTimer.value = setTimeout(() => {
+ clickHoldTimer.value = 0;
+ onClick();
+ }, props.uiOpts.clickHoldDuration);
+ } else {
+ clearTimeout(clickHoldTimer.value)
+ clickHoldTimer.value = 0;
+ onDblClick();
+ }
+ }
+}
+function onMouseUp(): void {
+ if (!props.uiOpts.touchDevice){
+ if (clickHoldTimer.value > 0){
+ clearTimeout(clickHoldTimer.value);
+ clickHoldTimer.value = 0;
+ onClick();
+ }
+ }
+}
+
+// Click-action handling
+const wasClicked = ref(false); // Used to increase z-index during transition after this tile (or a child) is clicked
+function onClick(): void {
+ if (isLeaf.value && !isExpandableLeaf.value){
+ console.log('Ignored click on non-expandable node');
+ return;
+ }
+ wasClicked.value = true;
+ emit(isLeaf.value ? 'leaf-click' : 'nonleaf-click', props.layoutNode);
+}
+function onClickHold(): void {
+ if (isLeaf.value && !isExpandableLeaf.value){
+ console.log('Ignored click-hold on non-expandable node');
+ return;
+ }
+ emit(isLeaf.value ? 'leaf-click-held' : 'nonleaf-click-held', props.layoutNode);
+}
+function onDblClick(): void {
+ onClickHold();
+}
+function onInfoIconClick(): void {
+ emit('info-click', props.layoutNode.name);
+}
+// Child click-action propagation
+function onInnerLeafClick(node: LayoutNode): void {
+ wasClicked.value = true;
+ emit('leaf-click', node);
+}
+function onInnerNonleafClick(node: LayoutNode): void {
+ wasClicked.value = true;
+ emit('nonleaf-click', node);
+}
+function onInnerLeafClickHeld(node: LayoutNode): void {
+ emit('leaf-click-held', node);
+}
+function onInnerNonleafClickHeld(node: LayoutNode): void {
+ emit('nonleaf-click-held', node);
+}
+function onInnerInfoIconClick(nodeName: string): void {
+ emit('info-click', nodeName);
+}
+
+// Mouse-hover handling
+const highlight = ref(false); // Used to draw a colored outline on mouse hover
+function onMouseEnter(): void {
+ if ((!isLeaf.value || isExpandableLeaf.value) && !inTransition.value){
+ highlight.value = true;
+ }
+}
+function onMouseLeave(): void {
+ highlight.value = false;
+}
+
+// Scrolling if overflownRoot
+const scrollOffset = ref(0); // Used to track scroll offset when displaying with overflow
+const pendingScrollHdlr = ref(0); // Used for throttling updating of scrollOffset
+function onScroll(): void {
+ if (pendingScrollHdlr.value == 0){
+ pendingScrollHdlr.value = setTimeout(() => {
+ scrollOffset.value = rootRef.value!.scrollTop;
+ pendingScrollHdlr.value = 0;
+ }, props.uiOpts.animationDelay);
+ }
+}
+// Scroll to focused child if overflownRoot
+watch(hasFocusedChild, (newVal: boolean) => {
+ if (newVal && isOverflownRoot.value){
+ let focusedChild = props.layoutNode.children.find(n => n.hasFocus)!
+ let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + props.lytOpts.tileSpacing;
+ let scrollTop = Math.max(0, bottomY - (props.overflownDim / 2)); // No need to manually cap at max
+ rootRef.value!.scrollTop = scrollTop;
+ }
+});
+
+// Transition related
+const inTransition = ref(false); // Used to avoid content overlap and overflow during 'user-perceivable' transitions
+const hasExpanded = ref(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
+function onTransitionEnd(){
+ if (inTransition.value){
+ inTransition.value = false;
+ wasClicked.value = false;
+ hasExpanded.value = props.layoutNode.children.length > 0;
+ }
+}
+// For setting transition state (allows external triggering, like via search and auto-mode)
+watch(() => props.layoutNode.pos, (newVal: [number, number], oldVal: [number, number]) => {
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){
+ inTransition.value = true;
+ setTimeout(onTransitionEnd, props.uiOpts.transitionDuration);
+ }
+});
+watch(() => props.layoutNode.dims, (newVal: [number, number], oldVal: [number, number]) => {
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){
+ inTransition.value = true;
+ setTimeout(onTransitionEnd, props.uiOpts.transitionDuration);
+ }
+});
+
+// For externally triggering fail animations (used by search and auto-mode)
+function triggerAnimation(animation: string){
+ let el = rootRef.value!;
+ el.classList.remove(animation);
+ el.offsetWidth; // Triggers reflow
+ el.classList.add(animation);
+}
+watch(() => props.layoutNode.failFlag, () =>
+ triggerAnimation(isLeaf.value ? 'animate-expand-shrink' : 'animate-shrink-expand')
+);
+
+// For 'flashing' the tile when focused
+const inFlash = ref(false); // Used to 'flash' the tile when focused
+watch(() => props.layoutNode.hasFocus, (newVal: boolean, oldVal: boolean) => {
+ if (newVal != oldVal && newVal){
+ inFlash.value = true;
+ setTimeout(() => {inFlash.value = false;}, props.uiOpts.transitionDuration);
+ }
+});
+
+// For temporarily enabling overflow after being unhidden
+const justUnhidden = ref(false); // Used to allow overflow temporarily after being unhidden
+watch(() => props.layoutNode.hidden, (newVal: boolean, oldVal: boolean) => {
+ if (oldVal && !newVal){
+ justUnhidden.value = true;
+ setTimeout(() => {justUnhidden.value = false}, props.uiOpts.transitionDuration + 100);
+ }
+});
+
+// Styles + classes
+const nonleafBgColor = computed(() => {
+ let colorArray = props.uiOpts.nonleafBgColors;
+ return colorArray[props.layoutNode.depth % colorArray.length];
+});
+const boxShadow = computed((): string => {
+ if (highlight.value){
+ return props.uiOpts.shadowHovered;
+ } else if (props.layoutNode.hasFocus && !inTransition.value){
+ return props.uiOpts.shadowFocused;
+ } else {
+ return props.uiOpts.shadowNormal;
+ }
+});
+const fontSz = computed((): number => {
+ // These values are a compromise between dynamic font size and code simplicity
+ if (props.layoutNode.dims[0] >= 150){
+ return props.lytOpts.headerSz * 0.8;
+ } else if (props.layoutNode.dims[0] >= 80){
+ return props.lytOpts.headerSz * 0.7;
+ } else {
+ return props.lytOpts.headerSz * 0.6;
+ }
+});
+//
+const styles = computed((): Record<string,string> => {
+ let layoutStyles = {
+ position: 'absolute',
+ left: props.layoutNode.pos[0] + 'px',
+ top: props.layoutNode.pos[1] + 'px',
+ width: props.layoutNode.dims[0] + 'px',
+ height: props.layoutNode.dims[1] + 'px',
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: boxShadow.value,
+ visibility: 'visible',
+ // Transition related
+ transitionDuration: (props.skipTransition ? 0 : props.uiOpts.transitionDuration) + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ zIndex: inTransition.value && wasClicked.value ? '1' : '0',
+ overflow: (inTransition.value && !isLeaf.value && !hasExpanded.value && !justUnhidden.value) ?
+ 'hidden' : 'visible',
+ // CSS variables
+ '--nonleafBgColor': nonleafBgColor.value,
+ '--tileSpacing': props.lytOpts.tileSpacing + 'px',
+ };
+ if (!isLeaf.value){
+ let borderR = props.uiOpts.borderRadius + 'px';
+ if (props.layoutNode.sepSweptArea != null){
+ borderR = props.layoutNode.sepSweptArea.sweptLeft ?
+ `${borderR} ${borderR} ${borderR} 0` :
+ `${borderR} 0 ${borderR} ${borderR}`;
+ }
+ layoutStyles.borderRadius = borderR;
+ }
+ if (isOverflownRoot.value){
+ layoutStyles.width = (props.layoutNode.dims[0] + props.uiOpts.scrollGap) + 'px';
+ layoutStyles.height = props.overflownDim + 'px';
+ layoutStyles.overflow = 'hidden scroll';
+ }
+ if (props.layoutNode.hidden){
+ layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0';
+ layoutStyles.visibility = 'hidden';
+ }
+ if (props.nonAbsPos){
+ layoutStyles.position = 'static';
+ }
+ return layoutStyles;
+});
+const leafStyles = computed((): Record<string,string> => {
+ let styles: Record<string,string> = {
+ borderRadius: 'inherit',
+ };
+ if (hasOneImage.value){
+ styles = {
+ ...styles,
+ backgroundImage: tolNode.value.imgName != null ?
+ `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName as string)}')` :
+ 'none',
+ backgroundColor: props.uiOpts.bgColorDark,
+ backgroundSize: 'cover',
+ };
+ }
+ return styles;
+});
+const leafHeaderStyles = computed((): Record<string,string> => {
+ let numChildren = tolNode.value.children.length;
+ let textColor = props.uiOpts.textColor;
+ for (let [threshold, color] of props.uiOpts.childQtyColors){
+ if (numChildren >= threshold){
+ textColor = color;
+ } else {
+ break;
+ }
+ }
+ return {
+ lineHeight: (fontSz.value * 1.3) + 'px',
+ fontSize: fontSz.value + 'px',
+ paddingLeft: (fontSz.value * 0.2) + 'px',
+ color: textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ };
+});
+function leafSubImgStyles(idx: number): Record<string,string> {
+ let [w, h] = props.layoutNode.dims;
+ return {
+ width: '100%',
+ height: '100%',
+ // Image (and scrims)
+ backgroundImage: (tolNode.value.imgName![idx]! != null) ?
+ `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName![idx]! as string)}')` :
+ 'none',
+ backgroundColor: props.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',
+ };
+}
+const leafFirstImgStyles = computed(() => leafSubImgStyles(0));
+const leafSecondImgStyles = computed(() => leafSubImgStyles(1));
+const nonleafStyles = computed((): Record<string,string> => {
+ let styles = {
+ width: '100%',
+ height: '100%',
+ backgroundColor: nonleafBgColor.value,
+ borderRadius: 'inherit',
+ };
+ if (isOverflownRoot.value){
+ styles.width = props.layoutNode.dims[0] + 'px';
+ styles.height = props.layoutNode.dims[1] + 'px';
+ }
+ return styles;
+});
+const nonleafHeaderStyles = computed((): Record<string,string> => {
+ let styles: Record<string,string> = {
+ position: 'static',
+ height: props.lytOpts.headerSz + 'px',
+ borderTopLeftRadius: 'inherit',
+ borderTopRightRadius: 'inherit',
+ backgroundColor: props.uiOpts.nonleafHeaderColor,
+ };
+ if (isOverflownRoot.value){
+ styles = {
+ ...styles,
+ position: 'sticky',
+ top: '0',
+ left: '0',
+ borderTopRightRadius: '0',
+ zIndex: '1',
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+ }
+ return styles;
+});
+const nonleafHeaderTextStyles = computed(() => ({
+ lineHeight: (fontSz.value * 1.3) + 'px',
+ fontSize: fontSz.value + 'px',
+ paddingLeft: (fontSz.value * 0.2) + 'px',
+ textAlign: 'center',
+ color: props.uiOpts.textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}));
+const sepSweptAreaStyles = computed((): Record<string,string> => {
+ let borderR = props.uiOpts.borderRadius + 'px';
+ let styles = {
+ position: 'absolute',
+ backgroundColor: nonleafBgColor.value,
+ boxShadow: boxShadow.value,
+ transitionDuration: props.uiOpts.transitionDuration + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ };
+ let area = props.layoutNode.sepSweptArea;
+ if (!props.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 {
- // 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
+ ...styles,
+ visibility: 'hidden',
+ left: '0',
+ top: props.lytOpts.headerSz + 'px',
+ width: '0',
+ height: '0',
+ borderRadius: borderR,
};
- },
- 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', ],
+ }
+});
+const sepSweptAreaHideEdgeClass = computed((): string => {
+ if (props.layoutNode.sepSweptArea == null){
+ return '';
+ } else if (props.layoutNode.sepSweptArea.sweptLeft){
+ return 'hide-right-edge';
+ } else {
+ return 'hide-top-edge';
+ }
+});
+const infoIconStyles = computed((): Record<string,string> => {
+ let size = (props.lytOpts.headerSz * 0.85);
+ let marginSz = (props.lytOpts.headerSz - size);
+ return {
+ width: size + 'px',
+ height: size + 'px',
+ minWidth: size + 'px',
+ minHeight: size + 'px',
+ margin: isLeaf.value ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`,
+ };
});
+const infoIconClasses = 'text-white/30 hover:text-white hover:cursor-pointer';
</script>
<style scoped>