aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2022-09-13 19:59:06 +1000
committerTerry Truong <terry06890@gmail.com>2022-09-13 20:00:17 +1000
commit23b5cc80ba02936659564dd03b173d3214ce5978 (patch)
treecdf6a183d1a0bfcb45a924585b764c723dd67b55
parente382d4173c990a49a9ef3db1b3681763a3e2e908 (diff)
Use Vue Composition API and ESLint
-rw-r--r--.eslintrc.js26
-rw-r--r--package.json10
-rw-r--r--src/App.vue2001
-rw-r--r--src/components/AncestryBar.vue150
-rw-r--r--src/components/HelpModal.vue108
-rw-r--r--src/components/IconButton.vue31
-rw-r--r--src/components/LoadingModal.vue30
-rw-r--r--src/components/SButton.vue10
-rw-r--r--src/components/SCollapsible.vue101
-rw-r--r--src/components/SearchModal.vue429
-rw-r--r--src/components/SettingsModal.vue231
-rw-r--r--src/components/TileInfoModal.vue324
-rw-r--r--src/components/TolTile.vue954
-rw-r--r--src/components/TutorialPane.vue193
-rw-r--r--src/components/icon/CloseIcon.vue5
-rw-r--r--src/components/icon/DownIcon.vue5
-rw-r--r--src/components/icon/EduIcon.vue5
-rw-r--r--src/components/icon/ExternalLinkIcon.vue5
-rw-r--r--src/components/icon/HelpIcon.vue5
-rw-r--r--src/components/icon/InfoIcon.vue5
-rw-r--r--src/components/icon/LinkIcon.vue5
-rw-r--r--src/components/icon/LoaderIcon.vue5
-rw-r--r--src/components/icon/LogInIcon.vue5
-rw-r--r--src/components/icon/PauseIcon.vue5
-rw-r--r--src/components/icon/PlayIcon.vue5
-rw-r--r--src/components/icon/SearchIcon.vue5
-rw-r--r--src/components/icon/SettingsIcon.vue5
27 files changed, 2253 insertions, 2410 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..eb86580
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,26 @@
+module.exports = {
+ "env": {
+ "browser": true,
+ "es2021": true
+ },
+ "extends": [
+ "eslint:recommended",
+ "plugin:vue/vue3-essential",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "overrides": [
+ ],
+ "parser": "vue-eslint-parser",
+ "parserOptions": {
+ "parser": "@typescript-eslint/parser",
+ "ecmaVersion": "latest",
+ "sourceType": "module"
+ },
+ "plugins": [
+ "vue",
+ "@typescript-eslint"
+ ],
+ "rules": {
+ "@typescript-eslint/no-non-null-assertion": "off"
+ }
+}
diff --git a/package.json b/package.json
index ad0739f..773d30a 100644
--- a/package.json
+++ b/package.json
@@ -15,13 +15,17 @@
"vue": "^3.2.25"
},
"devDependencies": {
- "@vitejs/plugin-vue": "^2.2.0",
+ "@typescript-eslint/eslint-plugin": "^5.36.2",
+ "@typescript-eslint/parser": "^5.36.2",
+ "@vitejs/plugin-vue": "^3.1.0",
"autoprefixer": "^10.4.2",
+ "eslint": "^8.23.1",
+ "eslint-plugin-vue": "^9.4.0",
"postcss": "^8.4.7",
"smartcrop-cli": "^2.0.3",
"tailwindcss": "^3.0.23",
"typescript": "^4.6.2",
- "vite": "^2.8.0",
- "vue-tsc": "^0.32.1"
+ "vite": "^3.1.0",
+ "vue-tsc": "^0.40.13"
}
}
diff --git a/src/App.vue b/src/App.vue
index c0e7f9c..6ed2423 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -25,7 +25,7 @@
</icon-button>
</div>
<!-- Content area -->
- <div class="grow min-h-0 flex flex-col relative" ref="contentArea">
+ <div class="grow min-h-0 flex flex-col relative" ref="contentAreaRef">
<div :style="tutPaneContainerStyles" class="z-10"> <!-- Used to slide-in/out the tutorial pane -->
<transition name="fade">
<tutorial-pane v-if="tutPaneOpen" :style="tutPaneStyles"
@@ -62,7 +62,7 @@
<search-modal v-if="searchOpen"
:tolMap="tolMap" :lytMap="layoutMap" :activeRoot="activeRoot" :lytOpts="lytOpts" :uiOpts="uiOpts"
@close="onSearchClose" @search="onSearch" @info-click="onInfoClick" @setting-chg="onSettingChg"
- @net-wait="onSearchNetWait" @net-get="endLoadInd" class="z-10" ref="searchModal"/>
+ @net-wait="onSearchNetWait" @net-get="endLoadInd" class="z-10"/>
</transition>
<transition name="fade">
<tile-info-modal v-if="infoModalNodeName != null && infoModalData != null"
@@ -86,8 +86,8 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, onMounted, onUnmounted, nextTick} from 'vue';
// Components
import TolTile from './components/TolTile.vue';
import TileInfoModal from './components/TileInfoModal.vue';
@@ -112,1034 +112,1021 @@ import {LayoutNode, LayoutOptions, LayoutTreeChg,
initLayoutTree, initLayoutMap, tryLayout} from './layout';
import {queryServer, InfoResponse, Action,
UiOptions, getDefaultLytOpts, getDefaultUiOpts, OptionType} from './lib';
-import {arraySum, randWeightedChoice, timeout} from './util';
+import {arraySum, randWeightedChoice} from './util';
// Constants
const SERVER_WAIT_MSG = 'Loading data';
const PROCESSING_WAIT_MSG = 'Processing';
-// Type representing auto-mode actions
-type AutoAction = 'move across' | 'move down' | 'move up' | Action;
-// Function used in auto-mode to reduce action cycles
-function getReverseAction(action: AutoAction): AutoAction | null {
- const reversePairs: AutoAction[][] = [
- ['move down', 'move up'],
- ['expand', 'collapse'],
- ['expandToView', 'unhideAncestor'],
- ];
- let pair = reversePairs.find(pair => pair.includes(action));
- if (pair != null){
- return pair[0] == action ? pair[1] : pair[0];
- } else {
+const EXCESS_TOLNODE_THRESHOLD = 1000; // Threshold where excess tolMap entries get removed
+
+// Refs
+const contentAreaRef = ref(null as HTMLElement | null);
+
+// Get/load option values
+function getLytOpts(): LayoutOptions {
+ let opts = getDefaultLytOpts();
+ for (let prop of Object.getOwnPropertyNames(opts) as (keyof LayoutOptions)[]){
+ let item = localStorage.getItem('LYT ' + prop);
+ if (item != null){
+ switch (typeof(opts[prop])){
+ case 'boolean': (opts[prop] as unknown as boolean) = Boolean(item); break;
+ case 'number': (opts[prop] as unknown as number) = Number(item); break;
+ case 'string': (opts[prop] as unknown as string) = item; break;
+ default: console.log(`WARNING: Found saved layout setting "${prop}" with unexpected type`);
+ }
+ }
+ }
+ return opts;
+}
+function getUiOpts(): UiOptions {
+ let opts = getDefaultUiOpts(getDefaultLytOpts());
+ for (let prop of Object.getOwnPropertyNames(opts) as (keyof UiOptions)[]){
+ let item = localStorage.getItem('UI ' + prop);
+ if (item != null){
+ switch (typeof(opts[prop])){
+ case 'boolean': (opts[prop] as unknown as boolean) = (item == 'true'); break;
+ case 'number': (opts[prop] as unknown as number) = Number(item); break;
+ case 'string': (opts[prop] as unknown as string) = item; break;
+ default: console.log(`WARNING: Found saved UI setting "${prop}" with unexpected type`);
+ }
+ }
+ }
+ return opts;
+}
+const lytOpts = ref(getLytOpts());
+const uiOpts = ref(getUiOpts());
+
+// Tree/layout data
+const tolMap = ref(new Map() as TolMap);
+tolMap.value.set('', new TolNode())
+const layoutTree = ref(initLayoutTree(tolMap.value, "", 0));
+layoutTree.value.hidden = true;
+const activeRoot = ref(layoutTree.value); // Root of the displayed subtree
+const layoutMap = ref(initLayoutMap(layoutTree.value)); // Maps names to LayoutNodes
+// Nodes to show in ancestry-bar (ordered from root downwards)
+const detachedAncestors = computed((): LayoutNode[] | null => {
+ if (activeRoot.value == layoutTree.value){
return null;
}
+ let ancestors = [];
+ let node = activeRoot.value.parent;
+ while (node != null){
+ ancestors.push(node);
+ node = node.parent;
+ }
+ return ancestors.reverse();
+});
+
+// For initialisation
+const justInitialised = ref(false); // Used to skip transition for the tile initially loaded from server
+async function initTreeFromServer(firstInit = true){
+ // Get possible target node from URL
+ let nodeName = (new URL(window.location.href)).searchParams.get('node');
+ // Query server
+ let urlParams = new URLSearchParams({type: 'node', tree: uiOpts.value.tree});
+ if (nodeName != null && firstInit){
+ urlParams.append('name', nodeName);
+ urlParams.append('toroot', '1');
+ }
+ let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams);
+ if (responseObj == null){
+ return;
+ }
+ // Get root node name
+ let rootName = null;
+ let nodeNames = Object.getOwnPropertyNames(responseObj);
+ for (let n of nodeNames){
+ if (responseObj[n].parent == null){
+ rootName = n;
+ break;
+ }
+ }
+ if (rootName == null){
+ console.log('ERROR: Server response has no root node');
+ return;
+ }
+ // Initialise tree
+ tolMap.value.clear();
+ nodeNames.forEach(n => {tolMap.value.set(n, responseObj[n])});
+ if (nodeName == null){
+ layoutTree.value = initLayoutTree(tolMap.value, rootName, 0);
+ layoutMap.value = initLayoutMap(layoutTree.value);
+ activeRoot.value = layoutTree.value;
+ } else {
+ layoutTree.value = initLayoutTree(tolMap.value, rootName, -1);
+ layoutMap.value = initLayoutMap(layoutTree.value);
+ // Set active root
+ let targetNode = layoutMap.value.get(nodeName)!;
+ let newRoot = targetNode.parent == null ? targetNode : targetNode.parent;
+ LayoutNode.hideUpward(newRoot, layoutMap.value);
+ activeRoot.value = newRoot;
+ setTimeout(() => setLastFocused(targetNode!), uiOpts.value.transitionDuration);
+ }
+ // Skip initial transition
+ if (firstInit){
+ justInitialised.value = true;
+ setTimeout(() => {justInitialised.value = false}, uiOpts.value.transitionDuration);
+ }
+ // Relayout
+ updateAreaDims();
+ relayoutWithCollapse(false);
}
+async function reInit(){
+ if (activeRoot.value != layoutTree.value){
+ // Collapse tree to root
+ await onDetachedAncestorClick(layoutTree.value, true);
+ }
+ await onNonleafClick(layoutTree.value, true);
+ await initTreeFromServer(false);
+}
+onMounted(() => initTreeFromServer());
-export default defineComponent({
- data(){
- // Create initial tree-of-life data
- let initialTolMap: TolMap = new Map();
- initialTolMap.set("", new TolNode());
- let layoutTree = initLayoutTree(initialTolMap, "", 0);
- layoutTree.hidden = true;
- // Get/load option values
- let lytOpts = this.getLytOpts();
- let uiOpts = this.getUiOpts();
- //
- return {
- // Tree/layout data
- tolMap: initialTolMap,
- layoutTree: layoutTree,
- activeRoot: layoutTree, // Root of the displayed subtree
- layoutMap: initLayoutMap(layoutTree), // Maps names to LayoutNodes
- overflownRoot: false, // Set when displaying a root tile with many children, with overflow
- // For modals
- infoModalNodeName: null as string | null, // Name of node to display info for, or null
- infoModalData: null as InfoResponse | null,
- searchOpen: false,
- settingsOpen: false,
- helpOpen: false,
- loadingMsg: null as null | string, // Message to display in loading-indicator
- // For search and auto-mode
- modeRunning: null as null | 'search' | 'autoMode',
- lastFocused: null as LayoutNode | null, // Used to un-focus
- // For auto-mode
- autoPrevAction: null as AutoAction | null, // Used to help prevent action cycles
- autoPrevActionFail: false, // Used to avoid re-trying a failed expand/collapse
- // For tutorial pane
- tutPaneOpen: !uiOpts.tutorialSkip,
- tutWelcome: !uiOpts.tutorialSkip,
- tutTriggerAction: null as Action | null, // Used to advance tutorial upon user-actions
- tutTriggerFlag: false,
- actionsDone: new Set() as Set<Action>, // Used to avoid disabling actions the user has already seen
- // Options
- lytOpts: lytOpts,
- uiOpts: uiOpts,
- // For layout and resize-handling
- mainAreaDims: [0, 0] as [number, number],
- tileAreaDims: [0, 0] as [number, number],
- lastResizeHdlrTime: 0, // Used to throttle resize handling
- afterResizeHdlr: 0, // Set via setTimeout() to execute after a run of resize events
- // Other
- justInitialised: false, // Used to skip transition for the tile initially loaded from server
- pendingLoadingRevealHdlr: 0, // Used to delay showing the loading-indicator
- changedSweepToParent: false, // Set during search animation for efficiency
- excessTolNodeThreshold: 1000, // Threshold where excess tolMap entries get removed
- };
- },
- computed: {
- wideMainArea(): boolean {
- return this.mainAreaDims[0] > this.mainAreaDims[1];
- },
- // Nodes to show in ancestry-bar (ordered from root downwards)
- detachedAncestors(): LayoutNode[] | null {
- if (this.activeRoot == this.layoutTree){
- return null;
- }
- let ancestors = [];
- let node = this.activeRoot.parent;
- while (node != null){
- ancestors.push(node);
- node = node.parent;
- }
- return ancestors.reverse();
- },
- // Styles
- buttonStyles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.altColorDark,
- };
- },
- tutPaneContainerStyles(): Record<string,string> {
- if (this.uiOpts.breakpoint == 'sm'){
- return {
- minHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px',
- maxHeight: (this.tutPaneOpen ? this.uiOpts.tutPaneSz : 0) + 'px',
- transitionProperty: 'max-height, min-height',
- transitionDuration: this.uiOpts.transitionDuration + 'ms',
- overflow: 'hidden',
- };
- } else {
- return {
- position: 'absolute',
- bottom: '0.5cm',
- right: '0.5cm',
- visibility: this.tutPaneOpen ? 'visible' : 'hidden',
- transitionProperty: 'visibility',
- transitionDuration: this.uiOpts.transitionDuration + 'ms',
- };
- }
- },
- tutPaneStyles(): Record<string,string> {
- if (this.uiOpts.breakpoint == 'sm'){
- return {
- height: this.uiOpts.tutPaneSz + 'px',
- }
- } else {
- return {
- height: this.uiOpts.tutPaneSz + 'px',
- minWidth: '10cm',
- maxWidth: '10cm',
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: '0 0 3px black',
- };
- }
- },
- ancestryBarContainerStyles(): Record<string,string> {
- let ancestryBarBreadth = this.detachedAncestors == null ? 0 : this.uiOpts.ancestryBarBreadth;
- let styles = {
- minWidth: 'auto',
- maxWidth: 'none',
- minHeight: 'auto',
- maxHeight: 'none',
- transitionDuration: this.uiOpts.transitionDuration + 'ms',
- transitionProperty: '',
- overflow: 'hidden',
- };
- if (this.wideMainArea){
- styles.minWidth = ancestryBarBreadth + 'px';
- styles.maxWidth = ancestryBarBreadth + 'px';
- styles.transitionProperty = 'min-width, max-width';
- } else {
- styles.minHeight = ancestryBarBreadth + 'px';
- styles.maxHeight = ancestryBarBreadth + 'px';
- styles.transitionProperty = 'min-height, max-height';
- }
- return styles;
- },
- },
- methods: {
- // For tile expand/collapse events
- async onLeafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
- if (!subAction && !this.onActionStart('expand')){
- return false;
- }
- // Function for expanding tile
- let doExpansion = async () => {
- this.primeLoadInd(PROCESSING_WAIT_MSG);
- let lytFnOpts = {
- allowCollapse: false,
- chg: {type: 'expand', node: layoutNode, tolMap: this.tolMap} as LayoutTreeChg,
- layoutMap: this.layoutMap
- };
- let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, lytFnOpts);
- // Handle auto-hide
- if (!success && this.uiOpts.autoHide){
- while (!success && layoutNode != this.activeRoot){
- let node = layoutNode;
- while (node.parent != this.activeRoot){
- node = node.parent!;
- }
- // Hide ancestor
- // Note: Not using onNonleafClickHeld() here to avoid a relayoutWithCollapse()
- LayoutNode.hideUpward(node, this.layoutMap);
- this.activeRoot = node;
- // Try relayout
- this.updateAreaDims();
- success = tryLayout(this.activeRoot, 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.tileAreaDims,
- {...this.lytOpts, layoutType: 'sqr-overflow'}, lytFnOpts);
- if (success){
- this.overflownRoot = true;
- }
- }
- //
- if (!subAction && !success){
- layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
- }
- this.$nextTick(this.endLoadInd);
- return success;
- };
- //
- let success: boolean;
- if (this.overflownRoot){ // If clicking child of overflowing active-root
- if (!this.uiOpts.autoHide){
- if (!subAction){
- layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
- }
- success = false;
- } else {
- success = await this.onLeafClickHeld(layoutNode);
- }
- } else {
- // Check if data for node-to-expand exists, getting from server if needed
- let tolNode = this.tolMap.get(layoutNode.name)!;
- if (!this.tolMap.has(tolNode.children[0])){
- let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: this.uiOpts.tree});
- let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams);
- if (responseObj == null){
- success = false;
- } else {
- Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])});
- success = await doExpansion();
- }
- } else {
- success = await doExpansion();
- }
- }
- if (!subAction){
- this.onActionEnd('expand');
- }
+// For layouting
+const mainAreaDims = ref([0, 0] as [number, number]);
+const tileAreaDims = ref([0, 0] as [number, number]);
+const wideMainArea = computed(() => mainAreaDims.value[0] > mainAreaDims.value[1]);
+const overflownRoot = ref(false); // Set when displaying a root tile with many children, with overflow
+function relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean {
+ let success: boolean;
+ if (overflownRoot.value){
+ if (keepOverflow){
+ success = tryLayout(activeRoot.value, tileAreaDims.value,
+ {...lytOpts.value, layoutType: 'sqr-overflow'}, {layoutMap: layoutMap.value});
return success;
- },
- async onNonleafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
- if (!subAction && !this.onActionStart('collapse')){
- return false;
- }
- // Relayout
- this.primeLoadInd(PROCESSING_WAIT_MSG);
- let success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts, {
- allowCollapse: false,
- chg: {type: 'collapse', node: layoutNode, tolMap: this.tolMap},
- layoutMap: this.layoutMap
- });
- // Update overflownRoot if root was collapsed
- if (success && this.overflownRoot){
- this.overflownRoot = false;
- }
- if (!subAction){
- if (!success){
- layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
- } else {
- // Possibly clear out excess nodes when a threshold is reached
- let numNodes = this.tolMap.size;
- let extraNodes = numNodes - this.layoutMap.size;
- if (extraNodes > this.excessTolNodeThreshold){
- for (let n of this.tolMap.keys()){
- if (!this.layoutMap.has(n)){
- this.tolMap.delete(n)
- }
- }
- console.log(`Cleaned up tolMap (removed ${numNodes - this.tolMap.size} out of ${numNodes})`);
- }
+ }
+ overflownRoot.value = false;
+ }
+ success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value,
+ {allowCollapse: true, layoutMap: layoutMap.value});
+ if (secondPass){
+ // Relayout again, which can help allocate remaining tiles 'evenly'
+ success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value,
+ {allowCollapse: false, layoutMap: layoutMap.value});
+ }
+ return success;
+}
+function updateAreaDims(){
+ // Set mainAreaDims and tileAreaDims
+ // Note: Tried setting these by querying tut_pane+ancestry_bar dimensions repeatedly,
+ // throughout their transitions, relayouting each time, but this makes the tile movements jerky
+ let contentAreaEl = contentAreaRef.value!;
+ let w = contentAreaEl.offsetWidth, h = contentAreaEl.offsetHeight;
+ if (tutPaneOpen.value && uiOpts.value.breakpoint == 'sm'){
+ h -= uiOpts.value.tutPaneSz;
+ }
+ mainAreaDims.value = [w, h];
+ if (detachedAncestors.value != null){
+ if (w > h){
+ w -= uiOpts.value.ancestryBarBreadth;
+ } else {
+ h -= uiOpts.value.ancestryBarBreadth;
+ }
+ }
+ w -= lytOpts.value.tileSpacing * 2;
+ h -= lytOpts.value.tileSpacing * 2;
+ tileAreaDims.value = [w, h];
+}
+
+// For resize handling
+let lastResizeHdlrTime = 0; // Used to throttle resize handling
+let afterResizeHdlr = 0; // Set via setTimeout() to execute after a run of resize events
+async function onResize(){
+ // Handle event if not recently done
+ let handleResize = async () => {
+ // Update layout/ui options with defaults, excluding user-modified ones
+ let lytOpts2 = getDefaultLytOpts();
+ let uiOpts2 = getDefaultUiOpts(lytOpts2);
+ let changedTree = false;
+ for (let prop of Object.getOwnPropertyNames(lytOpts2) as (keyof LayoutOptions)[]){
+ let item = localStorage.getItem('LYT ' + prop);
+ if (item == null && lytOpts.value[prop] != lytOpts2[prop]){
+ (lytOpts.value[prop] as any) = lytOpts2[prop];
+ }
+ }
+ for (let prop of Object.getOwnPropertyNames(uiOpts2) as (keyof UiOptions)[]){
+ let item = localStorage.getItem('UI ' + prop);
+ //Note: Using JSON.stringify here to roughly deep-compare values
+ if (item == null && JSON.stringify(uiOpts.value[prop]) != JSON.stringify(uiOpts2[prop])){
+ (uiOpts.value[prop] as any) = uiOpts2[prop];
+ if (prop == 'tree'){
+ changedTree = true;
}
}
- if (!subAction){
- this.onActionEnd('collapse');
- }
- this.$nextTick(this.endLoadInd);
- return success;
- },
- // For expand-to-view and ancestry-bar events
- async onLeafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
- // Special case for active root
- if (layoutNode == this.activeRoot){
- console.log('Ignored expand-to-view on active-root node');
- return false;
- }
- //
- if (!subAction && !this.onActionStart('expandToView')){
- return false;
- }
- // Function for expanding tile
- let doExpansion = async () => {
- this.primeLoadInd(PROCESSING_WAIT_MSG);
- // Hide ancestors
- LayoutNode.hideUpward(layoutNode, this.layoutMap);
- this.activeRoot = layoutNode;
- // Relayout
- this.updateAreaDims();
- 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.tileAreaDims, this.lytOpts, lytFnOpts);
- // If expanding active-root with too many children to fit, allow overflow
- if (!success){
- success = tryLayout(this.activeRoot, this.tileAreaDims,
- {...this.lytOpts, layoutType: 'sqr-overflow'}, lytFnOpts);
- if (success){
- this.overflownRoot = true;
- }
- }
- //
- if (!success && !subAction){
- layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
- }
- this.$nextTick(this.endLoadInd);
- return success;
- };
- // Check if data for node-to-expand exists, getting from server if needed
- let success: boolean;
- let tolNode = this.tolMap.get(layoutNode.name)!;
- if (!this.tolMap.has(tolNode.children[0])){
- let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: this.uiOpts.tree});
- let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams);
- if (responseObj == null){
- success = false;
- } else {
- Object.getOwnPropertyNames(responseObj).forEach(n => {this.tolMap.set(n, responseObj[n])});
- success = await doExpansion();
+ }
+ // Relayout
+ if (!changedTree){
+ updateAreaDims();
+ relayoutWithCollapse();
+ } else {
+ reInit();
+ }
+ };
+ let currentTime = new Date().getTime();
+ if (currentTime - lastResizeHdlrTime > uiOpts.value.transitionDuration){
+ lastResizeHdlrTime = currentTime;
+ await handleResize();
+ lastResizeHdlrTime = new Date().getTime();
+ }
+ // Also setup a handler to execute after a run of resize events
+ clearTimeout(afterResizeHdlr);
+ afterResizeHdlr = setTimeout(async () => {
+ afterResizeHdlr = 0;
+ await handleResize();
+ lastResizeHdlrTime = new Date().getTime();
+ }, 200); // If too small, touch-device detection when swapping to/from mobile-mode gets unreliable
+}
+onMounted(() => window.addEventListener('resize', onResize));
+onUnmounted(() => window.removeEventListener('resize', onResize));
+
+// For tile expand/collapse events
+async function onLeafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
+ if (!subAction && !onActionStart('expand')){
+ return false;
+ }
+ // Function for expanding tile
+ let doExpansion = async () => {
+ primeLoadInd(PROCESSING_WAIT_MSG);
+ let lytFnOpts = {
+ allowCollapse: false,
+ chg: {type: 'expand', node: layoutNode, tolMap: tolMap.value} as LayoutTreeChg,
+ layoutMap: layoutMap.value,
+ };
+ let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts);
+ // Handle auto-hide
+ if (!success && uiOpts.value.autoHide){
+ while (!success && layoutNode != activeRoot.value){
+ let node = layoutNode;
+ while (node.parent != activeRoot.value){
+ node = node.parent!;
}
- } else {
- success = await doExpansion();
- }
- if (!subAction){
- this.onActionEnd('expandToView');
- }
- return success;
- },
- async onNonleafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
- // Special case for active root
- if (layoutNode == this.activeRoot){
- console.log('Ignored expand-to-view on active-root node');
- return false;
- }
- //
- if (!subAction && !this.onActionStart('expandToView')){
- return false;
+ // Hide ancestor
+ // Note: Not using onNonleafClickHeld() here to avoid a relayoutWithCollapse()
+ LayoutNode.hideUpward(node, layoutMap.value);
+ activeRoot.value = node;
+ // Try relayout
+ updateAreaDims();
+ success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts);
+ }
+ }
+ // If expanding active-root with too many children to fit, allow overflow
+ if (!success && layoutNode == activeRoot.value){
+ success = tryLayout(activeRoot.value, tileAreaDims.value,
+ {...lytOpts.value, layoutType: 'sqr-overflow'}, lytFnOpts);
+ if (success){
+ overflownRoot.value = true;
}
- this.primeLoadInd(PROCESSING_WAIT_MSG);
- // Hide ancestors
- LayoutNode.hideUpward(layoutNode, this.layoutMap);
- this.activeRoot = layoutNode;
- // Relayout
- this.updateAreaDims();
- let success = this.relayoutWithCollapse();
- //
+ }
+ //
+ if (!subAction && !success){
+ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
+ }
+ nextTick(endLoadInd);
+ return success;
+ };
+ //
+ let success: boolean;
+ if (overflownRoot.value){ // If clicking child of overflowing active-root
+ if (!uiOpts.value.autoHide){
if (!subAction){
- this.onActionEnd('expandToView');
- }
- this.$nextTick(this.endLoadInd);
- return success;
- },
- async onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false, collapse = false): Promise<boolean> {
- if (!subAction && !this.onActionStart('unhideAncestor')){
- return false;
+ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
}
- this.primeLoadInd(PROCESSING_WAIT_MSG);
- // Unhide ancestors
- this.activeRoot = layoutNode;
- this.overflownRoot = false;
- //
- let success: boolean;
- this.updateAreaDims();
- if (!collapse){
- // Relayout, attempting to have the ancestor expanded
- this.relayoutWithCollapse(false);
- if (layoutNode.children.length > 0){
- success = this.relayoutWithCollapse(false); // Second pass for regularity
- } else {
- success = await this.onLeafClick(layoutNode, true);
- }
+ success = false;
+ } else {
+ success = await onLeafClickHeld(layoutNode);
+ }
+ } else {
+ // Check if data for node-to-expand exists, getting from server if needed
+ let tolNode = tolMap.value.get(layoutNode.name)!;
+ if (!tolMap.value.has(tolNode.children[0])){
+ let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: uiOpts.value.tree});
+ let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams);
+ if (responseObj == null){
+ success = false;
} else {
- success = await this.onNonleafClick(layoutNode, true); // For reducing tile-flashing on-screen
- }
- LayoutNode.showDownward(layoutNode);
- //
- if (!subAction){
- this.onActionEnd('unhideAncestor');
- }
- this.$nextTick(this.endLoadInd);
- return success;
- },
- // For tile-info events
- async onInfoClick(nodeName: string){
- if (!this.onActionStart('tileInfo')){
- return;
- }
- if (!this.searchOpen){ // Close an active non-search mode
- this.resetMode();
- }
- // Query server for tol-node info
- let urlParams = new URLSearchParams({type: 'info', name: nodeName, tree: this.uiOpts.tree});
- let responseObj: InfoResponse = await this.loadFromServer(urlParams);
- if (responseObj != null){
- // Set fields from response
- this.infoModalNodeName = nodeName;
- this.infoModalData = responseObj;
- }
- },
- onInfoClose(){
- this.infoModalNodeName = null;
- this.onActionEnd('tileInfo');
- },
- // For search events
- onSearchIconClick(){
- if (!this.onActionStart('search')){
- return;
- }
- if (!this.searchOpen){
- this.resetMode();
- this.searchOpen = true;
- }
- },
- onSearch(name: string){
- if (this.modeRunning != null){
- console.log('WARNING: Unexpected search event while search/auto mode is running')
- return;
- }
- this.searchOpen = false;
- this.modeRunning = 'search';
- if (this.tutWelcome){ // Don't keep welcome message up during an initial search
- this.onActionEnd('search');
- }
- this.expandToNode(name);
- },
- async expandToNode(name: string){
- if (this.modeRunning == null){
- return;
- }
- // Check if node is displayed
- let targetNode = this.layoutMap.get(name);
- if (targetNode != null && !targetNode.hidden){
- this.setLastFocused(targetNode);
- this.onSearchClose();
- return;
- }
- // Get nearest in-layout-tree ancestor
- let ancestorName = name;
- while (this.layoutMap.get(ancestorName) == null){
- ancestorName = this.tolMap.get(ancestorName)!.parent!;
- }
- let layoutNode = this.layoutMap.get(ancestorName)!;
- // If hidden, expand self/ancestor in ancestry-bar
- if (layoutNode.hidden){
- let nodeInAncestryBar = layoutNode;
- while (!this.detachedAncestors!.includes(nodeInAncestryBar)){
- nodeInAncestryBar = nodeInAncestryBar.parent!;
- }
- if (!this.uiOpts.searchJumpMode){
- await this.onDetachedAncestorClick(nodeInAncestryBar!, true);
- setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration);
- } else{
- await this.onDetachedAncestorClick(nodeInAncestryBar!, true, true);
- this.expandToNode(name);
- }
- return;
- }
- // Attempt tile-expand
- if (this.uiOpts.searchJumpMode){
- // Extend layout tree
- let tolNode = this.tolMap.get(name)!;
- let nodesToAdd = [name] as string[];
- while (tolNode.parent != layoutNode.name){
- nodesToAdd.push(tolNode.parent!);
- tolNode = this.tolMap.get(tolNode.parent!)!;
- }
- nodesToAdd.reverse();
- layoutNode.addDescendantChain(nodesToAdd, this.tolMap, this.layoutMap);
- // Expand-to-view on target-node's parent
- targetNode = this.layoutMap.get(name);
- if (targetNode!.parent != this.activeRoot){
- // Hide ancestors
- LayoutNode.hideUpward(targetNode!.parent!, this.layoutMap);
- this.activeRoot = targetNode!.parent!;
- this.updateAreaDims();
- await this.onNonleafClick(this.activeRoot, true);
- await this.onLeafClick(this.activeRoot, true);
- } else {
- await this.onLeafClick(this.activeRoot, true);
- }
- setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration);
- return;
- }
- if (this.overflownRoot){
- await this.onLeafClickHeld(layoutNode, true);
- setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration);
- return;
- }
- let success = await this.onLeafClick(layoutNode, true);
- if (success){
- setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration);
- return;
- }
- // Attempt expand-to-view on an ancestor halfway to the active root
- if (layoutNode == this.activeRoot){
- console.log('Screen too small to expand active root');
- this.onSearchClose();
- return;
- }
- let ancestorChain = [layoutNode];
- while (layoutNode.parent! != this.activeRoot){
- layoutNode = layoutNode.parent!;
- ancestorChain.push(layoutNode);
- }
- layoutNode = ancestorChain[Math.floor((ancestorChain.length - 1) / 2)]
- await this.onNonleafClickHeld(layoutNode, true);
- setTimeout(() => this.expandToNode(name), this.uiOpts.transitionDuration);
- },
- onSearchClose(){
- this.modeRunning = null;
- this.searchOpen = false;
- this.onActionEnd('search');
- },
- onSearchNetWait(){
- this.primeLoadInd(SERVER_WAIT_MSG);
- },
- // For auto-mode events
- onAutoIconClick(){
- if (!this.onActionStart('autoMode')){
- return;
- }
- this.resetMode();
- this.modeRunning = 'autoMode';
- if (this.tutWelcome){ // Don't keep welcome message up during an initial auto-mode
- this.onActionEnd('autoMode');
- }
- this.autoAction();
- },
- async autoAction(){
- if (this.modeRunning == null){
- return;
+ Object.getOwnPropertyNames(responseObj).forEach(n => {tolMap.value.set(n, responseObj[n])});
+ success = await doExpansion();
}
- if (this.lastFocused == null){
- // Pick random leaf LayoutNode
- let layoutNode = this.activeRoot;
- while (layoutNode.children.length > 0){
- let childWeights = layoutNode.children.map(n => n.tips);
- let idx = randWeightedChoice(childWeights);
- layoutNode = layoutNode.children[idx!];
- }
- this.setLastFocused(layoutNode);
- setTimeout(this.autoAction, this.uiOpts.autoActionDelay);
- } else {
- // Determine available actions
- let action: AutoAction | null;
- let actionWeights: {[key: string]: number}; // Maps actions to choice weights
- let node: LayoutNode = this.lastFocused;
- if (node.children.length == 0){
- actionWeights = {'move across': 1, 'move up': 2, 'expand': 3};
- } else {
- actionWeights = {
- 'move across': 1, 'move down': 2, 'move up': 1,
- 'collapse': 1, 'expandToView': 1, 'unhideAncestor': 1
- };
- }
- // Zero weights for disallowed actions
- if (node == this.activeRoot || node.parent!.children.length == 1){
- actionWeights['move across'] = 0;
- }
- if (node == this.activeRoot){
- actionWeights['move up'] = 0;
- }
- if (this.tolMap.get(node.name)!.children.length == 0 || this.overflownRoot){
- actionWeights['expand'] = 0;
- }
- if (!node.children.every(n => n.children.length == 0)){
- actionWeights['collapse'] = 0; // Only collapse if all children are leaves
- }
- if (node.parent != this.activeRoot){
- actionWeights['expandToView'] = 0; // Only expand-to-view if direct child of activeRoot
- }
- if (this.activeRoot.parent == null || node != this.activeRoot){
- actionWeights['unhideAncestor'] = 0; // Only expand ancestry-bar if able and activeRoot
- }
- // Avoid undoing previous action
- if (this.autoPrevAction != null){
- let revAction = getReverseAction(this.autoPrevAction);
- if (revAction != null && revAction in actionWeights){
- actionWeights[revAction as keyof typeof actionWeights] = 0;
- }
- if (this.autoPrevActionFail){
- actionWeights[this.autoPrevAction as keyof typeof actionWeights] = 0;
- }
- }
- // Choose action
- let actionList = Object.getOwnPropertyNames(actionWeights);
- let weightList = actionList.map(action => actionWeights[action]);
- if (arraySum(weightList) == 0){
- action = null;
- } else {
- action = actionList[randWeightedChoice(weightList)!] as AutoAction;
- }
- // Perform action
- this.autoPrevAction = action;
- let success = true;
- try {
- switch (action){
- case 'move across': // Bias towards siblings with higher tips
- let siblings = node.parent!.children.filter(n => n != node);
- let siblingWeights = siblings.map(n => n.tips + 1);
- this.setLastFocused(siblings[randWeightedChoice(siblingWeights)!]);
- break;
- case 'move down': // Bias towards children with higher tips
- let childWeights = node.children.map(n => n.tips + 1);
- this.setLastFocused(node.children[randWeightedChoice(childWeights)!]);
- break;
- case 'move up':
- this.setLastFocused(node.parent!);
- break;
- case 'expand':
- success = await this.onLeafClick(node, true);
- break;
- case 'collapse':
- success = await this.onNonleafClick(node, true);
- break;
- case 'expandToView':
- success = await this.onNonleafClickHeld(node, true);
- break;
- case 'unhideAncestor':
- success = await this.onDetachedAncestorClick(node.parent!, true);
- break;
+ } else {
+ success = await doExpansion();
+ }
+ }
+ if (!subAction){
+ onActionEnd('expand');
+ }
+ return success;
+}
+async function onNonleafClick(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
+ if (!subAction && !onActionStart('collapse')){
+ return false;
+ }
+ // Relayout
+ primeLoadInd(PROCESSING_WAIT_MSG);
+ let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, {
+ allowCollapse: false,
+ chg: {type: 'collapse', node: layoutNode, tolMap: tolMap.value},
+ layoutMap: layoutMap.value,
+ });
+ // Update overflownRoot if root was collapsed
+ if (success && overflownRoot.value){
+ overflownRoot.value = false;
+ }
+ if (!subAction){
+ if (!success){
+ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
+ } else {
+ // Possibly clear out excess nodes when a threshold is reached
+ let numNodes = tolMap.value.size;
+ let extraNodes = numNodes - layoutMap.value.size;
+ if (extraNodes > EXCESS_TOLNODE_THRESHOLD.value){
+ for (let n of tolMap.value.keys()){
+ if (!layoutMap.value.has(n)){
+ tolMap.value.delete(n)
}
- } catch (error) {
- this.autoPrevActionFail = true;
- this.onAutoClose();
- return;
}
- this.autoPrevActionFail = !success;
- setTimeout(this.autoAction, this.uiOpts.transitionDuration + this.uiOpts.autoActionDelay);
- }
- },
- onAutoClose(){
- this.modeRunning = null;
- this.onActionEnd('autoMode');
- },
- // For settings events
- onSettingsIconClick(){
- if (!this.onActionStart('settings')){
- return;
- }
- this.resetMode();
- this.settingsOpen = true;
- },
- async onSettingChg(optionType: OptionType, option: string,
- {relayout = false, reinit = false} = {}){
- // Save setting
- if (optionType == 'LYT'){
- localStorage.setItem(`${optionType} ${option}`,
- String(this.lytOpts[option as keyof LayoutOptions]));
- } else if (optionType == 'UI') {
- localStorage.setItem(`${optionType} ${option}`,
- String(this.uiOpts[option as keyof UiOptions]));
- }
- // Possibly relayout/reinitialise
- if (reinit){
- this.reInit();
- } else if (relayout){
- this.relayoutWithCollapse();
- }
- },
- onResetSettings(reinit: boolean){
- localStorage.clear();
- if (reinit){
- this.reInit();
- } else {
- this.relayoutWithCollapse();
- }
- },
- onSettingsClose(){
- this.settingsOpen = false;
- this.onActionEnd('settings');
- },
- // For help events
- onHelpIconClick(){
- if (!this.onActionStart('help')){
- return;
- }
- this.resetMode();
- this.helpOpen = true;
- },
- onHelpClose(){
- this.helpOpen = false;
- this.onActionEnd('help');
- },
- // For tutorial-pane events
- onStartTutorial(){
- if (!this.tutPaneOpen){
- this.tutPaneOpen = true;
- this.updateAreaDims();
- this.relayoutWithCollapse();
- }
- },
- onTutorialSkip(){
- this.uiOpts.tutorialSkip = true;
- this.onSettingChg('UI', 'tutorialSkip');
- },
- onTutStageChg(triggerAction: Action | null){
- this.tutWelcome = false;
- this.tutTriggerAction = triggerAction;
- },
- onTutPaneClose(){
- this.tutPaneOpen = false;
- if (this.tutWelcome){
- this.tutWelcome = false;
- } else if (this.uiOpts.tutorialSkip == false){
- this.uiOpts.tutorialSkip = true;
- this.onSettingChg('UI', 'tutorialSkip');
+ console.log(`Cleaned up tolMap (removed ${numNodes - tolMap.value.size} out of ${numNodes})`);
}
- this.uiOpts.disabledActions.clear();
- this.updateAreaDims();
- this.relayoutWithCollapse(true, true);
- },
- // For general action handling
- onActionStart(action: Action): boolean {
- if (this.isDisabled(action)){
- return false;
- }
- this.setLastFocused(null);
- return true;
- },
- onActionEnd(action: Action){
- // Update info used by tutorial pane
- this.actionsDone.add(action);
- if (this.tutPaneOpen){
- // Close welcome message on first action
- if (this.tutWelcome){
- this.onTutPaneClose();
- }
- // Tell TutorialPane if trigger-action was done
- if (this.tutTriggerAction == action){
- this.tutTriggerFlag = !this.tutTriggerFlag;
- }
- }
- },
- isDisabled(...actions: Action[]): boolean {
- let disabledActions = this.uiOpts.disabledActions;
- return actions.some(a => disabledActions.has(a));
- },
- resetMode(){
- if (this.infoModalNodeName != null){
- this.onInfoClose();
- }
- if (this.searchOpen || this.modeRunning == 'search'){
- this.onSearchClose();
- }
- if (this.modeRunning == 'autoMode'){
- this.onAutoClose();
- }
- if (this.settingsOpen){
- this.onSettingsClose();
- }
- if (this.helpOpen){
- this.onHelpClose();
+ }
+ }
+ if (!subAction){
+ onActionEnd('collapse');
+ }
+ nextTick(endLoadInd);
+ return success;
+}
+// For expand-to-view and ancestry-bar events
+async function onLeafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
+ // Special case for active root
+ if (layoutNode == activeRoot.value){
+ console.log('Ignored expand-to-view on active-root node');
+ return false;
+ }
+ //
+ if (!subAction && !onActionStart('expandToView')){
+ return false;
+ }
+ // Function for expanding tile
+ let doExpansion = async () => {
+ primeLoadInd(PROCESSING_WAIT_MSG);
+ // Hide ancestors
+ LayoutNode.hideUpward(layoutNode, layoutMap.value);
+ activeRoot.value = layoutNode;
+ // Relayout
+ updateAreaDims();
+ overflownRoot.value = false;
+ let lytFnOpts = {
+ allowCollapse: false,
+ chg: {type: 'expand', node: layoutNode, tolMap: tolMap.value} as LayoutTreeChg,
+ layoutMap: layoutMap.value,
+ };
+ let success = tryLayout(activeRoot.value, tileAreaDims.value, lytOpts.value, lytFnOpts);
+ // If expanding active-root with too many children to fit, allow overflow
+ if (!success){
+ success = tryLayout(activeRoot.value, tileAreaDims.value,
+ {...lytOpts.value, layoutType: 'sqr-overflow'}, lytFnOpts);
+ if (success){
+ overflownRoot.value = true;
}
- },
- // For other events
- async onResize(){
- // Handle event if not recently done
- let handleResize = async () => {
- // Update layout/ui options with defaults, excluding user-modified ones
- let lytOpts = getDefaultLytOpts();
- let uiOpts = getDefaultUiOpts(lytOpts);
- let changedTree = false;
- for (let prop of Object.getOwnPropertyNames(lytOpts) as (keyof LayoutOptions)[]){
- let item = localStorage.getItem('LYT ' + prop);
- if (item == null && this.lytOpts[prop] != lytOpts[prop]){
- this.lytOpts[prop] = lytOpts[prop];
- }
- }
- for (let prop of Object.getOwnPropertyNames(uiOpts) as (keyof UiOptions)[]){
- let item = localStorage.getItem('UI ' + prop);
- //Note: Using JSON.stringify here to roughly deep-compare values
- if (item == null && JSON.stringify(this.uiOpts[prop]) != JSON.stringify(uiOpts[prop])){
- this.uiOpts[prop] = uiOpts[prop];
- if (prop == 'tree'){
- changedTree = true;
- }
- }
- }
- // Relayout
- if (!changedTree){
- this.updateAreaDims();
- this.relayoutWithCollapse();
- } else {
- this.reInit();
- }
+ }
+ //
+ if (!success && !subAction){
+ layoutNode.failFlag = !layoutNode.failFlag; // Triggers failure animation
+ }
+ nextTick(endLoadInd);
+ return success;
+ };
+ // Check if data for node-to-expand exists, getting from server if needed
+ let success: boolean;
+ let tolNode = tolMap.value.get(layoutNode.name)!;
+ if (!tolMap.value.has(tolNode.children[0])){
+ let urlParams = new URLSearchParams({type: 'node', name: layoutNode.name, tree: uiOpts.value.tree});
+ let responseObj: {[x: string]: TolNode} = await loadFromServer(urlParams);
+ if (responseObj == null){
+ success = false;
+ } else {
+ Object.getOwnPropertyNames(responseObj).forEach(n => {tolMap.value.set(n, responseObj[n])});
+ success = await doExpansion();
+ }
+ } else {
+ success = await doExpansion();
+ }
+ if (!subAction){
+ onActionEnd('expandToView');
+ }
+ return success;
+}
+async function onNonleafClickHeld(layoutNode: LayoutNode, subAction = false): Promise<boolean> {
+ // Special case for active root
+ if (layoutNode == activeRoot.value){
+ console.log('Ignored expand-to-view on active-root node');
+ return false;
+ }
+ //
+ if (!subAction && !onActionStart('expandToView')){
+ return false;
+ }
+ primeLoadInd(PROCESSING_WAIT_MSG);
+ // Hide ancestors
+ LayoutNode.hideUpward(layoutNode, layoutMap.value);
+ activeRoot.value = layoutNode;
+ // Relayout
+ updateAreaDims();
+ let success = relayoutWithCollapse();
+ //
+ if (!subAction){
+ onActionEnd('expandToView');
+ }
+ nextTick(endLoadInd);
+ return success;
+}
+async function onDetachedAncestorClick(layoutNode: LayoutNode, subAction = false, collapse = false): Promise<boolean> {
+ if (!subAction && !onActionStart('unhideAncestor')){
+ return false;
+ }
+ primeLoadInd(PROCESSING_WAIT_MSG);
+ // Unhide ancestors
+ activeRoot.value = layoutNode;
+ overflownRoot.value = false;
+ //
+ let success: boolean;
+ updateAreaDims();
+ if (!collapse){
+ // Relayout, attempting to have the ancestor expanded
+ relayoutWithCollapse(false);
+ if (layoutNode.children.length > 0){
+ success = relayoutWithCollapse(false); // Second pass for regularity
+ } else {
+ success = await onLeafClick(layoutNode, true);
+ }
+ } else {
+ success = await onNonleafClick(layoutNode, true); // For reducing tile-flashing on-screen
+ }
+ LayoutNode.showDownward(layoutNode);
+ //
+ if (!subAction){
+ onActionEnd('unhideAncestor');
+ }
+ nextTick(endLoadInd);
+ return success;
+}
+
+// For tile-info modal/events
+const infoModalNodeName = ref(null as string | null); // Name of node to display info for, or null
+const infoModalData = ref(null as InfoResponse | null);
+async function onInfoClick(nodeName: string){
+ if (!onActionStart('tileInfo')){
+ return;
+ }
+ if (!searchOpen.value){ // Close an active non-search mode
+ resetMode();
+ }
+ // Query server for tol-node info
+ let urlParams = new URLSearchParams({type: 'info', name: nodeName, tree: uiOpts.value.tree});
+ let responseObj: InfoResponse = await loadFromServer(urlParams);
+ if (responseObj != null){
+ // Set fields from response
+ infoModalNodeName.value = nodeName;
+ infoModalData.value = responseObj;
+ }
+}
+function onInfoClose(){
+ infoModalNodeName.value = null;
+ onActionEnd('tileInfo');
+}
+
+// For search modal/events
+const searchOpen = ref(false);
+function onSearchIconClick(){
+ if (!onActionStart('search')){
+ return;
+ }
+ if (!searchOpen.value){
+ resetMode();
+ searchOpen.value = true;
+ }
+}
+function onSearch(name: string){
+ if (modeRunning.value != null){
+ console.log('WARNING: Unexpected search event while search/auto mode is running')
+ return;
+ }
+ searchOpen.value = false;
+ modeRunning.value = 'search';
+ if (tutWelcome.value){ // Don't keep welcome message up during an initial search
+ onActionEnd('search');
+ }
+ expandToNode(name);
+}
+async function expandToNode(name: string){
+ if (modeRunning.value == null){
+ return;
+ }
+ // Check if node is displayed
+ let targetNode = layoutMap.value.get(name);
+ if (targetNode != null && !targetNode.hidden){
+ setLastFocused(targetNode);
+ onSearchClose();
+ return;
+ }
+ // Get nearest in-layout-tree ancestor
+ let ancestorName = name;
+ while (layoutMap.value.get(ancestorName) == null){
+ ancestorName = tolMap.value.get(ancestorName)!.parent!;
+ }
+ let layoutNode = layoutMap.value.get(ancestorName)!;
+ // If hidden, expand self/ancestor in ancestry-bar
+ if (layoutNode.hidden){
+ let nodeInAncestryBar = layoutNode;
+ while (!detachedAncestors.value!.includes(nodeInAncestryBar)){
+ nodeInAncestryBar = nodeInAncestryBar.parent!;
+ }
+ if (!uiOpts.value.searchJumpMode){
+ await onDetachedAncestorClick(nodeInAncestryBar!, true);
+ setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration);
+ } else{
+ await onDetachedAncestorClick(nodeInAncestryBar!, true, true);
+ expandToNode(name);
+ }
+ return;
+ }
+ // Attempt tile-expand
+ if (uiOpts.value.searchJumpMode){
+ // Extend layout tree
+ let tolNode = tolMap.value.get(name)!;
+ let nodesToAdd = [name] as string[];
+ while (tolNode.parent != layoutNode.name){
+ nodesToAdd.push(tolNode.parent!);
+ tolNode = tolMap.value.get(tolNode.parent!)!;
+ }
+ nodesToAdd.reverse();
+ layoutNode.addDescendantChain(nodesToAdd, tolMap.value, layoutMap.value);
+ // Expand-to-view on target-node's parent
+ targetNode = layoutMap.value.get(name);
+ if (targetNode!.parent != activeRoot.value){
+ // Hide ancestors
+ LayoutNode.hideUpward(targetNode!.parent!, layoutMap.value);
+ activeRoot.value = targetNode!.parent!;
+ updateAreaDims();
+ await onNonleafClick(activeRoot.value, true);
+ await onLeafClick(activeRoot.value, true);
+ } else {
+ await onLeafClick(activeRoot.value, true);
+ }
+ setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration);
+ return;
+ }
+ if (overflownRoot.value){
+ await onLeafClickHeld(layoutNode, true);
+ setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration);
+ return;
+ }
+ let success = await onLeafClick(layoutNode, true);
+ if (success){
+ setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration);
+ return;
+ }
+ // Attempt expand-to-view on an ancestor halfway to the active root
+ if (layoutNode == activeRoot.value){
+ console.log('Screen too small to expand active root');
+ onSearchClose();
+ return;
+ }
+ let ancestorChain = [layoutNode];
+ while (layoutNode.parent! != activeRoot.value){
+ layoutNode = layoutNode.parent!;
+ ancestorChain.push(layoutNode);
+ }
+ layoutNode = ancestorChain[Math.floor((ancestorChain.length - 1) / 2)]
+ await onNonleafClickHeld(layoutNode, true);
+ setTimeout(() => expandToNode(name), uiOpts.value.transitionDuration);
+}
+function onSearchClose(){
+ modeRunning.value = null;
+ searchOpen.value = false;
+ onActionEnd('search');
+}
+function onSearchNetWait(){
+ primeLoadInd(SERVER_WAIT_MSG);
+}
+
+// For auto-mode
+type AutoAction = 'move across' | 'move down' | 'move up' | Action;
+function getReverseAction(action: AutoAction): AutoAction | null {
+ const reversePairs: AutoAction[][] = [
+ ['move down', 'move up'],
+ ['expand', 'collapse'],
+ ['expandToView', 'unhideAncestor'],
+ ];
+ let pair = reversePairs.find(pair => pair.includes(action));
+ if (pair != null){
+ return pair[0] == action ? pair[1] : pair[0];
+ } else {
+ return null;
+ }
+}
+const autoPrevAction = ref(null as AutoAction | null); // Used to help prevent action cycles
+const autoPrevActionFail = ref(false); // Used to avoid re-trying a failed expand/collapse
+function onAutoIconClick(){
+ if (!onActionStart('autoMode')){
+ return;
+ }
+ resetMode();
+ modeRunning.value = 'autoMode';
+ if (tutWelcome.value){ // Don't keep welcome message up during an initial auto-mode
+ onActionEnd('autoMode');
+ }
+ autoAction();
+}
+async function autoAction(){
+ if (modeRunning.value == null){
+ return;
+ }
+ if (lastFocused.value == null){
+ // Pick random leaf LayoutNode
+ let layoutNode = activeRoot.value;
+ while (layoutNode.children.length > 0){
+ let childWeights = layoutNode.children.map(n => n.tips);
+ let idx = randWeightedChoice(childWeights);
+ layoutNode = layoutNode.children[idx!];
+ }
+ setLastFocused(layoutNode);
+ setTimeout(autoAction, uiOpts.value.autoActionDelay);
+ } else {
+ // Determine available actions
+ let action: AutoAction | null;
+ let actionWeights: {[key: string]: number}; // Maps actions to choice weights
+ let node: LayoutNode = lastFocused.value;
+ if (node.children.length == 0){
+ actionWeights = {'move across': 1, 'move up': 2, 'expand': 3};
+ } else {
+ actionWeights = {
+ 'move across': 1, 'move down': 2, 'move up': 1,
+ 'collapse': 1, 'expandToView': 1, 'unhideAncestor': 1
};
- let currentTime = new Date().getTime();
- if (currentTime - this.lastResizeHdlrTime > this.uiOpts.transitionDuration){
- this.lastResizeHdlrTime = currentTime;
- await handleResize();
- this.lastResizeHdlrTime = new Date().getTime();
- }
- // Also setup a handler to execute after a run of resize events
- clearTimeout(this.afterResizeHdlr);
- this.afterResizeHdlr = setTimeout(async () => {
- this.afterResizeHdlr = 0;
- await handleResize();
- this.lastResizeHdlrTime = new Date().getTime();
- }, 200); // If too small, touch-device detection when swapping to/from mobile-mode gets unreliable
- },
- onKeyUp(evt: KeyboardEvent){
- if (this.uiOpts.disableShortcuts){
- return;
- }
- if (evt.key == 'Escape'){
- this.resetMode();
- } else if (evt.key == 'f' && evt.ctrlKey){
- evt.preventDefault();
- // Open/focus search bar
- if (!this.searchOpen){
- this.onSearchIconClick();
- } else {
- (this.$refs.searchModal as InstanceType<typeof SearchModal>).focusInput();
- }
- } else if (evt.key == 'F' && evt.ctrlKey){
- // If search bar is open, switch search mode
- if (this.searchOpen){
- this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode;
- this.onSettingChg('UI', 'searchJumpMode');
- }
- }
- },
- // For the loading-indicator
- primeLoadInd(msg: string){ // Sets up a loading message to display after a timeout
- clearTimeout(this.pendingLoadingRevealHdlr);
- this.pendingLoadingRevealHdlr = setTimeout(() => {
- this.loadingMsg = msg;
- }, 500);
- },
- endLoadInd(){ // Cancels or closes a loading message
- clearTimeout(this.pendingLoadingRevealHdlr);
- this.pendingLoadingRevealHdlr = 0;
- if (this.loadingMsg != null){
- this.loadingMsg = null;
- }
- },
- async loadFromServer(urlParams: URLSearchParams){ // Like queryServer(), but enables the loading indicator
- this.primeLoadInd(SERVER_WAIT_MSG);
- let responseObj = await queryServer(urlParams);
- this.endLoadInd();
- return responseObj;
- },
- // For initialisation
- async initTreeFromServer(firstInit = true){
- // Get possible target node from URL
- let nodeName = (new URL(window.location.href)).searchParams.get('node');
- // Query server
- let urlParams = new URLSearchParams({type: 'node', tree: this.uiOpts.tree});
- if (nodeName != null && firstInit){
- urlParams.append('name', nodeName);
- urlParams.append('toroot', '1');
- }
- let responseObj: {[x: string]: TolNode} = await this.loadFromServer(urlParams);
- if (responseObj == null){
- return;
- }
- // Get root node name
- let rootName = null;
- let nodeNames = Object.getOwnPropertyNames(responseObj);
- for (let n of nodeNames){
- if (responseObj[n].parent == null){
- rootName = n;
+ }
+ // Zero weights for disallowed actions
+ if (node == activeRoot.value || node.parent!.children.length == 1){
+ actionWeights['move across'] = 0;
+ }
+ if (node == activeRoot.value){
+ actionWeights['move up'] = 0;
+ }
+ if (tolMap.value.get(node.name)!.children.length == 0 || overflownRoot.value){
+ actionWeights['expand'] = 0;
+ }
+ if (!node.children.every(n => n.children.length == 0)){
+ actionWeights['collapse'] = 0; // Only collapse if all children are leaves
+ }
+ if (node.parent != activeRoot.value){
+ actionWeights['expandToView'] = 0; // Only expand-to-view if direct child of activeRoot
+ }
+ if (activeRoot.value.parent == null || node != activeRoot.value){
+ actionWeights['unhideAncestor'] = 0; // Only expand ancestry-bar if able and activeRoot
+ }
+ // Avoid undoing previous action
+ if (autoPrevAction.value != null){
+ let revAction = getReverseAction(autoPrevAction.value);
+ if (revAction != null && revAction in actionWeights){
+ actionWeights[revAction as keyof typeof actionWeights] = 0;
+ }
+ if (autoPrevActionFail.value){
+ actionWeights[autoPrevAction.value as keyof typeof actionWeights] = 0;
+ }
+ }
+ // Choose action
+ let actionList = Object.getOwnPropertyNames(actionWeights);
+ let weightList = actionList.map(action => actionWeights[action]);
+ if (arraySum(weightList) == 0){
+ action = null;
+ } else {
+ action = actionList[randWeightedChoice(weightList)!] as AutoAction;
+ }
+ // Perform action
+ autoPrevAction.value = action;
+ let success = true;
+ try {
+ switch (action){
+ case 'move across': { // Bias towards siblings with higher tips
+ let siblings = node.parent!.children.filter(n => n != node);
+ let siblingWeights = siblings.map(n => n.tips + 1);
+ setLastFocused(siblings[randWeightedChoice(siblingWeights)!]);
break;
}
- }
- if (rootName == null){
- console.log('ERROR: Server response has no root node');
- return;
- }
- // Initialise tree
- this.tolMap.clear();
- nodeNames.forEach(n => {this.tolMap.set(n, responseObj[n])});
- if (nodeName == null){
- this.layoutTree = initLayoutTree(this.tolMap, rootName, 0);
- this.layoutMap = initLayoutMap(this.layoutTree);
- this.activeRoot = this.layoutTree;
- } else {
- this.layoutTree = initLayoutTree(this.tolMap, rootName, -1);
- this.layoutMap = initLayoutMap(this.layoutTree);
- // Set active root
- let targetNode = this.layoutMap.get(nodeName)!;
- let newRoot = targetNode.parent == null ? targetNode : targetNode.parent;
- LayoutNode.hideUpward(newRoot, this.layoutMap);
- this.activeRoot = newRoot;
- setTimeout(() => this.setLastFocused(targetNode!), this.uiOpts.transitionDuration);
- }
- // Skip initial transition
- if (firstInit){
- this.justInitialised = true;
- setTimeout(() => {this.justInitialised = false}, this.uiOpts.transitionDuration);
- }
- // Relayout
- this.updateAreaDims();
- this.relayoutWithCollapse(false);
- },
- async reInit(){
- if (this.activeRoot != this.layoutTree){
- // Collapse tree to root
- await this.onDetachedAncestorClick(this.layoutTree, true);
- }
- await this.onNonleafClick(this.layoutTree, true);
- await this.initTreeFromServer(false);
- },
- getLytOpts(): LayoutOptions {
- let opts = getDefaultLytOpts();
- for (let prop of Object.getOwnPropertyNames(opts) as (keyof LayoutOptions)[]){
- let item = localStorage.getItem('LYT ' + prop);
- if (item != null){
- switch (typeof(opts[prop])){
- case 'boolean': (opts[prop] as unknown as boolean) = Boolean(item); break;
- case 'number': (opts[prop] as unknown as number) = Number(item); break;
- case 'string': (opts[prop] as unknown as string) = item; break;
- default: console.log(`WARNING: Found saved layout setting "${prop}" with unexpected type`);
- }
- }
- }
- return opts;
- },
- getUiOpts(): UiOptions {
- let opts = getDefaultUiOpts(getDefaultLytOpts());
- for (let prop of Object.getOwnPropertyNames(opts) as (keyof UiOptions)[]){
- let item = localStorage.getItem('UI ' + prop);
- if (item != null){
- switch (typeof(opts[prop])){
- case 'boolean': (opts[prop] as unknown as boolean) = (item == 'true'); break;
- case 'number': (opts[prop] as unknown as number) = Number(item); break;
- case 'string': (opts[prop] as unknown as string) = item; break;
- default: console.log(`WARNING: Found saved UI setting "${prop}" with unexpected type`);
- }
- }
- }
- return opts;
- },
- // For relayout
- relayoutWithCollapse(secondPass = true, keepOverflow = false): boolean {
- let success: boolean;
- if (this.overflownRoot){
- if (keepOverflow){
- success = tryLayout(this.activeRoot, this.tileAreaDims,
- {...this.lytOpts, layoutType: 'sqr-overflow'}, {layoutMap: this.layoutMap});
- return success;
- }
- this.overflownRoot = false;
- }
- success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts,
- {allowCollapse: true, layoutMap: this.layoutMap});
- if (secondPass){
- // Relayout again, which can help allocate remaining tiles 'evenly'
- success = tryLayout(this.activeRoot, this.tileAreaDims, this.lytOpts,
- {allowCollapse: false, layoutMap: this.layoutMap});
- }
- return success;
- },
- updateAreaDims(){
- // Set mainAreaDims and tileAreaDims
- // Note: Tried setting these by querying tut_pane+ancestry_bar dimensions repeatedly,
- // throughout their transitions, relayouting each time, but this makes the tile movements jerky
- let contentAreaEl = this.$refs.contentArea as HTMLElement;
- let w = contentAreaEl.offsetWidth, h = contentAreaEl.offsetHeight;
- if (this.tutPaneOpen && this.uiOpts.breakpoint == 'sm'){
- h -= this.uiOpts.tutPaneSz;
- }
- this.mainAreaDims = [w, h];
- if (this.detachedAncestors != null){
- if (w > h){
- w -= this.uiOpts.ancestryBarBreadth;
- } else {
- h -= this.uiOpts.ancestryBarBreadth;
- }
- }
- w -= this.lytOpts.tileSpacing * 2;
- h -= this.lytOpts.tileSpacing * 2;
- this.tileAreaDims = [w, h];
- },
- // Other
- setLastFocused(node: LayoutNode | null){
- if (this.lastFocused != null){
- this.lastFocused.hasFocus = false;
- }
- this.lastFocused = node;
- if (node != null){
- node.hasFocus = true;
- }
- },
- async collapseTree(){
- if (this.activeRoot != this.layoutTree){
- await this.onDetachedAncestorClick(this.layoutTree, true);
- }
- if (this.layoutTree.children.length > 0){
- await this.onNonleafClick(this.layoutTree);
- }
- },
- },
- watch: {
- modeRunning(newVal, oldVal){
- // For sweepToParent setting 'fallback', temporarily change to 'prefer' for efficiency
- if (newVal != null){
- if (this.lytOpts.sweepToParent == 'fallback'){
- this.lytOpts.sweepToParent = 'prefer';
- this.changedSweepToParent = true;
- }
- } else {
- if (this.changedSweepToParent){
- this.lytOpts.sweepToParent = 'fallback';
- this.changedSweepToParent = false;
+ case 'move down': { // Bias towards children with higher tips
+ let childWeights = node.children.map(n => n.tips + 1);
+ setLastFocused(node.children[randWeightedChoice(childWeights)!]);
+ break;
}
+ case 'move up':
+ setLastFocused(node.parent!);
+ break;
+ case 'expand':
+ success = await onLeafClick(node, true);
+ break;
+ case 'collapse':
+ success = await onNonleafClick(node, true);
+ break;
+ case 'expandToView':
+ success = await onNonleafClickHeld(node, true);
+ break;
+ case 'unhideAncestor':
+ success = await onDetachedAncestorClick(node.parent!, true);
+ break;
}
- },
- },
- mounted(){
- window.addEventListener('resize', this.onResize);
- window.addEventListener('keydown', this.onKeyUp);
- this.initTreeFromServer();
- },
- unmounted(){
- window.removeEventListener('resize', this.onResize);
- window.removeEventListener('keydown', this.onKeyUp);
- },
- components: {
- TolTile, TutorialPane, AncestryBar,
- IconButton, SearchIcon, PlayIcon, PauseIcon, SettingsIcon, HelpIcon, EduIcon,
- TileInfoModal, SearchModal, SettingsModal, HelpModal, LoadingModal,
- },
+ } catch (error) {
+ autoPrevActionFail.value = true;
+ onAutoClose();
+ return;
+ }
+ autoPrevActionFail.value = !success;
+ setTimeout(autoAction, uiOpts.value.transitionDuration + uiOpts.value.autoActionDelay);
+ }
+}
+function onAutoClose(){
+ modeRunning.value = null;
+ onActionEnd('autoMode');
+}
+
+// For settings modal/events
+const settingsOpen = ref(false);
+function onSettingsIconClick(){
+ if (!onActionStart('settings')){
+ return;
+ }
+ resetMode();
+ settingsOpen.value = true;
+}
+function onSettingsClose(){
+ settingsOpen.value = false;
+ onActionEnd('settings');
+}
+async function onSettingChg(optionType: OptionType, option: string, {relayout = false, reinit = false} = {}){
+ // Save setting
+ if (optionType == 'LYT'){
+ localStorage.setItem(`${optionType} ${option}`,
+ String(lytOpts.value[option as keyof LayoutOptions]));
+ } else if (optionType == 'UI') {
+ localStorage.setItem(`${optionType} ${option}`,
+ String(uiOpts.value[option as keyof UiOptions]));
+ }
+ // Possibly relayout/reinitialise
+ if (reinit){
+ reInit();
+ } else if (relayout){
+ relayoutWithCollapse();
+ }
+}
+function onResetSettings(reinit: boolean){
+ localStorage.clear();
+ if (reinit){
+ reInit();
+ } else {
+ relayoutWithCollapse();
+ }
+}
+
+// For help modal/events
+const helpOpen = ref(false);
+function onHelpIconClick(){
+ if (!onActionStart('help')){
+ return;
+ }
+ resetMode();
+ helpOpen.value = true;
+}
+function onHelpClose(){
+ helpOpen.value = false;
+ onActionEnd('help');
+}
+
+// For tutorial pane/events
+const tutPaneOpen = ref(!uiOpts.value.tutorialSkip);
+const tutWelcome = ref(!uiOpts.value.tutorialSkip);
+const tutTriggerAction = ref(null as Action | null); // Used to advance tutorial upon user-actions
+const tutTriggerFlag = ref(false);
+const actionsDone = ref(new Set() as Set<Action>); // Used to avoid disabling actions the user has already seen
+// For tutorial-pane events
+function onStartTutorial(){
+ if (!tutPaneOpen.value){
+ tutPaneOpen.value = true;
+ updateAreaDims();
+ relayoutWithCollapse();
+ }
+}
+function onTutorialSkip(){
+ uiOpts.value.tutorialSkip = true;
+ onSettingChg('UI', 'tutorialSkip');
+}
+function onTutStageChg(triggerAction: Action | null){
+ tutWelcome.value = false;
+ tutTriggerAction.value = triggerAction;
+}
+function onTutPaneClose(){
+ tutPaneOpen.value = false;
+ if (tutWelcome.value){
+ tutWelcome.value = false;
+ } else if (uiOpts.value.tutorialSkip == false){
+ uiOpts.value.tutorialSkip = true;
+ onSettingChg('UI', 'tutorialSkip');
+ }
+ uiOpts.value.disabledActions.clear();
+ updateAreaDims();
+ relayoutWithCollapse(true, true);
+}
+
+// For highlighting a node (after search, auto-mode, or startup)
+const lastFocused = ref(null as LayoutNode | null); // Used to un-focus
+function setLastFocused(node: LayoutNode | null){
+ if (lastFocused.value != null){
+ lastFocused.value.hasFocus = false;
+ }
+ lastFocused.value = node;
+ if (node != null){
+ node.hasFocus = true;
+ }
+}
+
+// For general action handling
+const modeRunning = ref(null as null | 'search' | 'autoMode');
+function resetMode(){
+ if (infoModalNodeName.value != null){
+ onInfoClose();
+ }
+ if (searchOpen.value || modeRunning.value == 'search'){
+ onSearchClose();
+ }
+ if (modeRunning.value == 'autoMode'){
+ onAutoClose();
+ }
+ if (settingsOpen.value){
+ onSettingsClose();
+ }
+ if (helpOpen.value){
+ onHelpClose();
+ }
+}
+function onActionStart(action: Action): boolean {
+ if (isDisabled(action)){
+ return false;
+ }
+ setLastFocused(null);
+ return true;
+}
+function onActionEnd(action: Action){
+ // Update info used by tutorial pane
+ actionsDone.value.add(action);
+ if (tutPaneOpen.value){
+ // Close welcome message on first action
+ if (tutWelcome.value){
+ onTutPaneClose();
+ }
+ // Tell TutorialPane if trigger-action was done
+ if (tutTriggerAction.value == action){
+ tutTriggerFlag.value = !tutTriggerFlag.value;
+ }
+ }
+}
+function isDisabled(...actions: Action[]): boolean {
+ let disabledActions = uiOpts.value.disabledActions;
+ return actions.some(a => disabledActions.has(a));
+}
+
+// For the loading-indicator
+const loadingMsg = ref(null as null | string); // Message to display in loading-indicator
+const pendingLoadingRevealHdlr = ref(0); // Used to delay showing the loading-indicator
+function primeLoadInd(msg: string){ // Sets up a loading message to display after a timeout
+ clearTimeout(pendingLoadingRevealHdlr.value);
+ pendingLoadingRevealHdlr.value = setTimeout(() => {
+ loadingMsg.value = msg;
+ }, 500);
+}
+function endLoadInd(){ // Cancels or closes a loading message
+ clearTimeout(pendingLoadingRevealHdlr.value);
+ pendingLoadingRevealHdlr.value = 0;
+ if (loadingMsg.value != null){
+ loadingMsg.value = null;
+ }
+}
+async function loadFromServer(urlParams: URLSearchParams){ // Like queryServer(), but enables the loading indicator
+ primeLoadInd(SERVER_WAIT_MSG);
+ let responseObj = await queryServer(urlParams);
+ endLoadInd();
+ return responseObj;
+}
+
+// For collapsing tree upon clicking 'Tilo'
+async function collapseTree(){
+ if (activeRoot.value != layoutTree.value){
+ await onDetachedAncestorClick(layoutTree.value, true);
+ }
+ if (layoutTree.value.children.length > 0){
+ await onNonleafClick(layoutTree.value);
+ }
+}
+
+// For temporarily changing a sweepToParent setting of 'fallback' to 'prefer', for efficiency
+const changedSweepToParent = ref(false);
+watch(modeRunning, (newVal) => {
+ if (newVal != null){
+ if (lytOpts.value.sweepToParent == 'fallback'){
+ lytOpts.value.sweepToParent = 'prefer';
+ changedSweepToParent.value = true;
+ }
+ } else {
+ if (changedSweepToParent.value){
+ lytOpts.value.sweepToParent = 'fallback';
+ changedSweepToParent.value = false;
+ }
+ }
+});
+
+// For keyboard shortcuts
+function onKeyDown(evt: KeyboardEvent){
+ if (uiOpts.value.disableShortcuts){
+ return;
+ }
+ if (evt.key == 'Escape'){
+ resetMode();
+ } else if (evt.key == 'f' && evt.ctrlKey){
+ evt.preventDefault();
+ // Open/focus search bar
+ if (!searchOpen.value){
+ onSearchIconClick();
+ }
+ } else if (evt.key == 'F' && evt.ctrlKey){
+ // If search bar is open, switch search mode
+ if (searchOpen.value){
+ uiOpts.value.searchJumpMode = !uiOpts.value.searchJumpMode;
+ onSettingChg('UI', 'searchJumpMode');
+ }
+ }
+}
+onMounted(() => {
+ window.addEventListener('keydown', onKeyDown); // 'keydown' needed to override default CTRL-F
+});
+onUnmounted(() => {
+ window.removeEventListener('keydown', onKeyDown);
+});
+
+// Styles
+const buttonStyles = computed(() => ({
+ color: uiOpts.value.textColor,
+ backgroundColor: uiOpts.value.altColorDark,
+}));
+const tutPaneContainerStyles = computed((): Record<string,string> => {
+ if (uiOpts.value.breakpoint == 'sm'){
+ return {
+ minHeight: (tutPaneOpen.value ? uiOpts.value.tutPaneSz : 0) + 'px',
+ maxHeight: (tutPaneOpen.value ? uiOpts.value.tutPaneSz : 0) + 'px',
+ transitionProperty: 'max-height, min-height',
+ transitionDuration: uiOpts.value.transitionDuration + 'ms',
+ overflow: 'hidden',
+ };
+ } else {
+ return {
+ position: 'absolute',
+ bottom: '0.5cm',
+ right: '0.5cm',
+ visibility: tutPaneOpen.value ? 'visible' : 'hidden',
+ transitionProperty: 'visibility',
+ transitionDuration: uiOpts.value.transitionDuration + 'ms',
+ };
+ }
+});
+const tutPaneStyles = computed((): Record<string,string> => {
+ if (uiOpts.value.breakpoint == 'sm'){
+ return {
+ height: uiOpts.value.tutPaneSz + 'px',
+ }
+ } else {
+ return {
+ height: uiOpts.value.tutPaneSz + 'px',
+ minWidth: '10cm',
+ maxWidth: '10cm',
+ borderRadius: uiOpts.value.borderRadius + 'px',
+ boxShadow: '0 0 3px black',
+ };
+ }
+});
+const ancestryBarContainerStyles = computed((): Record<string,string> => {
+ let ancestryBarBreadth = detachedAncestors.value == null ? 0 : uiOpts.value.ancestryBarBreadth;
+ let styles = {
+ minWidth: 'auto',
+ maxWidth: 'none',
+ minHeight: 'auto',
+ maxHeight: 'none',
+ transitionDuration: uiOpts.value.transitionDuration + 'ms',
+ transitionProperty: '',
+ overflow: 'hidden',
+ };
+ if (wideMainArea.value){
+ styles.minWidth = ancestryBarBreadth + 'px';
+ styles.maxWidth = ancestryBarBreadth + 'px';
+ styles.transitionProperty = 'min-width, max-width';
+ } else {
+ styles.minHeight = ancestryBarBreadth + 'px';
+ styles.maxHeight = ancestryBarBreadth + 'px';
+ styles.transitionProperty = 'min-height, max-height';
+ }
+ return styles;
});
</script>
diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue
index 3cfd116..1b4ee81 100644
--- a/src/components/AncestryBar.vue
+++ b/src/components/AncestryBar.vue
@@ -1,92 +1,86 @@
<template>
-<div :style="styles" @wheel.stop="onWheelEvt">
+<div :style="styles" @wheel.stop="onWheelEvt" ref="rootRef">
<tol-tile v-for="(node, idx) in dummyNodes" :key="node.name" class="shrink-0"
:layoutNode="node" :tolMap="tolMap" :nonAbsPos="true" :lytOpts="lytOpts" :uiOpts="uiOpts"
@leaf-click="onTileClick(nodes[idx])" @info-click="onInfoIconClick"/>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, onMounted, nextTick, PropType} from 'vue';
import TolTile from './TolTile.vue';
import {TolMap} from '../tol';
import {LayoutNode, LayoutOptions} from '../layout';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- nodes: {type: Array as PropType<LayoutNode[]>, required: true},
- vert: {type: Boolean, default: false},
- breadth: {type: Number, required: true},
- // Other
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- tolMap: {type: Object as PropType<TolMap>, required: true},
- },
- computed: {
- imgSz(){
- return this.breadth - this.lytOpts.tileSpacing - this.uiOpts.scrollGap;
- // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage
- },
- dummyNodes(){ // Childless versions of 'nodes' used to parameterise <tol-tile>s
- return this.nodes.map(n => {
- let newNode = new LayoutNode(n.name, []);
- newNode.dims = [this.imgSz, this.imgSz];
- return newNode;
- });
- },
- styles(): Record<string,string> {
- return {
- // For child layout
- display: 'flex',
- flexDirection: this.vert ? 'column' : 'row',
- alignItems: 'center',
- gap: this.lytOpts.tileSpacing + 'px',
- padding: this.lytOpts.tileSpacing + 'px',
- overflowX: this.vert ? 'hidden' : 'auto',
- overflowY: this.vert ? 'auto' : 'hidden',
- // Other
- backgroundColor: this.uiOpts.ancestryBarBgColor,
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- },
- methods: {
- // Click events
- onTileClick(node: LayoutNode){
- this.$emit('ancestor-click', node);
- },
- onInfoIconClick(data: string){
- this.$emit('info-click', data);
- },
- // For converting vertical scrolling to horizontal
- onWheelEvt(evt: WheelEvent){
- if (!this.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){
- this.$el.scrollLeft -= (evt.deltaY > 0 ? -30 : 30);
- }
- },
- // Other
- scrollToEnd(){
- if (this.vert){
- this.$el.scrollTop = this.$el.scrollHeight;
- } else {
- this.$el.scrollLeft = this.$el.scrollWidth;
- }
- },
- },
- watch: {
- // For scrolling-to-end upon node/screen changes
- nodes(){
- this.$nextTick(() => this.scrollToEnd());
- },
- vert(){
- this.$nextTick(() => this.scrollToEnd());
- },
- },
- mounted(){
- this.scrollToEnd();
- },
- components: {TolTile, },
- emits: ['ancestor-click', 'info-click', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ nodes: {type: Array as PropType<LayoutNode[]>, required: true},
+ vert: {type: Boolean, default: false},
+ breadth: {type: Number, required: true},
+ //
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+ tolMap: {type: Object as PropType<TolMap>, required: true},
});
+const emit = defineEmits(['ancestor-click', 'info-click']);
+
+// Computed prop data for display
+const imgSz = computed(() =>
+ props.breadth - props.lytOpts.tileSpacing - props.uiOpts.scrollGap
+ // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage
+);
+const dummyNodes = computed(() => props.nodes.map(n => {
+ let newNode = new LayoutNode(n.name, []);
+ newNode.dims = [imgSz.value, imgSz.value];
+ return newNode;
+}));
+
+// Click handling
+function onTileClick(node: LayoutNode){
+ emit('ancestor-click', node);
+}
+function onInfoIconClick(data: string){
+ emit('info-click', data);
+}
+
+// Scroll handling
+function onWheelEvt(evt: WheelEvent){ // For converting vertical scrolling to horizontal
+ if (!props.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){
+ rootRef.value!.scrollLeft -= (evt.deltaY > 0 ? -30 : 30);
+ }
+}
+function scrollToEnd(){
+ let el = rootRef.value!;
+ if (props.vert){
+ el.scrollTop = el.scrollHeight;
+ } else {
+ el.scrollLeft = el.scrollWidth;
+ }
+}
+watch(props.nodes, () => {
+ nextTick(() => scrollToEnd());
+});
+watch(() => props.vert, () => {
+ nextTick(() => scrollToEnd());
+});
+onMounted(() => scrollToEnd());
+
+// Styles
+const styles = computed(() => ({
+ // For child layout
+ display: 'flex',
+ flexDirection: props.vert ? 'column' : 'row',
+ alignItems: 'center',
+ gap: props.lytOpts.tileSpacing + 'px',
+ padding: props.lytOpts.tileSpacing + 'px',
+ overflowX: props.vert ? 'hidden' : 'auto',
+ overflowY: props.vert ? 'auto' : 'hidden',
+ // Other
+ backgroundColor: props.uiOpts.ancestryBarBgColor,
+ boxShadow: props.uiOpts.shadowNormal,
+}));
</script>
diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue
index 733810b..c403e53 100644
--- a/src/components/HelpModal.vue
+++ b/src/components/HelpModal.vue
@@ -1,9 +1,9 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose" ref="rootRef">
<!-- Outer div is slightly less dark to make scrollbar more distinguishable -->
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
w-[90%] max-w-[16cm] max-h-[80%] overflow-auto" :style="styles">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer"/>
<h1 class="text-center text-xl font-bold pt-2 pb-1">Help</h1>
<div class="flex flex-col gap-2 p-2">
@@ -84,7 +84,7 @@
</ul>
</p>
<br/>
- <p>
+ <div>
There are many other methods of visualisation.
Examples include <a href="https://itol.embl.de/" :style="aStyles">iTOL</a>
and <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a>
@@ -104,7 +104,7 @@
</div>
</div>
</div>
- </p>
+ </div>
<br/>
<h1 class="text-lg font-bold">Settings</h1>
<ul class="list-disc pl-4">
@@ -231,7 +231,7 @@
<a href="https://tree.opentreeoflife.org" :style="aStyles">Open Tree of Life</a>,
in <a href="https://tree.opentreeoflife.org/about/synthesis-release"
:style="aStyles">synthesis release</a>
- version 13.4, accessed 23/04/2022. The data is licensed under
+ version 13.4, accessed 23/04/2022. The data is licensed under
<a href="https://creativecommons.org/publicdomain/zero/1.0/" :style="aStyles">CC0</a>.
</li>
<li>
@@ -296,7 +296,7 @@
Thanks to <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a> for having
<a href="https://github.com/OneZoom/OZtree/tree/main/OZprivate/ServerScripts/TaxonMappingAndPopularity"
:style="aStyles">code</a>
- that automates node mapping.
+ that automates taxon ID mapping
</li>
<li>
Thanks to
@@ -426,67 +426,51 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, PropType} from 'vue';
import SButton from './SButton.vue';
import SCollapsible from './SCollapsible.vue';
import CloseIcon from './icon/CloseIcon.vue';
import DownIcon from './icon/DownIcon.vue';
-import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- tutOpen: {type: Boolean, default: false},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- scClasses(): string {
- return 'border border-stone-400 rounded';
- },
- scSummaryClasses(): string {
- return "relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2";
- },
- downIconClasses(): string {
- return 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
- },
- downIconExpandedClasses(): string {
- return this.downIconClasses + ' -rotate-90';
- },
- contentClasses(): string {
- return 'py-2 px-2 text-sm md:text-base';
- },
- aStyles(): Record<string,string> {
- return {
- color: this.uiOpts.altColorDark,
- };
- },
- linkIconClasses(): string {
- return 'inline-block w-3 h-3 ml-1';
- },
- touchDevice(): boolean {
- return this.uiOpts.touchDevice;
- },
- },
- methods: {
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
- }
- },
- onStartTutorial(){
- this.$emit('start-tutorial');
- this.$emit('close');
- },
- },
- components: {SButton, SCollapsible, CloseIcon, DownIcon, ExternalLinkIcon, },
- emits: ['close', 'start-tutorial', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null)
+const closeRef = ref(null as typeof CloseIcon | null);
+
+// Props + events
+const props = defineProps({
+ tutOpen: {type: Boolean, default: false},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+const touchDevice = computed(() => props.uiOpts.touchDevice)
+const emit = defineEmits(['close', 'start-tutorial']);
+
+// Event handlers
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+function onStartTutorial(){
+ emit('start-tutorial');
+ emit('close');
+}
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
+const aStyles = computed(() => ({
+ color: props.uiOpts.altColorDark,
+}));
+
+// Classes
+const scClasses = 'border border-stone-400 rounded';
+const scSummaryClasses = 'relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2';
+const downIconClasses = 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
+const downIconExpandedClasses = computed(() => downIconClasses + ' -rotate-90');
+const contentClasses = 'py-2 px-2 text-sm md:text-base';
</script>
diff --git a/src/components/IconButton.vue b/src/components/IconButton.vue
index 5684fb0..9357e97 100644
--- a/src/components/IconButton.vue
+++ b/src/components/IconButton.vue
@@ -5,24 +5,19 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {computed} from 'vue';
-export default defineComponent({
- props: {
- size: {type: Number, default: 36},
- disabled: {type: Boolean, default: false},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- minWidth: this.size + 'px',
- maxWidth: this.size + 'px',
- minHeight: this.size + 'px',
- maxHeight: this.size + 'px',
- padding: (this.size / 5) + 'px',
- };
- },
- },
+const props = defineProps({
+ size: {type: Number, default: 36},
+ disabled: {type: Boolean, default: false},
});
+
+const styles = computed(() => ({
+ minWidth: props.size + 'px',
+ maxWidth: props.size + 'px',
+ minHeight: props.size + 'px',
+ maxHeight: props.size + 'px',
+ padding: (props.size / 5) + 'px',
+}));
</script>
diff --git a/src/components/LoadingModal.vue b/src/components/LoadingModal.vue
index ee8d699..abd405c 100644
--- a/src/components/LoadingModal.vue
+++ b/src/components/LoadingModal.vue
@@ -8,26 +8,20 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {computed, PropType} from 'vue';
import LoaderIcon from './icon/LoaderIcon.vue';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- msg: {type: String, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColorDark2,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- },
- components: {LoaderIcon, },
+const props = defineProps({
+ msg: {type: String, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+
+const styles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColorDark2,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
</script>
diff --git a/src/components/SButton.vue b/src/components/SButton.vue
index 508f8e6..884fa30 100644
--- a/src/components/SButton.vue
+++ b/src/components/SButton.vue
@@ -5,12 +5,8 @@
</button>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-
-export default defineComponent({
- props: {
- disabled: {type: Boolean, default: false},
- },
+<script setup lang="ts">
+const props = defineProps({
+ disabled: {type: Boolean, default: false},
});
</script>
diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue
index 5b49c8c..39b4283 100644
--- a/src/components/SCollapsible.vue
+++ b/src/components/SCollapsible.vue
@@ -11,62 +11,51 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch} from 'vue';
-export default defineComponent({
- props: {
- modelValue: {type: Boolean, default: false}, // For using v-model on the component
- },
- data(){
- return {
- open: false,
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- overflow: this.open ? 'visible' : 'hidden',
- };
- },
- contentStyles(): Record<string,string> {
- return {
- overflow: 'hidden',
- opacity: this.open ? '1' : '0',
- transitionProperty: 'max-height, opacity',
- transitionDuration: '300ms',
- transitionTimingFunction: 'ease-in-out',
- };
- },
- },
- methods: {
- onClick(evt: Event){
- this.open = !this.open;
- this.$emit('update:modelValue', this.open);
- if (this.open){
- this.$emit('open');
- }
- },
- onEnter(el: HTMLDivElement){
- el.style.maxHeight = el.scrollHeight + 'px';
- },
- onAfterEnter(el: HTMLDivElement){
- el.style.maxHeight = 'none';
- // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short
- },
- onBeforeLeave(el: HTMLDivElement){
- el.style.maxHeight = el.scrollHeight + 'px';
- el.offsetWidth; // Triggers reflow
- },
- onLeave(el: HTMLDivElement){
- el.style.maxHeight = '0';
- },
- },
- watch: {
- modelValue(newVal, oldVal){
- this.open = newVal;
- },
- },
- emits: ['update:modelValue', 'open', ],
+// Props + events
+const props = defineProps({
+ modelValue: {type: Boolean, default: false}, // For using v-model on the component
});
+const emit = defineEmits(['update:modelValue', 'open']);
+
+// For open status
+const open = ref(false);
+watch(() => props.modelValue, (newVal) => {open.value = newVal})
+function onClick(){
+ open.value = !open.value;
+ emit('update:modelValue', open.value);
+ if (open.value){
+ emit('open');
+ }
+}
+
+// Styles
+const styles = computed(() => ({
+ overflow: open.value ? 'visible' : 'hidden',
+}));
+const contentStyles = computed(() => ({
+ overflow: 'hidden',
+ opacity: open.value ? '1' : '0',
+ transitionProperty: 'max-height, opacity',
+ transitionDuration: '300ms',
+ transitionTimingFunction: 'ease-in-out',
+}));
+
+// Open/close transitions
+function onEnter(el: HTMLDivElement){
+ el.style.maxHeight = el.scrollHeight + 'px';
+}
+function onAfterEnter(el: HTMLDivElement){
+ el.style.maxHeight = 'none';
+ // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short
+}
+function onBeforeLeave(el: HTMLDivElement){
+ el.style.maxHeight = el.scrollHeight + 'px';
+ el.offsetWidth; // Triggers reflow
+}
+function onLeave(el: HTMLDivElement){
+ el.style.maxHeight = '0';
+}
</script>
diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue
index 7406634..a035cac 100644
--- a/src/components/SearchModal.vue
+++ b/src/components/SearchModal.vue
@@ -1,8 +1,8 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex"
:style="styles">
- <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="searchInput"
+ <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef"
@keyup.enter="onSearch" @keyup.esc="onClose"
@input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/>
<div class="p-1 hover:cursor-pointer">
@@ -32,225 +32,226 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, onMounted, onUnmounted, PropType} from 'vue';
import SearchIcon from './icon/SearchIcon.vue';
-import LogInIcon from './icon/LogInIcon.vue';
import InfoIcon from './icon/InfoIcon.vue';
import {TolNode, TolMap} from '../tol';
import {LayoutNode, LayoutMap, LayoutOptions} from '../layout';
import {queryServer, SearchSugg, SearchSuggResponse, UiOptions} from '../lib';
-export default defineComponent({
- props: {
- lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists
- activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size
- tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- // Search-suggestion data
- searchSuggs: [] as SearchSugg[],
- searchHadMoreSuggs: false,
- suggsInput: '', // The input that resulted in the current suggestions (used to highlight matching text)
- // For search-suggestion requests
- lastSuggReqTime: 0, // Set when a search-suggestions request is initiated
- pendingSuggReqParams: null as null | URLSearchParams,
- // Used by a search-suggestion requester to request with the latest user input
- pendingDelayedSuggReq: 0, // Set via setTimeout() for a non-initial search-suggestions request
- pendingSuggInput: '', // Used to remember what input triggered a suggestions request
- // Other
- focusedSuggIdx: null as null | number, // Index of a search-suggestion selected using the arrow keys
- };
- },
- computed: {
- styles(): Record<string,string> {
- let br = this.uiOpts.borderRadius;
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: (this.searchSuggs.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- suggContainerStyles(): Record<string,string> {
- let br = this.uiOpts.borderRadius;
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- color: this.uiOpts.textColorAlt,
- borderRadius: `0 0 ${br}px ${br}px`,
- };
- },
- animateLabelStyles(): Record<string,string> {
- return {
- position: 'absolute',
- top: -this.lytOpts.headerSz - 2 + 'px',
- right: '0',
- height: this.lytOpts.headerSz + 'px',
- color: this.uiOpts.textColor,
- };
- },
- suggDisplayStrings(): [string, string, string, string][] {
- let result: [string, string, string, string][] = [];
- let input = this.suggsInput.toLowerCase();
- // For each SearchSugg
- for (let sugg of this.searchSuggs){
- let idx = sugg.name.indexOf(input);
- // Split suggestion text into parts before/within/after an input match
- let strings: [string, string, string, string];
- if (idx != -1){
- strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), ''];
- } else {
- strings = [input, '', '', ''];
- }
- // Indicate any distinct canonical-name
- if (sugg.canonicalName != null){
- strings[3] = ` (aka ${sugg.canonicalName})`;
- }
- //
- result.push(strings);
- }
- return result;
- },
- },
- methods: {
- // Search-suggestion events
- async onInput(){
- let input = this.$refs.searchInput as HTMLInputElement;
- // Check for empty input
- if (input.value.length == 0){
- this.searchSuggs = [];
- this.searchHadMoreSuggs = false;
- this.focusedSuggIdx = null;
- return;
- }
- // Get URL params to use for querying search-suggestions
- let urlParams = new URLSearchParams({
- type: 'sugg',
- name: input.value,
- limit: String(this.uiOpts.searchSuggLimit),
- tree: this.uiOpts.tree,
- });
- // Query server, delaying/skipping if a request was recently sent
- this.pendingSuggReqParams = urlParams;
- this.pendingSuggInput = input.value;
- let doReq = async () => {
- let suggInput = this.pendingSuggInput;
- let responseObj: SearchSuggResponse =
- await queryServer(this.pendingSuggReqParams!);
- if (responseObj == null){
- return;
- }
- this.searchSuggs = responseObj.suggs;
- this.searchHadMoreSuggs = responseObj.hasMore;
- this.suggsInput = suggInput;
- // Auto-select first result if present
- if (this.searchSuggs.length > 0){
- this.focusedSuggIdx = 0;
- } else {
- this.focusedSuggIdx = null;
- }
- };
- let currentTime = new Date().getTime();
- if (this.lastSuggReqTime == 0){
- this.lastSuggReqTime = currentTime;
- await doReq();
- if (this.lastSuggReqTime == currentTime){
- this.lastSuggReqTime = 0;
- }
- } else if (this.pendingDelayedSuggReq == 0){
- this.lastSuggReqTime = currentTime;
- this.pendingDelayedSuggReq = setTimeout(async () => {
- this.pendingDelayedSuggReq = 0;
- await doReq();
- if (this.lastSuggReqTime == currentTime){
- this.lastSuggReqTime = 0;
- }
- }, 300);
- }
- },
- onInfoIconClick(nodeName: string){
- this.$emit('info-click', nodeName);
- },
- onDownKey(){
- if (this.focusedSuggIdx != null){
- this.focusedSuggIdx = (this.focusedSuggIdx + 1) % this.searchSuggs.length;
- }
- },
- onUpKey(){
- if (this.focusedSuggIdx != null){
- this.focusedSuggIdx = (this.focusedSuggIdx - 1 + this.searchSuggs.length) % this.searchSuggs.length;
- // The addition after '-1' is to avoid becoming negative
- }
- },
- // Search events
- onSearch(){
- if (this.focusedSuggIdx == null){
- let input = (this.$refs.searchInput as HTMLInputElement).value.toLowerCase();
- this.resolveSearch(input)
- } else {
- let sugg = this.searchSuggs[this.focusedSuggIdx]
- this.resolveSearch(sugg.canonicalName || sugg.name);
- }
- },
- async resolveSearch(tolNodeName: string){
- if (tolNodeName == ''){
- return;
- }
- // Check if the node data is already here
- if (this.lytMap.has(tolNodeName)){
- this.$emit('search', tolNodeName);
- return;
- }
- // Ask server for nodes in parent-chain, updates tolMap, then emits search event
- let urlParams = new URLSearchParams({
- type: 'node',
- name: tolNodeName,
- toroot: '1',
- excl: this.activeRoot.name,
- tree: this.uiOpts.tree,
- });
- this.$emit('net-wait'); // Allows the parent component to show a loading-indicator
- let responseObj: {[x: string]: TolNode} = await queryServer(urlParams);
- this.$emit('net-get');
- if (responseObj == null){
- return;
- }
- let keys = Object.getOwnPropertyNames(responseObj);
- if (keys.length > 0){
- keys.forEach(key => {
- if (!this.tolMap.has(key)){
- this.tolMap.set(key, responseObj[key])
- }
- });
- this.$emit('search', tolNodeName);
- } else {
- // Trigger failure animation
- let input = this.$refs.searchInput as HTMLInputElement;
- input.classList.remove('animate-red-then-fade');
- input.offsetWidth; // Triggers reflow
- input.classList.add('animate-red-then-fade');
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const inputRef = ref(null as HTMLInputElement | null);
+
+// Props + events
+const props = defineProps({
+ lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists
+ activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size
+ tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const emit = defineEmits(['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get']);
+
+// Search-suggestion data
+const searchSuggs = ref([] as SearchSugg[]);
+const searchHadMoreSuggs = ref(false);
+const suggDisplayStrings = computed((): [string, string, string, string][] => {
+ let result: [string, string, string, string][] = [];
+ let input = suggsInput.value.toLowerCase();
+ // For each SearchSugg
+ for (let sugg of searchSuggs.value){
+ let idx = sugg.name.indexOf(input);
+ // Split suggestion text into parts before/within/after an input match
+ let strings: [string, string, string, string];
+ if (idx != -1){
+ strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), ''];
+ } else {
+ strings = [input, '', '', ''];
+ }
+ // Indicate any distinct canonical-name
+ if (sugg.canonicalName != null){
+ strings[3] = ` (aka ${sugg.canonicalName})`;
+ }
+ //
+ result.push(strings);
+ }
+ return result;
+});
+const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text)
+const focusedSuggIdx = ref(null as null | number); // Index of a search-suggestion selected using the arrow keys
+
+// For search-suggestion requests
+const lastSuggReqTime = ref(0); // Set when a search-suggestions request is initiated
+const pendingSuggReqParams = ref(null as null | URLSearchParams);
+ // Used by a search-suggestion requester to request with the latest user input
+const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for a non-initial search-suggestions request
+const pendingSuggInput = ref(''); // Used to remember what input triggered a suggestions request
+async function onInput(){
+ let input = inputRef.value!;
+ // Check for empty input
+ if (input.value.length == 0){
+ searchSuggs.value = [];
+ searchHadMoreSuggs.value = false;
+ focusedSuggIdx.value = null;
+ return;
+ }
+ // Get URL params to use for querying search-suggestions
+ let urlParams = new URLSearchParams({
+ type: 'sugg',
+ name: input.value,
+ limit: String(props.uiOpts.searchSuggLimit),
+ tree: props.uiOpts.tree,
+ });
+ // Query server, delaying/skipping if a request was recently sent
+ pendingSuggReqParams.value = urlParams;
+ pendingSuggInput.value = input.value;
+ let doReq = async () => {
+ let suggInput = pendingSuggInput.value;
+ let responseObj: SearchSuggResponse =
+ await queryServer(pendingSuggReqParams.value!);
+ if (responseObj == null){
+ return;
+ }
+ searchSuggs.value = responseObj.suggs;
+ searchHadMoreSuggs.value = responseObj.hasMore;
+ suggsInput.value = suggInput;
+ // Auto-select first result if present
+ if (searchSuggs.value.length > 0){
+ focusedSuggIdx.value = 0;
+ } else {
+ focusedSuggIdx.value = null;
+ }
+ };
+ let currentTime = new Date().getTime();
+ if (lastSuggReqTime.value == 0){
+ lastSuggReqTime.value = currentTime;
+ await doReq();
+ if (lastSuggReqTime.value == currentTime){
+ lastSuggReqTime.value = 0;
+ }
+ } else if (pendingDelayedSuggReq.value == 0){
+ lastSuggReqTime.value = currentTime;
+ pendingDelayedSuggReq.value = setTimeout(async () => {
+ pendingDelayedSuggReq.value = 0;
+ await doReq();
+ if (lastSuggReqTime.value == currentTime){
+ lastSuggReqTime.value = 0;
}
- },
- // Other
- onSearchModeChg(){
- this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode;
- this.$emit('setting-chg', 'searchJumpMode');
- },
- onClose(evt: Event){
- if (evt.target == this.$el){
- this.$emit('close');
+ }, 300);
+ }
+}
+
+// For search events
+function onSearch(){
+ if (focusedSuggIdx.value == null){
+ let input = inputRef.value!.value.toLowerCase();
+ resolveSearch(input)
+ } else {
+ let sugg = searchSuggs.value[focusedSuggIdx.value]
+ resolveSearch(sugg.canonicalName || sugg.name);
+ }
+}
+async function resolveSearch(tolNodeName: string){
+ if (tolNodeName == ''){
+ return;
+ }
+ // Check if the node data is already here
+ if (props.lytMap.has(tolNodeName)){
+ emit('search', tolNodeName);
+ return;
+ }
+ // Ask server for nodes in parent-chain, updates tolMap, then emits search event
+ let urlParams = new URLSearchParams({
+ type: 'node',
+ name: tolNodeName,
+ toroot: '1',
+ excl: props.activeRoot.name,
+ tree: props.uiOpts.tree,
+ });
+ emit('net-wait'); // Allows the parent component to show a loading-indicator
+ let responseObj: {[x: string]: TolNode} = await queryServer(urlParams);
+ emit('net-get');
+ if (responseObj == null){
+ return;
+ }
+ let keys = Object.getOwnPropertyNames(responseObj);
+ if (keys.length > 0){
+ keys.forEach(key => {
+ if (!props.tolMap.has(key)){
+ props.tolMap.set(key, responseObj[key])
}
- },
- focusInput(){ // Used from external component
- (this.$refs.searchInput as HTMLInputElement).focus();
- },
- },
- mounted(){
- (this.$refs.searchInput as HTMLInputElement).focus();
- },
- components: {SearchIcon, InfoIcon, LogInIcon, },
- emits: ['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get', ],
+ });
+ emit('search', tolNodeName);
+ } else {
+ // Trigger failure animation
+ let input = inputRef.value!;
+ input.classList.remove('animate-red-then-fade');
+ input.offsetWidth; // Triggers reflow
+ input.classList.add('animate-red-then-fade');
+ }
+}
+
+// More event handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value){
+ emit('close');
+ }
+}
+function onDownKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length;
+ }
+}
+function onUpKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length;
+ // The addition after '-1' is to avoid becoming negative
+ }
+}
+function onInfoIconClick(nodeName: string){
+ emit('info-click', nodeName);
+}
+
+// For keyboard shortcuts
+function onKeyDown(evt: KeyboardEvent){
+ if (props.uiOpts.disableShortcuts){
+ return;
+ }
+ if (evt.key == 'f' && evt.ctrlKey){
+ evt.preventDefault();
+ inputRef.value!.focus();
+ }
+}
+onMounted(() => window.addEventListener('keydown', onKeyDown))
+onUnmounted(() => window.removeEventListener('keydown', onKeyDown))
+
+// Focus input on mount
+onMounted(() => inputRef.value!.focus())
+
+// Styles
+const styles = computed((): Record<string,string> => {
+ let br = props.uiOpts.borderRadius;
+ return {
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+});
+const suggContainerStyles = computed((): Record<string,string> => {
+ let br = props.uiOpts.borderRadius;
+ return {
+ backgroundColor: props.uiOpts.bgColorAlt,
+ color: props.uiOpts.textColorAlt,
+ borderRadius: `0 0 ${br}px ${br}px`,
+ };
});
+const animateLabelStyles = computed(() => ({
+ position: 'absolute',
+ top: -props.lytOpts.headerSz - 2 + 'px',
+ right: '0',
+ height: props.lytOpts.headerSz + 'px',
+ color: props.uiOpts.textColor,
+}));
</script>
diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue
index 4f5f05e..df8444f 100644
--- a/src/components/SettingsModal.vue
+++ b/src/components/SettingsModal.vue
@@ -1,8 +1,8 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
min-w-[8cm] max-w-[80%] max-h-[80%] overflow-auto" :style="styles">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer" />
<h1 class="text-xl md:text-2xl font-bold text-center py-2" :class="borderBClasses">Settings</h1>
<div class="pb-2" :class="borderBClasses">
@@ -56,7 +56,7 @@
<input type="range"
min="15" :max="uiOpts.breakpoint == 'sm' ? 150 : 200" v-model.number="lytOpts.minTileSz"
@input="onSettingChgThrottled('LYT', 'minTileSz')" @change="onSettingChg('LYT', 'minTileSz')"
- name="minTileSizeInput" ref="minTileSzInput"/>
+ name="minTileSizeInput" ref="minTileSzRef"/>
<div class="my-auto text-right">{{lytOpts.minTileSz}} px</div>
<!-- Row 2 -->
<label for="maxTileSizeInput" @click="onReset('LYT', 'maxTileSz')" :class="rLabelClasses">
@@ -64,7 +64,7 @@
</label>
<input type="range" min="15" max="400" v-model.number="lytOpts.maxTileSz"
@input="onSettingChgThrottled('LYT', 'maxTileSz')" @change="onSettingChg('LYT', 'maxTileSz')"
- name="maxTileSizeInput" ref="maxTileSzInput"/>
+ name="maxTileSizeInput" ref="maxTileSzRef"/>
<div class="my-auto text-right">{{lytOpts.maxTileSz}} px</div>
<!-- Row 3 -->
<label for="tileSpacingInput" @click="onReset('LYT', 'tileSpacing')" :class="rLabelClasses">
@@ -107,129 +107,124 @@
Reset
</s-button>
<transition name="fade">
- <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndicator"> Saved </div>
+ <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndRef"> Saved </div>
</transition>
</div>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, PropType} from 'vue';
import SButton from './SButton.vue';
import CloseIcon from './icon/CloseIcon.vue';
import {UiOptions, OptionType, getDefaultLytOpts, getDefaultUiOpts} from '../lib';
import {LayoutOptions} from '../layout';
-export default defineComponent({
- props: {
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- sweepLeaves: this.lytOpts.layoutType == 'sweep',
- // For making only two of 'layoutType's values available for user selection
- saved: false, // Set to true after a setting is saved
- settingChgTimeout: 0, // Use to throttle some setting-change handling
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- borderBClasses(): string {
- return 'border-b border-stone-400';
- },
- rLabelClasses(): string { // For reset-upon-click labels
- return "w-fit hover:cursor-pointer hover:text-lime-600";
- },
- },
- methods: {
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
- }
- },
- onSettingChg(optionType: OptionType, option: string){
- // Maintain min/max-tile-size consistency
- if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){
- let minInput = this.$refs.minTileSzInput as HTMLInputElement;
- let maxInput = this.$refs.maxTileSzInput as HTMLInputElement;
- if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){
- this.lytOpts.maxTileSz = this.lytOpts.minTileSz;
- this.$emit('setting-chg', 'LYT', 'maxTileSz');
- } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){
- this.lytOpts.minTileSz = this.lytOpts.maxTileSz;
- this.$emit('setting-chg', 'LYT', 'minTileSz');
- }
- }
- // Notify parent component
- this.$emit('setting-chg', optionType, option,
- {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'});
- // Possibly make saved-indicator appear/animate
- if (!this.saved){
- this.saved = true;
- } else {
- let el = this.$refs.saveIndicator as HTMLDivElement;
- el.classList.remove('animate-flash-green');
- el.offsetWidth; // Triggers reflow
- el.classList.add('animate-flash-green');
- }
- },
- onSettingChgThrottled(optionType: OptionType, option: string){
- if (this.settingChgTimeout == 0){
- this.settingChgTimeout = setTimeout(() => {
- this.settingChgTimeout = 0;
- this.onSettingChg(optionType, option);
- }, this.uiOpts.animationDelay);
- }
- },
- onReset(optionType: OptionType, option: string){
- // Restore the setting's default
- let defaultLytOpts = getDefaultLytOpts();
- let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
- if (optionType == 'LYT'){
- let lytOpt = option as keyof LayoutOptions;
- if (this.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){
- return;
- }
- (this.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt];
- if (option == 'layoutType'){
- this.sweepLeaves = this.lytOpts.layoutType == 'sweep';
- }
- } else {
- let uiOpt = option as keyof UiOptions;
- if (this.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){
- return;
- }
- (this.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt];
- }
- // Notify parent component
- this.onSettingChg(optionType, option);
- },
- onResetAll(){
- // Restore default options
- let defaultLytOpts = getDefaultLytOpts();
- let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
- let needReinit = this.uiOpts.tree != defaultUiOpts.tree;
- Object.assign(this.lytOpts, defaultLytOpts);
- Object.assign(this.uiOpts, defaultUiOpts);
- // Notify parent component
- this.$emit('reset', needReinit);
- // Clear saved-indicator
- this.saved = false;
- },
- },
- watch: {
- sweepLeaves(newVal: boolean, oldVal: boolean){
- this.lytOpts.layoutType = newVal ? 'sweep' : 'rect';
- },
- },
- components: {SButton, CloseIcon, },
- emits: ['close', 'setting-chg', 'reset', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const closeRef = ref(null as typeof CloseIcon | null);
+const minTileSzRef = ref(null as HTMLInputElement | null);
+const maxTileSzRef = ref(null as HTMLInputElement | null);
+const saveIndRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+const emit = defineEmits(['close', 'setting-chg', 'reset', ]);
+
+// For settings
+const sweepLeaves = ref(props.lytOpts.layoutType == 'sweep');
+ // For making only two of 'layoutType's values available for user selection)
+watch(sweepLeaves, (newVal) => {props.lytOpts.layoutType = newVal ? 'sweep' : 'rect'})
+
+// Settings change handling
+const saved = ref(false); // Set to true after a setting is saved
+const settingChgTimeout = ref(0); // Used to throttle some setting-change handling
+function onSettingChg(optionType: OptionType, option: string){
+ // Maintain min/max-tile-size consistency
+ if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){
+ let minInput = minTileSzRef.value!;
+ let maxInput = maxTileSzRef.value!;
+ if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){
+ props.lytOpts.maxTileSz = props.lytOpts.minTileSz;
+ emit('setting-chg', 'LYT', 'maxTileSz');
+ } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){
+ props.lytOpts.minTileSz = props.lytOpts.maxTileSz;
+ emit('setting-chg', 'LYT', 'minTileSz');
+ }
+ }
+ // Notify parent component
+ emit('setting-chg', optionType, option,
+ {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'});
+ // Possibly make saved-indicator appear/animate
+ if (!saved.value){
+ saved.value = true;
+ } else {
+ let el = saveIndRef.value!;
+ el.classList.remove('animate-flash-green');
+ el.offsetWidth; // Triggers reflow
+ el.classList.add('animate-flash-green');
+ }
+}
+function onSettingChgThrottled(optionType: OptionType, option: string){
+ if (settingChgTimeout.value == 0){
+ settingChgTimeout.value = setTimeout(() => {
+ settingChgTimeout.value = 0;
+ onSettingChg(optionType, option);
+ }, props.uiOpts.animationDelay);
+ }
+}
+function onReset(optionType: OptionType, option: string){
+ // Restore the setting's default
+ let defaultLytOpts = getDefaultLytOpts();
+ let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
+ if (optionType == 'LYT'){
+ let lytOpt = option as keyof LayoutOptions;
+ if (props.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){
+ return;
+ }
+ (props.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt];
+ if (option == 'layoutType'){
+ sweepLeaves.value = props.lytOpts.layoutType == 'sweep';
+ }
+ } else {
+ let uiOpt = option as keyof UiOptions;
+ if (props.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){
+ return;
+ }
+ (props.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt];
+ }
+ // Notify parent component
+ onSettingChg(optionType, option);
+}
+function onResetAll(){
+ // Restore default options
+ let defaultLytOpts = getDefaultLytOpts();
+ let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
+ let needReinit = props.uiOpts.tree != defaultUiOpts.tree;
+ Object.assign(props.lytOpts, defaultLytOpts);
+ Object.assign(props.uiOpts, defaultUiOpts);
+ // Notify parent component
+ emit('reset', needReinit);
+ // Clear saved-indicator
+ saved.value = false;
+}
+
+// Close handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+
+// Styles and classes
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
+const borderBClasses = 'border-b border-stone-400';
+const rLabelClasses = "w-fit hover:cursor-pointer hover:text-lime-600"; // For reset-upon-click labels
</script>
diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue
index 39e2b5b..fc09d86 100644
--- a/src/components/TileInfoModal.vue
+++ b/src/components/TileInfoModal.vue
@@ -1,9 +1,9 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
max-w-[80%] w-2/3 min-w-[8cm] md:w-[14cm] lg:w-[16cm] max-h-[80%]" :style="styles">
<div class="pb-1 md:pb-2">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer"/>
<div class="absolute top-1 left-1 md:top-2 md:left-2 flex items-center">
<a :href="'/?node=' + encodeURIComponent(nodeName)" class="block w-8 h-8 p-[2px] hover:cursor-pointer"
@@ -23,7 +23,7 @@
<div v-if="tolNode.iucn != null">
<a href="https://en.wikipedia.org/wiki/Endangered_species_(IUCN_status)"
target="_blank" title="IUCN Conservation Status">IUCN</a>:
- <span :style="iucnStyles">{{getDisplayIucn(tolNode.iucn)}}</span>
+ <span :style="iucnStyles(tolNode.iucn)">{{getDisplayIucn(tolNode.iucn)}}</span>
</div>
<div>
<a :href="'https://tree.opentreeoflife.org/opentree/argus/opentree13.4@' + tolNode.otolId"
@@ -111,176 +111,174 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, PropType} from 'vue';
import SCollapsible from './SCollapsible.vue';
import CloseIcon from './icon/CloseIcon.vue';
import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
import DownIcon from './icon/DownIcon.vue';
import LinkIcon from './icon/LinkIcon.vue';
-import {TolNode, TolMap} from '../tol';
-import {LayoutNode, LayoutOptions} from '../layout';
-import {getImagePath, DescInfo, ImgInfo, NodeInfo, InfoResponse, UiOptions} from '../lib';
+import {TolNode} from '../tol';
+import {LayoutOptions} from '../layout';
+import {getImagePath, DescInfo, ImgInfo, InfoResponse, UiOptions} from '../lib';
import {capitalizeWords} from '../util';
-export default defineComponent({
- props: {
- // Node data to display
- nodeName: {type: String, required: true},
- infoResponse: {type: Object as PropType<InfoResponse>, required: true},
- // Options
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- linkCopied: false, // Used to temporarily show a 'link copied' label
- };
- },
- computed: {
- tolNode(): TolNode {
- return this.infoResponse.nodeInfo.tolNode;
- },
- nodes(): (TolNode | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.tolNode];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null);
- }
- },
- imgInfos(): (ImgInfo | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.infoResponse.nodeInfo.imgInfo];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null);
- }
- },
- descInfos(): (DescInfo | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.infoResponse.nodeInfo.descInfo];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null);
- }
- },
- subNames(): [string, string] | null {
- const regex = /\[(.+) \+ (.+)\]/;
- let results = regex.exec(this.nodeName);
- return results == null ? null : [results[1], results[2]];
- },
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- overflow: 'visible auto',
- };
- },
- iucnStyles(): Record<string,string> {
- let col = 'currentcolor';
- switch (this.tolNode.iucn){
- case 'least concern': col = 'green'; break;
- case 'near threatened': col = 'limegreen'; break;
- case 'vulnerable': col = 'goldenrod'; break;
- case 'endangered': col = 'darkorange'; break;
- case 'critically endangered': col = 'red'; break;
- case 'extinct in the wild':
- case 'extinct species': col = 'gray'; break;
- }
- return {
- color: col,
- };
- },
- linkCopyLabelStyles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColor,
- borderRadius: this.uiOpts.borderRadius + 'px',
- };
- },
- },
- methods: {
- getDisplayName(name: string, tolNode: TolNode | null): string {
- if (tolNode == null || tolNode.commonName == null){
- return capitalizeWords(name);
- } else {
- return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`;
- }
- },
- getDisplayIucn(iucn: string){
- switch (this.tolNode.iucn){
- case 'least concern': return 'LC';
- case 'near threatened': return 'NT';
- case 'vulnerable': return 'VN';
- case 'endangered': return 'EN';
- case 'critically endangered': return 'CR';
- case 'extinct in the wild': return 'EX';
- case 'extinct species': return 'ES';
- case 'data deficient': return 'DD';
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const closeRef = ref(null as typeof CloseIcon | null);
+
+// Props + events
+const props = defineProps({
+ // Node data to display
+ nodeName: {type: String, required: true},
+ infoResponse: {type: Object as PropType<InfoResponse>, required: true},
+ // Options
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const emit = defineEmits(['close']);
+
+// InfoResponse computed data
+const tolNode = computed(() => props.infoResponse.nodeInfo.tolNode);
+const nodes = computed((): (TolNode | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [tolNode.value];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null);
+ }
+});
+const imgInfos = computed((): (ImgInfo | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [props.infoResponse.nodeInfo.imgInfo];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null);
+ }
+});
+const descInfos = computed((): (DescInfo | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [props.infoResponse.nodeInfo.descInfo];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null);
+ }
+});
+const subNames = computed((): [string, string] | null => {
+ const regex = /\[(.+) \+ (.+)\]/;
+ let results = regex.exec(props.nodeName);
+ return results == null ? null : [results[1], results[2]];
+});
+
+// InfoResponse data converters
+function getDisplayName(name: string, tolNode: TolNode | null): string {
+ if (tolNode == null || tolNode.commonName == null){
+ return capitalizeWords(name);
+ } else {
+ return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`;
+ }
+}
+function getDisplayIucn(iucn: string){
+ switch (iucn){
+ case 'least concern': return 'LC';
+ case 'near threatened': return 'NT';
+ case 'vulnerable': return 'VN';
+ case 'endangered': return 'EN';
+ case 'critically endangered': return 'CR';
+ case 'extinct in the wild': return 'EX';
+ case 'extinct species': return 'ES';
+ case 'data deficient': return 'DD';
+ }
+}
+function licenseToUrl(license: string){
+ license = license.toLowerCase().replaceAll('-', ' ');
+ if (license == 'cc0'){
+ return 'https://creativecommons.org/publicdomain/zero/1.0/';
+ } else if (license == 'cc publicdomain'){
+ return 'https://creativecommons.org/licenses/publicdomain/';
+ } else {
+ const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/;
+ let results = regex.exec(license);
+ if (results != null){
+ let url = 'https://creativecommons.org/licenses/by';
+ if (results[1] != null){
+ url += '-nc';
}
- },
- getImgStyles(tolNode: TolNode | null): Record<string,string> {
- let imgName = null;
- if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case
- imgName = tolNode.imgName;
+ if (results[2] != null){
+ url += '-sa';
}
- return {
- width: '200px',
- height: '200px',
- backgroundImage: imgName != null ?
- `url('${getImagePath(imgName as string)}')` :
- 'none',
- backgroundColor: this.uiOpts.bgColorDark,
- backgroundSize: 'cover',
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- licenseToUrl(license: string){
- license = license.toLowerCase().replaceAll('-', ' ');
- if (license == 'cc0'){
- return 'https://creativecommons.org/publicdomain/zero/1.0/';
- } else if (license == 'cc publicdomain'){
- return 'https://creativecommons.org/licenses/publicdomain/';
+ if (results[4] != null){
+ url += '/' + results[4];
} else {
- const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/;
- let results = regex.exec(license);
- if (results != null){
- let url = 'https://creativecommons.org/licenses/by';
- if (results[1] != null){
- url += '-nc';
- }
- if (results[2] != null){
- url += '-sa';
- }
- if (results[4] != null){
- url += '/' + results[4];
- } else {
- url += '/4.0';
- }
- if (results[5] != null){
- url += '/' + results[5].substring(1);
- }
- return url;
- }
- return "[INVALID LICENSE]";
+ url += '/4.0';
}
- },
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
+ if (results[5] != null){
+ url += '/' + results[5].substring(1);
}
- },
- onLinkIconClick(evt: Event){
- // Copy link to clipboard
- let url = new URL(window.location.href);
- url.search = (new URLSearchParams({node: this.nodeName})).toString();
- navigator.clipboard.writeText(url.toString());
- // Show visual indicator
- this.linkCopied = true;
- setTimeout(() => {this.linkCopied = false}, 1500);
- },
- },
- components: {SCollapsible, CloseIcon, ExternalLinkIcon, DownIcon, LinkIcon, },
- emits: ['close', ],
-});
+ return url;
+ }
+ return "[INVALID LICENSE]";
+ }
+}
+
+// Close handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+
+// Copy-link handling
+const linkCopied = ref(false); // Used to temporarily show a 'link copied' label
+function onLinkIconClick(){
+ // Copy link to clipboard
+ let url = new URL(window.location.href);
+ url.search = (new URLSearchParams({node: props.nodeName})).toString();
+ navigator.clipboard.writeText(url.toString());
+ // Show visual indicator
+ linkCopied.value = true;
+ setTimeout(() => {linkCopied.value = false}, 1500);
+}
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+ overflow: 'visible auto',
+}));
+function getImgStyles(tolNode: TolNode | null): Record<string,string> {
+ let imgName = null;
+ if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case
+ imgName = tolNode.imgName;
+ }
+ return {
+ width: '200px',
+ height: '200px',
+ backgroundImage: imgName != null ?
+ `url('${getImagePath(imgName as string)}')` :
+ 'none',
+ backgroundColor: props.uiOpts.bgColorDark,
+ backgroundSize: 'cover',
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+}
+function iucnStyles(iucn: string): Record<string,string>{
+ let col = 'currentcolor';
+ switch (iucn){
+ case 'least concern': col = 'green'; break;
+ case 'near threatened': col = 'limegreen'; break;
+ case 'vulnerable': col = 'goldenrod'; break;
+ case 'endangered': col = 'darkorange'; break;
+ case 'critically endangered': col = 'red'; break;
+ case 'extinct in the wild':
+ case 'extinct species': col = 'gray'; break;
+ }
+ return {
+ color: col,
+ };
+}
+const linkCopyLabelStyles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColor,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+}));
</script>
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>
diff --git a/src/components/TutorialPane.vue b/src/components/TutorialPane.vue
index 2803498..4c24bae 100644
--- a/src/components/TutorialPane.vue
+++ b/src/components/TutorialPane.vue
@@ -2,7 +2,7 @@
<div :style="styles" class="relative flex flex-col justify-between">
<close-icon @click.stop="onClose" class="absolute top-2 right-2 w-8 h-8 hover:cursor-pointer"/>
<h1 class="text-center text-lg font-bold pt-3 pb-2">
- {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${lastStage})`}}
+ {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${LAST_STAGE})`}}
</h1>
<transition name="fade" mode="out-in">
<div v-if="stage == 0" :style="contentStyles">
@@ -58,119 +58,106 @@
Prev
</s-button>
<s-button :class="{invisible: !hidNextPrevOnce && stage == 1}"
- @click="stage != lastStage ? onNextClick() : onClose()" :style="buttonStyles">
- {{stage != lastStage ? 'Next' : 'Finish'}}
+ @click="stage != LAST_STAGE ? onNextClick() : onClose()" :style="buttonStyles">
+ {{stage != LAST_STAGE ? 'Next' : 'Finish'}}
</s-button>
</template>
</div>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, onMounted, PropType} from 'vue';
import SButton from './SButton.vue';
import CloseIcon from './icon/CloseIcon.vue';
import {Action, UiOptions} from '../lib';
-export default defineComponent({
- props: {
- actionsDone: {type: Object as PropType<Set<Action>>, required: true},
- // Used to avoid disabling actions already done
- triggerFlag: {type: Boolean, required: true},
- // Used to indicate that a tutorial-requested 'trigger' action has been done
- skipWelcome: {type: Boolean, default: false},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- stage: 0, // Indicates the current step of the tutorial (stage 0 is the welcome message)
- lastStage: 9,
- disabledOnce: false, // Set to true after disabling features at stage 1
- stageActions: [
- // Specifies, for stages 1+, what action to enable (can repeat an action to enable nothing new)
- 'expand', 'collapse', 'expandToView', 'unhideAncestor',
- 'tileInfo', 'search', 'autoMode', 'settings', 'help',
- ] as Action[],
- hidNextPrevOnce: false, // Used to hide prev/next buttons when initially at stage 1
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorDark,
- color: this.uiOpts.textColor,
- };
- },
- contentStyles(): Record<string,string> {
- return {
- padding: '0 0.5cm',
- overflow: 'auto',
- textAlign: 'center',
- };
- },
- buttonStyles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColor,
- };
- },
- touchDevice(): boolean {
- return this.uiOpts.touchDevice;
- },
- },
- methods: {
- onStartTutorial(){
- this.stage = 1;
- },
- onSkipTutorial(){
- this.$emit('skip');
- this.$emit('close');
- },
- onPrevClick(){
- this.stage = Math.max(1, this.stage - 1);
- },
- onNextClick(){
- this.stage = Math.min(this.stage + 1, this.lastStage);
- },
- onClose(){
- this.$emit('close');
- },
- },
- watch: {
- stage(newVal, oldVal){
- // If starting tutorial, disable 'all' actions
- if (newVal == 1 && !this.disabledOnce){
- for (let action of this.stageActions){
- if (action != null && !this.actionsDone.has(action)){
- this.uiOpts.disabledActions.add(action);
- }
- }
- this.disabledOnce = true;
- }
- // Enable action for this stage
- this.uiOpts.disabledActions.delete(this.stageActions[this.stage - 1]);
- // Notify of new trigger-action
- this.$emit('stage-chg', this.stageActions[this.stage - 1]);
- // After stage 1, show prev/next buttons
- if (newVal == 2){
- this.hidNextPrevOnce = true;
- }
- },
- // Called when a trigger-action is done, and advances to the next stage
- triggerFlag(){
- if (this.stage < this.lastStage){
- this.onNextClick();
- } else {
- this.onClose();
+// Props + events
+const props = defineProps({
+ actionsDone: {type: Object as PropType<Set<Action>>, required: true},
+ // Used to avoid disabling actions already done
+ triggerFlag: {type: Boolean, required: true},
+ // Used to indicate that a tutorial-requested 'trigger' action has been done
+ skipWelcome: {type: Boolean, default: false},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const touchDevice = computed(() => props.uiOpts.touchDevice);
+const emit = defineEmits(['close', 'stage-chg', 'skip']);
+
+// For tutorial stage
+const stage = ref(props.skipWelcome ? 1 : 0);
+ // Indicates the current step of the tutorial (stage 0 is the welcome message)
+const LAST_STAGE = 9;
+const STAGE_ACTIONS = [
+ // Specifies, for stages 1+, what action to enable (can repeat an action to enable nothing new)
+ 'expand', 'collapse', 'expandToView', 'unhideAncestor',
+ 'tileInfo', 'search', 'autoMode', 'settings', 'help',
+] as Action[];
+let disabledOnce = false; // Set to true after disabling features at stage 1
+const hidNextPrevOnce = ref(false); // Used to hide prev/next buttons when initially at stage 1
+
+// For stage changes
+function onStartTutorial(){
+ stage.value = 1;
+}
+function onSkipTutorial(){
+ emit('skip');
+ emit('close');
+}
+function onPrevClick(){
+ stage.value = Math.max(1, stage.value - 1);
+}
+function onNextClick(){
+ stage.value = Math.min(stage.value + 1, LAST_STAGE);
+}
+function onClose(){
+ emit('close');
+}
+function onStageChange(){
+ // If starting tutorial, disable 'all' actions
+ if (stage.value == 1 && !disabledOnce){
+ for (let action of STAGE_ACTIONS){
+ if (action != null && !props.actionsDone.has(action)){
+ props.uiOpts.disabledActions.add(action);
}
- },
- },
- created(){
- if (this.skipWelcome){
- this.stage += 1;
}
- },
- components: {CloseIcon, SButton, },
- emits: ['close', 'stage-chg', 'skip', ],
+ disabledOnce = true;
+ }
+ // Enable action for this stage
+ props.uiOpts.disabledActions.delete(STAGE_ACTIONS[stage.value - 1]);
+ // Notify of new trigger-action
+ emit('stage-chg', STAGE_ACTIONS[stage.value - 1]);
+ // After stage 1, show prev/next buttons
+ if (stage.value == 2){
+ hidNextPrevOnce.value = true;
+ }
+}
+onMounted(() => {
+ if (props.skipWelcome){
+ onStageChange();
+ }
+})
+watch(stage, onStageChange);
+watch(() => props.triggerFlag, () => {
+ if (stage.value < LAST_STAGE){
+ onNextClick();
+ } else {
+ onClose();
+ }
});
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorDark,
+ color: props.uiOpts.textColor,
+}));
+const contentStyles = {
+ padding: '0 0.5cm',
+ overflow: 'auto',
+ textAlign: 'center',
+};
+const buttonStyles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColor,
+}));
</script>
diff --git a/src/components/icon/CloseIcon.vue b/src/components/icon/CloseIcon.vue
index 7dceef9..a62b08a 100644
--- a/src/components/icon/CloseIcon.vue
+++ b/src/components/icon/CloseIcon.vue
@@ -5,8 +5,3 @@
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/DownIcon.vue b/src/components/icon/DownIcon.vue
index dc954d1..f7a5835 100644
--- a/src/components/icon/DownIcon.vue
+++ b/src/components/icon/DownIcon.vue
@@ -4,8 +4,3 @@
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/EduIcon.vue b/src/components/icon/EduIcon.vue
index e46f2a6..a7d0405 100644
--- a/src/components/icon/EduIcon.vue
+++ b/src/components/icon/EduIcon.vue
@@ -6,8 +6,3 @@
d="M112 240v128l144 80 144-80V240M480 368V192M256 320v128"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/ExternalLinkIcon.vue b/src/components/icon/ExternalLinkIcon.vue
index b7cb286..f672f3a 100644
--- a/src/components/icon/ExternalLinkIcon.vue
+++ b/src/components/icon/ExternalLinkIcon.vue
@@ -6,8 +6,3 @@
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/HelpIcon.vue b/src/components/icon/HelpIcon.vue
index 8486686..a61553a 100644
--- a/src/components/icon/HelpIcon.vue
+++ b/src/components/icon/HelpIcon.vue
@@ -5,8 +5,3 @@
<circle cx="248" cy="430" r="32" fill="currentColor"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue
index 0f390cf..47a14cd 100644
--- a/src/components/icon/InfoIcon.vue
+++ b/src/components/icon/InfoIcon.vue
@@ -6,8 +6,3 @@
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LinkIcon.vue b/src/components/icon/LinkIcon.vue
index 49996b7..2e43324 100644
--- a/src/components/icon/LinkIcon.vue
+++ b/src/components/icon/LinkIcon.vue
@@ -5,8 +5,3 @@
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LoaderIcon.vue b/src/components/icon/LoaderIcon.vue
index cd5093b..c2a0369 100644
--- a/src/components/icon/LoaderIcon.vue
+++ b/src/components/icon/LoaderIcon.vue
@@ -11,8 +11,3 @@
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LogInIcon.vue b/src/components/icon/LogInIcon.vue
index b91550b..97ab871 100644
--- a/src/components/icon/LogInIcon.vue
+++ b/src/components/icon/LogInIcon.vue
@@ -6,8 +6,3 @@
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/PauseIcon.vue b/src/components/icon/PauseIcon.vue
index dc768a2..be13f5f 100644
--- a/src/components/icon/PauseIcon.vue
+++ b/src/components/icon/PauseIcon.vue
@@ -5,8 +5,3 @@
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/PlayIcon.vue b/src/components/icon/PlayIcon.vue
index 7e5d823..4295328 100644
--- a/src/components/icon/PlayIcon.vue
+++ b/src/components/icon/PlayIcon.vue
@@ -4,8 +4,3 @@
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/SearchIcon.vue b/src/components/icon/SearchIcon.vue
index 40b8226..4a25aea 100644
--- a/src/components/icon/SearchIcon.vue
+++ b/src/components/icon/SearchIcon.vue
@@ -5,8 +5,3 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/SettingsIcon.vue b/src/components/icon/SettingsIcon.vue
index bea2a3f..e8738a9 100644
--- a/src/components/icon/SettingsIcon.vue
+++ b/src/components/icon/SettingsIcon.vue
@@ -15,8 +15,3 @@
2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>