From 96a8a5ed5b22b78368e25209059866915256cc56 Mon Sep 17 00:00:00 2001 From: Terry Truong Date: Tue, 10 May 2022 11:20:42 +1000 Subject: Enable display of active-root with overflow --- src/App.vue | 72 ++++++++++++++++++++++++++++++++++-------- src/components/AncestryBar.vue | 10 +++--- src/components/Tile.vue | 46 +++++++++++++++++++-------- src/layout.ts | 57 +++++++++++++++++++++++++++++---- 4 files changed, 147 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/App.vue b/src/App.vue index c9d752d..ba13827 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,7 +16,7 @@ import SettingsIcon from './components/icon/SettingsIcon.vue'; import type {TolMap} from './tol'; import {TolNode} from './tol'; import {LayoutNode, initLayoutTree, initLayoutMap, tryLayout} from './layout'; -import type {LayoutOptions} from './layout'; +import type {LayoutOptions, LayoutTreeChg} from './layout'; import {arraySum, randWeightedChoice} from './util'; // Note: Import paths lack a .ts or .js extension because .ts makes vue-tsc complain, and .js makes vite complain @@ -79,10 +79,10 @@ const defaultUiOpts = { // For other components appBgColor: '#292524', tileAreaOffset: 5, //px (space between root tile and display boundary) + scrollGap: 12, //px (gap for overflown-root and ancestry-bar scrollbars, used to prevent overlap) 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 @@ -98,6 +98,7 @@ export default defineComponent({ layoutTree: layoutTree, activeRoot: layoutTree, // Differs from layoutTree root when expand-to-view is used layoutMap: initLayoutMap(layoutTree), // Maps names to LayoutNode objects + overflownRoot: false, // Set when displaying a root tile with many children, with overflow // Modals and settings related infoModalNode: null as LayoutNode | null, // Node to display info for, or null helpOpen: false, @@ -175,12 +176,30 @@ export default defineComponent({ methods: { // For tile expand/collapse events onLeafClick(layoutNode: LayoutNode){ + // If clicking child of overflowing active-root + if (this.overflownRoot){ + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + return Promise.resolve(false); + } + // Function for expanding tile let doExpansion = () => { - let success = tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, { + let lytFnOpts = { allowCollapse: false, - chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap}, + chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg, layoutMap: this.layoutMap - }); + }; + let success = tryLayout( + this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, lytFnOpts); + // If expanding active-root with too many children to fit, allow overflow + if (!success && layoutNode == this.activeRoot){ + success = tryLayout(this.activeRoot, this.tileAreaPos, + [this.tileAreaDims[0] - this.uiOpts.scrollGap, this.tileAreaDims[1]], + {...this.lytOpts, layoutType: 'flex-sqr'}, lytFnOpts); + if (success){ + this.overflownRoot = true; + } + } + // Check for failure if (!success){ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } @@ -211,6 +230,10 @@ export default defineComponent({ if (!success){ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation } else { + // Update overflownRoot if root was collapsed + if (this.overflownRoot){ + this.overflownRoot = false; + } // Clear out excess nodes when a threshold is reached let numNodes = this.tolMap.size; let extraNodes = numNodes - this.layoutMap.size; @@ -228,18 +251,34 @@ export default defineComponent({ // For expand-to-view and ancestry-bar events onLeafClickHeld(layoutNode: LayoutNode){ if (layoutNode == this.activeRoot){ - console.log('Ignored expand-to-view on active-root node'); + this.onLeafClick(layoutNode); return; } // Function for expanding tile let doExpansion = () => { LayoutNode.hideUpward(layoutNode); this.activeRoot = layoutNode; - tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, { - allowCollapse: true, - chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap}, + this.overflownRoot = false; + let lytFnOpts = { + allowCollapse: false, + chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg, layoutMap: this.layoutMap - }); + }; + let success = tryLayout( + this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, lytFnOpts); + if (!success){ + success = tryLayout(this.activeRoot, this.tileAreaPos, + [this.tileAreaDims[0] - this.uiOpts.scrollGap, this.tileAreaDims[1]], + {...this.lytOpts, layoutType: 'flex-sqr'}, lytFnOpts); + if (success){ + this.overflownRoot = true; + } + } + // Check for failure + if (!success){ + layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation + } + return success; }; // Check if data for node-to-expand exists, getting from server if needed let tolNode = this.tolMap.get(layoutNode.name)!; @@ -272,6 +311,7 @@ export default defineComponent({ this.activeRoot = layoutNode; tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, {allowCollapse: true, layoutMap: this.layoutMap}); + this.overflownRoot = false; }, // For tile-info events onInfoIconClick(node: LayoutNode){ @@ -324,6 +364,11 @@ export default defineComponent({ return; } // Attempt tile-expand + if (this.overflownRoot){ + this.onLeafClickHeld(layoutNode); + setTimeout(() => this.expandToNode(name), this.uiOpts.tileChgDuration); + return; + } this.onLeafClick(layoutNode).then(success => { if (success){ setTimeout(() => this.expandToNode(name), this.uiOpts.tileChgDuration); @@ -331,8 +376,7 @@ export default defineComponent({ } // Attempt expand-to-view on ancestor just below activeRoot if (layoutNode == this.activeRoot){ - console.log('Unable to complete search (not enough room to expand active root)'); - // Note: Only happens if screen is significantly small or node has significantly many children + console.log('Screen too small to expand active root'); this.modeRunning = false; return; } @@ -381,7 +425,7 @@ export default defineComponent({ if (node == this.activeRoot){ actionWeights['move up'] = 0; } - if (this.tolMap.get(node.name)!.children.length == 0){ + if (this.tolMap.get(node.name)!.children.length == 0 || this.overflownRoot){ actionWeights['expand'] = 0; } } else { @@ -473,6 +517,7 @@ export default defineComponent({ this.height = document.documentElement.clientHeight; tryLayout(this.activeRoot, this.tileAreaPos, this.tileAreaDims, this.lytOpts, {allowCollapse: true, layoutMap: this.layoutMap}); + this.overflownRoot = false; // Prevent re-triggering until after a delay this.resizeThrottled = true; setTimeout(() => {this.resizeThrottled = false;}, this.resizeDelay); @@ -544,6 +589,7 @@ export default defineComponent({
diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue index a156a96..ca865e9 100644 --- a/src/components/AncestryBar.vue +++ b/src/components/AncestryBar.vue @@ -24,7 +24,7 @@ export default defineComponent({ }, tileSz(){ return (this.wideArea ? this.dims[1] : this.dims[0]) - - (this.uiOpts.ancestryTileMargin * 2) - this.uiOpts.ancestryBarScrollGap; + (this.uiOpts.ancestryTileMargin * 2) - this.uiOpts.scrollGap; }, usedNodes(){ // Childless versions of 'nodes' used to parameterise return this.nodes.map(n => { @@ -39,10 +39,10 @@ export default defineComponent({ return len > (this.wideArea ? this.dims[0] : this.dims[1]); }, width(){ - return this.dims[0] + (this.wideArea || this.overflowing ? 0 : -this.uiOpts.ancestryBarScrollGap); + return this.dims[0] + (this.wideArea || this.overflowing ? 0 : -this.uiOpts.scrollGap); }, height(){ - return this.dims[1] + (!this.wideArea || this.overflowing ? 0 : -this.uiOpts.ancestryBarScrollGap); + return this.dims[1] + (!this.wideArea || this.overflowing ? 0 : -this.uiOpts.scrollGap); }, styles(): Record { return { @@ -54,8 +54,8 @@ export default defineComponent({ overflowX: this.wideArea ? 'auto' : 'hidden', overflowY: this.wideArea ? 'hidden' : 'auto', // Extra padding for scrollbar inclusion - paddingRight: (this.overflowing && !this.wideArea ? this.uiOpts.ancestryBarScrollGap : 0) + 'px', - paddingBottom: (this.overflowing && this.wideArea ? this.uiOpts.ancestryBarScrollGap : 0) + 'px', + paddingRight: (this.overflowing && !this.wideArea ? this.uiOpts.scrollGap : 0) + 'px', + paddingBottom: (this.overflowing && this.wideArea ? this.uiOpts.scrollGap : 0) + 'px', // For child layout display: 'flex', flexDirection: this.wideArea ? 'row' : 'column', diff --git a/src/components/Tile.vue b/src/components/Tile.vue index 429973f..dc9c87d 100644 --- a/src/components/Tile.vue +++ b/src/components/Tile.vue @@ -15,8 +15,11 @@ export default defineComponent({ // Options lytOpts: {type: Object as PropType, required: true}, uiOpts: {type: Object, required: true}, - // For a leaf node, prevents usage of absolute positioning (used by AncestryBar) + // Other 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 { @@ -46,6 +49,9 @@ export default defineComponent({ displayName(): string { return capitalizeWords(this.tolNode.commonName || this.layoutNode.name); }, + isOverflownRoot(): boolean { + return this.overflownDim > 0 && !this.layoutNode.hidden && this.layoutNode.children.length > 0; + }, // Style related nonleafBgColor(): string { let colorArray = this.uiOpts.nonleafBgColors; @@ -68,16 +74,6 @@ export default defineComponent({ 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 { - ...layoutStyles, // Transition related transitionDuration: this.uiOpts.tileChgDuration + 'ms', transitionProperty: 'left, top, width, height, visibility', @@ -88,6 +84,19 @@ export default defineComponent({ '--nonleafBgColor': this.nonleafBgColor, '--tileSpacing': this.lytOpts.tileSpacing + 'px', }; + if (this.layoutNode.hidden){ + layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0'; + layoutStyles.visibility = 'hidden'; + } + if (this.nonAbsPos){ + layoutStyles.position = 'static'; + } + if (this.isOverflownRoot){ + layoutStyles.width = (this.layoutNode.dims[0] + this.uiOpts.scrollGap) + 'px'; + layoutStyles.height = this.overflownDim + 'px'; + layoutStyles.overflow = 'scroll'; + } + return layoutStyles; }, leafStyles(): Record { return { @@ -128,11 +137,20 @@ export default defineComponent({ `${borderR} ${borderR} ${borderR} 0` : `${borderR} 0 ${borderR} ${borderR}`; } - return { + let styles = { + position: 'static', + width: '100%', + height: '100%', backgroundColor: this.nonleafBgColor, borderRadius: borderR, boxShadow: this.boxShadow, }; + if (this.isOverflownRoot){ + styles.position = 'absolute'; + styles.width = this.layoutNode.dims[0] + 'px'; + styles.height = this.layoutNode.dims[1] + 'px'; + } + return styles; }, nonleafHeaderStyles(): Record { let borderR = this.uiOpts.borderRadius + 'px'; @@ -327,7 +345,7 @@ export default defineComponent({ class="self-end text-white/10 hover:text-white hover:cursor-pointer" @click.stop="onInfoIconClick" @mousedown.stop @mouseup.stop/>
-
+

{{displayName}}

@@ -345,7 +363,7 @@ export default defineComponent({
diff --git a/src/layout.ts b/src/layout.ts index fe17b01..f5873ca 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -140,7 +140,7 @@ export type LayoutOptions = { minTileSz: number; // Minimum size of a tile edge, in pixels maxTileSz: number; // Layout-algorithm related - layoutType: 'sqr' | 'rect' | 'sweep'; // The LayoutFn function to use + layoutType: 'sqr' | 'rect' | 'sweep' | 'flex-sqr'; // The LayoutFn function to use rectMode: 'horz' | 'vert' | 'linear' | 'auto' | 'auto first-row'; // 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 @@ -224,14 +224,17 @@ export function tryLayout( let tempTree = layoutTree.cloneNodeTree(chg); let success: boolean; switch (options.layoutType){ - case 'sqr': success = sqrLayout(tempTree, pos, dims, true, allowCollapse, options); break; - case 'rect': success = rectLayout(tempTree, pos, dims, true, allowCollapse, options); break; + case 'sqr': success = sqrLayout(tempTree, pos, dims, true, allowCollapse, options); break; + case 'rect': success = rectLayout(tempTree, pos, dims, true, allowCollapse, options); break; case 'sweep': success = sweepLayout(tempTree, pos, dims, true, allowCollapse, options); break; + case 'flex-sqr': success = flexSqrLayout(tempTree, pos, dims, true, allowCollapse, options); break; } if (success){ - // Center in layout area - tempTree.pos[0] += (dims[0] - tempTree.dims[0]) / 2; - tempTree.pos[1] += (dims[1] - tempTree.dims[1]) / 2; + if (options.layoutType != 'flex-sqr'){ + // Center in layout area + tempTree.pos[0] += (dims[0] - tempTree.dims[0]) / 2; + tempTree.pos[1] += (dims[1] - tempTree.dims[1]) / 2; + } // Copy to given LayoutNode tree tempTree.copyTreeForRender(layoutTree, layoutMap); } @@ -796,3 +799,45 @@ let sweepLayout: LayoutFn = function (node, pos, dims, showHeader, allowCollapse node.assignLayoutData(pos, usedDims, {showHeader, empSpc, sepSweptArea: null}); return true; } +// Lays out nodes like sqrLayout(), but may extend past the height limit to fit nodes +// Does not recurse on child nodes with children +let flexSqrLayout: LayoutFn = function(node, pos, dims, showHeader, allowCollapse, opts){ + if (node.children.length == 0){ + return oneSqrLayout(node, pos, dims, false, false, opts); + } + // Consider area excluding header and top/left spacing + let headerSz = showHeader ? opts.headerSz : 0; + let newPos = [opts.tileSpacing, opts.tileSpacing + headerSz]; + let newWidth = dims[0] - opts.tileSpacing; + if (newWidth <= 0){ + return false; + } + // Find number of rows and columns + let numChildren = node.children.length; + let maxNumCols = Math.floor(newWidth / (opts.minTileSz + opts.tileSpacing)); + if (maxNumCols == 0){ + if (allowCollapse){ + node.children = []; + LayoutNode.updateDCounts(node, 1 - node.dCount); + return oneSqrLayout(node, pos, dims, false, false, opts); + } + return false; + } + let numCols = Math.min(numChildren, maxNumCols); + let numRows = Math.ceil(numChildren / numCols); + let tileSz = Math.min(opts.maxTileSz, Math.floor(newWidth / numCols) - opts.tileSpacing); + // Layout children + for (let i = 0; i < numChildren; i++){ + let childX = newPos[0] + (i % numCols) * (tileSz + opts.tileSpacing); + let childY = newPos[1] + Math.floor(i / numCols) * (tileSz + opts.tileSpacing); + oneSqrLayout(node.children[i], [childX,childY], [tileSz,tileSz], false, false, opts); + } + // Create layout + let usedDims: [number, number] = [ + numCols * (tileSz + opts.tileSpacing) + opts.tileSpacing, + numRows * (tileSz + opts.tileSpacing) + opts.tileSpacing + headerSz + ]; + let empSpc = 0; // Intentionally not used + node.assignLayoutData(pos, usedDims, {showHeader, empSpc}); + return true; +} -- cgit v1.2.3