diff --git a/twitch/chatguessr.js b/twitch/chatguessr.js
new file mode 100644
index 0000000..3f5f9a4
--- /dev/null
+++ b/twitch/chatguessr.js
@@ -0,0 +1,69 @@
+(function() {
+ var isOpen = false,
+ guesses = {};
+
+ function DBsetOpen(open) {
+ $.inidb.SetBoolean("chatguessr", "isOpen", "", open);
+ isOpen = open;
+ sendData('status',open);
+ }
+
+ function DBClearG() {
+ guesses = {};
+ $.inidb.SetString("chatguessr", "guesses", JSON.stringify(guesses));
+ sendData('guesses',guesses);
+ }
+
+ function DBAddG(user,position) {
+ if(guesses[user] && guesses[user].length > 0){
+ }else{
+ guesses[user] = position;
+ $.inidb.SetString("chatguessr", "guesses", JSON.stringify(guesses));
+ sendData('guesses',guesses);
+ }
+ }
+
+ function sendData(tpe, data) {
+ $.panelsocketserver.sendJSONToAll(JSON.stringify({
+ 'eventFamily': 'chatguessr',
+ 'eventType': tpe,
+ 'data': data
+ }));
+ }
+
+ $.bind('command', function(event) {
+
+ const sender = "" + event.getSender().toLowerCase(),
+ command = event.getCommand(),
+ args = event.getArgs(),
+ action = args[0];
+
+ if (command.equalsIgnoreCase('g')) {
+ if(isOpen) DBAddG(sender,args);
+ }else if (command.equalsIgnoreCase('cg')) {
+ if (!action) {
+ $.say($.whisperPrefix(sender) + $.lang.get('chatguessr.help', ' Use "!cg [open | close | ...]" to open/close the feature.'));
+ } else if (action.equalsIgnoreCase('open')) {
+ if(!isOpen) DBsetOpen(true);
+ } else if (action.equalsIgnoreCase('close')) {
+ if(isOpen) DBsetOpen(false);
+ } else if (action.equalsIgnoreCase('clear')) {
+ DBClearG();
+ } else if (action.equalsIgnoreCase('timer')) {
+ sendData(action, new Date(Date.now().getTime()+value*1000*60));
+ } else {
+ $.say($.whisperPrefix(sender) + $.lang.get('chatguessr.help'));
+ }
+ }
+ });
+
+ $.bind('initReady', function() {
+ $.registerChatCommand('./custom/custom/chatguessr.js', 'cg');
+ $.registerChatCommand('./custom/custom/chatguessr.js', 'g',7);
+
+ $.registerChatSubcommand('cg', 'open', 2);
+ $.registerChatSubcommand('cg', 'close', 2);
+ $.registerChatSubcommand('cg', 'clear', 2);
+ });
+
+})();
\ No newline at end of file
diff --git a/web/chatguessr/SRC_CG.js b/web/chatguessr/SRC_CG.js
new file mode 100644
index 0000000..0b50d24
--- /dev/null
+++ b/web/chatguessr/SRC_CG.js
@@ -0,0 +1,1730 @@
+let deps = {
+ "axios": "^0.22.0",
+ "codegrid-js": "git+https://github.com/Tzhf/codegrid-js.git",
+ "dotenv": "^8.2.0",
+ "electron-store": "^8.0.1",
+ "electron-updater": "^4.3.9",
+ "jquery": "^3.5.1",
+ "tmi.js": "^1.8.5"
+ }
+
+const Game = require("./Classes/Game");
+const GameHelper = require("./utils/GameHelper");
+
+const tmi = require("./Classes/tmi");
+
+const game = new Game();
+
+class GameHandler {
+ constructor(win, settingsWindow) {
+ this.win = win;
+ this.settingsWindow = settingsWindow;
+ this.initTmi();
+ this.init();
+ }
+
+ init = () => {
+ game.init(this.win, settings);
+
+ // Browser Listening
+ this.win.webContents.on("did-navigate-in-page", (e, url) => {
+ if (GameHelper.isGameURL(url)) {
+ game.start(url, settings.isMultiGuess).then(() => {
+ this.win.webContents.send("game-started", game.isMultiGuess);
+ TMI.action(`${game.round == 1 ? "🌎 A new seed of " + game.mapName : "🌎 Round " + game.round} has started`);
+ openGuesses();
+ });
+ } else {
+ game.outGame();
+ this.win.webContents.send("game-quitted");
+ }
+ });
+
+ this.win.webContents.on("did-frame-finish-load", () => {
+ if (!game.isInGame) return;
+ this.win.webContents.send("refreshed-in-game", settings.noCompass);
+ // Checks and update seed when the game has refreshed
+ // update the current location if it was skipped
+ // if the streamer has guessed returns scores
+ game.refreshSeed().then((scores) => {
+ if (scores) showResults(scores.location, scores.scores);
+ });
+
+ this.win.webContents.executeJavaScript(`
+ window.nextRoundBtn = document.querySelector('[data-qa="close-round-result"]');
+ if(window.nextRoundBtn) {
+ nextRoundBtn.addEventListener("click", () => {
+ nextRoundBtn.setAttribute('disabled', 'disabled');
+ ipcRenderer.send('next-round-click');
+ });
+ }
+ `);
+ });
+
+ const showResults = (location, scores) => {
+ const round = game.seed.state === "finished" ? game.round : game.round - 1;
+ this.win.webContents.send("show-round-results", round, location, scores);
+ TMI.action(`🌎 Round ${round} has finished. Congrats ${GameHelper.toEmojiFlag(scores[0].flag)} ${scores[0].username} !`);
+ };
+
+ ipcMain.on("next-round-click", () => nextRound());
+
+ const nextRound = () => {
+ game.nextRound();
+ if (game.seed.state === "finished") {
+ processTotalScores();
+ } else {
+ this.win.webContents.send("next-round", game.isMultiGuess);
+ TMI.action(`🌎 Round ${game.round} has started`);
+ openGuesses();
+ }
+ };
+
+ TMI.client.on("guess", async (from, userstate, message, self) => {
+ const msg = message.split("!g")[1].trim();
+ if (!GameHelper.isCoordinates(msg)) return;
+
+ const location = { lat: parseFloat(msg.split(",")[0]), lng: parseFloat(msg.split(",")[1]) };
+ game.handleUserGuess(userstate, location).then((res) => {
+ if (res === "alreadyGuessed") return TMI.say(`${userstate["display-name"]} you already guessed`);
+ const { user, guess } = res;
+ this.win.webContents.send("render-guess", guess, game.nbGuesses);
+ if (settings.showHasGuessed) return TMI.say(`${GameHelper.toEmojiFlag(user.flag)} ${userstate["display-name"]} guessed`);
+
+ });
+ });
+}
+
+module.exports = GameHandler;
+
+const GameHandler = require("./GameHandler");
+
+const Scoreboard = require("./Classes/Scoreboard");
+const Store = require("./utils/Store");
+
+window.addEventListener("DOMContentLoaded", () => {
+ window.ipcRenderer = require("electron").ipcRenderer;
+ window.$ = window.jQuery = require("jquery");
+ window.MAP = null;
+
+ hijackMap();
+
+ const head = document.getElementsByTagName("head")[0];
+
+
+
+ const scoreboardContainer = document.createElement("div");
+ scoreboardContainer.setAttribute("id", "scoreboardContainer");
+ scoreboardContainer.innerHTML = `
+
+
+
+
+
+ # |
+ Player |
+ Streak |
+ Distance |
+ Score |
+
+
+
+
+
`;
+ document.body.appendChild(scoreboardContainer);
+
+
+
+ const init = () => {
+ const markerRemover = document.createElement("style");
+ markerRemover.innerHTML = ".map-pin{display:none}";
+
+ const settingsIcon = document.createElement("div");
+ settingsIcon.setAttribute("title", "Settings (ctrl+p)");
+ settingsIcon.id = "settingsIcon";
+ settingsIcon.innerHTML = "⚙️";
+ settingsIcon.addEventListener("click", () => {
+ ipcRenderer.send("openSettings");
+ });
+ document.body.appendChild(settingsIcon);
+
+ const scoreboard = new Scoreboard();
+
+ const showScoreboard = document.createElement("div");
+ showScoreboard.setAttribute("title", "Show scoreboard");
+ showScoreboard.id = "showScoreboard";
+ showScoreboard.innerHTML = "👁️🗨️";
+ showScoreboard.addEventListener("click", () => {
+ scoreboard.setVisibility();
+ });
+
+ ipcRenderer.on("game-started", (e, isMultiGuess) => {
+ document.body.appendChild(showScoreboard);
+ scoreboard.checkVisibility();
+ scoreboard.reset(isMultiGuess);
+ });
+
+ ipcRenderer.on("refreshed-in-game", (e, noCompass) => {
+ document.body.appendChild(showScoreboard);
+ scoreboard.checkVisibility();
+ drParseNoCompass(noCompass);
+ });
+
+ ipcRenderer.on("game-quitted", () => {
+ scoreboard.hide();
+ if ($("#showScoreboard")) $("#showScoreboard").remove();
+ markerRemover.remove();
+ clearMarkers();
+ });
+
+ ipcRenderer.on("render-guess", (e, guess, nbGuesses) => {
+ scoreboard.setTitle(`GUESSES (${nbGuesses})`);
+ scoreboard.renderGuess(guess);
+ });
+
+ ipcRenderer.on("render-multiguess", (e, guesses, nbGuesses) => {
+ scoreboard.setTitle(`GUESSES (${nbGuesses})`);
+ scoreboard.renderMultiGuess(guesses);
+ });
+
+ ipcRenderer.on("pre-round-results", () => document.body.appendChild(markerRemover));
+
+ ipcRenderer.on("show-round-results", (e, round, location, scores) => {
+ scoreboard.show();
+ scoreboard.setTitle(`ROUND ${round} RESULTS`);
+ scoreboard.displayScores(scores);
+ scoreboard.showSwitch(false);
+ populateMap(location, scores);
+ });
+
+ ipcRenderer.on("show-final-results", (e, totalScores) => {
+ document.body.appendChild(markerRemover);
+ scoreboard.show();
+ scoreboard.setTitle("HIGHSCORES");
+ scoreboard.showSwitch(false);
+ scoreboard.displayScores(totalScores, true);
+ clearMarkers();
+ });
+
+ ipcRenderer.on("next-round", (e, isMultiGuess) => {
+ scoreboard.checkVisibility();
+ scoreboard.reset(isMultiGuess);
+ scoreboard.showSwitch(true);
+ setTimeout(() => {
+ markerRemover.remove();
+ clearMarkers();
+ }, 1000);
+ });
+
+ ipcRenderer.on("switch-on", () => scoreboard.switchOn(true));
+ ipcRenderer.on("switch-off", () => scoreboard.switchOn(false));
+
+ ipcRenderer.on("game-settings-change", (e, noCompass) => drParseNoCompass(noCompass));
+ };
+});
+
+let markers = [];
+let polylines = [];
+function populateMap(location, scores) {
+ const infowindow = new google.maps.InfoWindow();
+ const icon = {
+ path: `M13.04,41.77c-0.11-1.29-0.35-3.2-0.99-5.42c-0.91-3.17-4.74-9.54-5.49-10.79c-3.64-6.1-5.46-9.21-5.45-12.07
+ c0.03-4.57,2.77-7.72,3.21-8.22c0.52-0.58,4.12-4.47,9.8-4.17c4.73,0.24,7.67,3.23,8.45,4.07c0.47,0.51,3.22,3.61,3.31,8.11
+ c0.06,3.01-1.89,6.26-5.78,12.77c-0.18,0.3-4.15,6.95-5.1,10.26c-0.64,2.24-0.89,4.17-1,5.48C13.68,41.78,13.36,41.78,13.04,41.77z
+ `,
+ fillColor: "#de3e3e",
+ fillOpacity: 0.7,
+ scale: 1.2,
+ strokeColor: "#000000",
+ strokeWeight: 1,
+ anchor: new google.maps.Point(14, 43),
+ labelOrigin: new google.maps.Point(13.5, 15),
+ };
+
+ const locationMarker = new google.maps.Marker({
+ position: location,
+ url: `http://maps.google.com/maps?q=&layer=c&cbll=${location.lat},${location.lng}`,
+ icon: icon,
+ map: MAP,
+ });
+ google.maps.event.addListener(locationMarker, "click", () => {
+ window.open(locationMarker.url, "_blank");
+ });
+ markers.push(locationMarker);
+
+ icon.scale = 1;
+ scores.forEach((score, index) => {
+ const color = index == 0 ? "#E3BB39" : index == 1 ? "#C9C9C9" : index == 2 ? "#A3682E" : score.color;
+ icon.fillColor = color;
+
+ const guessMarker = new google.maps.Marker({
+ position: score.position,
+ icon: icon,
+ map: MAP,
+ label: { color: "#000", fontWeight: "bold", fontSize: "16px", text: `${index + 1}` },
+ });
+ google.maps.event.addListener(guessMarker, "mouseover", () => {
+ infowindow.setContent(`
+
+ ${score.flag ? `` : ""}${score.username}
+ ${score.distance >= 1 ? parseFloat(score.distance.toFixed(1)) + "km" : parseInt(score.distance * 1000) + "m"}
+ ${score.score}
+
+ `);
+ infowindow.open(MAP, guessMarker);
+ });
+ google.maps.event.addListener(guessMarker, "mouseout", () => {
+ infowindow.close();
+ });
+ markers.push(guessMarker);
+
+ polylines.push(
+ new google.maps.Polyline({
+ strokeColor: color,
+ strokeWeight: 4,
+ strokeOpacity: 0.6,
+ geodesic: true,
+ map: MAP,
+ path: [score.position, location],
+ })
+ );
+ });
+}
+
+function clearMarkers() {
+ while (markers[0]) {
+ markers.pop().setMap(null);
+ }
+ while (polylines[0]) {
+ polylines.pop().setMap(null);
+ }
+}
+
+function hijackMap() {
+ const MAPS_API_URL = "https://maps.googleapis.com/maps/api/js?";
+ const GOOGLE_MAPS_PROMISE = new Promise((resolve, reject) => {
+ let scriptObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node.tagName === "SCRIPT" && node.src.startsWith(MAPS_API_URL)) {
+ node.onload = () => {
+ scriptObserver.disconnect();
+ scriptObserver = undefined;
+ resolve();
+ };
+ }
+ }
+ }
+ });
+
+ let bodyDone = false;
+ let headDone = false;
+
+ new MutationObserver((_, observer) => {
+ if (!bodyDone && document.body) {
+ if (scriptObserver) {
+ scriptObserver.observe(document.body, {
+ childList: true,
+ });
+ bodyDone = true;
+ }
+ }
+ if (!headDone && document.head) {
+ if (scriptObserver) {
+ scriptObserver.observe(document.head, {
+ childList: true,
+ });
+ headDone = true;
+ }
+ }
+ if (headDone && bodyDone) {
+ observer.disconnect();
+ }
+ }).observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+ });
+
+ function runAsClient(f) {
+ const script = document.createElement("script");
+ script.type = "text/javascript";
+ script.text = `(${f.toString()})()`;
+ document.body.appendChild(script);
+ }
+
+ GOOGLE_MAPS_PROMISE.then(() => {
+ runAsClient(() => {
+ const google = window.google;
+ const isGamePage = () => location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/");
+ const onMapUpdate = (map) => {
+ try {
+ if (!isGamePage()) return;
+ MAP = map;
+ } catch (error) {
+ console.error("GeoguessrHijackMap Error:", error);
+ }
+ };
+
+ const oldMap = google.maps.Map;
+ google.maps.Map = Object.assign(
+ function (...args) {
+ const res = oldMap.apply(this, args);
+ this.addListener("idle", () => {
+ if (MAP != null) return;
+ onMapUpdate(this);
+ });
+ return res;
+ },
+ {
+ prototype: Object.create(oldMap.prototype),
+ }
+ );
+ });
+ });
+}
+
+function drParseNoCompass(noCompass) {
+ const style = document.getElementById("noCompass");
+ if (noCompass) {
+ if (!style) {
+ const style = document.createElement("style");
+ style.id = "noCompass";
+ style.innerHTML = ".compass { display: none }.game-layout__compass{display: none}";
+ document.head.appendChild(style);
+ }
+ } else {
+ if (style) style.remove();
+ }
+}
+
+function drParseNoCar() {
+ if (!noCar) return;
+
+ const OPTIONS = { colorR: 0.5, colorG: 0.5, colorB: 0.5 };
+ const vertexOld =
+ "const float f=3.1415926;varying vec3 a;uniform vec4 b;attribute vec3 c;attribute vec2 d;uniform mat4 e;void main(){vec4 g=vec4(c,1);gl_Position=e*g;a=vec3(d.xy*b.xy+b.zw,1);a*=length(c);}";
+ const fragOld =
+ "precision highp float;const float h=3.1415926;varying vec3 a;uniform vec4 b;uniform float f;uniform sampler2D g;void main(){vec4 i=vec4(texture2DProj(g,a).rgb,f);gl_FragColor=i;}";
+ const vertexNew = `
+ const float f=3.1415926;
+ varying vec3 a;
+ varying vec3 potato;
+ uniform vec4 b;
+ attribute vec3 c;
+ attribute vec2 d;
+ uniform mat4 e;
+ void main(){
+ vec4 g=vec4(c,1);
+ gl_Position=e*g;
+ a = vec3(d.xy * b.xy + b.zw,1);
+ a *= length(c);
+ potato = vec3(d.xy, 1.0) * length(c);
+ }
+ `;
+
+ const fragNew = `
+ precision highp float;
+ const float h=3.1415926;
+ varying vec3 a;
+ varying vec3 potato;
+ uniform vec4 b;
+ uniform float f;
+ uniform sampler2D g;
+ void main(){
+
+ vec2 aD = potato.xy / a.z;
+ float thetaD = aD.y;
+
+ float thresholdD1 = 0.6;
+ float thresholdD2 = 0.7;
+
+ float x = aD.x;
+ float y = abs(4.0*x - 2.0);
+ float phiD = smoothstep(0.0, 1.0, y > 1.0 ? 2.0 - y : y);
+
+ vec4 i = vec4(thetaD > mix(thresholdD1, thresholdD2, phiD)
+ ? vec3(float(${OPTIONS.colorR}), float(${OPTIONS.colorG}), float(${OPTIONS.colorB})) // texture2DProj(g,a).rgb * 0.25
+ : texture2DProj(g,a).rgb,f);
+ gl_FragColor=i;
+ }
+ `;
+
+ function installShaderSource(ctx) {
+ const g = ctx.shaderSource;
+ function shaderSource() {
+ if (typeof arguments[1] === "string") {
+ let glsl = arguments[1];
+ if (glsl === vertexOld) glsl = vertexNew;
+ else if (glsl === fragOld) glsl = fragNew;
+ return g.call(this, arguments[0], glsl);
+ }
+ return g.apply(this, arguments);
+ }
+ shaderSource.bestcity = "bintulu";
+ ctx.shaderSource = shaderSource;
+ }
+
+ function installGetContext(el) {
+ const g = el.getContext;
+ el.getContext = function () {
+ if (arguments[0] === "webgl" || arguments[0] === "webgl2") {
+ const ctx = g.apply(this, arguments);
+ if (ctx && ctx.shaderSource && ctx.shaderSource.bestcity !== "bintulu") {
+ installShaderSource(ctx);
+ }
+ return ctx;
+ }
+ return g.apply(this, arguments);
+ };
+ }
+
+ const f = document.createElement;
+ document.createElement = function () {
+ if (arguments[0] === "canvas" || arguments[0] === "CANVAS") {
+ const el = f.apply(this, arguments);
+ installGetContext(el);
+ return el;
+ }
+ return f.apply(this, arguments);
+ };
+}
+const path = require("path");
+require("dotenv").config({ path: path.join(__dirname, "../../.env") });
+
+const axios = require("axios");
+
+const CG = require("codegrid-js").CodeGrid();
+const countryCodes = require("./countryCodes");
+const countryCodesNames = require("./countryCodesNames");
+
+class GameHelper {
+ /**
+ * Checks if '/game/' is in the URL
+ * @param {string} url Game URL
+ * @return {boolean}
+ */
+ static isGameURL = (url) => url.includes("/game/");
+
+ /**
+ * Gets the Game ID from a game URL
+ * Checks if ID is 16 characters in length
+ * @param {string} url Game URL
+ * @return {string|boolean} id or false
+ */
+ static getGameId = (url) => {
+ const id = url.substring(url.lastIndexOf("/") + 1);
+ if (id.length == 16) {
+ return id;
+ } else {
+ return false;
+ }
+ };
+
+ /**
+ * Fetch a game seed
+ * @param {string} url
+ * @return {Promise} Seed Promise
+ */
+ static fetchSeed = async (url) => {
+ return axios
+ .get(`https://www.geoguessr.com/api/v3/games/${url.substring(url.lastIndexOf("/") + 1)}`)
+ .then((res) => res.data)
+ .catch((error) => console.log(error));
+ };
+
+ /**
+ * Returns a country code
+ * @param {Object} location {lat, lng}
+ * @return {Promise} Country code Promise
+ */
+ static getCountryCode = async (location) => {
+ return axios
+ .get(`https://api.bigdatacloud.net/data/reverse-geocode?latitude=${location.lat}&longitude=${location.lng}&key=${process.env.BDC_KEY}`)
+ .then((res) => countryCodes[res.data.countryCode])
+ .catch((error) => {
+ // if BDC returns an error use CodeGrid
+ return new Promise((resolve, reject) => {
+ CG.getCode(location.lat, location.lng, (error, code) => {
+ resolve(code);
+ reject(new Error(error));
+ });
+ }).then((code) => countryCodes[code.toUpperCase()]);
+ });
+ };
+
+ /**
+ * Returns a country code
+ * It uses CodeGrid first and then BDC if needed
+ * @param {Object} location {lat, lng}
+ * @return {Promise} Country code Promise
+ */
+ static getCountryCodeLocally = async (location) => {
+ return new Promise((resolve, reject) => {
+ let coordinates = this.getSurroundings(location);
+ let promises = [];
+ coordinates.forEach((coord) => {
+ promises.push(this.getCountryCG(coord));
+ });
+ Promise.all(promises).then((values) => {
+ let unique = new Set(values);
+ if (unique.size === 1) {
+ console.log(unique.values().next().value);
+ } else {
+ this.getCountryBDC(location).then((data) => resolve(data));
+ }
+ });
+ });
+ };
+
+ /**
+ * Returns a country code (Only using BDC)
+ * Do not use externally - Used by getCountryCodeLocally
+ * Ultimately we will call our own API here and remove/
+ * replace getCountryCode
+ * @param {Object} location {lat, lng}
+ * @return {Promise} Country code Promise
+ */
+ static getCountryBDC = async (location) => {
+ return axios
+ .get(`https://api.bigdatacloud.net/data/reverse-geocode?latitude=${location.lat}&longitude=${location.lng}&key=${process.env.BDC_KEY}`)
+ .then((res) => countryCodes[res.data.countryCode])
+ .catch((error) => error);
+ };
+
+ /**
+ * Returns a country code (Only using CodeGrid)
+ * Do not use externally - Used by getCountryCodeLocally
+ * @param {Object} location {lat, lng}
+ * @return {Promise} Country code Promise
+ */
+ static getCountryCG = (location) => {
+ return new Promise((resolve, reject) => {
+ CG.getCode(location.lat, location.lng, (error, code) => {
+ if (error) {
+ reject(new Error(error));
+ } else {
+ resolve(countryCodes[code.toUpperCase()]);
+ }
+ });
+ });
+ };
+
+ /**
+ * Returns an array of 9 coodinates as objects.
+ * Each coordinate is 100 meters aways from the given
+ * coordinate y angles from 0 to 315
+ * The first coordinate is the original passed
+ * @param {Object} location {lat, lng}
+ * @return {Array} Coordinates [{lat, lng}, {lat, lng}] x 8
+ */
+ static getSurroundings = (location) => {
+ const meters = 100;
+ const R_EARTH = 6378.137;
+ const M = 1 / (((2 * Math.PI) / 360) * R_EARTH) / 1000;
+
+ function moveFrom(coords, angle, distance) {
+ let radianAngle = (angle * Math.PI) / 180;
+ let x = 0 + distance * Math.cos(radianAngle);
+ let y = 0 + distance * Math.sin(radianAngle);
+ let newLat = coords.lat + y * M;
+ let newLng = coords.lng + (x * M) / Math.cos(coords.lat * (Math.PI / 180));
+ return { lat: newLat, lng: newLng };
+ }
+ let coordinates = [location];
+ for (let angle = 0; angle < 360; angle += 45) {
+ coordinates.push(moveFrom({ lat: location.lat, lng: location.lng }, angle, meters));
+ }
+ return coordinates;
+ };
+
+ /**
+ * Check if the param is coordinates
+ * @param {string} coordinates
+ * @return {boolean}
+ */
+ static isCoordinates = (coordinates) => {
+ const regex = /^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?),\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$/g;
+ return regex.test(coordinates);
+ };
+
+ /**
+ * Returns map scale
+ * @param {Object} bounds map bounds
+ * @return {number} map scale
+ */
+ static calculateScale = (bounds) =>
+ GameHelper.haversineDistance({ lat: bounds.min.lat, lng: bounds.min.lng }, { lat: bounds.max.lat, lng: bounds.max.lng }) / 7.458421;
+
+ /**
+ * Returns distance in km between two coordinates
+ * @param {Object} mk1 {lat, lng}
+ * @param {Object} mk2 {lat, lng}
+ * @return {number} km
+ */
+ static haversineDistance = (mk1, mk2) => {
+ const R = 6371.071;
+ const rlat1 = mk1.lat * (Math.PI / 180);
+ const rlat2 = mk2.lat * (Math.PI / 180);
+ const difflat = rlat2 - rlat1;
+ const difflon = (mk2.lng - mk1.lng) * (Math.PI / 180);
+ const km =
+ 2 *
+ R *
+ Math.asin(Math.sqrt(Math.sin(difflat / 2) * Math.sin(difflat / 2) + Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(difflon / 2) * Math.sin(difflon / 2)));
+ return km;
+ };
+
+ /**
+ * Returns score based on distance and scale
+ * @param {number} distance
+ * @param {number} scale
+ * @return {number} score
+ */
+ static calculateScore = (distance, scale) => Math.round(5000 * Math.pow(0.99866017, (distance * 1000) / scale));
+
+ /**
+ * Returns guesses sorted by distance ASC
+ * @param {array} guesses
+ * @return {array} guesses
+ */
+ static sortByDistance = (guesses) => guesses.sort((a, b) => a.distance - b.distance);
+
+ /**
+ * Returns guesses sorted by score DESC
+ * @param {array} guesses
+ * @return {array} guesses
+ */
+ static sortByScore = (guesses) => guesses.sort((a, b) => b.score - a.score);
+
+ /** Converts a country code into an emoji flag
+ * @param {String} value
+ */
+ static toEmojiFlag = (value) => {
+ if (value.length == 2) {
+ return value.toUpperCase().replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
+ } else {
+ const flag = value
+ .toUpperCase()
+ .substring(0, 2)
+ .replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
+ const region = value
+ .toUpperCase()
+ .substring(2)
+ .replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397) + " ");
+ return `${flag} ${region}`.trim();
+ }
+ };
+
+ /** Replace special chars
+ * @param {String} val
+ */
+ static normalize = (val) => val.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
+
+ /** Matches words above 3 letters
+ * @param {String} input
+ * @param {String} key
+ */
+ static isMatch = (input, key) => input.length >= 3 && key.includes(input) && input.length <= key.length;
+
+ /** Find country by code or name
+ * @param {String} input
+ * @return {Object} countryCodesNames
+ */
+ static findCountry = (input) => {
+ const normalized = GameHelper.normalize(input);
+ return countryCodesNames.find((country) => country.code === normalized || GameHelper.isMatch(normalized, country.names.toLowerCase()));
+ };
+
+ /** Return a random country code
+ * @return {String}
+ */
+ static getRandomFlag = () => countryCodesNames[Math.floor(Math.random() * countryCodesNames.length)].code;
+
+ /** Make game summary link
+ * @param {string} streamer
+ * @param {string} mapName
+ * @param {Object} mode
+ * @param {Object[]} locations
+ * @param {Object[]} scores
+ * @return {Promise} link
+ */
+ static makeLink = (streamer, mapName, mode, locations, totalScores) => {
+ const players = totalScores.map((guess) => {
+ return { username: guess.username, flag: guess.flag, score: guess.score, rounds: guess.rounds };
+ });
+
+ return axios
+ .post(`${process.env.API_URL}/game`, {
+ streamer: streamer,
+ map: mapName,
+ mode: mode,
+ locations: locations,
+ players: players,
+ })
+ .then((res) => {
+ return `${process.env.BASE_URL}/game/${res.data.code}`;
+ })
+ .catch((err) => {
+ console.log(err);
+ });
+ };
+}
+
+module.exports = GameHelper;
+const GameHelper = require("./GameHelper");
+
+
+
+function mainWindow() {
+ let win = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ preload: path.join(__dirname, "../preload.js"),
+ enableRemoteModule: true,
+ contextIsolation: false,
+ webSecurity: false,
+ devTools: false,
+ },
+ });
+ win.setMenuBarVisibility(false);
+ win.loadURL("https://www.geoguessr.com/classic");
+
+ win.webContents.on("new-window", (e, link) => {
+ e.preventDefault();
+ shell.openExternal(link);
+ });
+
+ win.on("closed", () => {
+ win = null;
+ });
+
+ return win;
+}
+
+module.exports = mainWindow();
+const path = require("path");
+const { BrowserWindow } = require("electron");
+
+function updateWindow() {
+ const win = new BrowserWindow({
+ width: 400,
+ minWidth: 400,
+ height: 240,
+ minHeight: 240,
+ frame: false,
+ transparent: true,
+ webPreferences: {
+ nodeIntegration: true,
+ contextIsolation: false,
+ devTools: false,
+ },
+ });
+ win.setMenuBarVisibility(false);
+ win.loadURL(path.join(__dirname, "./update.html"));
+
+ return win;
+}
+
+module.exports = updateWindow();
+
+const ipcRenderer = require("electron").ipcRenderer;
+
+const message = document.getElementById("message");
+const restartButton = document.getElementById("restart-button");
+
+ipcRenderer.on("download_progress", () => {
+ ipcRenderer.removeAllListeners("download_progress");
+ message.innerHTML = `Download in progress...`;
+});
+
+ipcRenderer.on("update_downloaded", () => {
+ ipcRenderer.removeAllListeners("update_downloaded");
+ message.innerHTML = `
+ Update downloaded successfully. It will be installed on restart.
+ Restart now ?
+ `;
+ restartButton.classList.remove("hidden");
+});
+
+ipcRenderer.on("update_error", (err) => {
+ ipcRenderer.removeAllListeners("update_error");
+ message.innerHTML = "An error occured.";
+});
+
+function closeWindow() {
+ ipcRenderer.send("close_update_window");
+}
+function restartApp() {
+ ipcRenderer.send("restart_app");
+}
+const path = require("path");
+const { BrowserWindow, shell } = require("electron");
+
+function settingsWindow() {
+ const win = new BrowserWindow({
+ width: 600,
+ minWidth: 600,
+ height: 500,
+ minHeight: 500,
+ show: false,
+ frame: false,
+ transparent: true,
+ webPreferences: {
+ nodeIntegration: true,
+ contextIsolation: false,
+ devTools: false,
+ },
+ });
+ win.setMenuBarVisibility(false);
+ win.loadURL(path.join(__dirname, "./settings.html"));
+
+ win.webContents.on("new-window", (e, link) => {
+ e.preventDefault();
+ shell.openExternal(link);
+ });
+
+ return win;
+}
+
+
+
+const openTab = (e, tab) => {
+ const tabcontent = document.getElementsByClassName("tabcontent");
+ for (let i = 0; i < tabcontent.length; i++) {
+ tabcontent[i].style.display = "none";
+ }
+ const tablinks = document.getElementsByClassName("tablinks");
+ for (let i = 0; i < tablinks.length; i++) {
+ tablinks[i].className = tablinks[i].className.replace(" active", "");
+ }
+ document.getElementById(tab).style.display = "block";
+ e.currentTarget.className += " active";
+};
+document.getElementById("defaultOpen").click();
+const GameHelper = require("../utils/GameHelper");
+const Store = require("../utils/Store");
+const Guess = require("./Guess");
+
+class Game {
+ constructor() {
+ this.win;
+ this.settings;
+ this.url;
+ this.seed;
+ this.mapScale;
+ this.location;
+ this.country;
+ this.isInGame = false;
+ this.guessesOpen = false;
+ this.isMultiGuess = false;
+ this.guesses = [];
+ this.total = [];
+ this.lastLocation = {};
+ }
+
+ init = (win, settings) => {
+ this.win = win;
+ this.settings = settings;
+ this.lastLocation = Store.get("lastLocation", {});
+ };
+
+ start = async (url, isMultiGuess) => {
+ this.isInGame = true;
+ this.isMultiGuess = isMultiGuess;
+ if (this.url === url) {
+ this.refreshSeed();
+ } else {
+ this.url = url;
+ this.seed = await this.getSeed();
+ this.mapScale = GameHelper.calculateScale(this.seed.bounds);
+ this.getCountry();
+ this.clearGuesses();
+ }
+ };
+
+ outGame = () => {
+ this.isInGame = false;
+ this.closeGuesses();
+ };
+
+ streamerHasguessed = (newSeed) => newSeed.player.guesses.length != this.seed.player.guesses.length;
+
+ locHasChanged = (newSeed) => JSON.stringify(newSeed.rounds[newSeed.rounds.length - 1]) != JSON.stringify(this.getLocation());
+
+ refreshSeed = async () => {
+ const newSeed = await this.getSeed();
+ // If a guess has been comitted, process streamer guess then return scores
+ if (this.streamerHasguessed(newSeed)) {
+ this.win.webContents.send("pre-round-results");
+ this.closeGuesses();
+ this.seed = newSeed;
+
+ const location = this.location;
+ const scores = await this.makeGuess().then(() => this.getRoundScores());
+ return { location, scores };
+ // Else, if only the loc has changed, the location was skipped, replace current loc
+ } else if (this.locHasChanged(newSeed)) {
+ this.seed = newSeed;
+ this.getCountry();
+ return false;
+ }
+ };
+
+ getSeed = async () => await GameHelper.fetchSeed(this.url);
+
+ getCountry = async () => {
+ this.location = this.getLocation();
+ this.country = await GameHelper.getCountryCode(this.location);
+ };
+
+ makeGuess = async () => {
+ this.seed = await this.getSeed();
+
+ if (this.isMultiGuess) await this.processMultiGuesses();
+ const streamerGuess = await this.processStreamerGuess();
+
+ this.guesses.push(streamerGuess);
+ this.guesses.forEach((guess) => this.pushToTotal(guess));
+
+ this.lastLocation = { lat: this.location.lat, lng: this.location.lng };
+ Store.set("lastLocation", this.lastLocation);
+
+ if (this.seed.state != "finished") {
+ this.getCountry();
+ }
+ };
+
+ processMultiGuesses = () => {
+ let promises = [];
+ this.guesses.forEach(async (guess, index) => {
+ promises.push(
+ new Promise(async (resolve, reject) => {
+ const guessedCountry = await GameHelper.getCountryCode(guess.position);
+ guessedCountry === this.country ? guess.streak++ : (guess.streak = 0);
+ this.guesses[index].streak = guess.streak;
+ Store.setUserStreak(guess.user, guess.streak);
+ resolve();
+ })
+ );
+ });
+ return Promise.all(promises);
+ };
+
+ processStreamerGuess = async () => {
+ const index = this.seed.state === "finished" ? 1 : 2;
+ const streamerGuess = this.seed.player.guesses[this.seed.round - index];
+ const location = { lat: streamerGuess.lat, lng: streamerGuess.lng };
+
+ const streamer = Store.getOrCreateUser(this.settings.channelName, this.settings.channelName);
+ const guessedCountry = await GameHelper.getCountryCode(location);
+ guessedCountry === this.country ? streamer.addStreak() : streamer.setStreak(0);
+
+ const distance = GameHelper.haversineDistance(location, this.location);
+ const score = streamerGuess.timedOut ? 0 : GameHelper.calculateScore(distance, this.mapScale);
+ if (score == 5000) streamer.perfects++;
+ streamer.calcMeanScore(score);
+
+ streamer.nbGuesses++;
+ Store.saveUser(this.settings.channelName, streamer);
+
+ return new Guess(streamer.username, streamer.username, "#FFF", streamer.flag, location, streamer.streak, distance, score);
+ };
+
+ handleUserGuess = async (userstate, location) => {
+ const index = this.hasGuessedThisRound(userstate.username);
+
+ if (!this.isMultiGuess && index != -1) return "alreadyGuessed";
+
+ const user = Store.getOrCreateUser(userstate.username, userstate["display-name"]);
+ if (this.hasPastedPreviousGuess(user.previousGuess, location)) return "pastedPreviousGuess";
+
+ if (JSON.stringify(user.lastLocation) != JSON.stringify(this.lastLocation)) user.setStreak(0);
+
+ if (!this.isMultiGuess) {
+ const guessedCountry = await GameHelper.getCountryCode(location);
+ guessedCountry === this.country ? user.addStreak() : user.setStreak(0);
+ }
+
+ const distance = GameHelper.haversineDistance(location, this.location);
+ const score = GameHelper.calculateScore(distance, this.mapScale);
+ if (score == 5000) user.perfects++;
+ user.calcMeanScore(score);
+
+ const guess = new Guess(userstate.username, userstate["display-name"], userstate.color, user.flag, location, user.streak, distance, score);
+
+ // Modify guess or push it
+ if (this.isMultiGuess && index != -1) {
+ this.guesses[index] = guess;
+ this.guesses[index].modified = true;
+ } else {
+ user.nbGuesses++;
+ this.guesses.push(guess);
+ }
+
+ user.setLastLocation({ lat: this.location.lat, lng: this.location.lng });
+ user.setPreviousGuess(location);
+
+ Store.saveUser(userstate.username, user);
+
+ return { user: user, guess: guess };
+ };
+
+ nextRound = () => {
+ this.guesses = [];
+ if (this.seed.state != "finished") {
+ this.win.webContents.send("next-round", this.isMultiGuess);
+ } else {
+ this.win.webContents.send("final-results");
+ }
+ };
+
+ getLocation = () => this.seed.rounds[this.seed.round - 1];
+
+ getLocations = () => {
+ return this.seed.rounds.map((round) => {
+ return {
+ lat: round.lat,
+ lng: round.lng,
+ heading: Math.round(round.heading),
+ pitch: Math.round(round.pitch),
+ };
+ });
+ };
+
+ openGuesses = () => {
+ this.guessesOpen = true;
+ };
+
+ closeGuesses = () => {
+ this.guessesOpen = false;
+ };
+
+ clearGuesses = () => {
+ this.guesses = [];
+ this.total = [];
+ };
+
+ /**
+ * @param {string} user
+ * @return {Number} index
+ */
+ hasGuessedThisRound = (user) => this.guesses.findIndex((e) => e.user === user);
+
+ /**
+ * @param {Object} previousGuess
+ * @param {Object} location {lat, lng}
+ * @return {boolean}
+ */
+ hasPastedPreviousGuess = (previousGuess, location) => {
+ if (previousGuess === null) return false;
+ return previousGuess.lat === location.lat && previousGuess.lng === location.lng;
+ };
+
+ /**
+ * @param {Object} guess
+ */
+ pushToTotal = (guess) => {
+ const index = this.total.findIndex((e) => e.user === guess.user);
+ if (index != -1) {
+ this.total[index].scores.push({ round: this.seed.round - 1, score: guess.score });
+ this.total[index].score += guess.score;
+ this.total[index].distance += guess.distance;
+ this.total[index].streak = guess.streak;
+ this.total[index].color = guess.color;
+ this.total[index].flag = guess.flag;
+ this.total[index].rounds++;
+ } else {
+ this.total.push({ scores: [{ round: this.seed.round, score: guess.score }], ...guess, rounds: 1 });
+ }
+ };
+
+ /**
+ * @return {Guess[]} sorted guesses by Distance
+ */
+ getRoundScores = () => GameHelper.sortByDistance(this.guesses);
+
+ /**
+ * @return {Guess[]} sorted guesses by Score
+ */
+ getTotalScores() {
+ const scores = GameHelper.sortByScore(this.total);
+ // TODO: Remember to check equality
+ Store.userAddVictory(scores[0].user);
+ return scores;
+ }
+
+ get mapName() {
+ return this.seed.mapName;
+ }
+
+ get mode() {
+ return { noMove: this.seed.forbidMoving, noPan: this.seed.forbidRotating, noZoom: this.seed.forbidZooming };
+ }
+
+ get round() {
+ return this.seed.round;
+ }
+
+ get nbGuesses() {
+ return this.guesses.length;
+ }
+}
+
+module.exports = Game;
+class Guess {
+ /**
+ * @param {String} user
+ * @param {String} username
+ * @param {String} color
+ * @param {String} flag
+ * @param {Object} position {lat, lng}
+ * @param {Number} streak
+ * @param {Number} distance
+ * @param {Number} score
+ * @param {Boolean} modified
+ */
+ constructor(user, username, color, flag, position, streak, distance, score, modified = false) {
+ this.user = user;
+ this.username = username;
+ this.color = color === null ? "#FFF" : color;
+ this.flag = flag;
+ this.position = position;
+ this.streak = streak;
+ this.distance = distance;
+ this.score = score;
+ this.modified = modified;
+ }
+}
+
+module.exports = Guess;
+const Store = require("../../utils/Store");
+
+class Scoreboard {
+ constructor() {
+ this.visibility;
+ this.position;
+ this.container;
+ this.scoreboard;
+ this.title;
+ this.switchContainer;
+ this.switchBtn;
+ this.table;
+ this.columnState;
+ this.isMultiGuess = false;
+ this.isResults = false;
+ this.isScrolling = false;
+ this.speed = 50;
+ this.init();
+ }
+
+ init() {
+ this.visibility = this.getCookie("visibility", true);
+ this.position = this.getCookie("scoreboard_position", { top: 20, left: 5, width: 380, height: 180 });
+ this.container = $("#scoreboardContainer");
+ this.title = $("#scoreboardTitle");
+ this.switchContainer = $("#switchContainer");
+ this.switchBtn = $("#switchBtn");
+
+ this.scoreboard = $("#scoreboard");
+ this.scoreboard.css("top", this.position.top);
+ this.scoreboard.css("left", this.position.left);
+ this.scoreboard.css("width", this.position.width);
+ this.scoreboard.css("height", this.position.height);
+ this.scoreboard
+ .resizable({
+ handles: "n, e, s, w, ne, se, sw, nw",
+ containment: "#scoreboardContainer",
+ })
+ .draggable({
+ containment: "#scoreboardContainer",
+ })
+ .mouseup(() => {
+ const currentPosition = this.getPosition();
+ if (JSON.stringify(this.position) !== JSON.stringify(currentPosition)) {
+ this.setPosition(currentPosition);
+ this.setCookie("scoreboard_position", JSON.stringify(currentPosition));
+ }
+ });
+
+ this.switchBtn.on("change", () => {
+ if (this.switchBtn.is(":checked")) {
+ ipcRenderer.send("open-guesses");
+ } else {
+ ipcRenderer.send("close-guesses");
+ }
+ });
+
+ this.table = $("#datatable").DataTable({
+ info: false,
+ searching: false,
+ paging: false,
+ scrollY: 100,
+ scrollResize: true,
+ scrollCollapse: true,
+ language: { zeroRecords: " " },
+ dom: "Bfrtip",
+ buttons: [
+ {
+ extend: "colvis",
+ text: "⚙️",
+ className: "colvis-btn",
+ columns: ":not(.noVis)",
+ },
+ ],
+ columns: [
+ { data: "Position" },
+ { data: "Player" },
+ { data: "Streak" },
+ {
+ data: "Distance",
+ render: (data, type) => {
+ if (type === "display" || type === "filter") {
+ return this.toMeter(data);
+ }
+ return data;
+ },
+ },
+ { data: "Score" },
+ ],
+ columnDefs: [
+ { targets: 0, width: "35px", className: "noVis" },
+ { targets: 1, width: "auto", className: "noVis" },
+ { targets: 2, width: "55px" },
+ { targets: 3, width: "100px" },
+ { targets: 4, width: "75px", type: "natural" },
+ ],
+ });
+
+ // Column Visisbility
+ this.columnState = this.getCookie("CG_ColVis", [
+ { column: 0, state: true },
+ { column: 2, state: true },
+ { column: 3, state: true },
+ { column: 4, state: true },
+ ]);
+
+ // Handle ColVis change
+ this.table.on("column-visibility.dt", (e, settings, column, state) => {
+ if (this.isResults || this.isMultiGuess) return;
+
+ const i = this.columnState.findIndex((o) => o.column === column);
+ if (this.columnState[i]) {
+ this.columnState[i] = { column, state };
+ } else {
+ this.columnState.push({ column, state });
+ }
+
+ this.setCookie("CG_ColVis", JSON.stringify(this.columnState));
+ });
+
+ // SCROLLER
+ const sliderElem = ``;
+ $(".dt-buttons").append(sliderElem);
+
+ const slider = document.getElementById("scrollSpeedSlider");
+
+ slider.oninput = (e) => {
+ this.speed = e.currentTarget.value;
+ this.scroller(".dataTables_scrollBody");
+ };
+
+ const scrollBtn = `
+
+
+
+ `;
+ $(".dt-buttons").prepend(scrollBtn);
+
+ $("#scrollBtn").on("change", (e) => {
+ if (e.currentTarget.checked != true) {
+ this.isScrolling = $(e.currentTarget).is(":checked");
+ this.stop(".dataTables_scrollBody");
+ slider.style.display = "none";
+ } else {
+ this.isScrolling = $(e.currentTarget).is(":checked");
+ this.scroller(".dataTables_scrollBody");
+ slider.style.display = "inline";
+ }
+ });
+ }
+
+ /**
+ * @param {boolean} isMultiGuess
+ */
+ reset = (isMultiGuess) => {
+ this.isMultiGuess = isMultiGuess;
+ this.setColVis();
+ this.isResults = false;
+ this.setTitle("GUESSES (0)");
+ this.showSwitch(true);
+ this.table.clear().draw();
+ };
+
+ setVisibility = () => {
+ this.visibility = !this.visibility;
+ this.setCookie("visibility", this.visibility);
+ this.checkVisibility();
+ };
+
+ checkVisibility = () => {
+ if (this.visibility) {
+ this.show();
+ } else {
+ this.hide();
+ }
+ };
+
+ show = () => {
+ this.container.show();
+ };
+
+ hide = () => {
+ this.container.hide();
+ };
+
+ renderGuess = (guess) => {
+ const row = {
+ Position: "",
+ Player: `${guess.flag ? `` : ""}${
+ guess.username
+ }`,
+ Streak: guess.streak,
+ Distance: guess.distance,
+ Score: guess.score,
+ };
+
+ const rowNode = this.table.row.add(row).node();
+ rowNode.classList.add("expand");
+ setTimeout(() => {
+ rowNode.classList.remove("expand");
+ }, 200);
+
+ this.table.order([3, "asc"]).draw(false);
+ this.table
+ .column(0)
+ .nodes()
+ .each((cell, i) => {
+ cell.innerHTML = i + 1;
+ });
+ };
+
+ renderMultiGuess = (guesses) => {
+ const rows = guesses.map((guess) => {
+ return {
+ Position: "",
+ Player: `${guess.flag ? `` : ""}${
+ guess.username
+ }`,
+ Streak: "",
+ Distance: "",
+ Score: "",
+ };
+ });
+
+ this.table.clear().draw();
+ this.table.rows.add(rows).draw();
+ };
+
+ displayScores = (scores, isTotal = false) => {
+ this.isResults = true;
+ if (scores[0]) scores[0].color = "#E3BB39";
+ if (scores[1]) scores[1].color = "#C9C9C9";
+ if (scores[2]) scores[2].color = "#A3682E";
+ const rows = scores.map((score) => {
+ return {
+ Position: "",
+ Player: `${score.flag ? `` : ""}${
+ score.username
+ }`,
+ Streak: score.streak,
+ Distance: score.distance,
+ Score: `${score.score}${isTotal ? " [" + score.rounds + "]" : ""}`,
+ };
+ });
+
+ this.table.clear().draw();
+ this.table.rows.add(rows);
+
+ this.table.order([4, "desc"]).draw(false);
+
+ let content;
+ this.table
+ .column(0)
+ .nodes()
+ .each((cell, i) => {
+ content = i + 1;
+ if (isTotal) {
+ if (i == 0) content = "🏆";
+ else if (i == 1) content = "🥈";
+ else if (i == 2) content = "🥉";
+ }
+
+ cell.innerHTML = content;
+ });
+
+ // Restore columns visibility
+ this.table.columns().visible(true);
+ this.toTop(".dataTables_scrollBody");
+ };
+
+ scroller = (elem) => {
+ const div = $(elem);
+
+ const loop = () => {
+ if (!this.isScrolling) return;
+ div.stop().animate({ scrollTop: div[0].scrollHeight }, (div[0].scrollHeight - div.scrollTop() - 84) * this.speed, "linear", () => {
+ setTimeout(() => {
+ div.stop().animate({ scrollTop: 0 }, 1000, "swing", () => {
+ setTimeout(() => {
+ loop();
+ }, 3000);
+ });
+ }, 1000);
+ });
+ };
+ loop();
+ };
+
+ toTop = (elem) => {
+ this.stop(elem);
+ setTimeout(() => {
+ this.scroller(elem);
+ }, 3000);
+ };
+
+ stop(elem) {
+ $(elem).stop();
+ }
+
+ setColVis = () => {
+ if (this.isMultiGuess) {
+ this.table.columns([0, 2, 3, 4]).visible(false);
+ } else {
+ this.columnState.forEach((column) => {
+ this.table.column(column.column).visible(column.state);
+ });
+ }
+ };
+
+ getPosition = () => ({
+ top: this.scoreboard.position().top,
+ left: this.scoreboard.position().left,
+ width: this.scoreboard.width(),
+ height: this.scoreboard.height(),
+ });
+
+ setPosition = (position) => (this.position = position);
+
+ setTitle = (title) => this.title.text(title);
+
+ showSwitch = (state) => this.switchContainer.css("display", state ? "block" : "none");
+
+ switchOn = (state) => this.switchBtn.prop("checked", state);
+
+ toMeter = (distance) => (distance >= 1 ? parseFloat(distance.toFixed(1)) + "km" : parseInt(distance * 1000) + "m");
+
+ // ColVis Cookies
+ setCookie = (name, value, exdays = 60) => {
+ const d = new Date();
+ d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
+ const expires = "expires=" + d.toUTCString();
+ document.cookie = name + "=" + value + ";" + expires + ";path=/";
+ };
+
+ getCookie = (name, defaultValue = {}) => {
+ const cname = name + "=";
+ var decodedCookie = decodeURIComponent(document.cookie);
+ var ca = decodedCookie.split(";");
+ for (var i = 0; i < ca.length; i++) {
+ var c = ca[i];
+ while (c.charAt(0) == " ") {
+ c = c.substring(1);
+ }
+ if (c.indexOf(cname) == 0) {
+ return JSON.parse(c.substring(cname.length, c.length));
+ }
+ }
+ return defaultValue;
+ };
+}
+
+module.exports = Scoreboard;
+class Settings {
+ /**
+ * @param {String} channelName=""
+ * @param {String} botUsername=""
+ * @param {String} token=""
+ * @param {String} cgCmd="!cg"
+ * @param {String} cgMsg="To play along, go to this link, pick a location, and paste the whole command into chat: "
+ * @param {String} userGetStatsCmd="!me"
+ * @param {String} userClearStatsCmd="!clear"
+ * @param {String} setStreakCmd="!setstreak"
+ * @param {Boolean} showHasGuessed=true
+ * @param {Boolean} isMultiGuess=false
+ * @param {Boolean} noCar=false
+ * @param {Boolean} noCompass=false
+ */
+ constructor(
+ channelName = "",
+ botUsername = "",
+ token = "",
+ cgCmd = "!cg",
+ cgMsg = "To play along, go to this link, pick a location, and paste the whole command into chat: ",
+ userGetStatsCmd = "!me",
+ userClearStatsCmd = "!clear",
+ setStreakCmd = "!setstreak",
+ showHasGuessed = true,
+ isMultiGuess = false,
+ noCar = false,
+ noCompass = false
+ ) {
+ this.channelName = channelName;
+ this.botUsername = botUsername;
+ this.token = token;
+ this.cgCmd = cgCmd;
+ this.cgMsg = cgMsg;
+ this.userGetStatsCmd = userGetStatsCmd;
+ this.userClearStatsCmd = userClearStatsCmd;
+ this.setStreakCmd = setStreakCmd;
+ this.showHasGuessed = showHasGuessed;
+ this.isMultiGuess = isMultiGuess;
+ this.noCar = noCar;
+ this.noCompass = noCompass;
+ }
+
+ /**
+ * @param {boolean} noCar
+ * @param {boolean} noCompass
+ */
+ setGameSettings(isMultiGuess, noCar, noCompass) {
+ this.isMultiGuess = isMultiGuess;
+ this.noCar = noCar;
+ this.noCompass = noCompass;
+ }
+
+ /**
+ * @param {Object} commands
+ */
+ setTwitchCommands(commands) {
+ this.cgCmd = commands.cgCmdd;
+ this.cgMsg = commands.cgMsgg;
+ this.userGetStatsCmd = commands.userGetStats;
+ this.userClearStatsCmd = commands.userClearStats;
+ this.setStreakCmd = commands.setStreak;
+ this.showHasGuessed = commands.showHasGuessed;
+ }
+
+ /**
+ * @param {string} channelName
+ * @param {string} botUsername
+ * @param {string} token
+ */
+ setTwitchSettings(channelName, botUsername, token) {
+ this.channelName = channelName;
+ this.botUsername = botUsername;
+ this.token = token;
+ }
+}
+
+module.exports = Settings;
+class User {
+ /**
+ * @param {String} user
+ * @param {String} username
+ * @param {String} flag=""
+ * @param {Number} streak=0
+ * @param {Number} bestStreak=0
+ * @param {Number} correctGuesses=0
+ * @param {Number} nbGuesses=0
+ * @param {Number} perfects=0
+ * @param {Number} victories=0
+ * @param {Number} meanScore=null
+ * @param {Object} previousGuess={}
+ * @param {Object} lastLocation=null
+ */
+ constructor(
+ user,
+ username,
+ flag = "",
+ streak = 0,
+ bestStreak = 0,
+ correctGuesses = 0,
+ nbGuesses = 0,
+ perfects = 0,
+ victories = 0,
+ meanScore = null,
+ previousGuess = {},
+ lastLocation = null
+ ) {
+ this.user = user;
+ this.username = username;
+ this.flag = flag;
+ this.streak = streak;
+ this.bestStreak = bestStreak;
+ this.correctGuesses = correctGuesses;
+ this.nbGuesses = nbGuesses;
+ this.perfects = perfects;
+ this.victories = victories;
+ this.meanScore = meanScore;
+ this.previousGuess = previousGuess;
+ this.lastLocation = lastLocation;
+ }
+
+ /* Add 1 to streak and correctGuesses. */
+ addStreak() {
+ this.streak++;
+ this.correctGuesses++;
+ if (this.streak > this.bestStreak) this.bestStreak = this.streak;
+ }
+
+ /** Set user streak
+ * @param {Number} number
+ */
+ setStreak(number) {
+ this.streak = number;
+ if (this.streak > this.bestStreak) this.bestStreak = this.streak;
+ }
+
+ /** Set last location
+ * @param {Object} location
+ */
+ setLastLocation(location) {
+ this.lastLocation = location;
+ }
+
+ /** Set previous guess
+ * @param {Object} location
+ */
+ setPreviousGuess(location) {
+ this.previousGuess = location;
+ }
+
+ /** Set a country flag
+ * @param {String} flag country code
+ */
+ setFlag(flag) {
+ this.flag = flag;
+ }
+
+ /** Calculate mean score
+ * @param {Number} score
+ */
+ calcMeanScore(score) {
+ if (this.meanScore === null) {
+ this.meanScore = score;
+ } else {
+ this.meanScore = (this.meanScore * this.nbGuesses + score) / (this.nbGuesses + 1);
+ }
+ }
+}
+
diff --git a/web/chatguessr/index.html b/web/chatguessr/index.html
new file mode 100644
index 0000000..a8cf929
--- /dev/null
+++ b/web/chatguessr/index.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ GeoGuess
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ # | User | Distance | Score |
+
+ 1 | sora | 1230 | 1230 |
+ 2 | Sorb | 1230 | 1230 |
+ 3 | Sorc | 1230 | 1230 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/chatguessr/index.js b/web/chatguessr/index.js
new file mode 100644
index 0000000..3caa3e6
--- /dev/null
+++ b/web/chatguessr/index.js
@@ -0,0 +1,126 @@
+$(function () {
+ const webSocket = window.socket;
+ var guesses = {},
+ status = '-',
+ hidden = false;
+
+ const getQueryMap = () => {
+ let queryString = window.location.search,
+ queryParts = queryString.substring(1).split('&'),
+ queryMap = new Map();
+
+ for (let part of queryParts) {
+ let key = part.substring(0, part.indexOf('=')),
+ value = part.substring(part.indexOf('=') + 1, part.length);
+ if (key.length > 0 && value.length > 0) queryMap.set(key, value);
+ }
+ return queryMap;
+ }
+ Map.prototype.getOrElse = (option, def) => queryMap.has(option) ? queryMap.get(option): def;
+ const queryMap = getQueryMap();
+
+
+
+ $('#showhide').on('click',()=>{
+ if(hidden){
+ $('.containing').show();
+ hidden = false;
+ }else{
+ $('.containing').hide();
+ hidden = true;
+ }
+ })
+
+ const computeScore = (pos) => {
+ return 0;
+ }
+ const fillTable = ()=> {
+ $('#datatab > tr').remove();
+ var html = '';
+ for (let user in Object.keys(guesses))
+ html += '' + user + ' | ' + computeScore(guesses[user]) + ' |
';
+ $('#datatab').html(html);
+ }
+
+ const refreshUI = () => {
+ // if(queryMap.getOrElse('single',false)){
+ // $('#all').show(); $('#timer').hide(); $('#goal').hide();
+ // $('#follow').parent().closest('div').hide(); $('#subscribe').parent().closest('div').hide(); $('#donate').parent().closest('div').hide();
+ // if(queryMap.getOrElse('follow',false)) $('#follow').parent().closest('div').show();
+ // if(queryMap.getOrElse('subscribe',false)) $('#subscribe').parent().closest('div').show();
+ // if(queryMap.getOrElse('donate',false)) $('#donate').parent().closest('div').show();
+ // }else if(queryMap.getOrElse('goal',false)){
+ // $('#all').hide(); $('#timer').hide(); $('#goal').show();
+ // $('#cfollow').parent().closest('div').hide(); $('#csubscribe').parent().closest('div').hide();
+ // if(queryMap.getOrElse('follow',false)) $('#cfollow').parent().closest('div').show();
+ // if(queryMap.getOrElse('subscribe',false)) $('#csubscribe').parent().closest('div').show();
+ // }
+
+ // $('#follow').text(lastFollow);
+ // $('#subscribe').text(lastSub);
+ // $('#donate').text(lastDonator);
+ // $('#cfollow').parent().closest('div').progressbar({ value : countFollow/parseInt(queryMap.getOrElse('follow',9999))*100 });
+ // $('#csubscribe').parent().closest('div').progressbar({ value : countSub/parseInt(queryMap.getOrElse('subscribe',9999))*100});
+ // $('#cfollow').text(`${countFollow} / ${queryMap.getOrElse('follow',9999)}`);
+ // $('#csubscribe').text(`${countSub} / ${queryMap.getOrElse('subscribe',9999)}`);
+ }
+
+ const handleSocketMessage = (e)=>{
+ try {
+ let rawMessage = e.data,
+ message = JSON.parse(rawMessage);
+
+ if(!message.hasOwnProperty('eventFamily') || message.eventFamily != 'chatguessr' ||
+ !message.hasOwnProperty('eventType') || !message.hasOwnProperty('data'))
+ return;
+
+ console.log(message.eventType, message.data)
+ if(message.eventType == 'follow') {
+ lastFollow = message.data || lastFollow;
+ countFollow++;
+ } else if(message.eventType == 'subscribe') {
+ lastSub = message.data || lastSub;
+ countSub++;
+ } else if(message.eventType == 'donation') {
+ lastDonator = message.data || lastDonator;
+ } else if(message.eventType == 'timer') {
+ console.log("New timer !! ", message.data)
+ }
+ refreshUI();
+ } catch (ex) {
+ console.log(ex)
+ }
+ };
+
+
+ jQuery(async ()=>{
+ refreshUI();
+ try{
+ socket.addFamilyHandler("chatguessr", handleSocketMessage);
+ if(socket){
+ while(socket.getReadyState() === 0){
+ await new Promise(r => setTimeout(r, 500));
+ }
+ // socket.getDBValue("get_current_stream_info_lastFollow", 'streamInfo', 'lastFollow', (response)=>{
+ // if(response.streamInfo) lastFollow = response.streamInfo || lastFollow;
+ // refreshUI();
+ // })
+ // socket.getDBValue("get_current_stream_info_lastSub", 'streamInfo', 'lastSub', (response)=>{
+ // if(response.streamInfo) lastSub = response.streamInfo || lastSub;
+ // refreshUI();
+ // })
+ // socket.getDBValue("get_current_stream_info_lastDonator", 'streamInfo', 'lastDonator', (response)=>{
+ // if(response.streamInfo) lastDonator = response.streamInfo || lastDonator;
+ // refreshUI();
+ // })
+ // socket.getDBValue('get_current_stream_info_counts', 'panelData', 'stream', (e)=>{
+ // let data = JSON.parse(e.panelData);
+ // countFollow = data.followers || countFollow;
+ // countSub = 0;
+ // refreshUI();
+ // })
+ }
+ }catch(e) {console.log(e)}
+ })
+
+});
\ No newline at end of file
diff --git a/web/chatguessr/styles.css b/web/chatguessr/styles.css
new file mode 100644
index 0000000..7535c4e
--- /dev/null
+++ b/web/chatguessr/styles.css
@@ -0,0 +1,790 @@
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ --nord0: #2e3440;
+ --nord1: #3b4252;
+ --nord2: #434c5e;
+ --nord3: #4c566a;
+ --nord4: #d8dee9;
+ --nord5: #e5e9f0;
+ --nord6: #eceff4;
+ --nord7: #8fbcbb;
+ --nord8: #88c0d0;
+ --nord9: #81a1c1;
+ --blue: #5e81ac;
+ --red: #bf616a;
+ --orange: #d08770;
+ --yellow: #ebcb8b;
+ --green: #a3be8c;
+ --purple: #b48ead;
+
+ --fonts: 'IBMPlex Mono', sans-serif;
+ }
+
+ html,
+ body {
+ padding: 0;
+ margin: 0;
+ font-family: var(--fonts);
+ /* background-color: var(--nord0); */
+ color: var(--nord4);
+ }
+
+ a,
+ a:visited {
+ color: var(--nord7);
+ text-decoration: none !important;
+ }
+ a:hover {
+ color: var(--blue) !important;
+ }
+
+ .red a:hover,
+ .red {
+ color: var(--red);
+ }
+
+ .purple a:hover,
+ .purple {
+ color: var(--purple);
+ }
+
+ .green a:hover,
+ .green {
+ color: var(--green);
+ }
+
+ .yellow a:hover,
+ .yellow {
+ color: var(--yellow);
+ }
+
+ .blue a:hover,
+ .blue {
+ color: var(--blue);
+ }
+
+ .pointer {
+ cursor: pointer;
+ }
+
+ .ui-progressbar-value {
+ height:100%;
+ position:absolute;
+ background-color: var(--blue);
+ border-radius: 0.25rem;
+ z-index: 0;
+ left:0;
+ }
+
+ .no-border {
+ border:none!important;
+ }
+
+ img {
+ object-fit:cover;
+ background-color: var(--nord3);
+ box-shadow: 8px 8px rgba(0, 0, 0, 0.2);
+ }
+ img.no-bg {
+ background-color: transparent;
+ box-shadow: none;
+ }
+ .fit {
+ width:fit-content;
+ height:fit-content;
+ }
+
+ .img-wrap {
+ display: inline-block; width: 100%; height: 100%;
+ width:fit-content;
+ height:fit-content;
+ overflow:hidden;
+ }
+ .img-wrap > div:first-child, .img-wrap > span:first-child {
+ position: static !important
+ }
+ .img-wrap > div > img, .img-wrap > span > img {
+ position: inherit !important;
+ width:auto !important;
+ height:auto !important;
+ margin:0!important;
+ }
+
+ form {
+ margin: 1.5em auto 1em auto;
+ display:block;
+ width:400px;
+
+ }
+ input {
+ width: 100%;
+ padding: 0.5em;
+ border:none;
+ border-radius:0;
+ background-color: var(--nord1);
+ color: var(--nord4);
+ box-shadow: 8px 8px rgba(0, 0, 0, 0.2);
+ }
+
+ .card {
+ scrollbar-color: var(--nord4) var(--nord1);
+ scrollbar-width: thin;
+ box-shadow: 8px 8px rgba(0, 0, 0, 0.2);
+ background-color: var(--nord1);
+ }
+
+ header {
+ padding-bottom: 8px;
+ font-weight: bold;
+ cursor: pointer;
+ }
+ sub{
+ font-weight:normal;
+ font-size:0.7em;
+ cursor: pointer;
+ }
+
+ article>hr{
+ margin-top: 1em;
+ margin-bottom: 1em;
+ }
+
+ .tshadow{
+ text-shadow: 0 0 0.25em var(--nord0);
+ }
+
+ .opacity-4{opacity:0.4;}
+ .opacity-5{opacity:0.5;}
+ .grayscale-5{filter: grayscale(50%);}
+ .grayscale{filter: grayscale(100%);}
+ .index-100{z-index:100;}
+
+ /* purgecss end ignore */
+
+ .h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}
+ .h1,h1{font-size:calc(1.375rem + 1.5vw)}
+ .h2,h2{font-size:calc(1.325rem + .9vw)}
+ .h3,h3{font-size:calc(1.3rem + .6vw)}
+ .h4,h4{font-size:calc(1.275rem + .3vw)}
+ .h5,h5{font-size:1.25rem}
+ .h6,h6{font-size:1rem}
+ p{margin-top:0;margin-bottom:1rem}
+ abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}
+ address{margin-bottom:1rem;font-style:normal;line-height:inherit}
+ ol,ul{padding-left:2rem}
+ dl,ol,ul{margin-top:0;margin-bottom:1rem}
+ ol ol,ol ul,ul ol,ul ul{margin-bottom:0}
+ dt{font-weight:700}
+ dd{margin-bottom:.5rem;margin-left:0}
+ blockquote{margin:0 0 1rem}
+ b,strong{font-weight:bolder}
+
+
+ .small,small{font-size:.875em}
+ .mark,mark{padding:.2em;background-color:--red}
+ sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}
+ sub{bottom:-.25em}
+ sup{top:-.5em}
+
+ a:not([href]):not([class]),
+ a:not([href]):not([class]):hover{color:inherit;text-decoration:none}
+ code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}
+ pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}
+ pre code{font-size:inherit;color:inherit;word-break:normal}
+ code{font-size:.875em;color:var(--nord9);word-wrap:break-word}
+ a>code{color:inherit}
+ kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}
+ kbd kbd{padding:0;font-size:1em;font-weight:700}
+ figure{margin:0 0 1rem}
+ img,svg{vertical-align:middle}
+ table{caption-side:bottom;border-collapse:collapse}
+ caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--nord3);text-align:left}
+ th{text-align:inherit;text-align:-webkit-match-parent}
+ tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}
+ label{display:inline-block}
+ button{border-radius:0}
+ button:focus:not(:focus-visible){outline:0}
+ button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}
+ button,select{text-transform:none}[role=button]{cursor:pointer}
+ select{word-wrap:normal}
+ select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}
+ textarea{resize:vertical}
+ fieldset{min-width:0;padding:0;margin:0;border:0}
+ legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}
+ legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}
+ output{display:inline-block}
+ iframe{border:0}
+ summary{display:list-item;cursor:pointer}
+ progress{vertical-align:baseline}[hidden]{display:none!important}
+ .lead{font-size:1.25rem;font-weight:300}
+ .display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}
+ .display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}
+ .display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}
+ .display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}
+ .display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}
+ .display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}
+ .list-unstyled{padding-left:0;list-style:none}
+ .list-inline{padding-left:0;list-style:none}
+ .list-inline-item{display:inline-block}
+ .list-inline-item:not(:last-child){margin-right:.5rem}
+ .initialism{font-size:.875em;text-transform:uppercase}
+ .blockquote{margin-bottom:1rem;font-size:1.25rem}
+ .blockquote>:last-child{margin-bottom:0}
+ .blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}
+ .blockquote-footer::before{content:"— "}
+ .img-fluid{max-width:100%;height:auto}
+ .img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}
+ .figure{display:inline-block}
+ .figure-img{margin-bottom:.5rem;line-height:1}
+ .figure-caption{font-size:.875em;color:#6c757d}
+ .container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}
+ .row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}
+ .row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}
+ .col{flex:1 0 0%}
+ .row-cols-auto>*{flex:0 0 auto;width:auto}
+ .row-cols-1>*{flex:0 0 auto;width:100%}
+ .row-cols-2>*{flex:0 0 auto;width:50%}
+ .row-cols-3>*{flex:0 0 auto;width:33.3333333333%}
+ .row-cols-4>*{flex:0 0 auto;width:25%}
+ .row-cols-5>*{flex:0 0 auto;width:20%}
+ .row-cols-6>*{flex:0 0 auto;width:16.6666666667%}
+ .col-auto{flex:0 0 auto;width:auto}
+ .col-1{flex:0 0 auto;width:8.33333333%}
+ .col-2{flex:0 0 auto;width:16.66666667%}
+ .col-3{flex:0 0 auto;width:25%}
+ .col-4{flex:0 0 auto;width:33.33333333%}
+ .col-5{flex:0 0 auto;width:41.66666667%}
+ .col-6{flex:0 0 auto;width:50%}
+ .col-7{flex:0 0 auto;width:58.33333333%}
+ .col-8{flex:0 0 auto;width:66.66666667%}
+ .col-9{flex:0 0 auto;width:75%}
+ .col-10{flex:0 0 auto;width:83.33333333%}
+ .col-11{flex:0 0 auto;width:91.66666667%}
+ .col-12{flex:0 0 auto;width:100%}
+ .offset-1{margin-left:8.33333333%}
+ .offset-2{margin-left:16.66666667%}
+ .offset-3{margin-left:25%}
+ .offset-4{margin-left:33.33333333%}
+ .offset-5{margin-left:41.66666667%}
+ .offset-6{margin-left:50%}
+ .offset-7{margin-left:58.33333333%}
+ .offset-8{margin-left:66.66666667%}
+ .offset-9{margin-left:75%}
+ .offset-10{margin-left:83.33333333%}
+ .offset-11{margin-left:91.66666667%}
+ .g-0,.gx-0{--bs-gutter-x:0}
+ .g-0,.gy-0{--bs-gutter-y:0}
+ .g-1,.gx-1{--bs-gutter-x:0.25rem}
+ .g-1,.gy-1{--bs-gutter-y:0.25rem}
+ .g-2,.gx-2{--bs-gutter-x:0.5rem}
+ .g-2,.gy-2{--bs-gutter-y:0.5rem}
+ .g-3,.gx-3{--bs-gutter-x:1rem}
+ .g-3,.gy-3{--bs-gutter-y:1rem}
+ .g-4,.gx-4{--bs-gutter-x:1.5rem}
+ .g-4,.gy-4{--bs-gutter-y:1.5rem}
+ .g-5,.gx-5{--bs-gutter-x:3rem}
+ .g-5,.gy-5{--bs-gutter-y:3rem}
+ .card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}
+ .card>hr{margin-right:0;margin-left:0}
+ .card>.list-group{border-top:inherit;border-bottom:inherit}
+ .card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}
+ .card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}
+ .card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}
+ .card-body{flex:1 1 auto;padding:1rem 1rem}
+ .card-title{margin-bottom:.5rem}
+ .card-subtitle{margin-top:-.25rem;margin-bottom:0}
+ .card-text:last-child{margin-bottom:0}
+ .card-link:hover{text-decoration:none}
+ .card-link+.card-link{margin-left:1rem}
+ .card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}
+ .card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}
+ .card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}
+ .card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}
+ .card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}
+ .card-header-pills{margin-right:-.5rem;margin-left:-.5rem}
+ .card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}
+ .card-img,.card-img-bottom,.card-img-top{width:100%}
+ .card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}
+ .card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}
+ .card-group>.card{margin-bottom:.75rem}
+ .card-group>.card{flex:1 0 0%;margin-bottom:0}
+ .card-group>.card+.card{margin-left:0;border-left:0}
+ .card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}
+ .card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}
+ .card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}
+ .card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}
+ .card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}
+ .card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}
+ .spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}
+ .spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}
+ .spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}
+ .spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}
+ .offcanvas{position:fixed;bottom:0;z-index:1050;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}
+ @media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}
+ .offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}
+ .offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}
+ .offcanvas-title{margin-bottom:0;line-height:1.5}
+ .offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}
+ .offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}
+ .offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}
+ .offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}
+ .offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}
+ .offcanvas.show{transform:none}
+ .clearfix::after{display:block;clear:both;content:""}
+ .link-primary{color:#0d6efd}
+ .link-primary:focus,.link-primary:hover{color:#0a58ca}
+ .link-secondary{color:#6c757d}
+ .link-secondary:focus,.link-secondary:hover{color:#565e64}
+ .link-success{color:#198754}
+ .link-success:focus,.link-success:hover{color:#146c43}
+ .link-info{color:#0dcaf0}
+ .link-info:focus,.link-info:hover{color:#3dd5f3}
+ .link-warning{color:#ffc107}
+ .link-warning:focus,.link-warning:hover{color:#ffcd39}
+ .link-danger{color:#dc3545}
+ .link-danger:focus,.link-danger:hover{color:#b02a37}
+ .link-light{color:#f8f9fa}
+ .link-light:focus,.link-light:hover{color:#f9fafb}
+ .link-dark{color:#212529}
+ .link-dark:focus,.link-dark:hover{color:#1a1e21}
+ .ratio{position:relative;width:100%}
+ .ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}
+ .ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}
+ .ratio-1x1{--bs-aspect-ratio:100%}
+ .ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}
+ .ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}
+ .ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}
+ .fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}
+ .fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}
+ .sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}
+ .visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
+ .stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}
+ .text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .align-baseline{vertical-align:baseline!important}
+ .align-top{vertical-align:top!important}
+ .align-middle{vertical-align:middle!important}
+ .align-bottom{vertical-align:bottom!important}
+ .align-text-bottom{vertical-align:text-bottom!important}
+ .align-text-top{vertical-align:text-top!important}
+ .float-start{float:left!important}
+ .float-end{float:right!important}
+ .float-none{float:none!important}
+ .overflow-auto{overflow:auto!important}
+ .overflow-hidden{overflow:hidden!important}
+ .overflow-visible{overflow:visible!important}
+ .overflow-scroll{overflow:scroll!important}
+ .d-inline{display:inline!important}
+ .d-inline-block{display:inline-block!important}
+ .d-block{display:block!important}
+ .d-grid{display:grid!important}
+ .d-table{display:table!important}
+ .d-table-row{display:table-row!important}
+ .d-table-cell{display:table-cell!important}
+ .d-flex{display:flex!important}
+ .d-inline-flex{display:inline-flex!important}
+ .d-none{display:none!important}
+ .shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}
+ .shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}
+ .shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}
+ .shadow-none{box-shadow:none!important}
+ .position-static{position:static!important}
+ .position-relative{position:relative!important}
+ .position-absolute{position:absolute!important}
+ .position-fixed{position:fixed!important}
+ .position-sticky{position:-webkit-sticky!important;position:sticky!important}
+ .top-0{top:0!important}
+ .top-50{top:50%!important}
+ .top-100{top:100%!important}
+ .bottom-0{bottom:0!important}
+ .bottom-50{bottom:50%!important}
+ .bottom-100{bottom:100%!important}
+ .start-0{left:0!important}
+ .start-50{left:50%!important}
+ .start-100{left:100%!important}
+ .end-0{right:0!important}
+ .end-50{right:50%!important}
+ .end-100{right:100%!important}
+ .translate-middle{transform:translate(-50%,-50%)!important}
+ .translate-middle-x{transform:translateX(-50%)!important}
+ .translate-middle-y{transform:translateY(-50%)!important}
+ .border{border:1px solid #dee2e6!important}
+ .border-0{border:0!important}
+ .border-top{border-top:1px solid #dee2e6!important}
+ .border-top-0{border-top:0!important}
+ .border-end{border-right:1px solid #dee2e6!important}
+ .border-end-0{border-right:0!important}
+ .border-bottom{border-bottom:1px solid #dee2e6!important}
+ .border-bottom-0{border-bottom:0!important}
+ .border-start{border-left:1px solid #dee2e6!important}
+ .border-start-0{border-left:0!important}
+ .border-primary{border-color:#0d6efd!important}
+ .border-secondary{border-color:#6c757d!important}
+ .border-success{border-color:#198754!important}
+ .border-info{border-color:#0dcaf0!important}
+ .border-warning{border-color:#ffc107!important}
+ .border-danger{border-color:#dc3545!important}
+ .border-light{border-color:#f8f9fa!important}
+ .border-dark{border-color:#212529!important}
+ .border-white{border-color:#fff!important}
+ .border-1{border-width:1px!important}
+ .border-2{border-width:2px!important}
+ .border-3{border-width:3px!important}
+ .border-4{border-width:4px!important}
+ .border-5{border-width:5px!important}
+ .w-25{width:25%!important}
+ .w-50{width:50%!important}
+ .w-75{width:75%!important}
+ .w-100{width:100%!important}
+ .w-auto{width:auto!important}
+ .mw-100{max-width:100%!important}
+ .vw-100{width:100vw!important}
+ .min-vw-100{min-width:100vw!important}
+ .h-25{height:25%!important}
+ .h-50{height:50%!important}
+ .h-75{height:75%!important}
+ .h-100{height:100%!important}
+ .h-auto{height:auto!important}
+ .mh-100{max-height:100%!important}
+ .vh-100{height:100vh!important}
+ .min-vh-100{min-height:100vh!important}
+ .flex-fill{flex:1 1 auto!important}
+ .flex-row{flex-direction:row!important}
+ .flex-column{flex-direction:column!important}
+ .flex-row-reverse{flex-direction:row-reverse!important}
+ .flex-column-reverse{flex-direction:column-reverse!important}
+ .flex-grow-0{flex-grow:0!important}
+ .flex-grow-1{flex-grow:1!important}
+ .flex-shrink-0{flex-shrink:0!important}
+ .flex-shrink-1{flex-shrink:1!important}
+ .flex-wrap{flex-wrap:wrap!important}
+ .flex-nowrap{flex-wrap:nowrap!important}
+ .flex-wrap-reverse{flex-wrap:wrap-reverse!important}
+ .gap-0{gap:0!important}
+ .gap-1{gap:.25rem!important}
+ .gap-2{gap:.5rem!important}
+ .gap-3{gap:1rem!important}
+ .gap-4{gap:1.5rem!important}
+ .gap-5{gap:3rem!important}
+ .justify-content-start{justify-content:flex-start!important}
+ .justify-content-end{justify-content:flex-end!important}
+ .justify-content-center{justify-content:center!important}
+ .justify-content-between{justify-content:space-between!important}
+ .justify-content-around{justify-content:space-around!important}
+ .justify-content-evenly{justify-content:space-evenly!important}
+ .align-items-start{align-items:flex-start!important}
+ .align-items-end{align-items:flex-end!important}
+ .align-items-center{align-items:center!important}
+ .align-items-baseline{align-items:baseline!important}
+ .align-items-stretch{align-items:stretch!important}
+ .align-content-start{align-content:flex-start!important}
+ .align-content-end{align-content:flex-end!important}
+ .align-content-center{align-content:center!important}
+ .align-content-between{align-content:space-between!important}
+ .align-content-around{align-content:space-around!important}
+ .align-content-stretch{align-content:stretch!important}
+ .align-self-auto{align-self:auto!important}
+ .align-self-start{align-self:flex-start!important}
+ .align-self-end{align-self:flex-end!important}
+ .align-self-center{align-self:center!important}
+ .align-self-baseline{align-self:baseline!important}
+ .align-self-stretch{align-self:stretch!important}
+ .order-first{order:-1!important}
+ .order-0{order:0!important}
+ .order-1{order:1!important}
+ .order-2{order:2!important}
+ .order-3{order:3!important}
+ .order-4{order:4!important}
+ .order-5{order:5!important}
+ .order-last{order:6!important}
+ .m-0{margin:0!important}
+ .m-1{margin:.25rem!important}
+ .m-2{margin:.5rem!important}
+ .m-3{margin:1rem!important}
+ .m-4{margin:1.5rem!important}
+ .m-5{margin:3rem!important}
+ .m-auto{margin:auto!important}
+ .mx-0{margin-right:0!important;margin-left:0!important}
+ .mx-1{margin-right:.25rem!important;margin-left:.25rem!important}
+ .mx-2{margin-right:.5rem!important;margin-left:.5rem!important}
+ .mx-3{margin-right:1rem!important;margin-left:1rem!important}
+ .mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}
+ .mx-5{margin-right:3rem!important;margin-left:3rem!important}
+ .mx-auto{margin-right:auto!important;margin-left:auto!important}
+ .my-0{margin-top:0!important;margin-bottom:0!important}
+ .my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}
+ .my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}
+ .my-3{margin-top:1rem!important;margin-bottom:1rem!important}
+ .my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}
+ .my-5{margin-top:3rem!important;margin-bottom:3rem!important}
+ .my-auto{margin-top:auto!important;margin-bottom:auto!important}
+ .mt-0{margin-top:0!important}
+ .mt-1{margin-top:.25rem!important}
+ .mt-2{margin-top:.5rem!important}
+ .mt-3{margin-top:1rem!important}
+ .mt-4{margin-top:1.5rem!important}
+ .mt-5{margin-top:3rem!important}
+ .mt-auto{margin-top:auto!important}
+ .me-0{margin-right:0!important}
+ .me-1{margin-right:.25rem!important}
+ .me-2{margin-right:.5rem!important}
+ .me-3{margin-right:1rem!important}
+ .me-4{margin-right:1.5rem!important}
+ .me-5{margin-right:3rem!important}
+ .me-auto{margin-right:auto!important}
+ .mb-0{margin-bottom:0!important}
+ .mb-1{margin-bottom:.25rem!important}
+ .mb-2{margin-bottom:.5rem!important}
+ .mb-3{margin-bottom:1rem!important}
+ .mb-4{margin-bottom:1.5rem!important}
+ .mb-5{margin-bottom:3rem!important}
+ .mb-auto{margin-bottom:auto!important}
+ .ms-0{margin-left:0!important}
+ .ms-1{margin-left:.25rem!important}
+ .ms-2{margin-left:.5rem!important}
+ .ms-3{margin-left:1rem!important}
+ .ms-4{margin-left:1.5rem!important}
+ .ms-5{margin-left:3rem!important}
+ .ms-auto{margin-left:auto!important}
+ .p-0{padding:0!important}
+ .p-1{padding:.25rem!important}
+ .p-2{padding:.5rem!important}
+ .p-3{padding:1rem!important}
+ .p-4{padding:1.5rem!important}
+ .p-5{padding:3rem!important}
+ .px-0{padding-right:0!important;padding-left:0!important}
+ .px-1{padding-right:.25rem!important;padding-left:.25rem!important}
+ .px-2{padding-right:.5rem!important;padding-left:.5rem!important}
+ .px-3{padding-right:1rem!important;padding-left:1rem!important}
+ .px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}
+ .px-5{padding-right:3rem!important;padding-left:3rem!important}
+ .py-0{padding-top:0!important;padding-bottom:0!important}
+ .py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}
+ .py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}
+ .py-3{padding-top:1rem!important;padding-bottom:1rem!important}
+ .py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}
+ .py-5{padding-top:3rem!important;padding-bottom:3rem!important}
+ .pt-0{padding-top:0!important}
+ .pt-1{padding-top:.25rem!important}
+ .pt-2{padding-top:.5rem!important}
+ .pt-3{padding-top:1rem!important}
+ .pt-4{padding-top:1.5rem!important}
+ .pt-5{padding-top:3rem!important}
+ .pe-0{padding-right:0!important}
+ .pe-1{padding-right:.25rem!important}
+ .pe-2{padding-right:.5rem!important}
+ .pe-3{padding-right:1rem!important}
+ .pe-4{padding-right:1.5rem!important}
+ .pe-5{padding-right:3rem!important}
+ .pb-0{padding-bottom:0!important}
+ .pb-1{padding-bottom:.25rem!important}
+ .pb-2{padding-bottom:.5rem!important}
+ .pb-3{padding-bottom:1rem!important}
+ .pb-4{padding-bottom:1.5rem!important}
+ .pb-5{padding-bottom:3rem!important}
+ .ps-0{padding-left:0!important}
+ .ps-1{padding-left:.25rem!important}
+ .ps-2{padding-left:.5rem!important}
+ .ps-3{padding-left:1rem!important}
+ .ps-4{padding-left:1.5rem!important}
+ .ps-5{padding-left:3rem!important}
+ .font-monospace{font-family:var(--bs-font-monospace)!important}
+ .fs-1{font-size:calc(1.375rem + 1.5vw)!important}
+ .fs-2{font-size:calc(1.325rem + .9vw)!important}
+ .fs-3{font-size:calc(1.3rem + .6vw)!important}
+ .fs-4{font-size:calc(1.275rem + .3vw)!important}
+ .fs-5{font-size:1.25rem!important}
+ .fs-6{font-size:1rem!important}
+ .fst-italic{font-style:italic!important}
+ .fst-normal{font-style:normal!important}
+ .fw-light{font-weight:300!important}
+ .fw-lighter{font-weight:lighter!important}
+ .fw-normal{font-weight:400!important}
+ .fw-bold{font-weight:700!important}
+ .fw-bolder{font-weight:bolder!important}
+ .lh-1{line-height:1!important}
+ .lh-sm{line-height:1.25!important}
+ .lh-base{line-height:1.5!important}
+ .lh-lg{line-height:2!important}
+ .text-start{text-align:left!important}
+ .text-end{text-align:right!important}
+ .text-center{text-align:center!important}
+ .text-decoration-none{text-decoration:none!important}
+ .text-decoration-underline{text-decoration:underline!important}
+ .text-decoration-line-through{text-decoration:line-through!important}
+ .text-lowercase{text-transform:lowercase!important}
+ .text-uppercase{text-transform:uppercase!important}
+ .text-capitalize{text-transform:capitalize!important}
+ .text-wrap{white-space:normal!important}
+ .text-nowrap{white-space:nowrap!important}
+ .text-break{word-wrap:break-word!important;word-break:break-word!important}
+ .text-primary{color:#0d6efd!important}
+ .text-secondary{color:#6c757d!important}
+ .text-success{color:#198754!important}
+ .text-info{color:#0dcaf0!important}
+ .text-warning{color:#ffc107!important}
+ .text-danger{color:#dc3545!important}
+ .text-light{color:#f8f9fa!important}
+ .text-dark{color:#212529!important}
+ .text-white{color:#fff!important}
+ .text-body{color:#212529!important}
+ .text-muted{color:#6c757d!important}
+ .text-black-50{color:rgba(0,0,0,.5)!important}
+ .text-white-50{color:rgba(255,255,255,.5)!important}
+ .text-reset{color:inherit!important}
+ .bg-primary{background-color:#0d6efd!important}
+ .bg-secondary{background-color:#6c757d!important}
+ .bg-success{background-color:#198754!important}
+ .bg-info{background-color:#0dcaf0!important}
+ .bg-warning{background-color:#ffc107!important}
+ .bg-danger{background-color:#dc3545!important}
+ .bg-light{background-color:#f8f9fa!important}
+ .bg-dark{background-color:#212529!important}
+ .bg-body{background-color:#fff!important}
+ .bg-white{background-color:#fff!important}
+ .bg-transparent{background-color:transparent!important}
+ .bg-gradient{background-image:var(--bs-gradient)!important}
+ .user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}
+ .user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}
+ .user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}
+ .pe-none{pointer-events:none!important}
+ .pe-auto{pointer-events:auto!important}
+ .rounded{border-radius:.25rem!important}
+ .rounded-0{border-radius:0!important}
+ .rounded-1{border-radius:.2rem!important}
+ .rounded-2{border-radius:.25rem!important}
+ .rounded-3{border-radius:.3rem!important}
+ .rounded-circle{border-radius:50%!important}
+ .rounded-pill{border-radius:50rem!important}
+ .rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}
+ .rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}
+ .rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}
+ .rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}
+ .visible{visibility:visible!important}
+ .invisible{visibility:hidden!important}
+
+ .fs-1{font-size:2.5rem!important}
+ .fs-2{font-size:2rem!important}
+ .fs-3{font-size:1.75rem!important}
+ .fs-4{font-size:1.5rem!important}
+ .btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}
+ .btn:hover{color:#212529}
+ .btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}
+ .btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}
+ .btn-primary{color:#fff;background-color:var(--nord3);border-color:var(--nord3)}
+ .btn-primary:hover{color:#fff;background-color:var(--nord2);border-color:var(--nord2)}
+ .btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:var(--nord3);border-color:var(--nord3)}
+ .btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:var(--nord0);border-color:var(--nord0)}
+ .btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
+ .btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
+ .btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}
+ .btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}
+ .btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}
+ .btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}
+ .btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}
+ .btn-success{color:#fff;background-color:#198754;border-color:#198754}
+ .btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}
+ .btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}
+ .btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}
+ .btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}
+ .btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}
+ .btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}
+ .btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}
+ .btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}
+ .btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}
+ .btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}
+ .btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}
+ .btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}
+ .btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}
+ .btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}
+ .btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}
+ .btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}
+ .btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}
+ .btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
+ .btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}
+ .btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}
+ .btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}
+ .btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}
+ .btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}
+ .btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}
+ .btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}
+ .btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}
+ .btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}
+ .btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}
+ .btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}
+ .btn-dark{color:#fff;background-color:#212529;border-color:#212529}
+ .btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}
+ .btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}
+ .btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}
+ .btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}
+ .btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}
+ .btn-outline-primary{color:#0d6efd;border-color:#0d6efd}
+ .btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
+ .btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}
+ .btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
+ .btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}
+ .btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}
+ .btn-outline-secondary{color:#6c757d;border-color:#6c757d}
+ .btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}
+ .btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}
+ .btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}
+ .btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}
+ .btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}
+ .btn-outline-success{color:#198754;border-color:#198754}
+ .btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}
+ .btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}
+ .btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}
+ .btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}
+ .btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}
+ .btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}
+ .btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}
+ .btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}
+ .btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}
+ .btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}
+ .btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}
+ .btn-outline-warning{color:#ffc107;border-color:#ffc107}
+ .btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}
+ .btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}
+ .btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}
+ .btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}
+ .btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}
+ .btn-outline-danger{color:#dc3545;border-color:#dc3545}
+ .btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}
+ .btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}
+ .btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}
+ .btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}
+ .btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}
+ .btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}
+ .btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}
+ .btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}
+ .btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}
+ .btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}
+ .btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}
+ .btn-outline-dark{color:#212529;border-color:#212529}
+ .btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}
+ .btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}
+ .btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}
+ .btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}
+ .btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}
+ .btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}
+ .btn-link:hover{color:#0a58ca}
+ .btn-link.disabled,.btn-link:disabled{color:#6c757d}
+ .btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}
+ .btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}
+ .fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}
+ .fade:not(.show){opacity:0}
+
+ thead > tr {
+ background-color: transparent !important;
+ }
+ tr:nth-child(even) {
+ background-color: var(--nord3);
+ }
+ tr:nth-child(odd) {
+ background-color:var(--nord2);
+ }
+
+ .table-fixed { table-layout: fixed; }
+ .table-auto { table-layout: auto; }
+ table.no-overflow>tbody>tr>td { overflow:hidden; text-overflow: ellipsis; white-space: nowrap;}
\ No newline at end of file
diff --git a/web/overlay/index.js b/web/overlay/index.js
index 6b823dc..36059ca 100644
--- a/web/overlay/index.js
+++ b/web/overlay/index.js
@@ -73,7 +73,7 @@ $(function () {
};
- $(document).ready(async ()=>{
+ jQuery(async ()=>{
refreshUI();
try{
socket.addFamilyHandler("overlay", handleSocketMessage);