aboutsummaryrefslogtreecommitdiff
path: root/src/lib.ts
blob: e210e9702615946d2124c44d7c5397dc7c6d6a85 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
/*
 * Types/classes
 */
// Used for tree-of-life representation
// Maps tree-of-life node names to node objects
export type TolMap = Map<string, TolNode>;
// Represents a tree-of-life node
export class TolNode {
	otolId: string | null;
	children: string[];
	parent: string | null;
	tips: number;
	pSupport: boolean;
	commonName: null | string;
	imgName: null | string | [string, string] | [null, string] | [string, null];
	constructor(children: string[] = [], parent = null, tips = 0, pSupport = false){
		this.otolId = null;
		this.children = children;
		this.parent = parent;
		this.tips = tips;
		this.pSupport = pSupport;
		this.commonName = null;
		this.imgName = null;
	}
}
// Used for server search-responses
export type SearchSugg = {name: string, canonicalName: string | null};
	// Represents a search-string suggestion
export type SearchSuggResponse = {suggs: SearchSugg[], hasMore: boolean};
	// Holds search suggestions and an indication of if there was more
// Used for server info-responses
export type DescInfo = {text: string, wikiId: number, fromRedirect: boolean, fromDbp: boolean};
export type ImgInfo = {id: number, src: string, url: string, license: string, artist: string, credit: string}
export type TileInfoResponse = {
	tolNode: null | TolNode,
	descData: null | DescInfo | [DescInfo, DescInfo],
	imgData: null | ImgInfo | [ImgInfo, ImgInfo],
};

// Used by auto-mode and tutorial
export type Action =
	'expand' | 'collapse' | 'expandToView' | 'unhideAncestor' |
	'tileInfo' | 'search' | 'autoMode' | 'settings' | 'help';
//
export type UiOptions = {
	// Shared styling
	borderRadius: number, // CSS border-radius value, in px
	shadowNormal: string, // CSS box-shadow value
	shadowHighlight: string,
	shadowFocused: string,
	// Styling for App
	appBgColor: string, // CSS color
	tileAreaOffset: number, // Space between root tile and display boundary, in px
	// Styling for tiles
	headerColor: string, // CSS color
	childThresholds: [number, string][],
		// Specifies, for an increasing sequence of minimum-child-quantity values, CSS color to use
		//eg: [[1, 'green'], [10, 'orange'], [100, 'red']]
	infoIconSz: number, // px
	infoIconMargin: number, // px
	leafTilePadding: number, // px
	leafHeaderFontSz: number, // px
	nonleafBgColors: string[],
		// Specifies CSS colors to use at various tree depths
		// With N strings, tiles at depth M use the color at index M % N
	nonleafHeaderFontSz: number, // px
	nonleafHeaderColor: string, // CSS color
	nonleafHeaderBgColor: string, // CSS color
	// Styling for other components
	infoModalImgSz: number, // px
	ancestryBarBgColor: string, // CSS color
	ancestryBarImgSz: number, // px
	ancestryTileMargin: number, // px (gap between detached-ancestor tiles)
	tutorialPaneSz: number, // px
	tutorialPaneBgColor: string, // CSS color
	tutorialPaneTextColor: string, // CSS color
	// Timing related
	clickHoldDuration: number, // Time after mousedown when a click-and-hold is recognised, in ms
	tileChgDuration: number, // Transition time for tile_move/etc, in ms
	autoWaitTime: number, // Time between actions, in ms
	// Other
	useReducedTree: boolean,
	searchSuggLimit: number, // Max number of search suggestions
	jumpToSearchedNode: boolean,
	tutorialSkip: boolean,
	disabledActions: Set<Action>,
	scrollGap: number, // Size of scroll bar, in px
};

/*
 * General utility functions
 */

export type Breakpoint = 'sm' | 'md' | 'lg'; // These represent screen sizes
export function getBreakpoint(): Breakpoint {
	let w = window.innerWidth;
	if (w < 768){
		return 'sm';
	} else if (w < 1024){
		return 'md';
	} else {
		return 'lg';
	}
}

// Returns [0 ... len]
export function range(len: number): number[] {
	return [...Array(len).keys()];
}
// Returns sum of array values
export function arraySum(array: number[]): number {
	return array.reduce((x,y) => x+y);
}
// Returns an array of increasing evenly-spaced numbers from 'start' to 'end' with size 'size'
export function linspace(start: number, end: number, size: number): number[] {
	let step = (end - start) / (size - 1);
	let ar = [];
	for (let i = 0; i < size; i++){
		ar.push(start + step * i);
	}
	return ar;
}
// Returns array copy with vals clipped to within [min,max], redistributing to compensate
// Returns null on failure
export function limitVals(arr: number[], min: number, max: number): number[] | null {
	let vals = [...arr];
	let clipped = new Array(vals.length).fill(false);
	let owedChg = 0; // Stores total change made after clipping values
	while (true){
		// Clip values
		for (let i = 0; i < vals.length; i++){
			if (clipped[i]){
				continue;
			}
			if (vals[i] < min){
				owedChg += vals[i] - min;
				vals[i] = min;
				clipped[i] = true;
			} else if (vals[i] > max){
				owedChg += vals[i] - max;
				vals[i] = max;
				clipped[i] = true;
			}
		}
		if (Math.abs(owedChg) < Number.EPSILON){
			return vals;
		}
		// Compensate for changes made
		let indicesToUpdate = (owedChg > 0) ?
			range(vals.length).filter(idx => vals[idx] < max) :
			range(vals.length).filter(idx => vals[idx] > min);
		if (indicesToUpdate.length == 0){
			return null;
		}
		for (let i of indicesToUpdate){
			vals[i] += owedChg / indicesToUpdate.length;
		}
		owedChg = 0;
	}
}
// Usable to iterate through possible int arrays with ascending values in the range 0 to maxLen-1, starting with [0]
	// eg: With maxLen 3, updates [0] to [0,1], then to [0,2], then [0,1,2]
// Returns false when there is no next array
export function updateAscSeq(seq: number[], maxLen: number): boolean {
	// Try increasing last element, then preceding elements, then extending the array
	let i = seq.length - 1;
	while (true){
		if (i > 0 && seq[i] < (maxLen - 1) - (seq.length - 1 - i)){
			seq[i]++;
			return true;
		} else if (i > 0){
			i--;
		} else {
			if (seq.length < maxLen){
				seq.push(0);
				seq.splice(0, seq.length, ...range(seq.length));
				return true;
			} else {
				return false;
			}
		}
	}
}
// Given a non-empty array of non-negative weights, returns an array index chosen with weighted pseudorandomness
// Returns null if array contains all zeros
export function randWeightedChoice(weights: number[]): number | null {
	let thresholds = Array(weights.length);
	let sum = 0;
	for (let i = 0; i < weights.length; i++){
		sum += weights[i];
		thresholds[i] = sum;
	}
	let rand = Math.random();
	for (let i = 0; i < weights.length; i++){
		if (rand <= thresholds[i] / sum){
			return i;
		}
	}
	return null;
}
// Returns a string with words first-letter capitalised
export function capitalizeWords(str: string){
	str = str.replace(/\b\w/g, x => x.toUpperCase()); // '\b' matches word boundary, '\w' is like [a-zA-Z0-9_]
	str = str.replace(/(\w)'S/, '$1\'s'); // Avoid cases like "traveler's tree" -> "Traveler'S Tree"
	return str;
}
// Dynamically obtains scroll bar width
// From stackoverflow.com/questions/13382516/getting-scroll-bar-width-using-javascript
export function getScrollBarWidth(){
	// Create hidden outer div
	let outer = document.createElement('div');
	outer.style.visibility = 'hidden';
	outer.style.overflow = 'scroll';
	document.body.appendChild(outer);
	// Create inner div
	let inner = document.createElement('div');
	outer.appendChild(inner);
	// Get width difference
	let scrollBarWidth = outer.offsetWidth - inner.offsetWidth;
	// Remove temporary divs
	outer.parentNode!.removeChild(outer);
	//
	return scrollBarWidth;
}