1// FE-068: levels page level-progression plot. 2// 3// Progressive enhancement only: if Chart.js failed to load, the endpoint 4// errors, or there's no data, the #levelHistoryChart container is left 5// hidden and nothing else on the page is affected. Reuses the FE-063 6// pattern (data island, theme-toggle restyle) with /api/getlevels. 7(function () { 8 "use strict"; 9 10 var CONTAINER_ID = "levelHistoryChart"; 11 var CANVAS_ID = "levelHistoryCanvas"; 12 var DATA_ID = "levels-data"; 13 var CHART_URL = "https://unpkg.com/[email protected]/dist/chart.umd.min.js"; 14 var CHART_INTEGRITY = 15 "sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ"; 16 17 function themeColors() { 18 var style = getComputedStyle(document.body); 19 return { 20 text: style.color, 21 grid: "rgba(128, 128, 128, 0.25)", 22 line: "#dc3545", 23 }; 24 } 25 26 function timestampMs(value) { 27 // MySQL timestamps have no timezone. Convert their numeric components to 28 // a timezone-neutral coordinate so DST and the viewer's locale cannot 29 // distort elapsed-time spacing. 30 if (typeof value !== "string") return NaN; 31 var match = value.match( 32 /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/, 33 ); 34 if (!match) return NaN; 35 var parts = match.slice(1).map(Number); 36 var year = parts[0]; 37 var month = parts[1] - 1; 38 var valueMs = Date.UTC(year, month, parts[2], parts[3], parts[4], parts[5]); 39 var parsed = new Date(valueMs); 40 // Date.UTC normalizes impossible dates (for example, February 31). 41 // Reject them by round-tripping every component. Level history is modern 42 // game data, so excluding Date's special 0-99 year behavior is intentional. 43 if ( 44 year < 1000 || 45 parsed.getUTCFullYear() !== year || 46 parsed.getUTCMonth() !== month || 47 parsed.getUTCDate() !== parts[2] || 48 parsed.getUTCHours() !== parts[3] || 49 parsed.getUTCMinutes() !== parts[4] || 50 parsed.getUTCSeconds() !== parts[5] 51 ) { 52 return NaN; 53 } 54 return valueMs; 55 } 56 57 function formatDate(value) { 58 return new Intl.DateTimeFormat(undefined, { 59 year: "numeric", 60 month: "short", 61 day: "numeric", 62 timeZone: "UTC", 63 }).format(new Date(value)); 64 } 65 66 function buildConfig(entries) { 67 var colors = themeColors(); 68 return { 69 type: "line", 70 data: { 71 datasets: [ 72 { 73 label: "Level", 74 data: entries.map(function (e) { 75 return { 76 x: timestampMs(e.date), 77 y: Number(e.level), 78 timestamp: e.date, 79 }; 80 }), 81 borderColor: colors.line, 82 backgroundColor: colors.line, 83 stepped: true, 84 pointRadius: 0, 85 borderWidth: 2, 86 }, 87 ], 88 }, 89 options: { 90 responsive: true, 91 maintainAspectRatio: false, 92 interaction: { mode: "index", intersect: false }, 93 scales: { 94 x: { 95 type: "linear", 96 ticks: { 97 color: colors.text, 98 maxTicksLimit: 10, 99 callback: function (value) { 100 return formatDate(value); 101 }, 102 }, 103 grid: { color: colors.grid }, 104 }, 105 y: { 106 beginAtZero: true, 107 ticks: { color: colors.text, precision: 0 }, 108 grid: { color: colors.grid }, 109 }, 110 }, 111 plugins: { 112 legend: { display: false }, 113 tooltip: { 114 callbacks: { 115 title: function (items) { 116 return items.length ? items[0].raw.timestamp : ""; 117 }, 118 label: function (item) { 119 return "Level: " + item.raw.y; 120 }, 121 }, 122 }, 123 }, 124 }, 125 }; 126 } 127 128 function restyle(chart, entries) { 129 var next = buildConfig(entries); 130 chart.data.datasets[0].borderColor = next.data.datasets[0].borderColor; 131 chart.data.datasets[0].backgroundColor = 132 next.data.datasets[0].backgroundColor; 133 chart.options.scales.x.ticks.color = next.options.scales.x.ticks.color; 134 chart.options.scales.x.grid.color = next.options.scales.x.grid.color; 135 chart.options.scales.y.ticks.color = next.options.scales.y.ticks.color; 136 chart.options.scales.y.grid.color = next.options.scales.y.grid.color;
137 chart.update(); 138 } 139 140 function init(root, container) { 141 var canvas = container.querySelector("#" + CANVAS_ID); 142 var dataEl = root.querySelector("#" + DATA_ID); 143 if (!container || !canvas || !dataEl || !window.loadScriptOnce) return; 144 145 var name; 146 try { 147 name = JSON.parse(dataEl.textContent).name; 148 } catch (e) { 149 return; 150 } 151 if (!name) return; 152 153 var active = true; 154 var controller = new AbortController(); 155 var observer = null; 156 var themeTimeout = null; 157 var chart = null; 158 159 var chartReady = loadScriptOnce(CHART_URL, CHART_INTEGRITY); 160 var historyReady = fetch( 161 "/api/getlevels?name=" + encodeURIComponent(name), 162 { signal: controller.signal }, 163 ).then(function (response) { 164 if (!response.ok) throw new Error("bad response"); 165 return response.json(); 166 }); 167 168 Promise.all([chartReady, historyReady]) 169 .then(function (results) { 170 var entries = results[1]; 171 if (typeof Chart === "undefined" || !entries || entries.length === 0) { 172 return; 173 } 174 175 entries = entries 176 .filter(function (entry) { 177 return ( 178 entry && 179 entry.level !== null && 180 entry.level !== "" && 181 Number.isInteger(Number(entry.level)) && 182 Number.isFinite(timestampMs(entry.date)) 183 ); 184 }) 185 .sort(function (a, b) { 186 return timestampMs(a.date) - timestampMs(b.date); 187 }); 188 if (!active || !canvas.isConnected || entries.length === 0) return; 189 chart = new Chart(canvas, buildConfig(entries)); 190 container.hidden = false; 191 192 var themeSheet = document.getElementById("theme_sheet"); 193 if (themeSheet && window.MutationObserver) { 194 observer = new MutationObserver(function () { 195 // Wait for theme_utilities.js's stylesheet transition to finish. 196 clearTimeout(themeTimeout); 197 themeTimeout = setTimeout(function () { 198 if (active && canvas.isConnected) restyle(chart, entries); 199 }, 700); 200 }); 201 observer.observe(themeSheet, { 202 attributes: true, 203 attributeFilter: ["href"], 204 }); 205 } 206 }) 207 .catch(function (error) { 208 if (error.name === "AbortError") return; 209 // Endpoint failure or missing Chart.js: leave the container hidden. 210 }); 211 212 return function cleanup() { 213 if (!active) return; 214 active = false; 215 controller.abort(); 216 if (observer) observer.disconnect(); 217 clearTimeout(themeTimeout); 218 if (chart) chart.destroy(); 219 }; 220 } 221 222 registerPageInit("level-history", "#" + CONTAINER_ID, init); 223})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.