PageSourceSearch

https://legends.ml/app-js/eventhandler_level_history.js?v=2.26.0

js legends.ml collected 2026-10-08 09:26:10 UTC 7,102 bytes, 223 lines download raw bytes

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.