diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Tile.vue | 191 | ||||
| -rw-r--r-- | src/components/TileTree.vue | 71 | ||||
| -rw-r--r-- | src/lib.ts | 105 |
3 files changed, 241 insertions, 126 deletions
diff --git a/src/components/Tile.vue b/src/components/Tile.vue index 06d9899..57ba1e1 100644 --- a/src/components/Tile.vue +++ b/src/components/Tile.vue @@ -2,93 +2,176 @@ import {defineComponent, PropType} from 'vue'; import {LayoutNode} from '../lib'; +//component holds a tree-node structure representing a tile or tile-group to be rendered export default defineComponent({ - name: 'tile', - data(){ - return { - zIdx: 0, - overFlow: 'visible', - } - }, + name: 'tile', //need this to use self in template props: { layoutNode: {type: Object as PropType<LayoutNode>, required: true}, + isRoot: {type: Boolean, default: false}, + //settings passed in from parent component transitionDuration: {type: Number, required: true}, headerSz: {type: Number, required: true}, tileSpacing: {type: Number, required: true}, - center: {type: Array as unknown as PropType<[number,number]>, default: null}, + }, + data(){ + return { + //used during transitions and to emulate/show an apparently-joined div + zIdx: 0, + overflow: this.isRoot ? 'hidden' : 'visible', + } }, computed: { - name(){return this.layoutNode.tolNode.name.replaceAll('\'', '\\\'')} + showHeader(){ + return (this.layoutNode.showHeader && !this.layoutNode.sepSweptArea) || + (this.layoutNode.sepSweptArea && this.layoutNode.sepSweptArea.sweptLeft); + }, + tileStyles(): Record<string,string> { + return { + //place using layoutNode, with centering if root + position: 'absolute', + top: this.isRoot ? '50%' : this.layoutNode.pos[1] + 'px', + left: this.isRoot ? '50%' : this.layoutNode.pos[0] + 'px', + transform: this.isRoot ? 'translate(-50%, -50%)' : 'none', + width: this.layoutNode.dims[0] + 'px', + height: this.layoutNode.dims[1] + 'px', + //other bindings + transitionDuration: this.transitionDuration + 'ms', + zIndex: String(this.zIdx), + overflow: String(this.overflow), + //static + outline: 'black solid 1px', + backgroundColor: 'white', + transitionProperty: 'top, left, width, height', + transitionTimingFunction: 'ease-out', + }; + }, + leafStyles(): Record<string,string> { + return { + width: '100%', + height: '100%', + backgroundImage: 'url(\'/img/' + this.layoutNode.tolNode.name.replaceAll('\'', '\\\'') + '.jpg\')', + backgroundSize: 'cover', + opacity: (this.layoutNode.tolNode.children.length > 0) ? '1' : '0.7', + }; + }, + headerStyles(): Record<string,string> { + return { + height: this.headerSz + 'px', + backgroundColor: 'lightgray', + textAlign: 'center', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }; + }, + sepSweptAreaStyles(): Record<string,string> { + let commonStyles = { + position: 'absolute', + backgroundColor: 'white', + transitionDuration: this.transitionDuration + 'ms', + transitionProperty: 'top, left, width, height', + transitionTimingFunction: 'ease-out', + }; + let area = this.layoutNode.sepSweptArea; + if (area == null){ + return { + ...commonStyles, + visibility: 'hidden', + top: this.headerSz + 'px', + left: '0', + width: '0', + height: '0', + }; + } else { + return { + ...commonStyles, + top: area.pos[1] + 'px', + left: area.pos[0] + 'px', + width: (area.dims[0] + (area.sweptLeft ? 1 : 0)) + 'px', + height: (area.dims[1] + (area.sweptLeft ? 0 : 1)) + 'px', + }; + } + }, + sepSweptAreaOutlineClasses(){ + let area = this.layoutNode.sepSweptArea; + return ['outline-top-left', (area && area.sweptLeft) ? 'outline-bottom-left' : 'outline-top-right']; + }, }, methods: { - onImgClick(){ - this.$emit('tile-clicked', this.layoutNode); + onLeafClick(){ + this.$emit('leaf-clicked', this.layoutNode); //increase z-index and hide overflow during transition this.zIdx = 1; - this.overFlow = 'hidden'; - setTimeout(() => {this.zIdx = 0; this.overFlow = 'visible'}, this.transitionDuration); + this.overflow = 'hidden'; + setTimeout(() => {this.zIdx = 0; this.overflow = 'visible'}, this.transitionDuration); }, - onInnerTileClicked(node: LayoutNode){ - this.$emit('tile-clicked', node); + onInnerLeafClicked(node: LayoutNode){ + this.$emit('leaf-clicked', node); }, onHeaderClick(){ this.$emit('header-clicked', this.layoutNode); //increase z-index and hide overflow during transition this.zIdx = 1; - this.overFlow = 'hidden'; - setTimeout(() => {this.zIdx = 0; this.overFlow = 'visible'}, this.transitionDuration); + this.overflow = 'hidden'; + setTimeout(() => {this.zIdx = 0; this.overflow = 'visible'}, this.transitionDuration); }, onInnerHeaderClicked(node: LayoutNode){ this.$emit('header-clicked', node); - } - } -}) + }, + }, +}); </script> <template> -<div - :style="{position: 'absolute', - left: (center ? (center[0]-layoutNode.dims[0])/2 : layoutNode.pos[0]) + 'px', - top: (center ? (center[1]-layoutNode.dims[1])/2 : layoutNode.pos[1]) + 'px', - width: layoutNode.dims[0]+'px', height: layoutNode.dims[1]+'px', - zIndex: zIdx, overflow: overFlow, transitionDuration: transitionDuration+'ms'}" - class="transition-[left,top,width,height] ease-out outline outline-1 bg-white"> +<div :style="tileStyles"> <div v-if="layoutNode.children.length == 0" - :style="{backgroundImage: 'url(\'/img/' + name + '.jpg\')', - opacity: (layoutNode.tolNode.children.length > 0 ? 1 : 0.7)}" - class="hover:cursor-pointer w-full h-full bg-cover" @click="onImgClick" - /> + :style="leafStyles" class="hover:cursor-pointer" @click="onLeafClick"/> <div v-else> - <div - v-if="(layoutNode.showHeader && !layoutNode.sepSweptArea) || - (layoutNode.sepSweptArea && layoutNode.sepSweptArea.sweptLeft)" - :style="{height: headerSz+'px'}" - class="text-center overflow-hidden text-ellipsis hover:cursor-pointer bg-stone-300" - @click="onHeaderClick"> + <div v-if="showHeader" :style="headerStyles" class="hover:cursor-pointer" @click="onHeaderClick"> {{layoutNode.tolNode.name}} </div> - <div - :style="{position: 'absolute', - left: layoutNode.sepSweptArea ? layoutNode.sepSweptArea.pos[0]+'px' : 0, - top: layoutNode.sepSweptArea ? layoutNode.sepSweptArea.pos[1]+'px' : headerSz+'px', - width: layoutNode.sepSweptArea ? - (layoutNode.sepSweptArea.dims[0]+(layoutNode.sepSweptArea.sweptLeft ? 1 : 0))+'px' : 0, - height: layoutNode.sepSweptArea ? - (layoutNode.sepSweptArea.dims[1]+(layoutNode.sepSweptArea.sweptLeft ? 0 : 1))+'px' : 0, - transitionDuration: transitionDuration+'ms'}" - class="transition-[left,top,width,height] ease-out bg-white - before:absolute before:bg-black before:-top-[1px] before:-left-[1px] before:w-full before:h-full before:-z-10 - after:absolute after:bg-black after:-bottom-[1px] after:-left-[1px] after:w-full after:h-full after:-z-10"> - <div v-if="layoutNode.sepSweptArea && !layoutNode.sepSweptArea.sweptLeft" :style="{height: headerSz+'px'}" - class="text-center overflow-hidden text-ellipsis hover:cursor-pointer bg-stone-300" - @click="onHeaderClick"> + <div :style="sepSweptAreaStyles" :class="sepSweptAreaOutlineClasses"> + <div v-if="layoutNode?.sepSweptArea?.sweptLeft === false" + :style="headerStyles" class="hover:cursor-pointer" @click="onHeaderClick"> {{layoutNode.tolNode.name}} </div> </div> <tile v-for="child in layoutNode.children" :key="child.tolNode.name" :layoutNode="child" :headerSz="headerSz" :tileSpacing="tileSpacing" :transitionDuration="transitionDuration" - @tile-clicked="onInnerTileClicked" @header-clicked="onInnerHeaderClicked" - ></tile> + @leaf-clicked="onInnerLeafClicked" @header-clicked="onInnerHeaderClicked"/> </div> </div> </template> + +<style> +.outline-top-left::before { + content: ''; + position: absolute; + background-color: black; + top: -1px; + left: -1px; + width: 100%; + height: 100%; + z-index: -10; +} +.outline-bottom-left::after { + content: ''; + position: absolute; + background-color: black; + bottom: -1px; + left: -1px; + width: 100%; + height: 100%; + z-index: -10; +} +.outline-top-right::after { + content: ''; + position: absolute; + background-color: black; + top: -1px; + right: -1px; + width: 100%; + height: 100%; + z-index: -10; +} +</style> diff --git a/src/components/TileTree.vue b/src/components/TileTree.vue index 4969c2c..ab8ba1b 100644 --- a/src/components/TileTree.vue +++ b/src/components/TileTree.vue @@ -3,70 +3,79 @@ import {defineComponent} from 'vue'; import Tile from './Tile.vue'; import {TolNode, LayoutTree, LayoutNode} from '../lib'; import type {LayoutOptions} from '../lib'; -//regarding importing a file f1.ts: - //using 'import f1.ts' makes vue-tsc complain, and 'import f1.js' makes vite complain - //using 'import f1' might cause problems with build systems other than vite +//import paths lack a .ts or .js extension because .ts makes vue-tsc complain, and .js makes vite complain -import tol from '../tol.json'; -function preprocessTol(tree: any): void { - if (!tree.children){ - tree.children = []; +//obtain tree-of-life data +import tolRaw from '../tol.json'; +function preprocessTol(node: any): any { //adds 'children' fields if missing + if (node.children == null){ + node.children = []; } else { - tree.children.forEach(preprocessTol); + node.children.forEach(preprocessTol); } + return node; } -preprocessTol(tol); +const tol: TolNode = preprocessTol(tolRaw); -let defaultLayoutOptions: LayoutOptions = { +//configurable settings +let layoutOptions: LayoutOptions = { + //integer values specify pixels tileSpacing: 5, headerSz: 20, - minTileSz: 50, - maxTileSz: 200, - layoutType: 'sweep', //'sqr' | 'rect' | 'sweep' + minTileSz: 20, + maxTileSz: 500, + layoutType: 'sqr', //'sqr' | 'rect' | 'sweep' rectMode: 'auto', //'horz' | 'vert' | 'linear' | 'auto' - rectSpaceShifting: true, sweepMode: 'left', //'left' | 'top' | 'shorter' | 'auto' sweptNodesPrio: 'sqrt', //'linear' | 'sqrt' | 'sqrt-when-high' sweepingToParent: true, }; -let defaultOtherOptions = { +let otherOptions = { + //integer values specify milliseconds transitionDuration: 300, + resizeDelay: 100, //during window-resizing, relayout tiles after this delay instead of continously }; +//component holds a tree structure representing a subtree of 'tol' to be rendered +//collects events about tile expansion/collapse and window-resize, and initiates relayout of tiles export default defineComponent({ data(){ return { - layoutOptions: defaultLayoutOptions, - otherOptions: defaultOtherOptions, - layoutTree: new LayoutTree(tol as TolNode, 0, defaultLayoutOptions), + layoutTree: new LayoutTree(tol, layoutOptions, 0), width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, + layoutOptions: layoutOptions, + otherOptions: otherOptions, resizeThrottled: false, } }, methods: { onResize(){ if (!this.resizeThrottled){ + //update data and relayout tiles this.width = document.documentElement.clientWidth; this.height = document.documentElement.clientHeight; - if (!this.layoutTree.tryLayout([0,0], [this.width,this.height])) + if (!this.layoutTree.tryLayout([0,0], [this.width,this.height])){ console.log('Unable to layout tree'); + } //prevent re-triggering until after a delay this.resizeThrottled = true; - setTimeout(() => {this.resizeThrottled = false;}, 100); + setTimeout(() => {this.resizeThrottled = false;}, otherOptions.resizeDelay); } }, - onInnerTileClicked(node: LayoutNode){ - if (node.tolNode.children.length == 0){ + onInnerLeafClicked(clickedNode: LayoutNode){ + if (clickedNode.tolNode.children.length == 0){ console.log('Tile-to-expand has no children'); return; } - if (!this.layoutTree.tryLayoutOnExpand([0,0], [this.width,this.height], node)) + if (!this.layoutTree.tryLayoutOnExpand([0,0], [this.width,this.height], clickedNode)){ console.log('Unable to layout tree'); + } }, - onInnerHeaderClicked(node: LayoutNode){ - if (!this.layoutTree.tryLayoutOnCollapse([0,0], [this.width,this.height], node)) + onInnerHeaderClicked(clickedNode: LayoutNode){ + if (!this.layoutTree.tryLayoutOnCollapse([0,0], [this.width,this.height], clickedNode)){ console.log('Unable to layout tree'); + } }, }, created(){ @@ -78,16 +87,16 @@ export default defineComponent({ window.removeEventListener('resize', this.onResize); }, components: { - Tile - } -}) + Tile, + }, +}); </script> <template> -<div class="h-[100vh]"> +<div class="h-screen bg-stone-100"> <tile :layoutNode="layoutTree.root" :headerSz="layoutOptions.headerSz" :tileSpacing="layoutOptions.tileSpacing" - :transitionDuration="otherOptions.transitionDuration" :center="[width,height]" - @tile-clicked="onInnerTileClicked" @header-clicked="onInnerHeaderClicked"></tile> + :transitionDuration="otherOptions.transitionDuration" :isRoot="true" + @leaf-clicked="onInnerLeafClicked" @header-clicked="onInnerHeaderClicked"/> </div> </template> @@ -1,3 +1,12 @@ +/* + * Contains classes used for representing tree-of-life data, and tile-based layouts of such data. + * + * Generally, given a TolNode with child TolNodes representing tree-of-life T, + * a LayoutTree is created for a subtree of T, and represents a tile-based layout of that subtree. + * The LayoutTree holds LayoutNodes, each of which holds placement info for a linked TolNode. + */ + +//represents a tree-of-life node/tree export class TolNode { name: string; children: TolNode[]; @@ -6,25 +15,29 @@ export class TolNode { this.children = children; } } +//represents a tree of LayoutNode objects, and has methods for (re)computing layout export class LayoutTree { root: LayoutNode; options: LayoutOptions; - constructor(tol: TolNode, depth: number, options: LayoutOptions){ + //creates an object representing a TolNode tree, up to a given depth (0 means just the root) + constructor(tol: TolNode, options: LayoutOptions, depth: number){ this.root = this.initHelper(tol, depth); this.options = options; } + //used by constructor to initialise the LayoutNode tree initHelper(tolNode: TolNode, depth: number): LayoutNode { - if (depth > 0){ - let children = tolNode.children.map( - (n: TolNode) => this.initHelper(n, depth-1)); + if (depth == 0){ + return new LayoutNode(tolNode, []); + } else { + let children = tolNode.children.map((n: TolNode) => this.initHelper(n, depth-1)); let node = new LayoutNode(tolNode, children); children.forEach(n => n.parent = node); return node; - } else { - return new LayoutNode(tolNode, []); } } + //attempts layout of TolNode tree, for an area with given xy-coordinate and width+height (in pixels) tryLayout(pos: [number,number], dims: [number,number]){ + //create a new LayoutNode tree, keeping the old tree in case of failure let newLayout: LayoutNode | null; switch (this.options.layoutType){ case 'sqr': newLayout = sqrLayoutFn(this.root, pos, dims, true, this.options); break; @@ -37,6 +50,7 @@ export class LayoutTree { this.copyTreeForRender(newLayout, this.root); return true; } + //attempts layout after adding a node's children to the LayoutNode tree tryLayoutOnExpand(pos: [number,number], dims: [number,number], node: LayoutNode){ //add children node.children = node.tolNode.children.map((n: TolNode) => new LayoutNode(n, [])); @@ -50,6 +64,7 @@ export class LayoutTree { } return success; } + //attempts layout after removing a node's children from the LayoutNode tree tryLayoutOnCollapse(pos: [number,number], dims: [number,number], node: LayoutNode){ //remove children let children = node.children; @@ -63,17 +78,19 @@ export class LayoutTree { } return success; } + //used to copy a new LayoutNode tree's render-relevant data to the old tree copyTreeForRender(node: LayoutNode, target: LayoutNode): void { target.pos = node.pos; target.dims = node.dims; target.showHeader = node.showHeader; target.sepSweptArea = node.sepSweptArea; - //these are arguably redundant + //these are currently redundant, but maintain data-consistency target.dCount = node.dCount; target.empSpc = node.empSpc; //recurse on children node.children.forEach((n,i) => this.copyTreeForRender(n, target.children[i])); } + //used to update a LayoutNode tree's dCount fields after adding/removing a node's children updateDCounts(node: LayoutNode | null, diff: number): void{ while (node != null){ node.dCount += diff; @@ -81,32 +98,32 @@ export class LayoutTree { } } } +//contains settings that affect how layout is done export type LayoutOptions = { - tileSpacing: number; + tileSpacing: number; //spacing between tiles, in pixels (ignoring borders) headerSz: number; - minTileSz: number; + minTileSz: number; //minimum size of a tile edge, in pixels (ignoring borders) maxTileSz: number; - layoutType: 'sqr' | 'rect' | 'sweep'; - rectMode: 'horz' | 'vert' | 'linear' | 'auto'; - rectSpaceShifting: boolean; - sweepMode: 'left' | 'top' | 'shorter' | 'auto'; - sweptNodesPrio: 'linear' | 'sqrt' | 'sqrt-when-high'; - sweepingToParent: boolean; + layoutType: 'sqr' | 'rect' | 'sweep'; //the LayoutFn function to use + rectMode: 'horz' | 'vert' | 'linear' | 'auto'; //layout in 1 row, 1 column, 1 row or column, or multiple rows + sweepMode: 'left' | 'top' | 'shorter' | 'auto'; //sweep to left, top, shorter-side, or to minimise empty space + sweptNodesPrio: 'linear' | 'sqrt' | 'sqrt-when-high'; //specifies allocation of space to swept-vs-remaining nodes + sweepingToParent: boolean; //allow swept nodes to occupy empty space in a parent's swept-leaves area }; +//represents a node/tree, and holds layout data for a TolNode node/tree export class LayoutNode { - //structure-related tolNode: TolNode; children: LayoutNode[]; parent: LayoutNode | null; - //used for rendering + //used for rendering a corresponding tile pos: [number, number]; dims: [number, number]; showHeader: boolean; sepSweptArea: SepSweptArea | null; //used for layout heuristics dCount: number; //number of descendant leaf nodes - empSpc: number; - // + empSpc: number; //amount of unused space (in pixels) + //creates object with given fields ('parent' is generally initialised later, 'dCount' is computed) constructor( tolNode: TolNode, children: LayoutNode[], pos=[0,0] as [number,number], dims=[0,0] as [number,number], {showHeader=false, sepSweptArea=null as SepSweptArea|null, empSpc=0} = {}){ @@ -121,10 +138,11 @@ export class LayoutNode { this.empSpc = empSpc; } } +//used with layout option 'sweepingToParent', and represents, for a LayoutNode, a parent area to place leaf nodes in export class SepSweptArea { pos: [number, number]; dims: [number, number]; - sweptLeft: boolean; + sweptLeft: boolean; //true if the parent's leaves were swept left constructor(pos: [number, number], dims: [number, number], sweptLeft: boolean){ this.pos = pos; this.dims = dims; @@ -135,10 +153,19 @@ export class SepSweptArea { } } -type LayoutFn = (node: LayoutNode, pos: [number, number], dims: [number, number], showHeader: boolean, - opts: LayoutOptions, ownOpts?: {subLayoutFn?: LayoutFn, sepAreaInfo?: {avail: SepSweptArea, usedLen: number}|null} - ) => LayoutNode | null; - +//type for functions called by LayoutTree to perform layout +//returns a new LayoutNode tree for a given LayoutNode's TolNode tree, or null if layout was unsuccessful +type LayoutFn = ( + node: LayoutNode, + pos: [number, number], + dims: [number, number], + showHeader: boolean, + opts: LayoutOptions, + ownOpts?: { + subLayoutFn?: LayoutFn, + sepAreaInfo?: {avail: SepSweptArea, usedLen: number}|null, + }, +) => LayoutNode | null; //lays out nodes as squares in a rectangle, with spacing let sqrLayoutFn: LayoutFn = function (node, pos, dims, showHeader, opts){ if (node.children.length == 0){ @@ -306,28 +333,24 @@ let rectLayoutFn: LayoutFn = function (node, pos, dims, showHeader, opts, ownOpt childLyts[nodeIdx] = newChild; empSpc += newChild.empSpc + (childW*childH)-(newChild.dims[0]*newChild.dims[1]); //handle horizontal empty-space-shifting - if (opts.rectSpaceShifting){ - let empHorz = childW - newChild.dims[0]; - if (c < rowsOfCnts[r].length-1){ - cellXs[nodeIdx+1] -= empHorz; - cellWs[nodeIdx+1] += empHorz; - empSpc -= empHorz * childH; - } else { - minEmpH = Math.min(minEmpH, empHorz); - } + let empHorz = childW - newChild.dims[0]; + if (c < rowsOfCnts[r].length-1){ + cellXs[nodeIdx+1] -= empHorz; + cellWs[nodeIdx+1] += empHorz; + empSpc -= empHorz * childH; + } else { + minEmpH = Math.min(minEmpH, empHorz); } //other updates minEmpVert = Math.min(childH-newChild.dims[1], minEmpVert); } //handle vertical empty-space-shifting - if (opts.rectSpaceShifting){ - if (r < rowBrks.length-1){ - cellYs[r+1] -= minEmpVert; - cellHs[r+1] += minEmpVert; - empSpc -= minEmpVert * availW; - } else { - lastEmpV = minEmpVert; - } + if (r < rowBrks.length-1){ + cellYs[r+1] -= minEmpVert; + cellHs[r+1] += minEmpVert; + empSpc -= minEmpVert * availW; + } else { + lastEmpV = minEmpVert; } } //check with best-so-far |
