// ==UserScript== // @name Novel Stats Charts // @namespace https://github.com/MarvNC // @version 1.24.2 // @description A userscript that generates charts about novel series. // @author Marv // @icon https://avatars.githubusercontent.com/u/17340496 // @match https://bookwalker.jp/series/* // @match https://global.bookwalker.jp/series/* // @downloadURL https://raw.githubusercontent.com/MarvNC/Book-Stats-Charts/main/release-dates.user.js // @updateURL https://raw.githubusercontent.com/MarvNC/Book-Stats-Charts/main/release-dates.user.js // @require https://cdn.jsdelivr.net/npm/handsontable@12.4.0/dist/handsontable.full.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.js // @require https://cdn.jsdelivr.net/npm/chartjs-plugin-trendline@2.1.0/dist/chartjs-plugin-trendline.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/0.5.7/chartjs-plugin-annotation.min.js // @require https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js // @resource hotCSS https://cdn.jsdelivr.net/npm/handsontable@12.4.0/dist/handsontable.full.min.css // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_xmlhttpRequest // @run-at document-idle // ==/UserScript== const volRegex = /(\d+\.?\d*)/g; const dayMs = 86400000; const monthMs = 2592000000; const weightMultiple = 0.8; const ignoreThreshold = 10; const digits = 0; const momentFormat = "YYYY/MM/DD"; const maxVol = 250; const lineColor = "#7296F5"; const otherLineColor = "#f572af"; (async function () { let pageType = getPageType(document.URL); if (pageType == "bw" && !document.URL.match(/\d+\/list/)) { window.location.replace( `https://bookwalker.jp/series/${ document.URL.match(/\/series\/(\d+)/)[1] }/list`, ); } else if (pageType == "bwg") { // Add JP bookwalker search link let jpTitleElem = document.querySelector("h1 > span > div"); if (jpTitleElem) { let jpTitle = jpTitleElem.innerText.slice(2).split(",")[0]; jpTitleElem.innerHTML = jpTitleElem.innerHTML.replace( jpTitle, `${jpTitle}`, ); } } let dateChart = document.createElement("CANVAS"); let delayChart = document.createElement("CANVAS"); let pageChart = document.createElement("CANVAS"); let thisPage = await getPageInfo(document, document.URL); console.log(thisPage); let textFeedback = document.createElement("h1"); textFeedback.className = "titleHeader"; let div = document.createElement("div"); div.className = "charts"; div.style.width = `95%`; resizable(div.className); thisPage.insertChart.append(div); div.append(textFeedback); addCSS(); let thisSeriesData = await getSeriesInfo( thisPage.bookURLs, textFeedback, div, ); textFeedback.innerHTML = `Drag from the right side to resize.
`; textFeedback.style.marginBottom = "1em"; // compare given URL against current series page let compare = document.createElement("input"); let compareBtn = document.createElement("button"); compare.setAttribute("type", "text"); compare.setAttribute("value", "Enter a Bookwalker URL to compare to."); compare.onfocus = () => { compare.value = ""; compare.onfocus = null; }; compare.style.width = "100%"; compareBtn.style.width = "100%"; compareBtn.innerText = "Compare with URL"; compareBtn.onclick = async () => { compareBtn.onclick = null; let url = compare.value; let text = await xmlhttpRequestText(url); let doc = document.createElement("html"); doc.innerHTML = text; let otherPage = await getPageInfo(doc, url); let otherSeriesData = await getSeriesInfo(otherPage.bookURLs, compareBtn); let otherSeries = new Series( otherPage.title, otherSeriesData, dateChartThing, ); div.insertBefore(otherSeries.container, dateChart); otherSeries.lineColor = otherLineColor; otherSeries.updateData(); let intersectBtn = document.createElement("button"); let intersectText; intersectBtn.innerText = "Guess at an intersection date of these two series using current wait values"; intersectBtn.onclick = async () => { if (!intersectText) { intersectText = document.createElement("h2"); intersectText.style.padding = ".5em 0em .5em 0em"; div.insertBefore(intersectText, dateChart); } let mainWait = parseInt(thisSeries.predictField.value), otherWait = parseInt(otherSeries.predictField.value), mainPoint = () => thisSeries.seriesData[thisSeries.seriesData.length - 1], otherPoint = () => otherSeries.seriesData[otherSeries.seriesData.length - 1]; if ( (mainWait < otherWait && mainPoint().volume < otherPoint().volume) || (mainWait > otherWait && mainPoint().volume > otherPoint().volume) ) { let older = () => { let mainDate = moment(mainPoint().date, momentFormat), otherDate = moment(otherPoint().date, momentFormat); return mainDate.add(mainWait, "d").valueOf() > otherDate.add(otherWait, "d").valueOf() ? otherSeries : thisSeries; }; do { older().addRow(); } while (mainPoint().volume != otherPoint().volume); let latest = () => { let mainDate = moment(mainPoint().date, momentFormat), otherDate = moment(otherPoint().date, momentFormat); return mainDate.valueOf() > otherDate.valueOf() ? mainDate : otherDate; }; intersectText.innerText = `These series are predicted to intersect at volume ${ mainPoint().volume } on ${dateString(latest())}.`; } else intersectText.innerText = `Looks like these series don't intersect with the given values.`; }; div.insertBefore(intersectBtn, dateChart); }; let dateChartThing = new Chart(dateChart, { type: "line", data: { datasets: [], }, options: { title: { display: true, text: "Release Dates", }, scales: { xAxes: [ { type: "time", time: { unit: "month", tooltipFormat: "D MMMM YYYY", }, afterDataLimits: (axis) => { // 1 month padding on both sides axis.max = Math.max(axis.max, moment().valueOf()) + monthMs; axis.min -= monthMs; }, }, ], yAxes: [ { scaleLabel: { display: true, labelString: "Volume Number", }, ticks: { beginAtZero: true, stepSize: 1, }, afterDataLimits: (axis) => { axis.max += 1; }, }, ], }, annotation: { annotations: [ { type: "line", scaleID: "x-axis-0", value: moment(), borderColor: "#7577D9", borderWidth: 1, }, ], }, }, }); let delayChartThing = new Chart(delayChart, { type: "bar", data: { datasets: [ { label: thisPage.title, }, ], }, options: { title: { display: true, text: "Days per volume", }, scales: { yAxes: [ { scaleLabel: { display: true, labelString: "Days Waited", }, ticks: { beginAtZero: true, }, }, ], }, }, }); let pageChartThing = new Chart(pageChart, { type: "bar", data: { datasets: [ { label: thisPage.title, }, ], }, options: { title: { display: true, text: "Pages per volume", }, scales: { yAxes: [ { scaleLabel: { display: true, labelString: "Pages", }, ticks: { beginAtZero: true, stepSize: 25, }, }, ], }, }, }); let thisSeries = new Series( thisPage.title, thisSeriesData, dateChartThing, delayChartThing, pageChartThing, ); div.append(compare); div.append(compareBtn); div.append(thisSeries.container); div.append(dateChart); div.append(delayChart); div.append(pageChart); thisSeries.updateData(); })(); /** * Represents a series, and returns a div with stuff in it. */ class Series { constructor( title, seriesData, dateChartThing, delayChartThing = null, pageChartThing = null, ) { this.title = title; this.seriesData = seriesData; this.originalData = JSON.parse(JSON.stringify(this.seriesData)); this.dateChartThing = dateChartThing; this.delayChartThing = delayChartThing; this.pageChartThing = pageChartThing; this.seriesStats = getStats(this.seriesData); this.container = document.createElement("div"); this.lineColor = lineColor; this.container.className = "series"; let table = document.createElement("div"); let tableContainer = document.createElement("div"); tableContainer.className = "charts tableContainer"; tableContainer.append(table); resizable(tableContainer.className); table.className = "handsontable"; tableContainer.style.overflowY = "scroll"; tableContainer.style.height = "auto"; tableContainer.style.width = "100%"; let daysFormatter = ( hotInstance, td, row, column, prop, value, cellProperties, ) => { value = parseFloat(value); td.innerHTML = value.toFixed(digits); }; let hotSettings = (data) => { return { data: this.seriesData, rowHeaders: true, colHeaders: ["Volume", "Title", "Date", "Days Waited", "Pages"], columns: [ { data: "volume", type: "numeric" }, // {data: 'consec'}, { data: "title" }, { data: "date", dateFormat: momentFormat, type: "date", correctFormat: true, }, { data: "wait", renderer: daysFormatter, type: "numeric" }, { data: "pageCount", type: "numeric" }, ], columnSorting: true, filters: true, dropdownMenu: true, licenseKey: "non-commercial-and-evaluation", contextMenu: true, manualRowResize: true, manualColumnResize: true, manualRowMove: true, manualColumnMove: true, dropdownMenu: true, afterChange: (event, data) => { this.updateData(event, data); }, afterCreateRow: (row) => { this.addRow(row); }, afterRemoveRow: () => { this.updateData(); }, }; }; this.HOT = new Handsontable(table, hotSettings(this.seriesData)); let btnDiv = document.createElement("div"); let resetBtn = document.createElement("button"); resetBtn.innerText = "Reset data to original values"; resetBtn.onclick = () => { this.seriesData = JSON.parse(JSON.stringify(this.originalData)); this.HOT.destroy(); this.HOT = new Handsontable(table, hotSettings(this.seriesData)); this.updateData(); }; btnDiv.append(resetBtn); let sequentialBtn = document.createElement("button"); sequentialBtn.innerText = "Use sequential numbering (for series w/o vol. numbers)"; sequentialBtn.onclick = () => { this.seriesData.forEach((datum, index) => { datum.volume = index + 1; }); this.updateData(); }; btnDiv.append(sequentialBtn); btnDiv.append(document.createElement("br")); this.predictBtn = document.createElement("button"); this.predictField = document.createElement("input"); this.predictBtn.innerText = "Add prediction using value:"; this.predictBtn.onclick = () => { this.addRow(); }; this.predictField.setAttribute("type", "text"); this.predictField.setAttribute( "value", this.seriesStats.weightedWait.toFixed(digits), ); let predictDropdown = document.createElement("select"); let addDropdown = (input, value, text) => { let op = new Option(); op.value = value; op.text = text; input.options.add(op); }; addDropdown( predictDropdown, this.seriesStats.weightedWait.toFixed(digits), `Weighted average (weighing recent waits more): ${this.seriesStats.weightedWait.toFixed( digits, )}`, ); addDropdown( predictDropdown, this.seriesStats.medianWait.toFixed(digits), `Median time: ${this.seriesStats.medianWait.toFixed(digits)}`, ); addDropdown( predictDropdown, this.seriesStats.avgWait.toFixed(digits), `Average time: ${this.seriesStats.avgWait.toFixed(digits)}`, ); predictDropdown.onchange = () => { this.predictField.value = predictDropdown.value; this.updateData(); }; btnDiv.append(this.predictBtn); btnDiv.append(this.predictField); btnDiv.append(predictDropdown); this.constantDD = false; let constantDDText = document.createElement("p"); constantDDText.innerText = "Try to match release timings (consistent release date of month)"; let constantDDSwitch = htmlToElement(``); constantDDSwitch.onclick = () => { this.constantDD = constantDDSwitch.firstElementChild.checked; this.updateData(); }; btnDiv.append(constantDDText); btnDiv.append(constantDDSwitch); let titleElem = document.createElement("h1"); titleElem.className = "titleHeader"; titleElem.innerHTML = `${this.title}`; this.dataText = document.createElement("h2"); this.container.append(titleElem); this.container.append(this.dataText); this.container.append(btnDiv); this.container.append(tableContainer); } addRow(row = null) { if (!row) { this.HOT.alter("insert_row", this.seriesData.length); return; } let datum = this.seriesData[row]; datum.volume = datum.volume ?? this.seriesData[row - 1].volume + 1; // TODO: add support for different waits datum.wait = datum.wait ?? parseFloat(this.predictField.value); datum.date = datum.date ?? moment(this.seriesData[row - 1].date, momentFormat) .add(datum.wait, "d") .format(momentFormat); if (this.constantDD) { let datumDate = moment(datum.date, momentFormat); let constantDate = Math.round( this.seriesData .map((datum) => moment(datum.date, momentFormat).date()) .slice(0, this.seriesData.length - 1) .reduce((prev, curr) => prev + curr, 0) / (this.seriesData.length - 1), ); if (datumDate.date() != constantDate) { let forward = moment(datumDate), backward = moment(datumDate); while ( forward.date() != constantDate && backward.date() != constantDate ) { forward.add(1, "d"); backward.subtract(1, "d"); } datumDate = forward.date() == constantDate ? forward : backward; datum.wait = datumDate.diff( moment(this.seriesData[row - 1].date, momentFormat), "d", ); datum.date = datumDate.format(momentFormat); } } datum.title = datum.title ?? `Predicted Volume ${datum.volume}`; this.updateData(); } updateData(event = null, data = null) { if (data == "loadData") return; if (data == "edit" && event && event[0][1].match(/wait|date/)) { let index = event[0][0]; if (event[0][1].match(/wait/)) { this.seriesData[index].date = moment( this.seriesData[index - 1].date, momentFormat, ) .add(this.seriesData[index].wait, "d") .format(momentFormat); } else { this.seriesData[index].wait = moment( this.seriesData[index].date, momentFormat, ).diff(moment(this.seriesData[index - 1].date, momentFormat), "d"); } } this.HOT.render(); this.seriesData[0].wait = 0; for (let i = 1; i < this.seriesData.length; i++) { let datum = this.seriesData[i]; datum.wait = moment(datum.date, momentFormat).diff( moment(this.seriesData[i - 1].date, momentFormat), "d", ); } this.seriesStats = getStats(this.seriesData); this.dataText.innerHTML = `Average wait: ${this.seriesStats.avgWait.toFixed( digits, )} days, median wait: ${this.seriesStats.medianWait.toFixed( digits, )} days, recency-weighted wait: ${this.seriesStats.weightedWait.toFixed( digits, )} days, standard deviation: ${this.seriesStats.stdDev.toFixed( digits, )} days, days since last volume: ${ this.seriesStats.daysSince } days, z value: ${this.seriesStats.zValue.toFixed( 4, )} deviations from mean, probability of new vol. by today: ${this.seriesStats.pValue.toFixed( 4, )}

Average page count: ${this.seriesStats.avgPages.toFixed( digits, )} pages, median page count: ${this.seriesStats.medianPages.toFixed( digits, )} pages`; this.dataText.style.margin = "1em"; let dateChartLine = this.dateChartThing.data.datasets.find( (data) => data.label == this.title, ); if (!dateChartLine) { this.dateChartThing.data.datasets.push({ label: this.title, fill: false, borderColor: this.lineColor, trendlineLinear: { style: "rgba(255,105,180, .6)", lineStyle: "dotted", width: 2, }, }); dateChartLine = this.dateChartThing.data.datasets[ this.dateChartThing.data.datasets.length - 1 ]; } dateChartLine.data = this.seriesData.map((datum) => { return { y: datum.volume, t: moment(datum.date, momentFormat) }; }); this.dateChartThing.update(); if (this.delayChartThing) { this.delayChartThing.data.labels = this.seriesData.map( (datum) => datum.volume, ); this.delayChartThing.data.datasets.find( (data) => (data.label = this.title), ).data = this.seriesData.map((datum) => datum.wait.toFixed(digits)); this.delayChartThing.update(); } if (this.pageChartThing) { this.pageChartThing.data.labels = this.seriesData.map( (datum) => datum.volume, ); this.pageChartThing.data.datasets.find( (data) => data.label == this.title, ).data = this.seriesData.map((datum) => datum.pageCount); this.pageChartThing.update(); } } } /** * Gets book URLs, element to insert, and title of a page * @param {document} doc page document * @param {string} url */ async function getPageInfo(doc, url, main = true) { let bookURLs = [], insertChart, title; let type = getPageType(url); if (type == "bw") { insertChart = doc.querySelector("section.o-contents-section"); let titleElem = doc.querySelector("h2.o-contents-section__title"); title = titleElem ? titleElem.innerText : "Unknown title"; let match = title.match(/『(.*)』/); title = match ? match[1] : title; let last = doc.querySelector("div.pager.clearfix > ul .last a[href]"); if (main && last) { for (let i = 1; i <= parseInt(last.href.split("").pop()); i++) { let otherUrl = last.href.substr(0, last.href.length - 1) + i; console.log(otherUrl); let otherDoc = document.createElement("html"); otherDoc.innerHTML = await xmlhttpRequestText(otherUrl); bookURLs.unshift( ...(await getPageInfo(otherDoc, otherUrl, false)).bookURLs, ); otherDoc.remove(); } } else { [ ...doc.querySelector(".o-contents-section__body .m-tile-list").children, ].forEach((book) => { let em = book.querySelector("p a[href]"); if (em) bookURLs.unshift(em.href); else { em = book.querySelector("div"); if (em.dataset.url) bookURLs.unshift(em.dataset.url); } }); } } if (type == "bwg") { insertChart = doc.querySelector(".book-list-area"); title = doc .querySelector(".title-main-inner") .childNodes[0].textContent.trim(); console.log(title); let bookslist = doc.querySelector(".o-tile-list"); Array.from(bookslist.children).forEach((book) => { let em = book.querySelector(".o-tile-book-info a[title]"); bookURLs.unshift(em.href); }); console.log(bookURLs); } return { bookURLs, insertChart, title }; } /** * Fetches info about a series given a list of book URLs * @param {string[]} bookURLs list of URLs to fetch * @param {Element} textFeedback document element to update with feedback * @param {*} div thing to make resizable or not while getting books */ async function getSeriesInfo(bookURLs, textFeedback = null, div = null) { let seriesData = []; let vol = 1, lastDate; if (div) resizable(div.className, false); for (let url of bookURLs) { let { volume, date, pageCount, title } = await getInfo(url); if (!lastDate) lastDate = date; seriesData.push({ volume: volume, title: title, date: date.format(momentFormat), wait: (date.valueOf() - lastDate.valueOf()) / dayMs, pageCount: pageCount, consec: vol, }); lastDate = date; vol++; console.log({ volume, date, pageCount }); if (textFeedback) { textFeedback.innerText = `Retrieved data for volume ${volume} released on ${dateString( date, )} with ${pageCount} pages. (${vol}/${bookURLs.length})`; } } if (div) resizable(div.className, true); return seriesData; } /** * Gets information about a given URL. * @param {string} url URL of page to fetch info of */ async function getInfo(url) { let volume, date, pageCount, title, dateString; let doc = document.createElement("html"); doc.innerHTML = await xmlhttpRequestText(url); let type = getPageType(url); if (type == "bw") { let titleElem = doc.querySelector("h1.p-main__title"); title = titleElem ? titleElem.innerText : "Unknown title"; const dataLabels = [...doc.querySelector(".p-information__data").children]; let originalDateElem = dataLabels.find( (elem) => elem.innerText == "底本発行日", ); let releaseDateElem = dataLabels.find( (elem) => elem.innerText == "配信開始日", ); let originalDateString = originalDateElem ? originalDateElem.nextElementSibling.innerText : null; let releaseDateString = releaseDateElem ? releaseDateElem.nextElementSibling.innerText : null; // Sometimes the date is deformed dateString = originalDateString?.length >= 10 ? originalDateString : releaseDateString; let pageCountElem = [ ...doc.querySelector(".p-information__data").children, ].find((elem) => elem.innerText == "ページ概数"); pageCount = pageCountElem ? parseInt(pageCountElem.nextElementSibling.innerText) : 0; } else if (type == "bwg") { let titleElem = doc.querySelector("h1"); title = titleElem ? titleElem.innerHTML.split(" elem.firstElementChild.innerText == "Available since") .lastElementChild.innerText.split(" (")[0]; let pageCountString = Array.from( doc.querySelector(".product-detail").firstElementChild.children, ).find((elem) => elem.firstElementChild.innerText == "Page count") .lastElementChild.innerText; pageCount = parseInt(/\d+/.exec(pageCountString)[0] ?? 1); } let matches = fullWidthNumConvert(title).match(volRegex); matches = matches ? matches.map((elem) => parseFloat(elem)) : []; // find last element in matches that's less than max vol volume = matches.reverse().find((elem) => elem < maxVol) ?? 1; date = dateString ? moment(dateString) : null; doc.remove(); return { volume, date, pageCount, title }; } /** * given series info, return stats for waits and pages * @param {Object[]} data */ function getStats(data) { let waits = data .map((datum) => datum.wait) .filter((wait) => wait > ignoreThreshold), pages = data .map((datum) => datum.pageCount) .filter((pages) => pages > ignoreThreshold); let avgWait = avg(waits), medianWait = median(waits), avgPages = avg(pages), medianPages = median(pages); let weightedWait = 0, i = waits.length, totalWeight = 0, currWeight = 1; while (i > 0) { i--; weightedWait += waits[i] * currWeight; totalWeight += currWeight; currWeight *= weightMultiple; } let stdDev = getStandardDeviation(waits), daysSince = moment().diff( moment(data[data.length - 1].date, momentFormat), "d", ), zValue = (daysSince - avgWait) / stdDev, probability = getZPercent(zValue); weightedWait /= totalWeight; return { avgWait, medianWait, avgPages, medianPages, weightedWait, stdDev, daysSince, zValue, pValue: probability, }; } /** * Returns the type of page of the url ('bw' or 'bwg') for bookwalker * and bookwalker global. * @param {string} url */ function getPageType(url) { let type = ""; if (url.includes("bookwalker.jp") && !url.includes("global")) { type = "bw"; } else if (url.includes("global")) { type = "bwg"; } return type; } /** * converts full width characters in a string to being normal width * @param {string} fullWidthNum string to convert */ function fullWidthNumConvert(fullWidthNum) { return fullWidthNum.replace(/[\uFF10-\uFF19\uFF0e]/g, function (m) { return String.fromCharCode(m.charCodeAt(0) - 0xfee0); }); } /** * Average of array. * @param {number[]} arr input array to average */ function avg(arr) { return arr.reduce((prev, curr) => prev + curr, 0) / arr.length; } /** * Median of array. * @param {number[]} values input array to find median of */ function median(values) { let values_ = [...values]; if (values_.length === 0) return 0; values_.sort(function (a, b) { return a - b; }); let half = Math.floor(values_.length / 2); if (values_.length % 2) return values_[half]; return (values_[half - 1] + values_[half]) / 2.0; } /** * Standard deviation (from stackoverflow) * @param {number[]} array array to get std dev of */ function getStandardDeviation(array) { const n = array.length; const mean = avg(array); return Math.sqrt(avg(array.map((x) => Math.pow(x - mean, 2)))); } /** * Returns the p-value of a given z-score. (from stackoverflow) * @param {number} z standard deviations from mean */ function getZPercent(z) { //z == number of standard deviations from the mean //if z is greater than 6.5 standard deviations from the mean //the number of significant digits will be outside of a reasonable //range if (z < -6.5) return 0.0; if (z > 6.5) return 1.0; let factK = 1, sum = 0, term = 1, k = 0, loopStop = Math.exp(-23); while (Math.abs(term) > loopStop) { term = (((0.3989422804 * Math.pow(-1, k) * Math.pow(z, k)) / (2 * k + 1) / Math.pow(2, k)) * Math.pow(z, k + 1)) / factK; sum += term; k++; factK *= k; } sum += 0.5; return sum; } /** * Format date to D MMMM YYYY * @param {Date} date */ function dateString(date) { return date.format("DD MMMM YYYY"); } /** * Sets resizing on an element. * @param {string} className the name of the class to resize * @param {*} resize whether to resize right and bottom */ function resizable(className, resize = true) { interact(`.${className}`) .resizable({ // resize from all edges and corners edges: { left: false, right: resize, bottom: resize, top: false, }, modifiers: [ // minimum size interact.modifiers.restrictSize({ min: { width: 600 }, }), ], inertia: true, }) .on("resizemove", (event) => { let { x, y } = event.target.dataset; x = parseFloat(x) || 0; y = parseFloat(y) || 0; Object.assign(event.target.style, { width: `${event.rect.width}px`, height: `${event.rect.height}px`, transform: `translate(${event.deltaRect.left}px, ${event.deltaRect.top}px)`, }); Object.assign(event.target.dataset, { x, y }); }); } /** * Promise that gets a URL and resolves with the text (because cors) * @param {string} url URL to get */ function xmlhttpRequestText(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, onload: async (response) => { resolve(response.responseText); }, }); }); } /** * Makes an html element from a string * @param {string} html html to make */ function htmlToElement(html) { let template = document.createElement("template"); html = html.trim(); // Never return a text node of whitespace as the result template.innerHTML = html; return template.content.firstChild; } /** * Adds needed CSS to the page. */ function addCSS() { GM_addStyle(`.charts { width: 100%; padding: 1em 1em; border-width: medium; border-style: dashed; border-color: #D6D8D9; touch-action: none; box-sizing: border-box; text-align: center; height: auto; overflow: auto; } .series { padding: 0em; height: auto; } .titleHeader { font-size: large; } .switch { position: relative; display: inline-block; width: 30px; height: 17px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; } .slider:before { position: absolute; content: ""; height: 13px; width: 13px; left: 2px; bottom: 2px; background-color: white; -webkit-transition: .4s; transition: .4s; } input:checked + .slider { background-color: #2196F3; } input:focus + .slider { box-shadow: 0 0 1px #2196F3; } input:checked + .slider:before { -webkit-transform: translateX(13px); -ms-transform: translateX(13px); transform: translateX(13px); } /* Rounded sliders */ .slider.round { border-radius: 17px; } .slider.round:before { border-radius: 50%; }`); GM_addStyle(GM_getResourceText("hotCSS")); }