code:
// TinyColor v1.4.1
// https://github.com/bgrins/TinyColor
// Brian Grinstead, MIT License

(function(Math) {

	var trimLeft = /^\s+/,
		trimRight = /\s+$/,
		tinyCounter = 0,
		mathRound = Math.round,
		mathMin = Math.min,
		mathMax = Math.max,
		mathRandom = Math.random;

	function tinycolor (color, opts) {

		color = (color) ? color : '';
		opts = opts || { };

		// If input is already a tinycolor, return itself
		if (color instanceof tinycolor) {
		   return color;
		}
		// If we are called as a function, call using new instead
		if (!(this instanceof tinycolor)) {
			return new tinycolor(color, opts);
		}

		var rgb = inputToRGB(color);
		this._originalInput = color,
		this._r = rgb.r,
		this._g = rgb.g,
		this._b = rgb.b,
		this._a = rgb.a,
		this._roundA = mathRound(100*this._a) / 100,
		this._format = opts.format || rgb.format;
		this._gradientType = opts.gradientType;

		// Don't let the range of [0,255] come back in [0,1].
		// Potentially lose a little bit of precision here, but will fix issues where
		// .5 gets interpreted as half of the total, instead of half of 1
		// If it was supposed to be 128, this was already taken care of by `inputToRgb`
		if (this._r < 1) { this._r = mathRound(this._r); }
		if (this._g < 1) { this._g = mathRound(this._g); }
		if (this._b < 1) { this._b = mathRound(this._b); }

		this._ok = rgb.ok;
		this._tc_id = tinyCounter++;
	}

	tinycolor.prototype = {
		isDark: function() {
			return this.getBrightness() < 128;
		},
		isLight: function() {
			return !this.isDark();
		},
		isValid: function() {
			return this._ok;
		},
		getOriginalInput: function() {
		  return this._originalInput;
		},
		getFormat: function() {
			return this._format;
		},
		getAlpha: function() {
			return this._a;
		},
		getBrightness: function() {
			//http://www.w3.org/TR/AERT#color-contrast
			var rgb = this.toRgb();
			return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
		},
		getLuminance: function() {
			//http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
			var rgb = this.toRgb();
			var RsRGB, GsRGB, BsRGB, R, G, B;
			RsRGB = rgb.r/255;
			GsRGB = rgb.g/255;
			BsRGB = rgb.b/255;

			if (RsRGB <= 0.03928) {R = RsRGB / 12.92;} else {R = Math.pow(((RsRGB + 0.055) / 1.055), 2.4);}
			if (GsRGB <= 0.03928) {G = GsRGB / 12.92;} else {G = Math.pow(((GsRGB + 0.055) / 1.055), 2.4);}
			if (BsRGB <= 0.03928) {B = BsRGB / 12.92;} else {B = Math.pow(((BsRGB + 0.055) / 1.055), 2.4);}
			return (0.2126 * R) + (0.7152 * G) + (0.0722 * B);
		},
		setAlpha: function(value) {
			this._a = boundAlpha(value);
			this._roundA = mathRound(100*this._a) / 100;
			return this;
		},
		toHsv: function() {
			var hsv = rgbToHsv(this._r, this._g, this._b);
			return { h: hsv.h * 360, s: hsv.s, v: hsv.v, a: this._a };
		},
		toHsvString: function() {
			var hsv = rgbToHsv(this._r, this._g, this._b);
			var h = mathRound(hsv.h * 360), s = mathRound(hsv.s * 100), v = mathRound(hsv.v * 100);
			return (this._a == 1) ?
			  "hsv("  + h + ", " + s + "%, " + v + "%)" :
			  "hsva(" + h + ", " + s + "%, " + v + "%, "+ this._roundA + ")";
		},
		toHsl: function() {
			var hsl = rgbToHsl(this._r, this._g, this._b);
			return { h: hsl.h * 360, s: hsl.s, l: hsl.l, a: this._a };
		},
		toHslString: function() {
			var hsl = rgbToHsl(this._r, this._g, this._b);
			var h = mathRound(hsl.h * 360), s = mathRound(hsl.s * 100), l = mathRound(hsl.l * 100);
			return (this._a == 1) ?
			  "hsl("  + h + ", " + s + "%, " + l + "%)" :
			  "hsla(" + h + ", " + s + "%, " + l + "%, "+ this._roundA + ")";
		},
		toHex: function(allow3Char) {
			return rgbToHex(this._r, this._g, this._b, allow3Char);
		},
		toHexString: function(allow3Char) {
			return '#' + this.toHex(allow3Char);
		},
		toHex8: function(allow4Char) {
			return rgbaToHex(this._r, this._g, this._b, this._a, allow4Char);
		},
		toHex8String: function(allow4Char) {
			return '#' + this.toHex8(allow4Char);
		},
		toRgb: function() {
			return { r: mathRound(this._r), g: mathRound(this._g), b: mathRound(this._b), a: this._a };
		},
		toRgbString: function() {
			return (this._a == 1) ?
			  "rgb("  + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ")" :
			  "rgba(" + mathRound(this._r) + ", " + mathRound(this._g) + ", " + mathRound(this._b) + ", " + this._roundA + ")";
		},
		toPercentageRgb: function() {
			return { r: mathRound(bound01(this._r, 255) * 100) + "%", g: mathRound(bound01(this._g, 255) * 100) + "%", b: mathRound(bound01(this._b, 255) * 100) + "%", a: this._a };
		},
		toPercentageRgbString: function() {
			return (this._a == 1) ?
			  "rgb("  + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%)" :
			  "rgba(" + mathRound(bound01(this._r, 255) * 100) + "%, " + mathRound(bound01(this._g, 255) * 100) + "%, " + mathRound(bound01(this._b, 255) * 100) + "%, " + this._roundA + ")";
		},
		toName: function() {
			if (this._a === 0) {
				return "transparent";
			}

			if (this._a < 1) {
				return false;
			}

			return hexNames[rgbToHex(this._r, this._g, this._b, true)] || false;
		},
		toFilter: function(secondColor) {
			var hex8String = '#' + rgbaToArgbHex(this._r, this._g, this._b, this._a);
			var secondHex8String = hex8String;
			var gradientType = this._gradientType ? "GradientType = 1, " : "";

			if (secondColor) {
				var s = tinycolor(secondColor);
				secondHex8String = '#' + rgbaToArgbHex(s._r, s._g, s._b, s._a);
			}

			return "progid:DXImageTransform.Microsoft.gradient("+gradientType+"startColorstr="+hex8String+",endColorstr="+secondHex8String+")";
		},
		toString: function(format) {
			var formatSet = !!format;
			format = format || this._format;

			var formattedString = false;
			var hasAlpha = this._a < 1 && this._a >= 0;
			var needsAlphaFormat = !formatSet && hasAlpha && (format === "hex" || format === "hex6" || format === "hex3" || format === "hex4" || format === "hex8" || format === "name");

			if (needsAlphaFormat) {
				// Special case for "transparent", all other non-alpha formats
				// will return rgba when there is transparency.
				if (format === "name" && this._a === 0) {
					return this.toName();
				}
				return this.toRgbString();
			}
			if (format === "rgb") {
				formattedString = this.toRgbString();
			}
			if (format === "prgb") {
				formattedString = this.toPercentageRgbString();
			}
			if (format === "hex" || format === "hex6") {
				formattedString = this.toHexString();
			}
			if (format === "hex3") {
				formattedString = this.toHexString(true);
			}
			if (format === "hex4") {
				formattedString = this.toHex8String(true);
			}
			if (format === "hex8") {
				formattedString = this.toHex8String();
			}
			if (format === "name") {
				formattedString = this.toName();
			}
			if (format === "hsl") {
				formattedString = this.toHslString();
			}
			if (format === "hsv") {
				formattedString = this.toHsvString();
			}

			return formattedString || this.toHexString();
		},
		clone: function() {
			return tinycolor(this.toString());
		},

		_applyModification: function(fn, args) {
			var color = fn.apply(null, [this].concat([].slice.call(args)));
			this._r = color._r;
			this._g = color._g;
			this._b = color._b;
			this.setAlpha(color._a);
			return this;
		},
		lighten: function() {
			return this._applyModification(lighten, arguments);
		},
		brighten: function() {
			return this._applyModification(brighten, arguments);
		},
		darken: function() {
			return this._applyModification(darken, arguments);
		},
		desaturate: function() {
			return this._applyModification(desaturate, arguments);
		},
		saturate: function() {
			return this._applyModification(saturate, arguments);
		},
		greyscale: function() {
			return this._applyModification(greyscale, arguments);
		},
		spin: function() {
			return this._applyModification(spin, arguments);
		},

		_applyCombination: function(fn, args) {
			return fn.apply(null, [this].concat([].slice.call(args)));
		},
		analogous: function() {
			return this._applyCombination(analogous, arguments);
		},
		complement: function() {
			return this._applyCombination(complement, arguments);
		},
		monochromatic: function() {
			return this._applyCombination(monochromatic, arguments);
		},
		splitcomplement: function() {
			return this._applyCombination(splitcomplement, arguments);
		},
		triad: function() {
			return this._applyCombination(triad, arguments);
		},
		tetrad: function() {
			return this._applyCombination(tetrad, arguments);
		}
	};

	// If input is an object, force 1 into "1.0" to handle ratios properly
	// String input requires "1.0" as input, so 1 will be treated as 1
	tinycolor.fromRatio = function(color, opts) {
		if (typeof color == "object") {
			var newColor = {};
			for (var i in color) {
				if (color.hasOwnProperty(i)) {
					if (i === "a") {
						newColor[i] = color[i];
					}
					else {
						newColor[i] = convertToPercentage(color[i]);
					}
				}
			}
			color = newColor;
		}

		return tinycolor(color, opts);
	};

	// Given a string or object, convert that input to RGB
	// Possible string inputs:
	//
	//     "red"
	//     "#f00" or "f00"
	//     "#ff0000" or "ff0000"
	//     "#ff000000" or "ff000000"
	//     "rgb 255 0 0" or "rgb (255, 0, 0)"
	//     "rgb 1.0 0 0" or "rgb (1, 0, 0)"
	//     "rgba (255, 0, 0, 1)" or "rgba 255, 0, 0, 1"
	//     "rgba (1.0, 0, 0, 1)" or "rgba 1.0, 0, 0, 1"
	//     "hsl(0, 100%, 50%)" or "hsl 0 100% 50%"
	//     "hsla(0, 100%, 50%, 1)" or "hsla 0 100% 50%, 1"
	//     "hsv(0, 100%, 100%)" or "hsv 0 100% 100%"
	//
	function inputToRGB(color) {

		var rgb = { r: 0, g: 0, b: 0 };
		var a = 1;
		var s = null;
		var v = null;
		var l = null;
		var ok = false;
		var format = false;

		if (typeof color == "string") {
			color = stringInputToObject(color);
		}

		if (typeof color == "object") {
			if (isValidCSSUnit(color.r) && isValidCSSUnit(color.g) && isValidCSSUnit(color.b)) {
				rgb = rgbToRgb(color.r, color.g, color.b);
				ok = true;
				format = String(color.r).substr(-1) === "%" ? "prgb" : "rgb";
			}
			else if (isValidCSSUnit(color.h) && isValidCSSUnit(color.s) && isValidCSSUnit(color.v)) {
				s = convertToPercentage(color.s);
				v = convertToPercentage(color.v);
				rgb = hsvToRgb(color.h, s, v);
				ok = true;
				format = "hsv";
			}
			else if (isValidCSSUnit(color.h) && isValidCSSUnit(color.s) && isValidCSSUnit(color.l)) {
				s = convertToPercentage(color.s);
				l = convertToPercentage(color.l);
				rgb = hslToRgb(color.h, s, l);
				ok = true;
				format = "hsl";
			}

			if (color.hasOwnProperty("a")) {
				a = color.a;
			}
		}

		a = boundAlpha(a);

		return {
			ok: ok,
			format: color.format || format,
			r: mathMin(255, mathMax(rgb.r, 0)),
			g: mathMin(255, mathMax(rgb.g, 0)),
			b: mathMin(255, mathMax(rgb.b, 0)),
			a: a
		};
	}

	// Conversion Functions
	// --------------------

	// `rgbToHsl`, `rgbToHsv`, `hslToRgb`, `hsvToRgb` modified from:
	// <http://mjijackson.com/2008/02/rgb-to-hsl-and-rgb-to-hsv-color-model-conversion-algorithms-in-javascript>

	// `rgbToRgb`
	// Handle bounds / percentage checking to conform to CSS color spec
	// <http://www.w3.org/TR/css3-color/>
	// *Assumes:* r, g, b in [0, 255] or [0, 1]
	// *Returns:* { r, g, b } in [0, 255]
	function rgbToRgb(r, g, b){
		return {
			r: bound01(r, 255) * 255,
			g: bound01(g, 255) * 255,
			b: bound01(b, 255) * 255
		};
	}

	// `rgbToHsl`
	// Converts an RGB color value to HSL.
	// *Assumes:* r, g, and b are contained in [0, 255] or [0, 1]
	// *Returns:* { h, s, l } in [0,1]
	function rgbToHsl(r, g, b) {

		r = bound01(r, 255);
		g = bound01(g, 255);
		b = bound01(b, 255);

		var max = mathMax(r, g, b), min = mathMin(r, g, b);
		var h, s, l = (max + min) / 2;

		if(max == min) {
			h = s = 0; // achromatic
		}
		else {
			var d = max - min;
			s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
			switch(max) {
				case r: h = (g - b) / d + (g < b ? 6 : 0); break;
				case g: h = (b - r) / d + 2; break;
				case b: h = (r - g) / d + 4; break;
			}

			h /= 6;
		}

		return { h: h, s: s, l: l };
	}

	// `hslToRgb`
	// Converts an HSL color value to RGB.
	// *Assumes:* h is contained in [0, 1] or [0, 360] and s and l are contained [0, 1] or [0, 100]
	// *Returns:* { r, g, b } in the set [0, 255]
	function hslToRgb(h, s, l) {
		var r, g, b;

		h = bound01(h, 360);
		s = bound01(s, 100);
		l = bound01(l, 100);

		function hue2rgb(p, q, t) {
			if(t < 0) t += 1;
			if(t > 1) t -= 1;
			if(t < 1/6) return p + (q - p) * 6 * t;
			if(t < 1/2) return q;
			if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;
			return p;
		}

		if(s === 0) {
			r = g = b = l; // achromatic
		}
		else {
			var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
			var p = 2 * l - q;
			r = hue2rgb(p, q, h + 1/3);
			g = hue2rgb(p, q, h);
			b = hue2rgb(p, q, h - 1/3);
		}

		return { r: r * 255, g: g * 255, b: b * 255 };
	}

	// `rgbToHsv`
	// Converts an RGB color value to HSV
	// *Assumes:* r, g, and b are contained in the set [0, 255] or [0, 1]
	// *Returns:* { h, s, v } in [0,1]
	function rgbToHsv(r, g, b) {

		r = bound01(r, 255);
		g = bound01(g, 255);
		b = bound01(b, 255);

		var max = mathMax(r, g, b), min = mathMin(r, g, b);
		var h, s, v = max;

		var d = max - min;
		s = max === 0 ? 0 : d / max;

		if(max == min) {
			h = 0; // achromatic
		}
		else {
			switch(max) {
				case r: h = (g - b) / d + (g < b ? 6 : 0); break;
				case g: h = (b - r) / d + 2; break;
				case b: h = (r - g) / d + 4; break;
			}
			h /= 6;
		}
		return { h: h, s: s, v: v };
	}

	// `hsvToRgb`
	// Converts an HSV color value to RGB.
	// *Assumes:* h is contained in [0, 1] or [0, 360] and s and v are contained in [0, 1] or [0, 100]
	// *Returns:* { r, g, b } in the set [0, 255]
	 function hsvToRgb(h, s, v) {

		h = bound01(h, 360) * 6;
		s = bound01(s, 100);
		v = bound01(v, 100);

		var i = Math.floor(h),
			f = h - i,
			p = v * (1 - s),
			q = v * (1 - f * s),
			t = v * (1 - (1 - f) * s),
			mod = i % 6,
			r = [v, q, p, p, t, v][mod],
			g = [t, v, v, q, p, p][mod],
			b = [p, p, t, v, v, q][mod];

		return { r: r * 255, g: g * 255, b: b * 255 };
	}

	// `rgbToHex`
	// Converts an RGB color to hex
	// Assumes r, g, and b are contained in the set [0, 255]
	// Returns a 3 or 6 character hex
	function rgbToHex(r, g, b, allow3Char) {

		var hex = [
			pad2(mathRound(r).toString(16)),
			pad2(mathRound(g).toString(16)),
			pad2(mathRound(b).toString(16))
		];

		// Return a 3 character hex if possible
		if (allow3Char && hex[0].charAt(0) == hex[0].charAt(1) && hex[1].charAt(0) == hex[1].charAt(1) && hex[2].charAt(0) == hex[2].charAt(1)) {
			return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0);
		}

		return hex.join("");
	}

	// `rgbaToHex`
	// Converts an RGBA color plus alpha transparency to hex
	// Assumes r, g, b are contained in the set [0, 255] and
	// a in [0, 1]. Returns a 4 or 8 character rgba hex
	function rgbaToHex(r, g, b, a, allow4Char) {

		var hex = [
			pad2(mathRound(r).toString(16)),
			pad2(mathRound(g).toString(16)),
			pad2(mathRound(b).toString(16)),
			pad2(convertDecimalToHex(a))
		];

		// Return a 4 character hex if possible
		if (allow4Char && hex[0].charAt(0) == hex[0].charAt(1) && hex[1].charAt(0) == hex[1].charAt(1) && hex[2].charAt(0) == hex[2].charAt(1) && hex[3].charAt(0) == hex[3].charAt(1)) {
			return hex[0].charAt(0) + hex[1].charAt(0) + hex[2].charAt(0) + hex[3].charAt(0);
		}

		return hex.join("");
	}

	// `rgbaToArgbHex`
	// Converts an RGBA color to an ARGB Hex8 string
	// Rarely used, but required for "toFilter()"
	function rgbaToArgbHex(r, g, b, a) {

		var hex = [
			pad2(convertDecimalToHex(a)),
			pad2(mathRound(r).toString(16)),
			pad2(mathRound(g).toString(16)),
			pad2(mathRound(b).toString(16))
		];

		return hex.join("");
	}

	// `equals`
	// Can be called with any tinycolor input
	tinycolor.equals = function (color1, color2) {
		if (!color1 || !color2) { return false; }
		return tinycolor(color1).toRgbString() == tinycolor(color2).toRgbString();
	};

	tinycolor.random = function() {
		return tinycolor.fromRatio({
			r: mathRandom(),
			g: mathRandom(),
			b: mathRandom()
		});
	};

	// Modification Functions
	// ----------------------
	// Thanks to less.js for some of the basics here
	// <https://github.com/cloudhead/less.js/blob/master/lib/less/functions.js>

	function desaturate(color, amount) {
		amount = (amount === 0) ? 0 : (amount || 10);
		var hsl = tinycolor(color).toHsl();
		hsl.s -= amount / 100;
		hsl.s = clamp01(hsl.s);
		return tinycolor(hsl);
	}

	function saturate(color, amount) {
		amount = (amount === 0) ? 0 : (amount || 10);
		var hsl = tinycolor(color).toHsl();
		hsl.s += amount / 100;
		hsl.s = clamp01(hsl.s);
		return tinycolor(hsl);
	}

	function greyscale(color) {
		return tinycolor(color).desaturate(100);
	}

	function lighten (color, amount) {
		amount = (amount === 0) ? 0 : (amount || 10);
		var hsl = tinycolor(color).toHsl();
		hsl.l += amount / 100;
		hsl.l = clamp01(hsl.l);
		return tinycolor(hsl);
	}

	function brighten(color, amount) {
		amount = (amount === 0) ? 0 : (amount || 10);
		var rgb = tinycolor(color).toRgb();
		rgb.r = mathMax(0, mathMin(255, rgb.r - mathRound(255 * - (amount / 100))));
		rgb.g = mathMax(0, mathMin(255, rgb.g - mathRound(255 * - (amount / 100))));
		rgb.b = mathMax(0, mathMin(255, rgb.b - mathRound(255 * - (amount / 100))));
		return tinycolor(rgb);
	}

	function darken (color, amount) {
		amount = (amount === 0) ? 0 : (amount || 10);
		var hsl = tinycolor(color).toHsl();
		hsl.l -= amount / 100;
		hsl.l = clamp01(hsl.l);
		return tinycolor(hsl);
	}

	// Spin takes a positive or negative amount within [-360, 360] indicating the change of hue.
	// Values outside of this range will be wrapped into this range.
	function spin(color, amount) {
		var hsl = tinycolor(color).toHsl();
		var hue = (hsl.h + amount) % 360;
		hsl.h = hue < 0 ? 360 + hue : hue;
		return tinycolor(hsl);
	}

	// Combination Functions
	// ---------------------
	// Thanks to jQuery xColor for some of the ideas behind these
	// <https://github.com/infusion/jQuery-xcolor/blob/master/jquery.xcolor.js>

	function complement(color) {
		var hsl = tinycolor(color).toHsl();
		hsl.h = (hsl.h + 180) % 360;
		return tinycolor(hsl);
	}

	function triad(color) {
		var hsl = tinycolor(color).toHsl();
		var h = hsl.h;
		return [
			tinycolor(color),
			tinycolor({ h: (h + 120) % 360, s: hsl.s, l: hsl.l }),
			tinycolor({ h: (h + 240) % 360, s: hsl.s, l: hsl.l })
		];
	}

	function tetrad(color) {
		var hsl = tinycolor(color).toHsl();
		var h = hsl.h;
		return [
			tinycolor(color),
			tinycolor({ h: (h + 90) % 360, s: hsl.s, l: hsl.l }),
			tinycolor({ h: (h + 180) % 360, s: hsl.s, l: hsl.l }),
			tinycolor({ h: (h + 270) % 360, s: hsl.s, l: hsl.l })
		];
	}

	function splitcomplement(color) {
		var hsl = tinycolor(color).toHsl();
		var h = hsl.h;
		return [
			tinycolor(color),
			tinycolor({ h: (h + 72) % 360, s: hsl.s, l: hsl.l}),
			tinycolor({ h: (h + 216) % 360, s: hsl.s, l: hsl.l})
		];
	}

	function analogous(color, results, slices) {
		results = results || 6;
		slices = slices || 30;

		var hsl = tinycolor(color).toHsl();
		var part = 360 / slices;
		var ret = [tinycolor(color)];

		for (hsl.h = ((hsl.h - (part * results >> 1)) + 720) % 360; --results; ) {
			hsl.h = (hsl.h + part) % 360;
			ret.push(tinycolor(hsl));
		}
		return ret;
	}

	function monochromatic(color, results) {
		results = results || 6;
		var hsv = tinycolor(color).toHsv();
		var h = hsv.h, s = hsv.s, v = hsv.v;
		var ret = [];
		var modification = 1 / results;

		while (results--) {
			ret.push(tinycolor({ h: h, s: s, v: v}));
			v = (v + modification) % 1;
		}

		return ret;
	}

	// Utility Functions
	// ---------------------

	tinycolor.mix = function(color1, color2, amount) {
		amount = (amount === 0) ? 0 : (amount || 50);

		var rgb1 = tinycolor(color1).toRgb();
		var rgb2 = tinycolor(color2).toRgb();

		var p = amount / 100;

		var rgba = {
			r: ((rgb2.r - rgb1.r) * p) + rgb1.r,
			g: ((rgb2.g - rgb1.g) * p) + rgb1.g,
			b: ((rgb2.b - rgb1.b) * p) + rgb1.b,
			a: ((rgb2.a - rgb1.a) * p) + rgb1.a
		};

		return tinycolor(rgba);
	};

	// Readability Functions
	// ---------------------
	// <http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef (WCAG Version 2)

	// `contrast`
	// Analyze the 2 colors and returns the color contrast defined by (WCAG Version 2)
	tinycolor.readability = function(color1, color2) {
		var c1 = tinycolor(color1);
		var c2 = tinycolor(color2);
		return (Math.max(c1.getLuminance(),c2.getLuminance())+0.05) / (Math.min(c1.getLuminance(),c2.getLuminance())+0.05);
	};

	// `isReadable`
	// Ensure that foreground and background color combinations meet WCAG2 guidelines.
	// The third argument is an optional Object.
	//      the 'level' property states 'AA' or 'AAA' - if missing or invalid, it defaults to 'AA';
	//      the 'size' property states 'large' or 'small' - if missing or invalid, it defaults to 'small'.
	// If the entire object is absent, isReadable defaults to {level:"AA",size:"small"}.

	// *Example*
	//    tinycolor.isReadable("#000", "#111") => false
	//    tinycolor.isReadable("#000", "#111",{level:"AA",size:"large"}) => false
	tinycolor.isReadable = function(color1, color2, wcag2) {
		var readability = tinycolor.readability(color1, color2);
		var wcag2Parms, out;

		out = false;

		wcag2Parms = validateWCAG2Parms(wcag2);
		switch (wcag2Parms.level + wcag2Parms.size) {
			case "AAsmall":
			case "AAAlarge":
				out = readability >= 4.5;
				break;
			case "AAlarge":
				out = readability >= 3;
				break;
			case "AAAsmall":
				out = readability >= 7;
				break;
		}
		return out;

	};

	// `mostReadable`
	// Given a base color and a list of possible foreground or background
	// colors for that base, returns the most readable color.
	// Optionally returns Black or White if the most readable color is unreadable.
	// *Example*
	//    tinycolor.mostReadable(tinycolor.mostReadable("#123", ["#124", "#125"],{includeFallbackColors:false}).toHexString(); // "#112255"
	//    tinycolor.mostReadable(tinycolor.mostReadable("#123", ["#124", "#125"],{includeFallbackColors:true}).toHexString();  // "#ffffff"
	//    tinycolor.mostReadable("#a8015a", ["#faf3f3"],{includeFallbackColors:true,level:"AAA",size:"large"}).toHexString(); // "#faf3f3"
	//    tinycolor.mostReadable("#a8015a", ["#faf3f3"],{includeFallbackColors:true,level:"AAA",size:"small"}).toHexString(); // "#ffffff"
	tinycolor.mostReadable = function(baseColor, colorList, args) {
		var bestColor = null;
		var bestScore = 0;
		var readability;
		var includeFallbackColors, level, size ;
		args = args || {};
		includeFallbackColors = args.includeFallbackColors ;
		level = args.level;
		size = args.size;

		for (var i= 0; i < colorList.length ; i++) {
			readability = tinycolor.readability(baseColor, colorList[i]);
			if (readability > bestScore) {
				bestScore = readability;
				bestColor = tinycolor(colorList[i]);
			}
		}

		if (tinycolor.isReadable(baseColor, bestColor, {"level":level,"size":size}) || !includeFallbackColors) {
			return bestColor;
		}
		else {
			args.includeFallbackColors=false;
			return tinycolor.mostReadable(baseColor,["#fff", "#000"],args);
		}
	};

	// Big List of Colors
	// ------------------
	// <http://www.w3.org/TR/css3-color/#svg-color>
	var names = tinycolor.names = {
		aliceblue: "f0f8ff",
		antiquewhite: "faebd7",
		aqua: "0ff",
		aquamarine: "7fffd4",
		azure: "f0ffff",
		beige: "f5f5dc",
		bisque: "ffe4c4",
		black: "000",
		blanchedalmond: "ffebcd",
		blue: "00f",
		blueviolet: "8a2be2",
		brown: "a52a2a",
		burlywood: "deb887",
		burntsienna: "ea7e5d",
		cadetblue: "5f9ea0",
		chartreuse: "7fff00",
		chocolate: "d2691e",
		coral: "ff7f50",
		cornflowerblue: "6495ed",
		cornsilk: "fff8dc",
		crimson: "dc143c",
		cyan: "0ff",
		darkblue: "00008b",
		darkcyan: "008b8b",
		darkgoldenrod: "b8860b",
		darkgray: "a9a9a9",
		darkgreen: "006400",
		darkgrey: "a9a9a9",
		darkkhaki: "bdb76b",
		darkmagenta: "8b008b",
		darkolivegreen: "556b2f",
		darkorange: "ff8c00",
		darkorchid: "9932cc",
		darkred: "8b0000",
		darksalmon: "e9967a",
		darkseagreen: "8fbc8f",
		darkslateblue: "483d8b",
		darkslategray: "2f4f4f",
		darkslategrey: "2f4f4f",
		darkturquoise: "00ced1",
		darkviolet: "9400d3",
		deeppink: "ff1493",
		deepskyblue: "00bfff",
		dimgray: "696969",
		dimgrey: "696969",
		dodgerblue: "1e90ff",
		firebrick: "b22222",
		floralwhite: "fffaf0",
		forestgreen: "228b22",
		fuchsia: "f0f",
		gainsboro: "dcdcdc",
		ghostwhite: "f8f8ff",
		gold: "ffd700",
		goldenrod: "daa520",
		gray: "808080",
		green: "008000",
		greenyellow: "adff2f",
		grey: "808080",
		honeydew: "f0fff0",
		hotpink: "ff69b4",
		indianred: "cd5c5c",
		indigo: "4b0082",
		ivory: "fffff0",
		khaki: "f0e68c",
		lavender: "e6e6fa",
		lavenderblush: "fff0f5",
		lawngreen: "7cfc00",
		lemonchiffon: "fffacd",
		lightblue: "add8e6",
		lightcoral: "f08080",
		lightcyan: "e0ffff",
		lightgoldenrodyellow: "fafad2",
		lightgray: "d3d3d3",
		lightgreen: "90ee90",
		lightgrey: "d3d3d3",
		lightpink: "ffb6c1",
		lightsalmon: "ffa07a",
		lightseagreen: "20b2aa",
		lightskyblue: "87cefa",
		lightslategray: "789",
		lightslategrey: "789",
		lightsteelblue: "b0c4de",
		lightyellow: "ffffe0",
		lime: "0f0",
		limegreen: "32cd32",
		linen: "faf0e6",
		magenta: "f0f",
		maroon: "800000",
		mediumaquamarine: "66cdaa",
		mediumblue: "0000cd",
		mediumorchid: "ba55d3",
		mediumpurple: "9370db",
		mediumseagreen: "3cb371",
		mediumslateblue: "7b68ee",
		mediumspringgreen: "00fa9a",
		mediumturquoise: "48d1cc",
		mediumvioletred: "c71585",
		midnightblue: "191970",
		mintcream: "f5fffa",
		mistyrose: "ffe4e1",
		moccasin: "ffe4b5",
		navajowhite: "ffdead",
		navy: "000080",
		oldlace: "fdf5e6",
		olive: "808000",
		olivedrab: "6b8e23",
		orange: "ffa500",
		orangered: "ff4500",
		orchid: "da70d6",
		palegoldenrod: "eee8aa",
		palegreen: "98fb98",
		paleturquoise: "afeeee",
		palevioletred: "db7093",
		papayawhip: "ffefd5",
		peachpuff: "ffdab9",
		peru: "cd853f",
		pink: "ffc0cb",
		plum: "dda0dd",
		powderblue: "b0e0e6",
		purple: "800080",
		rebeccapurple: "663399",
		red: "f00",
		rosybrown: "bc8f8f",
		royalblue: "4169e1",
		saddlebrown: "8b4513",
		salmon: "fa8072",
		sandybrown: "f4a460",
		seagreen: "2e8b57",
		seashell: "fff5ee",
		sienna: "a0522d",
		silver: "c0c0c0",
		skyblue: "87ceeb",
		slateblue: "6a5acd",
		slategray: "708090",
		slategrey: "708090",
		snow: "fffafa",
		springgreen: "00ff7f",
		steelblue: "4682b4",
		tan: "d2b48c",
		teal: "008080",
		thistle: "d8bfd8",
		tomato: "ff6347",
		turquoise: "40e0d0",
		violet: "ee82ee",
		wheat: "f5deb3",
		white: "fff",
		whitesmoke: "f5f5f5",
		yellow: "ff0",
		yellowgreen: "9acd32"
	};

	// Make it easy to access colors via `hexNames[hex]`
	var hexNames = tinycolor.hexNames = flip(names);

	// Utilities
	// ---------

	// `{ 'name1': 'val1' }` becomes `{ 'val1': 'name1' }`
	function flip(o) {
		var flipped = { };
		for (var i in o) {
			if (o.hasOwnProperty(i)) {
				flipped[o[i]] = i;
			}
		}
		return flipped;
	}

	// Return a valid alpha value [0,1] with all invalid values being set to 1
	function boundAlpha(a) {
		a = parseFloat(a);

		if (isNaN(a) || a < 0 || a > 1) {
			a = 1;
		}

		return a;
	}

	// Take input from [0, n] and return it as [0, 1]
	function bound01(n, max) {
		if (isOnePointZero(n)) { n = "100%"; }

		var processPercent = isPercentage(n);
		n = mathMin(max, mathMax(0, parseFloat(n)));

		// Automatically convert percentage into number
		if (processPercent) {
			n = parseInt(n * max, 10) / 100;
		}

		// Handle floating point rounding errors
		if ((Math.abs(n - max) < 0.000001)) {
			return 1;
		}

		// Convert into [0, 1] range if it isn't already
		return (n % max) / parseFloat(max);
	}

	// Force a number between 0 and 1
	function clamp01(val) {
		return mathMin(1, mathMax(0, val));
	}

	// Parse a base-16 hex value into a base-10 integer
	function parseIntFromHex(val) {
		return parseInt(val, 16);
	}

	// Need to handle 1.0 as 100%, since once it is a number, there is no difference between it and 1
	// <http://stackoverflow.com/questions/7422072/javascript-how-to-detect-number-as-a-decimal-including-1-0>
	function isOnePointZero(n) {
		return typeof n == "string" && n.indexOf('.') != -1 && parseFloat(n) === 1;
	}

	// Check to see if string passed in is a percentage
	function isPercentage(n) {
		return typeof n === "string" && n.indexOf('%') != -1;
	}

	// Force a hex value to have 2 characters
	function pad2(c) {
		return c.length == 1 ? '0' + c : '' + c;
	}

	// Replace a decimal with it's percentage value
	function convertToPercentage(n) {
		if (n <= 1) {
			n = (n * 100) + "%";
		}

		return n;
	}

	// Converts a decimal to a hex value
	function convertDecimalToHex(d) {
		return Math.round(parseFloat(d) * 255).toString(16);
	}
	// Converts a hex value to a decimal
	function convertHexToDecimal(h) {
		return (parseIntFromHex(h) / 255);
	}

	var matchers = (function() {

		// <http://www.w3.org/TR/css3-values/#integers>
		var CSS_INTEGER = "[-\\+]?\\d+%?";

		// <http://www.w3.org/TR/css3-values/#number-value>
		var CSS_NUMBER = "[-\\+]?\\d*\\.\\d+%?";

		// Allow positive/negative integer/number.  Don't capture the either/or, just the entire outcome.
		var CSS_UNIT = "(?:" + CSS_NUMBER + ")|(?:" + CSS_INTEGER + ")";

		// Actual matching.
		// Parentheses and commas are optional, but not required.
		// Whitespace can take the place of commas or opening paren
		var PERMISSIVE_MATCH3 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?";
		var PERMISSIVE_MATCH4 = "[\\s|\\(]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")[,|\\s]+(" + CSS_UNIT + ")\\s*\\)?";

		return {
			CSS_UNIT: new RegExp(CSS_UNIT),
			rgb: new RegExp("rgb" + PERMISSIVE_MATCH3),
			rgba: new RegExp("rgba" + PERMISSIVE_MATCH4),
			hsl: new RegExp("hsl" + PERMISSIVE_MATCH3),
			hsla: new RegExp("hsla" + PERMISSIVE_MATCH4),
			hsv: new RegExp("hsv" + PERMISSIVE_MATCH3),
			hsva: new RegExp("hsva" + PERMISSIVE_MATCH4),
			hex3: /^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,
			hex6: /^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,
			hex4: /^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,
			hex8: /^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/
		};
	})();

	// `isValidCSSUnit`
	// Take in a single string / number and check to see if it looks like a CSS unit
	// (see `matchers` above for definition).
	function isValidCSSUnit(color) {
		return !!matchers.CSS_UNIT.exec(color);
	}

	// `stringInputToObject`
	// Permissive string parsing.  Take in a number of formats, and output an object
	// based on detected format.  Returns `{ r, g, b }` or `{ h, s, l }` or `{ h, s, v}`
	function stringInputToObject(color) {

		color = color.replace(trimLeft,'').replace(trimRight, '').toLowerCase();
		var named = false;
		if (names[color]) {
			color = names[color];
			named = true;
		}
		else if (color == 'transparent') {
			return { r: 0, g: 0, b: 0, a: 0, format: "name" };
		}

		// Try to match string input using regular expressions.
		// Keep most of the number bounding out of this function - don't worry about [0,1] or [0,100] or [0,360]
		// Just return an object and let the conversion functions handle that.
		// This way the result will be the same whether the tinycolor is initialized with string or object.
		var match;
		if ((match = matchers.rgb.exec(color))) {
			return { r: match[1], g: match[2], b: match[3] };
		}
		if ((match = matchers.rgba.exec(color))) {
			return { r: match[1], g: match[2], b: match[3], a: match[4] };
		}
		if ((match = matchers.hsl.exec(color))) {
			return { h: match[1], s: match[2], l: match[3] };
		}
		if ((match = matchers.hsla.exec(color))) {
			return { h: match[1], s: match[2], l: match[3], a: match[4] };
		}
		if ((match = matchers.hsv.exec(color))) {
			return { h: match[1], s: match[2], v: match[3] };
		}
		if ((match = matchers.hsva.exec(color))) {
			return { h: match[1], s: match[2], v: match[3], a: match[4] };
		}
		if ((match = matchers.hex8.exec(color))) {
			return {
				r: parseIntFromHex(match[1]),
				g: parseIntFromHex(match[2]),
				b: parseIntFromHex(match[3]),
				a: convertHexToDecimal(match[4]),
				format: named ? "name" : "hex8"
			};
		}
		if ((match = matchers.hex6.exec(color))) {
			return {
				r: parseIntFromHex(match[1]),
				g: parseIntFromHex(match[2]),
				b: parseIntFromHex(match[3]),
				format: named ? "name" : "hex"
			};
		}
		if ((match = matchers.hex4.exec(color))) {
			return {
				r: parseIntFromHex(match[1] + '' + match[1]),
				g: parseIntFromHex(match[2] + '' + match[2]),
				b: parseIntFromHex(match[3] + '' + match[3]),
				a: convertHexToDecimal(match[4] + '' + match[4]),
				format: named ? "name" : "hex8"
			};
		}
		if ((match = matchers.hex3.exec(color))) {
			return {
				r: parseIntFromHex(match[1] + '' + match[1]),
				g: parseIntFromHex(match[2] + '' + match[2]),
				b: parseIntFromHex(match[3] + '' + match[3]),
				format: named ? "name" : "hex"
			};
		}

		return false;
	}

	function validateWCAG2Parms(parms) {
		// return valid WCAG2 parms for isReadable.
		// If input parms are invalid, return {"level":"AA", "size":"small"}
		var level, size;
		parms = parms || {"level":"AA", "size":"small"};
		level = (parms.level || "AA").toUpperCase();
		size = (parms.size || "small").toLowerCase();
		if (level !== "AA" && level !== "AAA") {
			level = "AA";
		}
		if (size !== "small" && size !== "large") {
			size = "small";
		}
		return {"level":level, "size":size};
	}

	// Node: Export function
	if (typeof module !== "undefined" && module.exports) {
		module.exports = tinycolor;
	}
	// AMD/requirejs: Define the module
	else if (typeof define === 'function' && define.amd) {
		define(function () {return tinycolor;});
	}
	// Browser: Expose to window
	else {
		window.tinycolor = tinycolor;
	}

	})(Math);


code:
/*  Jimmy: Blueprint for event structure, packaged for convenience.
 *  $event = {
 *      buffer = [] : EventNPC, refer to below
 *      schema = 1 : Integer, defines the current version on the save, useful for update tracking.
 *  }
 *
 *  EventNPC = {
 *      slot: Where the NPC is positioned in NPCList.
 *              EG: 0       $NPCList[0]
 *      time: The time the NPC was generated.
 *              EG: 805     13:48 / 1:48pm
 *      area: Where it was generated.
 *              EG: ['::Alleyways', 'eventsstreet', 'eventday', 'street8']
 *                      Passage        Widget 1      Widget 2    Widget 3
 *  }
 */

/**
 * Handles event data for NPC objects for debugging.
 * @module EventData
 */
class EventData {
    constructor() {
        this.disable = false;
    }

    Push(passage, index, time) {
        if (this.disable) return;
        V.event = ensure(V.event, {
            buffer: [],
            schema: 1
        });
        V.event.buffer.push({
            slot: index, // TODO: Remove in favour of direct insertion. (Nvm)
            time: time,
            area: [passage, ...DOL.Stack.slice(0, -1)]
        });
    }

    Pop(index) {
        if (this.disable) return;
        if (V.event) {
            V.event.buffer = V.event.buffer.filter(e => e.slot !== index); // TODO: Splice backwards instead.
            if (V.event.buffer.length === 0) {
                this.Clear();
            }
        }
    }

    Get(index) {
        if (V.event) {
            return V.event.buffer.find(e => e.slot === index);
        }
        return undefined;
    }

    GetEvery(index) {
        if (V.event) {
            return V.event.buffer.filter(e => e.slot === index);
        }
        return [];
    }

    Clear()  {
        if (this.disable) return;
        delete V.event;
    }

    Update() {
        if (V.event == undefined) {
            return;
        }
        // Check if $event contains schema
        switch (V.event.schema) {
            case 1:
                // No need for updates.
                return;
            default:
                // Update to newer schema (1 atm).
                // .event ['Farm Work', 'Farm Work', 'Farm Work', 'Farm Work']
                // .eventtime [497, 497, 497, 497]
                // .eventslot [0, 1, 2, 3]
                const event = [...V.event];
                V.event = {
                    buffer: [],
                    schema: 1
                };
                for (let i = 0; i < event.length; i++) {
                    this.Push(event[i], V.eventslot[i], V.eventtime[i])
                }
                delete V.eventtime;
                delete V.eventslot;
                return;
        }
    }

    IsSlotTaken(index) {
        if (V.event == undefined) return false;
        return V.event.buffer.some(e => e.slot === index);
    }

    get Disable() {
        return this.disable;
    }

    set Disable(value) {
        if (typeof value === 'boolean') {
            this.disable = value;
        } else {
            console.debug('EventData.disable set with unexpected data-type, requires boolean.');
        }
    }
};

// Jimmy: Potentially flawed design style, static class basically.
// But ends up being just an extended function in reality. (Not actually tho)
window.EventSystem = new EventData();

repo_name: Area/degrees

license: mit


code:
window.overlayShowHide = function (elementId) {
	var div = document.getElementById(elementId);
	if (div != undefined) {
		div.classList.toggle("hidden");
		if (elementId === "debugOverlay") {
			V.debugMenu[0] = !V.debugMenu[0];
		}
	}
}

window.overlayMenu = function (elementId, type) {
	switch (type) {
		case "debug":
			var debug = ["debugMain", "debugCharacter", "debugEvents"]
			for (var i = 0, l = debug.length; i < l; i++) {
				var div = document.getElementById(debug[i]);
				if (div != undefined) {
					V.debugMenu[1] = elementId;
					if (elementId === debug[i]) {
						div.classList.remove("hidden");
					} else {
						div.classList.add("hidden");
					}
				}
			}
			break;
	}
}

/*Sidebar swipe*/
document.addEventListener('touchstart', handleTouchStart, false);
document.addEventListener('touchmove', handleTouchMove, false);

var xDown = null;
var yDown = null;

function getTouches(evt) {
	return evt.touches ||			 // browser API
		evt.originalEvent.touches; // jQuery
}

function handleTouchStart(evt) {
	var firstTouch = getTouches(evt)[0];
	xDown = firstTouch.clientX;
	yDown = firstTouch.clientY;
};

function handleTouchMove(evt) {
	if (!xDown || !yDown) {
		return;
	}

	/**
	 * Activate the swipe only when finger near the UI Bar.
	 * 50px - +/- width of unstowed UI Bar
	 * 280px - +/- width of unstowed UI bar
	 */
	if (isUIBarStowed()) {
		if (xDown > 50) {
			return;
		}
	} else {
		if (xDown > 280) {
			return;
		}
	}

	var xUp = evt.touches[0].clientX;
	var yUp = evt.touches[0].clientY;

	var xDiff = xDown - xUp;
	var yDiff = yDown - yUp;

	if (Math.abs(xDiff) > Math.abs(yDiff)) {/*most significant*/
		if (xDiff > 0) {
			UIBar.stow();/* left swipe */
		} else {
			UIBar.unstow();/* right swipe */
		}
	} else {
		if (yDiff > 0) {
			/* up swipe */
		} else {
			/* down swipe */
		}
	}
	/* reset values */
	xDown = null;
	yDown = null;
};

function isUIBarStowed() {
	return $('#ui-bar').hasClass('stowed');
}

var disableNumberifyInVisibleElements = [
	'#passage-testing-room',
];

// Number-ify links
window.Links = window.Links || {};
Links.currentLinks = [];

function getPrettyKeyNumber(counter) {
	var str = "";

	if (counter > 30)
		str = "Ctrl + ";
	else if (counter > 20)
		str = "Alt + ";
	else if (counter > 10)
		str = "Shift + ";

	if (counter % 10 === 0)
		str += "0";
	else if (counter < 10)
		str += counter;
	else {
		var c = Math.floor(counter / 10);
		str += (counter - (10 * c)).toString();
	}

	return str;
}

$(document).on(':passagerender', function (ev) {
	Links.currentLinks = [];

	if (passage() == "GiveBirth") {
		$(ev.content).find("[type=checkbox]").on('propertychange change', function () {
			new Wikifier(null, '<<resetPregButtons>>');
			Links.generateLinkNumbers(ev.content);
		});
	}

	Links.generateLinkNumbers(ev.content);
});

Links.keyNumberMatcher = /^\([^\)]+\)/

Links.generateLinkNumbers = function generateLinkNumbers(content) {
	if (!V.numberify_enabled || !StartConfig.enableLinkNumberify)
		return;

	for (var i = 0; i < disableNumberifyInVisibleElements.length; i++) {
		if ($(content).find(disableNumberifyInVisibleElements[i]).length || $(content).is(disableNumberifyInVisibleElements[i]))
			return; // simply skip this render
	}

	// wanted to use .macro-link, but wardrobe and something else doesn't get selected, lmao
	Links.currentLinks = $(content)
		.find(".link-internal")
		.not(".no-numberify *, .no-numberify");

	$(Links.currentLinks).each(function (i, el) {
		if (Links.keyNumberMatcher.test(el.innerHTML)) {
			el.innerHTML = el.innerHTML.replace(Links.keyNumberMatcher, `(${getPrettyKeyNumber(i + 1)})`)
		} else {
			$(el).html("(" + getPrettyKeyNumber(i + 1) + ") " + $(el).html());
		}
	});
}
Links.generate = () => Links.generateLinkNumbers(document.getElementsByClassName("passage")[0] || document);

$(document).on('keyup', function (ev) {
	if (!V.numberify_enabled || !StartConfig.enableLinkNumberify || V.tempDisable)
		return;

	if (document.activeElement.tagName === "INPUT" && document.activeElement.type !== "radio"
		&& document.activeElement.type !== "checkbox")
		return;

	if ((ev.keyCode >= 48 && ev.keyCode <= 57) || (ev.keyCode >= 96 && ev.keyCode <= 105)) {
		var fixedKeyIndex = (ev.keyCode < 60 ? ev.keyCode - 48 : ev.keyCode - 96);

		var requestedLinkIndex = [
			9,
			0,
			1,
			2,
			3,
			4,
			5,
			6,
			7,
			8
		][fixedKeyIndex];

		if (ev.ctrlKey)
			requestedLinkIndex += 30;
		else if (ev.altKey)
			requestedLinkIndex += 20;
		else if (ev.shiftKey)
			requestedLinkIndex += 10;

		if ($(Links.currentLinks).length >= requestedLinkIndex + 1)
			$(Links.currentLinks[requestedLinkIndex]).click();
	}
});

var defaultSkinColorRanges = {
	"hStart": 45, "hEnd": 45,
	"sStart": 0.2, "sEnd": 0.4,
	"bStart": 4.5, "bEnd": 0.7,
};

window.skinColor = function (enabled, percent, overwrite) {
	if (enabled === "f") {
		return "";
	}

	var ranges = ensureIsArray(overwrite || defaultSkinColorRanges);
	var totalProgress = percent / 100;

	var scaledProgress = ranges.length * totalProgress;
	var rangeIndex = totalProgress === 1
		? ranges.length - 1
		: Math.floor(scaledProgress);
	var progress = totalProgress === 1
		? 1
		: scaledProgress - rangeIndex;

	var { hStart, hEnd, sStart, sEnd, bStart, bEnd } = ranges[rangeIndex];

	var hue = (hEnd - hStart) * progress + hStart;
	var saturation = (sEnd - sStart) * progress + sStart;
	var brightness = (bEnd - bStart) * progress + bStart;

	var hueCss = `hue-rotate(${hue}deg)`;
	var saturationCss = `saturate(${saturation.toFixed(2)})`;
	var brightnessCss = `brightness(${brightness.toFixed(2)})`;

	return `${hueCss} ${saturationCss} ${brightnessCss}`;
}

window.closeFeats = function (id) {
	var div1 = document.getElementById("feat-" + id);
	var div2 = document.getElementById("closeFeat-" + id);
	div1.style.display = "none";
	div2.style.display = "none";
}

window.filterFeats = function () {
	new Wikifier(null, '<<replace #featsList>><<featsList>><</replace>>');
}

window.getTimeNumber = function (t) {
	var time = new Date(t);
	var result = time.getTime();
	if (isNaN(result)) {
		return 9999999999999999;
	}
	return result;
}

window.extendStats = function () {
	V.extendedStats = !V.extendedStats;
	var captionDiv = document.getElementById('storyCaptionDiv'),
		statsDiv = document.getElementById('stats');
	if (V.extendedStats === true) {
		captionDiv.classList.add("storyCaptionDivExtended");
		statsDiv.classList.add("statsExtended");
	} else {
		captionDiv.classList.remove("storyCaptionDivExtended");
		statsDiv.classList.remove("statsExtended");
	}
	new Wikifier(null, '<<replace #stats>><<statsCaption>><</replace>>');
}

window.customColour = function (color, saturation, brightness, contrast, sepia) {
	return 'filter: hue-rotate(' + color + 'deg) saturate(' + saturation + ') brightness(' + brightness + ') contrast(' + contrast + ') sepia(' + sepia + ')';
}

window.zoom = function (size, set) {
	if (size === undefined) {
		size = document.getElementById("numberslider-input-zoom").value;
	}
	var parsedSize = parseInt(size);
	var body = document.getElementsByTagName("body")[0];
	if (parsedSize >= 50 && parsedSize <= 200 && parsedSize !== 100) {
		body.style.zoom = size + "%";
		if (set === true) {
			V.zoom = size;
		}
	} else {
		body.style.zoom = "";
		if (set === true) {
			V.zoom = 100;
		}
	}
}

// Checks if image was loaded with errors, input is the id: '#idOfImg'
window.isImageOk = function (id) {
	return jQuery(id).naturalWidth !== 0 || true;
}

window.beastTogglesCheck = function () {
	T.beastVars = [
		"bestialitydisable",
		"swarmdisable",
		"parasitedisable",
		"analpregdisable",
		"tentacledisable",
		"slimedisable",
		"voredisable",
		"spiderdisable",
		"slugdisable",
		"waspdisable",
		"beedisable",
		"lurkerdisable",
		"horsedisable",
		"plantdisable"
	];
	T.anyBeastOn = T.beastVars.some(x => V[x] == 'f');
}

window.settingsAsphyxiation = function () {
	let updateText = () => {
		let val = V.asphyxiaLvl;
		let text = null;
		switch (val) {
			case 0:
				text = "Don't touch my neck!"; break;
			case 1:
				text = "NPCs may grab you by the neck. Doesn't impede breathing."; break;
			case 2:
				text = "NPCs may try to choke you during consensual intercourse."; break;
			case 3:
				text = "NPCs may try to strangle you during non-consensual intercourse."; break;
			case 4:
				text = "NPCs will often try to strangle you during non-consensual intercourse."; break;

			default:
				text = "Error: bad value: " + val;
				val = 0;
		}
		jQuery('#numberslider-value-asphyxialvl').text('').append(text).addClass('small-description');
	};

	jQuery(document).ready(() => {
		updateText();
		jQuery('#numberslider-input-asphyxialvl').on('input change', function (e) { updateText(); });
	});
}

window.settingsNudeGenderAppearance = function () {
	let updateText = () => {
		let val = V.NudeGenderDC;
		let text = null;
		switch (val) {
			case 0:
				text = "NPCs will ignore your genitals when perceiving your gender."; break;
			case 1:
				text = "NPCs will consider your genitals when perceiving your gender."; break;
			case 2:
				text = "NPCs will judge your gender based on your genitals."; break;

			default:
				text = "Error: bad value: " + val;
				val = 0;
		}
		jQuery('#numberslider-value-nudegenderdc').text('').append(text).addClass('small-description')
		                                          .css('margin-left', '1em');
	};

	jQuery(document).ready(() => {
		updateText();
		jQuery('#numberslider-input-nudegenderdc').on('input change', function (e) { updateText(); })
		                                          .css('width', '100%');
	});
}

window.settingsBodywriting = function () {
	let updateText = () => {
		let val = V.bodywritingLvl;
		let text = null;
		switch (val) {
			case 0:
				text = "NPCs may not write on you."; break;
			case 1:
				text = "NPCs may ask to write on you."; break;
			case 2:
				text = "NPCs may forcibly write on you."; break;
			case 3:
				text = "NPCs may forcibly write on and tattoo you."; break;
			default:
				text = "Error: bad value: " + val;
				val = 0;
		}
		// delete the below code when $bodywritingdisable is fully replaced by $bodywritingLvl
		V.bodywritingdisable = "f";
		if (val == 0) V.bodywritingdisable = "t";

		jQuery('#numberslider-value-bodywritinglvl').text('').append(text).addClass('small-description');
	};

	jQuery(document).ready(() => {
		updateText();
		jQuery('#numberslider-input-bodywritinglvl').on('input change', function (e) { updateText(); });
	});
}

window.settingsNamedNpcBreastSize = function (id, persist) {
	const breastSizes = ["nipple","budding","tiny","small","pert","modest","full","large","ample","massive","huge","gigantic","enormous"];

	let updateText = () => {
		const npc = persist ? V.per_npc[T.pNPCId] : V.NPCName[T.npcId];
		const val = npc.breastsize;

		const text = breastSizes[val];

		if (val > 0) {
			npc.breastdesc = text + " breast";
			npc.breastsdesc = text + " breasts";
		} else {
			npc.breastdesc = text;
			npc.breastsdesc = text + "s";
		}

		jQuery('#numberslider-value-' + id).text(npc.breastsdesc);
	};

	jQuery(document).ready(() => {
		updateText();
		jQuery('#numberslider-input-' + id).on('input change', function (e) { updateText(); });
	});
}

window.settingsNamedNpcGenderUpdate = function () {
	let updateButtonsActive = () => {
		jQuery('[id*=radiobutton-npcname-npcidpenissize]').prop("disabled", V.NPCName[T.npcId].gender == "f");
	};

	jQuery(document).ready(() => {
		updateButtonsActive();
		jQuery('[id*=radiobutton-npcname-npcidgender]').on('change', function (e) { updateButtonsActive(); });
	});
}

window.settingsPersistentNpcGenderUpdate = function () {
	let updateButtonsActive = () => {
		jQuery('[id*=radiobutton-' + Util.slugify('$per_npc[_pNPCId].penissize') + ']').prop("disabled", V.per_npc[T.pNPCId].gender == "f");
		jQuery('[id*=radiobutton-' + Util.slugify('$per_npc[_pNPCId].pronoun') + ']').prop("disabled", V.per_npc[T.pNPCId].pronoun == "i");
	};

	jQuery(document).ready(() => {
		updateButtonsActive();
		jQuery('[id*=radiobutton-' + Util.slugify('$per_npc[_pNPCId].gender') + ']').on('change', function (e) { updateButtonsActive(); });
	});
}

window.settingsPCGenderUpdate = function () {
	let updateButtonsActive = () => {
		jQuery('[id*=radiobutton-penissize]').prop("disabled", V.player.gender == "f");
		jQuery('[id*=radiobutton-playerballsexist]').prop("disabled", V.player.gender !== "h");
		jQuery('[id*=radiobutton-background-8]').prop("disabled", V.player.gender == "h");
	};

	jQuery(document).ready(() => {
		updateButtonsActive();
		jQuery('.playergender [id*=radiobutton-playergender]').on('change', function (e) { updateButtonsActive(); });
	});
}

window.settingsDoubleAnalToggleGreyOut = function() {
    let updateButtonsActive = () => {
        jQuery('[id*=checkbox-analdoubledisable]').prop("disabled", V.analdisable == "t");
    };

    jQuery(document).ready(() => {
        updateButtonsActive();
        jQuery('[id*=checkbox-analdisable]').on('change', function (e) { updateButtonsActive(); });
    });
}


code:
function colourContainerClasses() {
	return 'hair-' + (V.haircolour || '').replace(/ /g, '-') +
		' ' + 'eye-' + (V.makeup.eyelenses != 0 ? V.makeup.eyelenses : (V.eyecolour || '')).replace(/ /g, '-') +
		' ' + 'upper-' + (V.upperwet > 100 ? 'wet' : '') + (V.worn.upper.colour_combat || V.worn.upper.colour || '').replace(/ /g, '-') +
		' ' + 'lower-' + (V.lowerwet > 100 ? 'wet' : '') + (V.worn.lower.colour_combat || V.worn.lower.colour || '').replace(/ /g, '-') +
		' ' + 'under_lower-' + (V.underlowerwet > 100 ? 'wet' : '') + (V.worn.under_lower.colour || '').replace(/ /g, '-') +
		' ' + 'under_upper-' + (V.underupperwet > 100 ? 'wet' : '') + (V.worn.under_upper.colour || '').replace(/ /g, '-') +
		' ' + 'head-' + (V.worn.head.colour_combat || V.worn.head.colour || '').replace(/ /g, '-') +
		' ' + 'face-' + (V.worn.face.colour_combat || V.worn.face.colour || '').replace(/ /g, '-') +
		' ' + 'neck-' + (V.worn.neck.colour_combat || V.worn.neck.colour || '').replace(/ /g, '-') +
		' ' + 'hands-' + (V.worn.hands.colour_combat || V.worn.hands.colour || '').replace(/ /g, '-') +
		' ' + 'legs-' + (V.worn.legs.colour_combat || V.worn.legs.colour || '').replace(/ /g, '-') +
		' ' + 'feet-' + (V.worn.feet.colour_combat || V.worn.feet.colour || '').replace(/ /g, '-') +
		' ' + 'upper_acc-' + (V.worn.upper.accessory_colour_combat || V.worn.upper.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'lower_acc-' + (V.worn.lower.accessory_colour_combat || V.worn.lower.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'under_lower_acc-' + (V.worn.under_lower.accessory_colour_combat || V.worn.under_lower.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'under_upper_acc-' + (V.worn.under_upper.accessory_colour_combat || V.worn.under_upper.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'head_acc-' + (V.worn.head.accessory_colour_combat || V.worn.head.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'face_acc-' + (V.worn.face.accessory_colour_combat || V.worn.face.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'neck_acc-' + (V.worn.neck.accessory_colour_combat || V.worn.neck.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'hands_acc-' + (V.worn.hands.accessory_colour_combat || V.worn.hands.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'legs_acc-' + (V.worn.legs.accessory_colour_combat || V.worn.legs.accessory_colour || '').replace(/ /g, '-') +
		' ' + 'feet_acc-' + (V.worn.feet.accessory_colour_combat || V.worn.feet.accessory_colour || '').replace(/ /g, '-')
}
window.colourContainerClasses = colourContainerClasses; // export function

function limitedColourContainerClasses() {
	return 'hair-' + (V.haircolour || '').replace(/ /g, '-') +
		' ' + 'eye-' + (V.makeup.eyelenses != 0 ? V.makeup.eyelenses : (V.eyecolour || '')).replace(/ /g, '-')
}
window.limitedColourContainerClasses = limitedColourContainerClasses; // export function

function debugColourContainerClasses(color) {
	return 'hair-' + (color.hair || '').replace(/ /g, '-') +
		' ' + 'eye-' + (color.eyes || '').replace(/ /g, '-') +
		' ' + 'upper-' + (color.upper[0] || '').replace(/ /g, '-') +
		' ' + 'lower-' + (color.lower[0] || '').replace(/ /g, '-') +
		' ' + 'under_lower-' + (color.under_lower[0] || '').replace(/ /g, '-') +
		' ' + 'under_upper-' + (color.under_upper[0] || '').replace(/ /g, '-') +
		' ' + 'head-' + (color.head[0] || '').replace(/ /g, '-') +
		' ' + 'face-' + (color.face[0] || '').replace(/ /g, '-') +
		' ' + 'neck-' + (color.neck[0] || '').replace(/ /g, '-') +
		' ' + 'hands-' + (color.hands[0] || '').replace(/ /g, '-') +
		' ' + 'legs-' + (color.legs[0] || '').replace(/ /g, '-') +
		' ' + 'feet-' + (color.feet[0] || '').replace(/ /g, '-') +
		' ' + 'upper_acc-' + (color.upper[1] || '').replace(/ /g, '-') +
		' ' + 'lower_acc-' + (color.lower[1] || '').replace(/ /g, '-') +
		' ' + 'under_lower_acc-' + (color.under_lower[1] || '').replace(/ /g, '-') +
		' ' + 'under_upper_acc-' + (color.under_upper[1] || '').replace(/ /g, '-') +
		' ' + 'head_acc-' + (color.head[1] || '').replace(/ /g, '-') +
		' ' + 'face_acc-' + (color.face[1] || '').replace(/ /g, '-') +
		' ' + 'neck_acc-' + (color.neck[1] || '').replace(/ /g, '-') +
		' ' + 'hands_acc-' + (color.hands[1] || '').replace(/ /g, '-') +
		' ' + 'legs_acc-' + (color.legs[1] || '').replace(/ /g, '-') +
		' ' + 'feet_acc-' + (color.feet[1] || '').replace(/ /g, '-')
}
window.debugColourContainerClasses = debugColourContainerClasses; // export function

window.getClothingCost = function (item, slot) {
	let cost = setup.clothes[slot][clothesIndex(slot, item)].cost * V.clothesPrice;

	if (setup.clothes.under_lower.findIndex(x => x.name == item.name && x.modder === item.modder) >= 0 || setup.clothes.under_upper.findIndex(x => x.name == item.name && x.modder === item.modder) >= 0)
		cost *= V.clothesPriceUnderwear;
	else if (item.type.includes('school'))
		cost *= V.clothesPriceSchool;

	// the lewder item is, the more affected by the multiplier it is
	let lewdness = Math.clamp((item.reveal - 400) / 500, 0, 1);
	let lewdCoef = 1 + (V.clothesPriceLewd - 1) * lewdness;
	cost *= lewdCoef;

	if (V.passage === "School Library Shop"){
		cost *= 1.4 + (((V.delinquency - 500) / 5000) + ((V.NPCName[V.NPCNameList.indexOf("Sydney")].love - 50) / -500))
	}

	return Math.round(cost);
}

// makes all existing specified upper/lower clothes to be over_upper/over_lower
// it assumes that over_xxx equipment slots are empty, otherwise it will overwrite anything in those slots
// use this function in version update widget when over clothes will be ready
window.convertNormalToOver = function () {
	let clothesToConvert = ['bathrobe', 'bathrobe bottom', 'peacoat', 'shadbelly coat', 'puffer jacket', 'brown leather jacket', 'black leather jacket', 'vampire jacket'];

	// function that converts a clothing item
	let convertItem = (item) => {
		console.log('converting ' + item.name);

		if (item.outfitPrimary) {
			Object.keys(item.outfitPrimary).forEach(slot => {
				if (slot == 'upper' || slot == 'lower') {
					item.outfitPrimary['over_' + slot] = item.outfitPrimary[slot];
					delete item.outfitPrimary[slot];
				}
			});
		}
		else if (item.outfitSecondary) {
			for (let i = 0; i < item.outfitSecondary.length; i += 2) {
				if (item.outfitSecondary[i] == 'upper' || item.outfitSecondary[i] == 'lower') {
					item.outfitSecondary[i] = 'over_' + item.outfitSecondary[i];
				}
			}
		}
		if (item.set == 'upper' || item.set == 'lower')
			item.set = 'over_' + item.set;

		return item;
	};

	for (let index in clothesToConvert) {
		let itemName = clothesToConvert[index];

		// convert clothing sets
		V.outfit.forEach(outf => {
			if (outf.upper == itemName) {
				outf.upper = "naked";
				outf.over_upper = itemName;
				if (outf.colors) {
					outf.colors.over_upper = outf.colors.upper;
					outf.colors.upper = [0, 0];
				}
			}
			if (outf.lower == itemName) {
				outf.lower = "naked";
				outf.over_lower = itemName;
				if (outf.colors) {
					outf.colors.over_lower = outf.colors.lower;
					outf.colors.lower = [0, 0];
				}
			}
		});

		// convert clothes in wardrobe
		for (let i = V.wardrobe.upper.length - 1; i >= 0; i--) {
			if (V.wardrobe.upper[i].name == itemName) {
				V.wardrobe.over_upper.push(convertItem(V.wardrobe.upper[i]));
				V.wardrobe.upper.splice(i, 1);
			}
		}
		for (let i = V.wardrobe.lower.length - 1; i >= 0; i--) {
			if (V.wardrobe.lower[i].name == itemName) {
				V.wardrobe.over_lower.push(convertItem(V.wardrobe.lower[i]));
				V.wardrobe.lower.splice(i, 1);
			}
		}

		// convert worn clothes
		if (V.worn.upper.name == itemName) {
			V.worn.over_upper = convertItem(V.worn.upper);
			V.worn.upper = clone(setup.clothes.upper[0]);
		}
		if (V.worn.lower.name == itemName) {
			V.worn.over_lower = convertItem(V.worn.lower);
			V.worn.lower = clone(setup.clothes.lower[0]);
		}

		// convert carried clothes
		if (V.carried.upper.name == itemName) {
			V.carried.over_upper = convertItem(V.carried.upper);
			V.carried.upper = clone(setup.clothes.upper[0]);
		}
		if (V.carried.lower.name == itemName) {
			V.carried.over_lower = convertItem(V.carried.lower);
			V.carried.lower = clone(setup.clothes.lower[0]);
		}

		// convert stripped stored clothes
		for (let i = V.store.upper.length - 1; i>= 0; i--) {
			if (V.store.upper[i].name == itemName) {
				V.store.over_upper.push(convertItem(V.store.upper[i]));
				V.store.upper.splice(i, 1);
			}
		}
		for (let i = V.store.lower.length - 1; i>= 0; i--) {
			if (V.store.lower[i].name == itemName) {
				V.store.over_lower.push(convertItem(V.store.lower[i]));
				V.store.lower.splice(i, 1);
			}
		}

		// convert try on stored
		if (V.tryOn.ownedStored.upper.name == itemName) {
			V.tryOn.ownedStored.over_upper = convertItem(V.tryOn.ownedStored.upper);
			V.tryOn.ownedStored.upper = clone(setup.clothes.upper[0]);
		}
		if (V.tryOn.ownedStored.lower.name == itemName) {
			V.tryOn.ownedStored.over_lower = convertItem(V.tryOn.ownedStored.lower);
			V.tryOn.ownedStored.lower = clone(setup.clothes.lower[0]);
		}

		// convert try on equipped
		if (V.tryOn.tryingOn.upper && V.tryOn.tryingOn.upper.name == itemName) {
			V.tryOn.tryingOn.over_upper = convertItem(V.tryOn.tryingOn.upper);
			V.tryOn.tryingOn.upper = null;
		}
		if (V.tryOn.tryingOn.lower && V.tryOn.tryingOn.lower.name == itemName) {
			V.tryOn.tryingOn.over_lower = convertItem(V.tryOn.tryingOn.lower);
			V.tryOn.tryingOn.lower = null;
		}
	}
}


code:
function copyToClipboard(textarea, data) {
	textarea.value = data;
	textarea.setAttribute("style", "");
	textarea.select();
	document.execCommand("copy");
	alert("Copied to clipboard!");
	textarea.setAttribute("style", "display:none");
}

Macro.add('canvasColoursEditor', {
	handler: function () {
		if (!Renderer.lastCall) return;
		let cheat = !!this.args[0];

		function redrawImg() {
			if (redrawImg.id) clearTimeout(redrawImg.id);
			// throttle a little to avoid immediate redraw
			redrawImg.id = setTimeout(() => {
				Wikifier.wikifyEval(' <<updatesidebarimg>>');
			}, 50);
		}

		let groups = [
			{
				name: 'Hair',
				colours: setup.colours.hair,
				default: setup.colours.hair_default,
				setVars(variable) {
					V.haircolour = variable;
					redrawImg();
				},
				exportPrefix: 'setup.colours.hair = ',
				exportSuffix: ';'
			}, {
				name: 'Eyes',
				colours: setup.colours.eyes,
				default: setup.colours.eyes_default,
				setVars(variable) {
					V.eyecolour = variable;
					redrawImg();
				},
				exportPrefix: 'setup.colours.eyes = ',
				exportSuffix: ';'
			}, {
				name: 'Clothes',
				colours: setup.colours.clothes,
				default: setup.colours.clothes_default,
				setVars(variable) {
					for (let item of Object.values(V.worn)) {
						if (item.colour !== 0) item.colour = variable;
						if (item.accessory_colour !== 0) item.accessory_colour = variable;
					}
					redrawImg();
				},
				exportPrefix: 'setup.colours.clothes = ',
				exportSuffix: ';'
			}, {
				name: 'Lipstick',
				colours: setup.colours.lipstick,
				default: setup.colours.lipstick_default,
				setVars(variable) {
					V.makeup.lipstick = variable;
					redrawImg();
				},
				exportPrefix: 'setup.colours.lipstick = ',
				exportSuffix: ';'
			}, {
				name: 'Eyeshadow',
				colours: setup.colours.eyeshadow,
				default: setup.colours.eyeshadow_default,
				setVars(variable) {
					V.makeup.eyeshadow = variable;
					redrawImg();
				},
				exportPrefix: 'setup.colours.eyeshadow = ',
				exportSuffix: ';'
			}, {
				name: 'Mascara',
				colours: setup.colours.mascara,
				default: setup.colours.mascara_default,
				setVars(variable) {
					V.makeup.mascara = variable;
					redrawImg();
				},
				exportPrefix: 'setup.colours.mascara = ',
				exportSuffix: ';'
			}
		]
		elechildren(this.output,
			cheat ? "Links will re-colour your character. Won't check for clothes' list of valid colours, use for debugging purposes only! " : "",
			element('div',
				{class: 'editorcolours'},
				groups.map(group =>
					element('div',
						[
							element('div',
								{ class: 'export-block' },
								[
									element('a', {
										onclick() {
											let textarea = this.parentElement.querySelector('textarea');
											let colours = group.colours.map(c => {
												// Make deep copy and delete defaults
												let c2 = clone(c);
												for (let k in group.default) {
													if (c2.canvasfilter[k] === group.default[k]) {
														delete c2.canvasfilter[k];
													} else if (k === "contrast") {
														if ('contrast' in c2.canvasfilter) {
															c2.canvasfilter.contrast /= group.default.contrast;
														}
													} else if (k === 'brightness') {
														if ('brightness' in c2.canvasfilter) {
															c2.canvasfilter.brightness -= group.default.brightness;
														}
													}
												}
												return c2;
											});
											copyToClipboard(textarea, group.exportPrefix +
												JSON.stringify(colours) +
												group.exportSuffix);
										}
									}, 'Export'),
									element('textarea', {style: 'display:none'})
								]
							), // div.export-block
							element('h4',
								[
									group.name,
									element('br')
								]
							),
							element('table',
								[
									element('thead', [
										element('th',''),
										element('th','Brightness'),
										element('th','Contrast'),
										element('th',''),
									]),
									element('tbody',
										group.colours.map(colour =>
											element('tr', {},
												[
													element('td', [
														eInput({
															type: 'color',
															value: colour.canvasfilter.blend,
															set(value) {
																colour.canvasfilter.blend = value;
																redrawImg();
															}
														})
													]),
													element('td', [
														eInput({
															type: 'number',
															class: 'editlayer-brightness',
															value: colour.canvasfilter.brightness,
															set(value) {
																colour.canvasfilter.brightness = value;
																redrawImg();
															},
															min: -1,
															max: +1,
															step: 0.01
														}),
													]),
													element('td', [
														eInput({
															type: 'number',
															class: 'editlayer-contrast',
															value: colour.canvasfilter.contrast,
															set(value) {
																colour.canvasfilter.contrast = value;
																redrawImg();
															},
															min: 0,
															max: +4,
															step: 0.01
														}),
													]),
													element('td', [
														cheat ? element('a', {
																onclick() {
																	group.setVars(colour.variable);
																	redrawImg();
																}
															},
															' ' + colour.name_cap)
															: (' ' + colour.name_cap)
													])
												]
											) // colour div
										) // colours (tbody children)
									) // tbody
								] // table children
							), // table
						] // group div children
					) // group div
				) // groups
			) // div flex
		) // this.output
	}
});
Macro.add('canvasLayersEditor', {
	handler: function () {
		if (!Renderer.lastCall) return;
		let layers = Renderer.lastCall[1];

		function redraw() {
			// TODO @aimozg make it work in static render mode too
			Renderer.lastAnimation.invalidateCaches();
			Renderer.lastAnimation.redraw(); // it will queue redraw on next animation frame, so shouldn't lag much
		}

		function redrawFull() {
			// TODO @aimozg make it work in static render mode too
			Renderer.lastAnimation.stop();
			Renderer.animateLayersAgain();
		}

		elechild(this.output, element('div', [
			element('button', {
				type: 'button',
				onclick() {
					let layerProps = ["name", "show", "src", "mask", "z", "alpha", "desaturate", "brightness", "blendMode", "blend", "animation", "frames", "dx", "dy", "width", "height"];
					copyToClipboard(this.parentElement.querySelector("textarea"), JSON.stringify(layers.map(layer => {
						let copy = {};
						for (let key of layerProps) {
							if (key in layer && layer.show !== false) copy[key] = layer[key];
						}
						return copy
					})))
				}
			}, 'Export'),
			element('textarea', {style: 'display:none'})
		]));
		elechild(this.output, element('table', {class: 'editorlayers'}, [
			element('thead', [
				element('tr',
					[
						element('th', 'name'),
						element('th', 'show'),
						element('th', 'src'),
						element('th', 'z'),
						element('th', 'alpha'),
						element('th', 'desaturate'),
						element('th', 'brightness'),
						element('th', 'contrast'),
						element('th', 'blendMode'),
						element('th', 'blend'),
						element('th', 'animation'),
						element('th', 'mask')
					])]),
			element('tbody',
				layers.map(layer => element('tr', [
					element('th', layer.name || ''),
					element('td', eCheckbox({
						class: 'editlayer-show',
						value: !!layer.show,
						set(value) {
							layer.show = value;
							redraw();
						}
					})),
					element('td', [
							element('a', {
								onclick() {
									delete Renderer.ImageCaches[layer.src];
									layer.src = layer.src.split('#')[0] + '#' + new Date().getTime()
									redraw();
								}
							}, '↺'),
							eInput({
								class: 'editlayer-src',
								value: layer.src.split('#')[0],
								set(value) {
									layer.src = value;
									redraw();
								}
							})
						]
					),
					element('td', eInput({
						class: 'editlayer-z',
						type: 'number',
						value: layer.z,
						set(value) {
							layer.z = value;
							redraw();
						}
					})),
					element('td', eInput({
						class: 'editlayer-alpha',
						type: 'number',
						value: layer.alpha,
						set(value) {
							layer.alpha = value;
							redraw();
						},
						min: 0,
						max: 1,
						step: 0.1
					})),
					element('td', eCheckbox({
						value: layer.desaturate,
						set(value) {
							layer.desaturate = value;
							redraw();
						},
						class: 'editlayer-desaturate'
					})),
					element('td', eInput({
						class: 'editlayer-brightness',
						type: 'number',
						value: layer.brightness,
						set(value) {
							layer.brightness = value;
							redraw();
						},
						min: -1,
						max: +1,
						step: 0.01
					})),
					element('td', eInput({
						class: 'editlayer-contrast',
						type: 'number',
						value: layer.contrast,
						set(value) {
							layer.contrast = value;
							redraw();
						},
						min: 0,
						max: +4,
						step: 0.01
					})),
					element('td', eSelect({
						class: 'editlayer-blendmode',
						items: [{
							value: '',
							text: 'none'
						}, 'hard-light', 'multiply', 'screen', 'soft-light', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn'],
						value: layer.blendMode,
						set(value) {
							layer.blendMode = value;
							redraw();
						}
					})),
					element('td', eInput({
						type: 'color',
						value: layer.blend,
						set(value) {
							layer.blend = value;
							redraw();
						},
						class: 'editlayer-blend'
					})),
					element('td', eInput({
						class: 'editlayer-animation',
						value: layer.animation,
						onchange: function () {
							layer.animation = this.value;
							redrawFull();
						}
					})),
					element('td', eInput({
						class: 'editlayer-masksrc',
						value: layer.masksrc,
						onchange: function () {
							layer.masksrc = this.value;
							redrawFull();
						}
					}))
				]))
			)
		]));
	}
})
Macro.add('canvasModelEditor', {
	handler: function () {
		let model = Renderer.lastModel;
		if (!model) return;
		let options = model.options;

		function redraw() {
			model.redraw();
		}

		let optionListeners = []; // list of functions to call when model is imported
		function updateControls() {
			for (let control of optionListeners) control();
		}

		function optionCategory(name) {
			return element('div', {class: 'optioncategory'}, name);
		}

		function optionContainer(name, editor) {
			return [
				element('label', {class: 'optionlabel', 'for': 'modeloption-' + name}, name),
				element('div', {class: 'optioneditor'}, editor)
			]
		}

		function booleanOption(name) {
			return optionContainer(name,
				eCheckbox({
					id: 'modeloption-' + name,
					value: options[name],
					set(value) {
						options[name] = value;
						redraw();
					},
					$oncreate(e) {
						optionListeners.push(() => {
							e.value = options[name]
						})
					}
				})
			);
		}

		function stringOption(name) {
			return optionContainer(name, eInput({
				id: 'modeloption-' + name,
				value: options[name],
				type: 'text',
				set(value) {
					options[name] = value;
					redraw()
				},
				$oncreate(e) {
					optionListeners.push(() => {
						e.value = options[name]
					})
				}
			}))
		}

		function numberOption(name, min, max, step, range) {
			let rangeLabel;
			if (range) {
				rangeLabel = element('label',
					{'for': 'modeloption-' + name},
					'' + options[name]
				)
			} else {
				rangeLabel = '';
			}
			return optionContainer(name, [
					eInput({
						id: 'modeloption-' + name,
						value: options[name],
						type: range ? 'range' : 'number',
						min: min,
						max: max,
						step: step,
						set(value) {
							if (rangeLabel) rangeLabel.textContent = value;
							options[name] = value;
							redraw();
						},
						$oncreate(e) {
							optionListeners.push(() => {
								e.value = options[name]
							})
						}
					}),
					rangeLabel
				]
			);
		}

		function selectOption(name, values, number) {
			return optionContainer(name,
				eSelect({
					id: 'modeloption-' + name,
					items: values,
					value: options[name],
					set(value) {
						if (number) value = +value;
						options[name] = value;
						redraw();
					},
					$oncreate(e) {
						optionListeners.push(() => {
							e.value = options[name]
						})
					}
				})
			);
		}

		let generatedOptions = model.generatedOptions();
		if (model.name !== "main") {
			elechild(this.output, element('div', [
					element('h3', 'Model options'),
					element('div', {class: 'editormodelgroups'}, [
							element('div', {class: 'editormodelgroup'},
								Object.keys(model.options)
									.filter(opt => !generatedOptions.includes(options) && opt !== 'filters')
									.map(opt => {
										let value = model.options[opt];
										switch (typeof value) {
											case 'number':
												return numberOption(opt);
											case 'boolean':
												return booleanOption(opt);
											case 'string':
											default:
												return stringOption(opt);
										}
									})
							)
						]
					)
				])
			)
			return;
		}
		let bodyWritings = ["", ...Object.keys(setup.bodywriting)];

		let hairColourOptions = [...Object.keys(setup.colours.hair_map), "custom"];
		let xhairColourOptions = ["", ...Object.keys(setup.colours.hair_map), "custom"];
		let clothesColourOptions = [...Object.keys(setup.colours.clothes_map), "custom"];
		let eyesColourOptions = [...Object.keys(setup.colours.eyes_map), "custom"];
		let lipstickColourOptions = [...Object.keys(setup.colours.lipstick_map), "", "custom"];
		let eyeshadowColourOptions = [...Object.keys(setup.colours.eyeshadow_map), "", "custom"];
		let mascaraColourOptions = [...Object.keys(setup.colours.mascara_map), "", "custom"];
		elechild(this.output, element('div', [
			element('button', {
				type: 'button',
				onclick() {
					let ocopy = {};
					let defaults = model.defaultOptions();
					for (let key of Object.keys(options)) {
						if (generatedOptions.includes(key)) continue;
						if (key === 'filters') continue;
						if (options[key] === defaults[key]) continue;
						ocopy[key] = options[key];
					}
					copyToClipboard(this.parentElement.querySelector("textarea"), JSON.stringify(ocopy))
				}
			}, 'Export'),
			element('button', {
				type: 'button',
				onclick() {
					let textarea = this.parentElement.querySelector("textarea");
					if (textarea.getAttribute('style')) {
						textarea.setAttribute('style', '');
						textarea.value = '';
						this.textContent = "Paste and click again to import";
					} else {
						let ioptions = JSON.parse(textarea.value);
						Object.assign(options, ioptions);
						model.redraw();
						updateControls();
						textarea.setAttribute('style', 'display:none');
						this.textContent = "Import";
					}
				}
			}, 'Import'),
			element('textarea', {rows: 1, style: 'display:none'})
		]));
		elechild(this.output, element('div', [
			element('h3', 'Model options'),
			element('div', {class: 'editormodelgroups'}, [
				element('div', {class: 'editormodelgroup'},
					[
						optionCategory("Group toggles"),
						booleanOption("show_face"),
						booleanOption("show_hair"),
						booleanOption("show_tanlines"),
						booleanOption("show_writings"),
						booleanOption("show_tf"),
						booleanOption("show_clothes"),
						optionCategory("Body"),
						booleanOption("mannequin"),
						selectOption("breasts", ["", "default", "cleavage"]),
						numberOption("breast_size", 1, 6, 1),
						booleanOption("crotch_visible"),
						booleanOption("crotch_exposed"),
						selectOption("penis", ["", "default", "virgin"]),
						numberOption("penis_size", -2, 4, 1),
						selectOption("penis_parasite", ["", "urchin", "slime"]),
						booleanOption("balls"),
						selectOption("nipples_parasite", ["", "urchin", "slime"]),
						selectOption("clit_parasite", ["", "urchin", "slime"]),
						selectOption("arm_left", ["none", "idle", "cover"]),
						selectOption("arm_right", ["none", "idle", "cover"]),

						optionCategory("Skin"),
						selectOption("skin_type", ["light", "medium", "dark", "gyaru", "ylight", "ymedium", "ydark", "ygyaru"]),
						numberOption("skin_tone", 0, 1, 0.01, true),
						numberOption("skin_tone_breasts", -0.01, 1, 0.01, true),
						numberOption("skin_tone_penis", -0.01, 1, 0.01, true),
						numberOption("skin_tone_swimshorts", -0.01, 1, 0.01, true),
						numberOption("skin_tone_swimsuitTop", -0.01, 1, 0.01, true),
						numberOption("skin_tone_swimsuitBottom", -0.01, 1, 0.01, true),
						numberOption("skin_tone_bikiniTop", -0.01, 1, 0.01, true),
						numberOption("skin_tone_bikiniBottom", -0.01, 1, 0.01, true),

						optionCategory("Hair"),
						selectOption("hair_colour", hairColourOptions),
						selectOption("hair_sides_type", ["", "default", "braid left", "braid right", "flat ponytail", "loose", "messy", "pigtails", "ponytail", "short", "side tail left", "side tail right", "straight", "swept left", "twin braids", "twintails", "curl", "defined curl", "neat"]),
						selectOption("hair_sides_length", ["short", "shoulder", "chest", "navel", "thighs", "feet"]),
						selectOption("hair_sides_position", ["front", "back"]),
						selectOption("hair_fringe_type", ["", "default", "thin flaps", "wide flaps", "hime", "loose", "messy", "overgrown", "ringlets", "split", "straight", "swept left", "back", "parted", "flat", "quiff", "straight curl", "ringlet curl", "curtain"]),
						selectOption("hair_fringe_length", ["short", "shoulder", "chest", "navel", "thighs", "feet"]),
						selectOption("brows_colour", xhairColourOptions),
						selectOption("pbhair_colour", xhairColourOptions),
						numberOption("pbhair_level", 0, 9, 1),
						numberOption("pbhair_strip", 0, 3, 1),
						numberOption("pbhair_balls", 0, 9, 1),

						optionCategory("Face"),
						selectOption("facestyle", ["default"]),
						booleanOption("freckles"),
						booleanOption("trauma"),
						booleanOption("blink"),
						booleanOption("eyes_half"),
						booleanOption("eyes_bloodshot"),
						selectOption("eyes_colour", eyesColourOptions),
						selectOption("brows", ["none", "top", "low", "orgasm", "mid"]),
						selectOption("mouth", ["none", "neutral", "cry", "frown", "smile"]),
						numberOption("tears", 0, 4, 1),
						numberOption("blush", 0, 5, 1),
						selectOption("lipstick_colour", lipstickColourOptions),
						selectOption("eyeshadow_colour", eyeshadowColourOptions),
						selectOption("mascara_colour", mascaraColourOptions),

						optionCategory("Misc"),
						booleanOption("upper_tucked"),
						booleanOption("hood_down")
					]),
				element('div', {class: 'editormodelgroup'},
					[
						optionCategory("Transformations"),
						selectOption("angel_wings_type", ["disabled", "hidden", "default"]),
						selectOption("angel_wing_right", ["idle", "cover"]),
						selectOption("angel_wing_left", ["idle", "cover"]),
						selectOption("angel_halo_type", ["disabled", "hidden", "default"]),
						selectOption("fallen_wings_type", ["disabled", "hidden", "default"]),
						selectOption("fallen_wing_right", ["idle", "cover"]),
						selectOption("fallen_wing_left", ["idle", "cover"]),
						selectOption("fallen_halo_type", ["disabled", "hidden", "default"]),
						selectOption("demon_wings_type", ["disabled", "hidden", "default"]),
						selectOption("demon_wings_state", ["idle", "cover", "flaunt"]),
						selectOption("demon_tail_type", ["disabled", "hidden", "default", "classic"]),
						selectOption("demon_tail_state", ["idle", "cover", "flaunt"]),
						selectOption("demon_horns_type", ["disabled", "hidden", "default", "classic"]),
						selectOption("wolf_tail_type", ["disabled", "hidden", "default", "feral"]),
						selectOption("wolf_ears_type", ["disabled", "hidden", "default", "feral"]),
						selectOption("wolf_pits_type", ["disabled", "hidden", "default"]),
						selectOption("wolf_pubes_type", ["disabled", "hidden", "default"]),
						selectOption("wolf_cheeks_type", ["disabled", "hidden", "feral"]),
						selectOption("cat_tail_type", ["disabled", "hidden", "default"]),
						selectOption("cat_ears_type", ["disabled", "hidden", "default"]),
						selectOption("cow_horns_type", ["disabled", "hidden", "default"]),
						selectOption("cow_tail_type", ["disabled", "hidden", "default"]),
						selectOption("cow_ears_type", ["disabled", "hidden", "default"]),
						selectOption("bird_wings_type", ["disabled", "hidden", "default"]),
						selectOption("bird_wing_right", ["idle", "cover"]),
						selectOption("bird_wing_left", ["idle", "cover"]),
						selectOption("bird_tail_type", ["disabled", "hidden", "default"]),
						selectOption("bird_eyes_type", ["disabled", "hidden", "default"]),
						selectOption("bird_malar_type", ["disabled", "hidden", "default"]),
						selectOption("bird_plumage_type", ["disabled", "hidden", "default"]),
						selectOption("bird_pubes_type", ["disabled", "hidden", "default"]),
						selectOption("bunny_ears_type", ["disabled", "hidden", "default"]),

						optionCategory("Body writings"),
						selectOption("writing_forehead", bodyWritings),
						selectOption("writing_left_cheek", bodyWritings),
						selectOption("writing_right_cheek", bodyWritings),
						selectOption("writing_breasts", bodyWritings),
						selectOption("writing_breasts_extra", bodyWritings),
						selectOption("writing_left_shoulder", bodyWritings),
						selectOption("writing_right_shoulder", bodyWritings),
						selectOption("writing_pubic", bodyWritings),
						selectOption("writing_left_thigh", bodyWritings),
						selectOption("writing_right_thigh", bodyWritings),

						optionCategory("Dripping fluids"),
						selectOption("drip_vaginal", ["", "Start", "VerySlow", "Slow", "Fast", "VeryFast"]),
						selectOption("drip_anal", ["", "Start", "VerySlow", "Slow", "Fast", "VeryFast"]),
						selectOption("drip_mouth", ["", "Start", "VerySlow", "Slow", "Fast", "VeryFast"]),
					]),
				element('div', {class: 'editormodelgroup'},
					[
						setup.clothes_all_slots.map(slot => [
							optionCategory("Clothes: " + slot),
							selectOption("worn_" + slot,
								Object.values(setup.clothes[slot]).map(item => ({
									value: item.index,
									text: item.name
								})),
								true
							),
							numberOption("worn_" + slot + "_alpha", 0, 1, 0.1, true),
							selectOption("worn_" + slot + "_integrity", ["tattered", "torn", "frayed", "full"]),
							selectOption("worn_" + slot + "_colour", clothesColourOptions),
							selectOption("worn_" + slot + "_acc_colour", clothesColourOptions)
						])
					]
				)])
		]))
	}
})


code:
/**
 * (Low-level API)
 * Prepare a layer to be rendered.
 *
 * See {@link CompositeLayerSpec} definition for details on options.
 *
 * Arguments are processed depending on their type:
 * * String argument is `src` option (image source)
 * * Number argument is `z` option (z-index)
 * * Object argument is full or partial  CompositeLayerSpec object
 * Leftmost arguments have most priority.
 * @example
 * <<run canvaslayer(10, 'img.png', {blendMode:'hard-light', blend:'#00ff00'});>>
 * <<run canvaslayer({z:10, src:'img.png'}, {blendMode:'hard-light', blend:'#00ff00'});>>
 */
function canvaslayer() {
	var layers = T.CanvasLayers;
	if (!layers) throw "'canvaslayer()' without 'canvasstart'"

	var theOptions = {};
	for (var i = arguments.length-1; i>=0; i--) {
		var arg = arguments[i];
		switch (typeof arg) {
			case 'object':
				theOptions = Object.assign(theOptions, arg);
				break;
			case 'string':
				theOptions.src = arg;
				break;
			case 'number':
				theOptions.z = arg;
				break;
			default:
				throw "Invalid canvaslayer() argument "+i+": "+(typeof arg);
		}
	}

	if (typeof theOptions.src !== 'string') {
		console.error(arguments);
		throw "canvaslayer() options missing 'src'"
	}
	layers.push(theOptions);
}
window.canvaslayer = canvaslayer;

// use 2x2 pixels in generated images
Renderer.pixelSize = 2;

Renderer.Stats = {
	trace: false,
	traceAnim: false,
	lastLoadTime: 0,
	lastRenderTime: 0,

	logmsgLoad: new ObservableValue(""),
	logmsgRender: new ObservableValue(""),
	logmsgAnimate: new ObservableValue(""),

	nlayers: 0,
	ncached: 0
};
Renderer.defaultListener = {
	error: function(error) {
		// strip source data
		let msg = (error.stack||error.message||(''+error))
			.replace(/\(?[^( )]*:\d+:\d+\)?/mg,'')
			.replace(/\(?eval at [\w.]+/mg,'');
		Errors.report(msg);
	},
	composeLayers: function(layers) {
		Renderer.Stats.loadErrors = 0;
		if (Renderer.Stats.trace) {
			console.log(DOL.Perflog.millitime().toFixed(3), "Composing "+layers.length+" layers...");
		}
	},
	processingStep: function(layer, processing, canvas, dt) {
		DOL.Perflog.logWidgetTime("_render:"+processing, dt);
	},
	loadError: function(layer, src) {
		// logged to console by Renderer itself
		Renderer.Stats.loadErrors++;
		Errors.report("Failed to load image "+src+" for layer "+layer);
	},
	loadingDone: function(time, layersLoaded) {
		Renderer.Stats.lastLoadTime = time;
		let msg = "Loaded "+layersLoaded+" images in " + time.toFixed(3) + ' ms';
		if (Renderer.Stats.loadErrors > 0) msg += ' ('+Renderer.Stats.loadErrors+' failed)';
		Renderer.Stats.logmsgLoad.value = msg;
		if (Renderer.Stats.trace) {
			console.log(DOL.Perflog.millitime().toFixed(3), msg);
		}
	},
	beforeRender: function(layers) {
		Renderer.Stats.nlayers = layers.length;
		Renderer.Stats.ncached = 0;
	},
	layerCacheHit: function(layer) {
		Renderer.Stats.ncached++;
	},
	renderingDone: function(time) {
		Renderer.Stats.lastRenderTime = time;
		let msg = "Rendered "+Renderer.Stats.nlayers+" layers"+
			" ("+Renderer.Stats.ncached+" cached)"+
			" in "+time.toFixed(3)+' ms'
		if (Renderer.Stats.trace) {
			console.log(DOL.Perflog.millitime().toFixed(3),msg);
		}
		Renderer.Stats.logmsgRender.value = msg;
	},
	keyframe: function(animation, keyframeIndex, keyframe) {
		if (Renderer.Stats.traceAnim) {
			console.log(DOL.Perflog.millitime().toFixed(3), "animation",animation,"keyframe",keyframeIndex,"frame",keyframe.frame,"duration",keyframe.duration);
		}
	},
	keyframeRender: function(spec, cacheHit, cacheRenderTime) {
		if (Renderer.Stats.traceAnim) {
			console.log(DOL.Perflog.millitime().toFixed(3), "KeyframeRender",spec,cacheHit?"cache hit, render time "+cacheRenderTime.toFixed(3)+" ms":"cache miss");
		}
		if (cacheHit && Renderer.Stats.logmsgAnimate) {
			Renderer.Stats.logmsgAnimate.value = "Cached keyframe rendered in "+cacheRenderTime.toFixed(3)+" ms"
		}
	},
	animationStop: function () {
		if (Renderer.Stats.traceAnim) {
			console.log(DOL.Perflog.millitime().toFixed(3), "Animation stopped");
		}
	}
}


code:
/*
 * On caching.
 *
 * I. We want to cache loaded images.
 * For that, there is Renderer.ImageCaches, "url"-><img> mapping
 *
 * II. We want to cache processed images.
 * For that, there is cachedImage and cachedProcessing in the CompositeLayer.
 * cachedProcessing is JSON string of all processing options.
 * We don't use some global "url + processing" cache because of bloat risk.
 * CanvasModel layers are not recreated on render, so rendering same model instance twice uses caching.
 *
 * III. We want to cache identical keyframes of animation, composed of multiple layers.
 * That's done in the animation code.
 *
 * IV. We want caches to persist between passages (if possible).
 * To do that we reuse same CanvasModel between passages.
 *
 * V. We still need to have separate instances of same model and don't want their caches to intersect.
 * For that, CanvasModels are cached by "slot" optional parameter.
 *
 * =======
 * Example
 * =======
 *
 * In the sidebar <<img>> widget, "main" CanvasModel is rendered using "sidebar" cache slot.
 * Whenever it is requested, it is same CanvasModel instance, so image processing is done only for changing layers.
 *
 * If in some test passage we render 10 more "main" CanvasModels without cache slot, they hold no cached layers and
 * are re-composed. (Source images are still cached globally under their url)
 */

/**
 * @typedef {object} CanvasModelLayer
 *
 * @property {boolean} [show] Show this layer, default false (if no show:true or showfn present, needs explicit <<showlayer>>). Do not use undefined/null/0/"" to hide layer!
 * @property {string} [src] Image path. Either `src` or `srcfn` is required
 * @property {number} [z] Z-index (rendering order), higher=above, lower=below. Either `z` of `zfn` is required
 * @property {number} [alpha] Layer opacity, from 0 (invisible) to 1 (opaque, default)
 * @property {boolean} [desaturate] Convert image to grayscale (before recoloring), default false
 * @property {number} [brightness] Adjust brightness, from -1 to +1 (before recoloring), default 0
 * @property {number} [contrast] Adjust contrast (before recoloring), default 1
 * @property {string} [blendMode] Recoloring mode (see docs for globalCompositeOperation; "hard-light", "multiply" and "screen" ), default none
 * @property {string|object} [blend] Color for recoloring, CSS color string or gradient spec (see model.d.ts)
 * @property {string} [masksrc] Mask image path. If present, only parts where mask is opaque will be displayed
 * @property {string} [animation] Name of animation to apply, default none
 * @property {number} [frames] Frame numbers used to display static images, array of subsprite indices. For example, if model frame count is 6 but layer has only 3 subsprites, default frames would be [0, 0, 1, 1, 2, 2].
 * @property {string[]} [filters] Names of filters that should be applied to the layer; filters themselves are taken from model options
 * @property {number} [dx] Layer X position on the image, default 0
 * @property {number} [dy] Layer Y position on the image, default 0
 * @property {number} [width] Layer subsprite width, default = model width
 * @property {number} [height] Layer subsprite width, default = model height
 *
 * The following functions can be used instead of constant properties. Their arguments are (options) where options are model options provided in render call (from _modeloptions variable for <<rendermodel>>/<<animatemodel>> widget)
 * @property {function} [showfn] (options)=>boolean Function generating `show` property. Should return boolean, do not use undefined/null/0/"" to hide layer, use of !! (double not) operator recommended.
 * @property {function} [srcfn] (options)=>string
 * @property {function} [zfn] (options)=>number
 * @property {function} [alphafn] (options)=>number
 * @property {function} [desaturatefn] (options)=>boolean
 * @property {function} [brightnessfn] (options)=>number
 * @property {function} [contrastftn] (options)=>number
 * @property {function} [blendModefn] (options)=>(string|object)
 * @property {function} [blendfn] (options)=>string
 * @property {function} [masksrcfn] (options)=>string
 * @property {function} [animationfn] (options)=>string
 * @property {function} [framesfn] (options)=>number[]
 * @property {function} [filtersfn] (options)=>string[]
 * @property {function} [dxfn] (options)=>number
 * @property {function} [dyfn] (options)=>number
 * @property {function} [widthfn] (options)=>number
 * @property {function} [heightfn] (options)=>number
 */

/**
 * @typedef {object} CanvasModelOptions
 * @property {string} name Model name, for debugging
 * @property {number} width Frame width
 * @property {number} height Frame height
 * @property {number} frames Number of frames for CSS animation
 * @property {object<string,CanvasModelLayer>} layers Layers (by name)
 * @property {function} [generatedOptions] Function ()=>string[] names of generated options
 * @property {function} [defaultOptions] Function ()=>object returning default options
 * @property {function} [preprocess] Preprocessing function (options)=>void to generate temp options
 */

// Consider doing proper class inheritance
/**
 * @property {string} name Model name, for debugging
 * @property {number} width Frame width
 * @property {number} height Frame height
 * @property {number} frames Number of frames for CSS animation
 * @property {function} defaultOptions Function ()=>object returning default options
 * @property {string[]} generatedOptions Names of generated options
 * @property {object<string,CanvasModelLayer>} layers Layers (by name)
 * @property {CanvasModelLayer[]} layerList Layers
 * @property {CanvasRenderingContext2D} canvas
 */
window.CanvasModel = class CanvasModel {
	/**
	 * @param {CanvasModelOptions} options
	 */
	constructor(options) {
		this.name = options.name;
		this.width = options.width;
		this.height = options.height;
		this.frames = options.frames || 1;
		if ('generatedOptions' in options) this.generatedOptions = options.generatedOptions;
		if ('defaultOptions' in options) this.defaultOptions = options.defaultOptions;
		if ('preprocess' in options) this.preprocess = options.preprocess;
		this.layers = clone(options.layers);
		for (let name in this.layers) {
			if (!this.layers.hasOwnProperty(name)) continue;
			let layer = this.layers[name];
			layer.name = name;
			assignDefaults(layer, {
				show: false, // By default, all layers have to be enabled manually
				brightness: 0.0,
				contrast: 1.0,
				blend: '',
				blendMode: '',
				alpha: 1.0,
				desaturate: false
			});
			layer.defaultOptions = clone(layer); // deep copy
		}
		this.layerList = Object.values(this.layers);

		// Last used options
		this.options = { filters: {} };
		this.animated = false;
		this.canvas = null;
		this.rendererListener = null;
	}

	generatedOptions() {
		return [];
	}

	defaultOptions() {
		return {
			filters: {}
		}
	}

	createCanvas(cssAnimated) {
		return Renderer.createCanvas(this.width * (cssAnimated ? this.frames : 1), this.height);
	}

	reset() {
		this.options = {};
		for (let layer of this.layerList) {
			// Reset options
			jQuery.extend(true, layer, layer.defaultOptions);
		}
	}

	showLayer(name, filters) {
		let layer = this.layers[name];
		if (!layer) {
			console.error("Layer not found: " + this.name + "/" + name);
			return;
		}
		layer.show = true;
		for (let filter of filters) {
			if (filter === null || filter === undefined) continue; // null & undefined are allowed as "empty filter"
			if (typeof filter !== 'object') {
				console.error("Invalid layer " + name + " filter " + typeof (filter), filter);
				continue;
			}
			Object.assign(layer, filter);
		}
	}

	/**
	 * Update layers according to options and render them as static image
	 * @param {CanvasRenderingContext2D} canvas Canvas to render on (can be created with {@link createCanvas})
	 * @param {object} options Options to use when rendering model
	 * @param [listener] Listener for Renderer events
	 */
	render(canvas,options,listener) {
		if (typeof options === undefined) options = this.options;
		this.canvas = canvas;
		this.options = options;
		this.listener = listener;
		this.animated = false;
		this.redraw();
	}

	/**
	 * Update layers according to options and animate them
	 * @param {CanvasRenderingContext2D} canvas Canvas to render on (can be created with {@link createCanvas})
	 * @param {object} options Options to use when rendering model
	 * @param [listener] Listener for Renderer events
	 * @return {AnimatingCanvas} AnimatingCanvas object
	 */
	animate(canvas, options, listener) {
		this.canvas = canvas;
		this.options = options;
		this.listener = listener;
		this.animated = true;
		return this.redraw();
	}

	/**
	 * Redraw the model onto same canvas
	 */
	redraw() {
		if (!this.canvas) {
			Errors.report("CanvasModel.redraw() called but model was never rendered!")
			return;
		}
		Renderer.lastModel = this;
		if (this.animated) {
			return Renderer.animateLayers(this.canvas,
				this.compile(this.options),
				this.listener,
				true);
		} else {
			return Renderer.composeLayers(this.canvas,
				this.compile(this.options),
				this.canvas.canvas.width/this.width,
				this.listener);
		}
	}

	/**
	 * Pre-process options. Typically you calculate some expression here and store them as generated options
	 * Override in subclass.
	 * @param options Model options
	 */
	preprocess(options) {
	}

	/**
	 * Compile list of layers according to options
	 * @param options Model options
	 * @return {CompositeLayerSpec[]} layers
	 */
	compile(options) {
		const debug = V.debug;
		if (!options) options = {filters: {}};
		if (!('filters' in options)) options.filters = {};
		try {
			this.preprocess(options);
		} catch (e) {
			console.error(e);
			throw "Error in model preprocessing: "+e.stack
		}
		for (let layer of this.layerList) {
			// Reset some options
			layer.brightness = layer.defaultOptions.brightness;
			layer.contrast = layer.defaultOptions.contrast;
		}

		function propeval(layer, propname) {
			if (propname !== "show" && !debug && !layer.show) {
				// Situation A:
				// layer.srcfn: () => 'img/items/' + V.item.name + '.png'
				// and if V.item is undefined layer is skipped
				// So we don't want to eval skipped layer here
				//
				// Situation B:
				// Layer is skipped by mistake.
				// We want to debug its properties and show manually
				// So we might want to eval it still
				//
				// This is why we eval all properties in debug mode, but ignore their errors
				return;
			}
			let fnkey = propname + "fn";
			if (fnkey in layer) {
				try {
					layer[propname] = layer[fnkey](options);
				} catch (e) {
					if (layer.show) {
						console.error("Error evaluating layer " + layer.name + " property " + propname,)
					}
				}
			}
		}

		for (let layer of this.layerList) {
			propeval(layer, "show");
			propeval(layer, "src");
			if (!layer.src) {
				layer.src = ''; // force string value
				layer.show = false;
			}
			propeval(layer, "z");
			if (typeof layer.z !== 'number' && layer.show !== false) console.error("Layer " + layer.name + " missing property z");
			propeval(layer, "alpha");
			propeval(layer, "blendMode");
			propeval(layer, "blend");
			propeval(layer, "desaturate");
			propeval(layer, "brightness");
			propeval(layer, "contrast");
			propeval(layer, "masksrc");
			propeval(layer, "animation");
			propeval(layer, "filters");
			propeval(layer, "dx");
			propeval(layer, "dy");
			propeval(layer, "width");
			propeval(layer, "height");
			if (layer.show !== false && layer.filters) {
				for (let filterName of layer.filters) {
					let filter = options.filters[filterName];
					if (!filter) {
						// console.warn("Layer " + layer.name + " needs filter " + filterName + " but it is not provided");
						continue;
					}
					Renderer.mergeLayerData(layer, filter, true);
				}
			}
		}
		return this.layerList;
	}
}

/**
 * @type {object<string,CanvasModelOptions>}
 */
Renderer.CanvasModels = {}
/**
 * @type {object<string,object<string,CanvasModel>>}
 */
Renderer.CanvasModelCaches = {}
/**
 * Find or create new CanvasModel.
 * @param {string} modelName CanvasModel name in Renderer.CanvasModels
 * @param {string} [slot] Cache id to speed up rendering between passages.
 * @return {CanvasModel}
 */
Renderer.locateModel = function(modelName, slot) {
	let options = Renderer.CanvasModels[modelName];
	if (!options) {
		Errors.report("Requested non-existing model "+modelName);
		return new CanvasModel({name:"empty",width:1,height:1,layers:{}});
	}
	if (!slot) {
		return new CanvasModel(options);
	} else {
		let cache = Renderer.CanvasModelCaches[modelName];
		if (!cache) {
			cache = {};
			Renderer.CanvasModelCaches[modelName] = cache;
		}
		let model = cache[slot];
		if (model) return model;
		model = new CanvasModel(options);
		cache[slot] = model;
		return model;
	}
}