diff --git a/counters/Judgement Visualizer by Albert/Chewy-Regular.ttf b/counters/Judgement Visualizer by Albert/Chewy-Regular.ttf
new file mode 100644
index 0000000..4bfa56a
Binary files /dev/null and b/counters/Judgement Visualizer by Albert/Chewy-Regular.ttf differ
diff --git a/counters/Judgement Visualizer by Albert/arrow.png b/counters/Judgement Visualizer by Albert/arrow.png
new file mode 100644
index 0000000..6d9f01d
Binary files /dev/null and b/counters/Judgement Visualizer by Albert/arrow.png differ
diff --git a/counters/Judgement Visualizer by Albert/demo.gif b/counters/Judgement Visualizer by Albert/demo.gif
new file mode 100644
index 0000000..c2644c0
Binary files /dev/null and b/counters/Judgement Visualizer by Albert/demo.gif differ
diff --git a/counters/Judgement Visualizer by Albert/index.html b/counters/Judgement Visualizer by Albert/index.html
new file mode 100644
index 0000000..7594aea
--- /dev/null
+++ b/counters/Judgement Visualizer by Albert/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/counters/Judgement Visualizer by Albert/main.css b/counters/Judgement Visualizer by Albert/main.css
new file mode 100644
index 0000000..6c7fc2c
--- /dev/null
+++ b/counters/Judgement Visualizer by Albert/main.css
@@ -0,0 +1,209 @@
+@font-face {
+ font-family: 'Chewy';
+ src: url('./Chewy-Regular.ttf');
+}
+
+:root {
+ --color-perfect: #ffffff;
+ --container-width: auto;
+ --bar-height: 20px;
+ --tick-width: 6px;
+ --tick-height: 30px;
+ --center-line-width: 6px;
+ --center-line-height: 40px;
+ --tick-offset-y: 0px;
+ --arrow-size: 18px;
+ --hit-counts-offset-y: 0px;
+ --hit-counts-font-size: 30px;
+ --bg-padding: 10px;
+ --bg-offset-y: 0px;
+ --max-bar-width: 0px;
+ --overall-bg-height: 30px;
+ --auto-bg-offset-y: 0px;
+ --auto-hit-margin-top: 0px;
+ --auto-hit-margin-bottom: 0px;
+ --container-pad-top: 0px;
+ --container-pad-bottom: 0px;
+}
+
+body, html {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ background: transparent;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ font-family: 'Chewy', Verdana, sans-serif;
+ transform: translateZ(0);
+ -webkit-font-smoothing: antialiased;
+}
+
+#container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: var(--container-width);
+ min-width: calc(var(--max-bar-width) + (var(--bg-padding) * 2) + 20px);
+ transition: opacity 300ms linear;
+ padding-top: var(--container-pad-top);
+ padding-bottom: var(--container-pad-bottom);
+}
+
+.main {
+ order: 2;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: var(--container-width);
+ height: 50px;
+ background: transparent;
+ position: relative;
+}
+
+/* Background Timing Windows */
+.colors-container {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--bar-height);
+ width: var(--container-width);
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.colors-container > div {
+ opacity: 1;
+ position: absolute;
+ height: var(--bar-height);
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ transition: width 200ms linear;
+}
+
+/* Dynamic Background Layer */
+.bg-layer {
+ position: absolute;
+ z-index: -2;
+ width: calc(var(--max-bar-width) + (var(--bg-padding) * 2));
+ height: calc(var(--overall-bg-height) + (var(--bg-padding) * 2));
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, calc(-50% + var(--auto-bg-offset-y)));
+ border-radius: 0;
+ pointer-events: none;
+}
+
+/* Hit Error Ticks */
+.tick-container {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--container-width);
+ height: var(--tick-height);
+ top: 50%;
+ transform: translateY(-50%);
+ margin-top: var(--tick-offset-y);
+}
+
+.tick {
+ position: absolute;
+ width: var(--tick-width);
+ height: var(--tick-height);
+ background-color: var(--color-perfect);
+ border-radius: calc(var(--tick-width) / 2);
+ z-index: 10;
+}
+
+/* Center Indicator */
+.middle-line {
+ position: absolute;
+ width: var(--center-line-width);
+ height: var(--center-line-height);
+ background-color: #fff;
+ border-radius: calc(var(--center-line-width) / 2);
+ z-index: 11;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+/* Image Arrow */
+#arrow-container {
+ order: 3;
+ display: flex;
+ justify-content: center;
+ width: var(--container-width);
+ height: var(--arrow-size);
+}
+
+.arrow {
+ transition: transform 150ms ease-out;
+ width: calc(var(--arrow-size) * 1.5);
+ height: var(--arrow-size);
+ background-image: url('./arrow.png');
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ transform: translate3d(0px, 0px, 0px);
+ filter: drop-shadow(0px 2px 3px rgba(0,0,0,0.8));
+}
+
+/* Hit Counts Grid */
+#hit-counts-grid {
+ order: 4;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ width: 100%;
+
+ margin-top: calc(var(--hit-counts-offset-y) + var(--auto-hit-margin-top));
+ margin-bottom: var(--auto-hit-margin-bottom);
+
+ font-weight: bold;
+ font-size: var(--hit-counts-font-size);
+}
+
+.side-counts {
+ display: flex;
+ min-width: 0;
+ gap: calc(var(--max-bar-width) * 0.04);
+}
+
+.left-counts {
+ justify-content: flex-end;
+}
+
+.right-counts {
+ justify-content: flex-start;
+}
+
+.center-count {
+ display: flex;
+ justify-content: center;
+ margin: 0 calc(var(--max-bar-width) * 0.04);
+}
+
+.count-box {
+ text-align: center;
+ text-shadow: 0 0 6px rgba(0,0,0,1);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+ transform: translateZ(0);
+ -webkit-font-smoothing: antialiased;
+}
+
+.digit {
+ display: inline-block;
+ width: 0.65em;
+ text-align: center;
+}
+
+.hidden {
+ opacity: 0 !important;
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/counters/Judgement Visualizer by Albert/main.js b/counters/Judgement Visualizer by Albert/main.js
new file mode 100644
index 0000000..c51bebc
--- /dev/null
+++ b/counters/Judgement Visualizer by Albert/main.js
@@ -0,0 +1,607 @@
+class WebSocketManager {
+ constructor(host) {
+ this.host = host;
+ this.sockets = {};
+ this.createConnection = this.createConnection.bind(this);
+ }
+
+ createConnection(url, callback, filters) {
+ let interval;
+ const counterPath = window.COUNTER_PATH || "";
+ const query = url.includes("?") ? `&l=${encodeURI(counterPath)}` : `?l=${encodeURI(counterPath)}`;
+ const fullUrl = `ws://${this.host}${url}${query}`;
+ this.sockets[url] = new WebSocket(fullUrl);
+
+ this.sockets[url].onopen = () => {
+ if (interval) clearInterval(interval);
+ if (filters) this.sockets[url].send(`applyFilters:${JSON.stringify(filters)}`);
+ };
+
+ this.sockets[url].onclose = () => {
+ delete this.sockets[url];
+ interval = setTimeout(() => this.createConnection(url, callback, filters), 1000);
+ };
+
+ this.sockets[url].onmessage = ({ data }) => {
+ if (!data) return;
+ try {
+ const parsed = JSON.parse(data);
+ if (parsed && typeof parsed === "object" && !("error" in parsed)) callback(parsed);
+ } catch (e) {}
+ };
+ }
+
+ api_v2(callback, filters) { this.createConnection("/websocket/v2", callback, filters); }
+ api_v2_precise(callback, filters) { this.createConnection("/websocket/v2/precise", callback, filters); }
+ commands(callback) { this.createConnection("/websocket/commands", callback); }
+
+ sendCommand(name, command, retry = 1) {
+ const socket = this.sockets["/websocket/commands"];
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(`${name}:${command}`);
+ } else if (retry <= 50) {
+ setTimeout(() => this.sendCommand(name, command, retry + 1), 100);
+ }
+ }
+}
+
+const getWindows = (mode, od, modsString) => {
+ let mOd = od;
+ const mods = modsString || "";
+
+ if (mode === "mania") {
+ if (mods.includes("EZ")) mOd = od * 0.5;
+ const hrMult = mods.includes("HR") ? 5/7 : 1;
+
+ return [
+ 16,
+ ((64 - 3 * mOd) * hrMult),
+ ((97 - 3 * mOd) * hrMult),
+ ((127 - 3 * mOd) * hrMult),
+ ((151 - 3 * mOd) * hrMult)
+ ];
+ }
+
+ if (mods.includes("EZ")) mOd = od / 2;
+ else if (mods.includes("HR")) mOd = Math.min(od * 1.4, 10);
+
+ if (mode === "taiko") {
+ return [
+ (50 - 3 * mOd),
+ (mOd >= 5 ? 119.5 - 8 * mOd : 110 - 6 * mOd),
+ (mOd >= 5 ? 135 - 8 * mOd : 120 - 5 * mOd)
+ ];
+ }
+
+ return [
+ (80 - 6 * mOd),
+ (140 - 8 * mOd),
+ (200 - 10 * mOd)
+ ];
+};
+
+const median = (arr) => {
+ if (!arr || arr.length === 0) return 0;
+ const sorted = arr.slice().sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
+};
+
+const setCSSVar = (name, value) => document.documentElement.style.setProperty(name, value);
+
+const getGridPalette = (mode) => {
+ if (mode === "mania") return [settings.color300g, settings.color300, settings.color200, settings.color100, settings.color50, settings.color0];
+ if (mode === "taiko") return [settings.color300, settings.color200, settings.color0, settings.color0];
+ return [settings.color300, settings.color200, settings.color100, settings.color0];
+};
+
+const getBarPalette = (mode) => {
+ if (mode === "mania") return [settings.color300g, settings.color300, settings.color200, settings.color100, settings.color50];
+ if (mode === "taiko") return [settings.color300, settings.color200, settings.color0];
+ return [settings.color300, settings.color200, settings.color100];
+};
+
+const formatCache = new Map();
+const formatNum = (num) => {
+ if (formatCache.has(num)) return formatCache.get(num);
+ const str = num.toString().split('').map(d => `${d} `).join('');
+ if (formatCache.size > 5000) formatCache.clear(); // prevent unbounded memory growth
+ formatCache.set(num, str);
+ return str;
+};
+
+class TickPool {
+ constructor(size, container) {
+ this.size = size;
+ this.container = container;
+ this.ticks = Array.from({ length: size }, () => {
+ const el = document.createElement("div");
+ el.className = "tick hidden";
+ el.style.opacity = '0';
+ container.appendChild(el);
+ return el;
+ });
+ this.index = 0;
+ this.animations = new Array(size);
+ }
+
+ reset() {
+ for (let i = 0; i < this.size; i++) {
+ if (this.animations[i]) {
+ this.animations[i].cancel();
+ this.animations[i] = null;
+ }
+ this.ticks[i].style.opacity = '0';
+ }
+ this.index = 0;
+ }
+
+ add(error, windows, mode) {
+ const el = this.ticks[this.index];
+ if (this.animations[this.index]) {
+ this.animations[this.index].cancel();
+ }
+
+ let tickColor = settings.tickColor;
+ if (settings.useDynamicTickColor) {
+ const msAbs = Math.abs(error);
+ const palette = getGridPalette(mode);
+ tickColor = palette[palette.length - 1];
+ if (windows && windows.length > 0) {
+ for (let i = 0; i < windows.length; i++) {
+ if (msAbs <= windows[i]) { tickColor = palette[i]; break; }
+ }
+ }
+ }
+
+ el.className = "tick";
+ el.style.backgroundColor = tickColor;
+ el.style.transform = `translate3d(${error * 2}px, 0, 0)`;
+
+ const holdPhase = settings.tickDuration || 0;
+ const fadePhase = settings.fadeOutDuration || 0;
+ const totalDuration = holdPhase + fadePhase;
+
+ if (totalDuration > 0) {
+ this.animations[this.index] = el.animate([
+ { opacity: 1, offset: 0 },
+ { opacity: 1, offset: holdPhase / totalDuration },
+ { opacity: 0, offset: 1 }
+ ], {
+ duration: totalDuration,
+ fill: 'forwards'
+ });
+ } else {
+ el.style.opacity = '1';
+ }
+
+ this.index = (this.index + 1) % this.size;
+ }
+}
+
+const wsManager = new WebSocketManager(window.location.host);
+
+let settings = {
+ color300g: "#ffffff", color300: "#ffcc22", color200: "#47e547", color100: "#50b4ff", color50: "#888888", color0: "#ff4747",
+ useDynamicTickColor: false, tickColor: "#ffffff",
+ showBg: false, bgColor: "#000000", bgOpacity: 50, bgPadding: 10,
+ hideArrow: false, hideHitErrorBar: false,
+ tickWidth: 6, tickHeight: 30, tickDuration: 1000, fadeOutDuration: 1000, barHeight: 20,
+ tickOffsetY: 0, showHitCounts: true, hitCountsOffsetY: 0, hitCountsFontSize: 40,
+ centerLineWidth: 6, centerLineHeight: 40, hitCountsOnTop: false, arrowOnTop: false
+};
+
+let cache = { state: "", mode: "osu", od: 0, mods: "", rate: 1, windows: [], processedHits: 0, firstObj: 0, curTotalHits: 0, lastTime: 0, centerHits: 0 };
+let cachedBoxes = [];
+
+// e = early hits, l = late hits
+let hitTally = {
+ mania: [ {e:0, l:0}, {e:0, l:0}, {e:0, l:0}, {e:0, l:0}, {e:0, l:0} ], // 300g, 300, 200, 100, 50
+ taiko: [ {e:0, l:0}, {e:0, l:0} ], // great, ok
+ std: [ {e:0, l:0}, {e:0, l:0}, {e:0, l:0} ], // 300, 100, 50
+};
+
+let displayTally = {
+ mania: [ {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1} ],
+ taiko: [ {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1} ],
+ std: [ {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1}, {e:0, l:0, t:0, _e:-1, _l:-1} ],
+};
+
+let resetTimeout = null;
+
+const tickPool = new TickPool(250, document.querySelector(".tick-container"));
+const containerEl = document.getElementById("container");
+const colorsEl = document.querySelector(".colors-container");
+const gridEl = document.getElementById("hit-counts-grid");
+const arrowEl = document.querySelector(".arrow");
+const preciseBuffer = [];
+
+function buildGrid(mode, windowsLength) {
+ gridEl.innerHTML = "";
+ const actualWindows = (mode === "taiko") ? 2 : windowsLength;
+ const numBoxes = actualWindows * 2 + 1;
+ const centerIdx = Math.floor(numBoxes / 2);
+ const palette = getGridPalette(mode);
+
+ const leftContainer = document.createElement("div");
+ leftContainer.className = "side-counts left-counts";
+
+ const centerContainer = document.createElement("div");
+ centerContainer.className = "center-count";
+
+ const rightContainer = document.createElement("div");
+ rightContainer.className = "side-counts right-counts";
+
+ for (let i = 0; i < numBoxes; i++) {
+ const box = document.createElement("div");
+ box.className = "count-box";
+ box.style.color = palette[Math.abs(i - centerIdx)] || palette[palette.length - 1];
+ box.innerHTML = formatNum(0);
+
+ if (i < centerIdx) {
+ leftContainer.appendChild(box);
+ } else if (i === centerIdx) {
+ centerContainer.appendChild(box);
+ } else {
+ rightContainer.appendChild(box);
+ }
+ }
+
+ gridEl.appendChild(leftContainer);
+ gridEl.appendChild(centerContainer);
+ gridEl.appendChild(rightContainer);
+
+ cachedBoxes = Array.from(gridEl.querySelectorAll('.count-box'));
+}
+
+function resetOverlay() {
+ tickPool.reset();
+ preciseBuffer.length = 0;
+ cache.centerHits = 0;
+
+ hitTally.mania.forEach(t => { t.e = 0; t.l = 0; });
+ hitTally.taiko.forEach(t => { t.e = 0; t.l = 0; });
+ hitTally.std.forEach(t => { t.e = 0; t.l = 0; });
+
+ displayTally.mania.forEach(t => { t.e = 0; t.l = 0; t.t = 0; t._e = -1; t._l = -1; });
+ displayTally.taiko.forEach(t => { t.e = 0; t.l = 0; t.t = 0; t._e = -1; t._l = -1; });
+ displayTally.std.forEach(t => { t.e = 0; t.l = 0; t.t = 0; t._e = -1; t._l = -1; });
+
+ const zeroStr = formatNum(0);
+ cachedBoxes.forEach(el => el.innerHTML = zeroStr);
+ arrowEl.style.transform = `translate3d(0px, 0px, 0px)`;
+}
+
+function distributeDelta(newTotal, display, preciseTally, isMiss = false) {
+ const targetTotal = newTotal || 0;
+ const delta = targetTotal - display.t;
+ if (delta <= 0) return;
+
+ if (isMiss) {
+ const availableE = Math.max(0, preciseTally.e - display.e);
+ const addE = Math.min(delta, availableE);
+ display.e += addE;
+ display.l += (delta - addE);
+ } else {
+ const preciseTotal = preciseTally.e + preciseTally.l;
+ if (preciseTotal === 0) {
+ const half = Math.floor(delta / 2);
+ display.e += half;
+ display.l += (delta - half);
+ } else {
+ const expectedE = Math.round(targetTotal * (preciseTally.e / preciseTotal));
+ let addE = expectedE - display.e;
+ addE = Math.max(0, Math.min(delta, addE));
+ display.e += addE;
+ display.l += (delta - addE);
+ }
+ }
+ display.t = targetTotal;
+}
+
+function syncTrueHitsToGrid(hits) {
+ if (!cachedBoxes.length) return;
+
+ const updateCenter = (index, value) => {
+ if (value > cache.centerHits) {
+ cache.centerHits = value;
+ cachedBoxes[index].innerHTML = formatNum(value);
+ }
+ };
+
+ const setBox = (index, tally, key) => {
+ if (tally[key] !== tally["_" + key]) {
+ cachedBoxes[index].innerHTML = formatNum(tally[key]);
+ tally["_" + key] = tally[key];
+ }
+ };
+
+ if (cache.mode === "mania" && cachedBoxes.length === 11) {
+ updateCenter(5, hits.geki || 0);
+ distributeDelta(hits[300], displayTally.mania[0], hitTally.mania[0]);
+ setBox(4, displayTally.mania[0], 'e'); setBox(6, displayTally.mania[0], 'l');
+ distributeDelta(hits.katu, displayTally.mania[1], hitTally.mania[1]);
+ setBox(3, displayTally.mania[1], 'e'); setBox(7, displayTally.mania[1], 'l');
+ distributeDelta(hits[100], displayTally.mania[2], hitTally.mania[2]);
+ setBox(2, displayTally.mania[2], 'e'); setBox(8, displayTally.mania[2], 'l');
+ distributeDelta(hits[50], displayTally.mania[3], hitTally.mania[3]);
+ setBox(1, displayTally.mania[3], 'e'); setBox(9, displayTally.mania[3], 'l');
+ distributeDelta(hits[0], displayTally.mania[4], hitTally.mania[4], true);
+ setBox(0, displayTally.mania[4], 'e'); setBox(10, displayTally.mania[4], 'l');
+
+ } else if (cache.mode === "taiko" && cachedBoxes.length === 5) {
+ updateCenter(2, hits[300] || 0);
+ distributeDelta(hits[100], displayTally.taiko[0], hitTally.taiko[0]);
+ setBox(1, displayTally.taiko[0], 'e'); setBox(3, displayTally.taiko[0], 'l');
+ distributeDelta(hits[0], displayTally.taiko[1], hitTally.taiko[1], true);
+ setBox(0, displayTally.taiko[1], 'e'); setBox(4, displayTally.taiko[1], 'l');
+
+ } else if (cachedBoxes.length === 7) {
+ updateCenter(3, hits[300] || 0);
+ distributeDelta(hits[100], displayTally.std[0], hitTally.std[0]);
+ setBox(2, displayTally.std[0], 'e'); setBox(4, displayTally.std[0], 'l');
+ distributeDelta(hits[50], displayTally.std[1], hitTally.std[1]);
+ setBox(1, displayTally.std[1], 'e'); setBox(5, displayTally.std[1], 'l');
+ distributeDelta(hits[0], displayTally.std[2], hitTally.std[2], true);
+ setBox(0, displayTally.std[2], 'e'); setBox(6, displayTally.std[2], 'l');
+ }
+}
+
+wsManager.commands((data) => {
+ try {
+ if (data.command !== "getSettings") return;
+ Object.assign(settings, data.message);
+
+ if (settings.barHeight !== undefined) setCSSVar("--bar-height", `${settings.barHeight}px`);
+ if (settings.tickWidth !== undefined) setCSSVar("--tick-width", `${settings.tickWidth}px`);
+ if (settings.tickHeight !== undefined) setCSSVar("--tick-height", `${settings.tickHeight}px`);
+ if (settings.centerLineWidth !== undefined) setCSSVar("--center-line-width", `${settings.centerLineWidth}px`);
+ if (settings.centerLineHeight !== undefined) setCSSVar("--center-line-height", `${settings.centerLineHeight}px`);
+ if (settings.hitCountsFontSize !== undefined) setCSSVar("--hit-counts-font-size", `${settings.hitCountsFontSize}px`);
+ if (settings.hitCountsOffsetY !== undefined) setCSSVar("--hit-counts-offset-y", `${settings.hitCountsOffsetY}px`);
+ if (settings.tickOffsetY !== undefined) setCSSVar("--tick-offset-y", `${settings.tickOffsetY}px`);
+
+ if (settings.hitCountsOnTop !== undefined) {
+ gridEl.style.order = settings.hitCountsOnTop ? "0" : "4";
+ }
+
+ const isBarHidden = settings.hideHitErrorBar;
+ const isArrowHidden = isBarHidden || settings.hideArrow;
+ const isBgVisible = !isBarHidden && settings.showBg;
+
+ const arrowCont = document.getElementById("arrow-container");
+ if (arrowCont) {
+ arrowCont.style.display = isArrowHidden ? "none" : "flex";
+ }
+ if (settings.arrowOnTop !== undefined) {
+ arrowCont.style.order = settings.arrowOnTop ? "1" : "3";
+ arrowCont.style.transform = settings.arrowOnTop ? "rotateX(180deg)" : "none";
+ }
+
+ const ticksEl = document.querySelector(".tick-container");
+ const centerLine = document.querySelector(".middle-line");
+ const barDisplay = isBarHidden ? "none" : "block";
+ if (colorsEl) colorsEl.style.display = barDisplay;
+ if (ticksEl) ticksEl.style.display = barDisplay;
+ if (centerLine) centerLine.style.display = barDisplay;
+
+ const bH = isBarHidden ? 0 : (settings.barHeight !== undefined ? Number(settings.barHeight) : 20);
+ const tH = isBarHidden ? 0 : (settings.tickHeight !== undefined ? Number(settings.tickHeight) : 30);
+ const tOff = isBarHidden ? 0 : (settings.tickOffsetY !== undefined ? Number(settings.tickOffsetY) : 0);
+ const bgPad = isBgVisible ? (settings.bgPadding !== undefined ? Number(settings.bgPadding) : 10) : 0;
+
+ let topEdge = isBarHidden ? 0 : Math.min(-bH / 2, tOff - tH / 2);
+ let bottomEdge = isBarHidden ? 0 : Math.max(bH / 2, tOff + tH / 2);
+
+ if (!isArrowHidden) {
+ const arrowReach = 43;
+ if (settings.arrowOnTop) {
+ topEdge = Math.min(topEdge, -arrowReach);
+ } else {
+ bottomEdge = Math.max(bottomEdge, arrowReach);
+ }
+ }
+
+ const newHeight = bottomEdge - topEdge;
+ const newCenterY = (topEdge + bottomEdge) / 2;
+
+ setCSSVar("--overall-bg-height", `${newHeight}px`);
+ setCSSVar("--auto-bg-offset-y", `${newCenterY}px`);
+
+ let autoHitMargin = 0;
+ if (!settings.hitCountsOnTop) {
+ const naturalStart = (!settings.arrowOnTop && !isArrowHidden) ? 43 : 25;
+ const absoluteBottomRaw = isBarHidden ? 0 : Math.max(bH / 2, tOff + tH / 2);
+ const absoluteBottom = absoluteBottomRaw + bgPad;
+ if (absoluteBottom > naturalStart) autoHitMargin = absoluteBottom - naturalStart;
+ const buffer = isBgVisible ? 5 : 0;
+ setCSSVar("--auto-hit-margin-top", `${autoHitMargin + buffer}px`);
+ setCSSVar("--auto-hit-margin-bottom", `0px`);
+ } else {
+ const naturalStart = (settings.arrowOnTop && !isArrowHidden) ? -43 : -25;
+ const absoluteTopRaw = isBarHidden ? 0 : Math.min(-bH / 2, tOff - tH / 2);
+ const absoluteTop = absoluteTopRaw - bgPad;
+ if (absoluteTop < naturalStart) autoHitMargin = Math.abs(absoluteTop - naturalStart);
+ const buffer = isBgVisible ? 5 : 0;
+ setCSSVar("--auto-hit-margin-bottom", `${autoHitMargin + buffer}px`);
+ setCSSVar("--auto-hit-margin-top", `0px`);
+ }
+
+ let containerPadTop = 0;
+ let containerPadBottom = 0;
+
+ if (!settings.hitCountsOnTop) {
+ const staticTop = (settings.arrowOnTop && !isArrowHidden) ? -43 : -25;
+ const absoluteTopRaw = isBarHidden ? 0 : Math.min(-bH / 2, tOff - tH / 2);
+ const absoluteTop = absoluteTopRaw - bgPad;
+ if (absoluteTop < staticTop) containerPadTop = Math.abs(absoluteTop - staticTop) + (isBgVisible ? 5 : 0);
+ } else {
+ const staticBottom = (!settings.arrowOnTop && !isArrowHidden) ? 43 : 25;
+ const absoluteBottomRaw = isBarHidden ? 0 : Math.max(bH / 2, tOff + tH / 2);
+ const absoluteBottom = absoluteBottomRaw + bgPad;
+ if (absoluteBottom > staticBottom) containerPadBottom = (absoluteBottom - staticBottom) + (isBgVisible ? 5 : 0);
+ }
+
+ setCSSVar("--container-pad-top", `${containerPadTop}px`);
+ setCSSVar("--container-pad-bottom", `${containerPadBottom}px`);
+
+ const bgEl = document.querySelector(".bg-layer");
+ if (bgEl) {
+ bgEl.style.display = isBgVisible ? "block" : "none";
+ if (settings.bgColor) bgEl.style.backgroundColor = settings.bgColor;
+ if (settings.bgOpacity !== undefined) bgEl.style.opacity = settings.bgOpacity / 100;
+ if (settings.bgPadding !== undefined) setCSSVar("--bg-padding", `${settings.bgPadding}px`);
+ }
+
+ if (cache.windows && cache.windows.length > 0) {
+ const barPal = getBarPalette(cache.mode);
+ Array.from(colorsEl.children).forEach((div, index) => {
+ div.style.backgroundColor = barPal[index] || barPal[barPal.length - 1];
+ });
+ const numBoxes = (cache.mode === "taiko" ? 2 : cache.windows.length) * 2 + 1;
+ const centerIdx = Math.floor(numBoxes / 2);
+ const gridPal = getGridPalette(cache.mode);
+ cachedBoxes.forEach((box, i) => {
+ box.style.color = gridPal[Math.abs(i - centerIdx)] || gridPal[gridPal.length - 1];
+ });
+ }
+ } catch (e) {}
+});
+
+wsManager.sendCommand("getSettings", window.COUNTER_PATH ? encodeURI(window.COUNTER_PATH) : "");
+
+wsManager.api_v2((data) => {
+ if (!data.state?.name) return;
+ if (data.state.name === "play") {
+ if (resetTimeout) { clearTimeout(resetTimeout); resetTimeout = null; }
+ const mode = data.play?.mode?.name ?? cache.mode;
+ const od = data.beatmap?.stats?.od?.original ?? cache.od;
+ const mods = data.play?.mods?.name ?? cache.mods;
+ if (cache.mode !== mode || cache.od !== od || cache.mods !== mods) {
+ cache.mode = mode; cache.od = od; cache.mods = mods;
+ cache.rate = data.play?.mods?.rate || 1;
+ cache.firstObj = data.beatmap?.time?.firstObject || 0;
+ cache.windows = getWindows(cache.mode, cache.od, cache.mods || "");
+ colorsEl.innerHTML = "";
+ buildGrid(cache.mode, cache.windows.length);
+ const barPal = getBarPalette(cache.mode);
+ const fragment = document.createDocumentFragment();
+
+ let maxBarWidth = 0;
+ cache.windows.forEach((width, index) => {
+ if (cache.mode === "taiko" && index > 2) return;
+ const w = width * 4;
+ if (w > maxBarWidth) maxBarWidth = w;
+ const div = document.createElement("div");
+ div.style.width = `${w}px`;
+ div.style.backgroundColor = barPal[index] || barPal[barPal.length - 1];
+ div.style.zIndex = 10 - index;
+ fragment.appendChild(div);
+ });
+ colorsEl.appendChild(fragment);
+
+ setCSSVar("--max-bar-width", `${maxBarWidth}px`);
+ resetOverlay();
+ cache.processedHits = 0;
+ cache.curTotalHits = 0;
+ }
+
+ // Hide entirely if playing Catch / Fruits
+ if (mode === "catch" || mode === "fruits") {
+ containerEl.classList.add("hidden");
+ } else {
+ containerEl.classList.remove("hidden");
+ }
+
+ const totalHits = (data.play?.hits?.geki || 0)
+ + (data.play?.hits?.[300] || 0)
+ + (data.play?.hits?.katu || 0)
+ + (data.play?.hits?.[100] || 0)
+ + (data.play?.hits?.[50] || 0)
+ + (data.play?.hits?.[0] || 0);
+
+ if (totalHits === 0 && cache.curTotalHits > 0) {
+ resetOverlay();
+ cache.curTotalHits = 0;
+ }
+
+ if (totalHits >= cache.curTotalHits) {
+ cache.curTotalHits = totalHits;
+ if (data.play?.hits) syncTrueHitsToGrid(data.play.hits);
+ }
+ } else if (cache.state === "play") {
+ containerEl.classList.add("hidden");
+ resetTimeout = setTimeout(() => {
+ resetOverlay();
+ cache.processedHits = 0;
+ cache.curTotalHits = 0;
+ resetTimeout = null;
+ }, settings.fadeOutDuration);
+ }
+ cache.state = data.state.name;
+}, ["state", { field: "play", keys: ["mode", "mods", "hits"] }, { field: "beatmap", keys: ["mode", "stats", "time"] }]);
+
+wsManager.api_v2_precise((data) => {
+ if (cache.state !== "play") return;
+ if (cache.mode === "catch" || cache.mode === "fruits") return;
+
+ const hitErrors = data.hitErrors;
+
+ if (data.currentTime < (cache.lastTime || 0) - 50) {
+ resetOverlay();
+ cache.lastTime = data.currentTime;
+ cache.processedHits = hitErrors.length;
+ return;
+ }
+ cache.lastTime = data.currentTime;
+
+ if (hitErrors.length < cache.processedHits) {
+ if (hitErrors.length === 0) {
+ resetOverlay();
+ cache.processedHits = 0;
+ } else if (cache.processedHits - hitErrors.length > 5) {
+ resetOverlay();
+ cache.processedHits = hitErrors.length;
+ }
+ return;
+ }
+
+ if (hitErrors.length > cache.processedHits) {
+ const newHits = hitErrors.slice(cache.processedHits);
+ cache.processedHits = hitErrors.length;
+
+ const mode = cache.mode;
+ const windows = cache.windows;
+
+ for (let i = 0; i < newHits.length; i++) {
+ const ms = newHits[i];
+ const msAbs = Math.abs(ms);
+ tickPool.add(ms, windows, mode);
+ preciseBuffer.push(ms);
+
+ if (mode === "mania") {
+ if (msAbs <= windows[0]) { /* perfect hit — no early/late to record */ }
+ else if (msAbs <= windows[1]) { ms < 0 ? hitTally.mania[0].e++ : hitTally.mania[0].l++; }
+ else if (msAbs <= windows[2]) { ms < 0 ? hitTally.mania[1].e++ : hitTally.mania[1].l++; }
+ else if (msAbs <= windows[3]) { ms < 0 ? hitTally.mania[2].e++ : hitTally.mania[2].l++; }
+ else if (msAbs <= windows[4]) { ms < 0 ? hitTally.mania[3].e++ : hitTally.mania[3].l++; }
+ else { ms < 0 ? hitTally.mania[4].e++ : hitTally.mania[4].l++; }
+ } else if (mode === "taiko") {
+ if (msAbs <= windows[0]) { /* perfect hit — no early/late to record */ }
+ else if (msAbs <= windows[1]) { ms < 0 ? hitTally.taiko[0].e++ : hitTally.taiko[0].l++; }
+ else { ms < 0 ? hitTally.taiko[1].e++ : hitTally.taiko[1].l++; }
+ } else {
+ if (msAbs <= windows[0]) { /* perfect hit — no early/late to record */ }
+ else if (msAbs <= windows[1]) { ms < 0 ? hitTally.std[0].e++ : hitTally.std[0].l++; }
+ else if (msAbs <= windows[2]) { ms < 0 ? hitTally.std[1].e++ : hitTally.std[1].l++; }
+ else { ms < 0 ? hitTally.std[2].e++ : hitTally.std[2].l++; }
+ }
+ }
+
+ if (preciseBuffer.length > 100) {
+ preciseBuffer.splice(0, preciseBuffer.length - 100);
+ }
+
+ arrowEl.style.transform = `translate3d(${median(preciseBuffer) * 2}px, 0, 0)`;
+ }
+}, ["hitErrors", "currentTime"]);
\ No newline at end of file
diff --git a/counters/Judgement Visualizer by Albert/metadata.txt b/counters/Judgement Visualizer by Albert/metadata.txt
new file mode 100644
index 0000000..0fde1c4
--- /dev/null
+++ b/counters/Judgement Visualizer by Albert/metadata.txt
@@ -0,0 +1,7 @@
+Usecase: Ingame Overlay, OBS Overlay
+Name: Judgement Visualizer
+Version: 1.0.0
+Author: Albert
+CompatibleWith: Tosu
+Resolution: 1000x250
+authorLinks: https://github.com/AlberttFrgk/
\ No newline at end of file
diff --git a/counters/Judgement Visualizer by Albert/pv.png b/counters/Judgement Visualizer by Albert/pv.png
new file mode 100644
index 0000000..c77dbd0
Binary files /dev/null and b/counters/Judgement Visualizer by Albert/pv.png differ
diff --git a/counters/Judgement Visualizer by Albert/settings.json b/counters/Judgement Visualizer by Albert/settings.json
new file mode 100644
index 0000000..859e1fd
--- /dev/null
+++ b/counters/Judgement Visualizer by Albert/settings.json
@@ -0,0 +1,218 @@
+[
+ {
+ "uniqueID": "color300g",
+ "type": "color",
+ "title": "MAX (300g) Color",
+ "description": "Used on Mania MAX (300g).",
+ "options": [],
+ "value": "#ffffff"
+ },
+ {
+ "uniqueID": "color300",
+ "type": "color",
+ "title": "Perfect (300) Color",
+ "description": "Used on Mania Perfect (300), osu! 300, and Taiko Great.",
+ "options": [],
+ "value": "#ffcc22"
+ },
+ {
+ "uniqueID": "color200",
+ "type": "color",
+ "title": "Great (200) Color",
+ "description": "Used on Mania Great (200), osu! 100, and Taiko Ok.",
+ "options": [],
+ "value": "#47e547"
+ },
+ {
+ "uniqueID": "color100",
+ "type": "color",
+ "title": "Good (100) Color",
+ "description": "Used on Mania Good (100) and osu! 50.",
+ "options": [],
+ "value": "#50b4ff"
+ },
+ {
+ "uniqueID": "color50",
+ "type": "color",
+ "title": "Bad (50) Color",
+ "description": "Used on Mania Bad (50).",
+ "options": [],
+ "value": "#888888"
+ },
+ {
+ "uniqueID": "color0",
+ "type": "color",
+ "title": "Miss (0) Color",
+ "description": "Used on Misses across all game modes.",
+ "options": [],
+ "value": "#ff4747"
+ },
+ {
+ "uniqueID": "useDynamicTickColor",
+ "type": "checkbox",
+ "title": "Use Dynamic Tick Color",
+ "description": "Colors ticks based on hit window. Uncheck to use Solid Tick Color.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "tickColor",
+ "type": "color",
+ "title": "Solid Tick Color",
+ "description": "Used if Dynamic Tick Color is disabled.",
+ "options": [],
+ "value": "#ffffff"
+ },
+ {
+ "uniqueID": "tickWidth",
+ "type": "number",
+ "title": "Tick Width (px)",
+ "description": "",
+ "options": [],
+ "value": 6
+ },
+ {
+ "uniqueID": "tickHeight",
+ "type": "number",
+ "title": "Tick Height (px)",
+ "description": "",
+ "options": [],
+ "value": 30
+ },
+ {
+ "uniqueID": "centerLineWidth",
+ "type": "number",
+ "title": "Center Line Width (px)",
+ "description": "",
+ "options": [],
+ "value": 6
+ },
+ {
+ "uniqueID": "centerLineHeight",
+ "type": "number",
+ "title": "Center Line Height (px)",
+ "description": "",
+ "options": [],
+ "value": 40
+ },
+ {
+ "uniqueID": "tickOffsetY",
+ "type": "number",
+ "title": "Tick Y-Offset (px)",
+ "description": "Moves ticks up (negative) or down (positive).",
+ "options": [],
+ "value": 0
+ },
+ {
+ "uniqueID": "tickDuration",
+ "type": "number",
+ "title": "Tick Hold Duration (ms)",
+ "description": "",
+ "options": [],
+ "value": 1000
+ },
+ {
+ "uniqueID": "fadeOutDuration",
+ "type": "number",
+ "title": "Tick Fade Duration (ms)",
+ "description": "",
+ "options": [],
+ "value": 1000
+ },
+ {
+ "uniqueID": "barHeight",
+ "type": "number",
+ "title": "Background Bar Height (px)",
+ "description": "",
+ "options": [],
+ "value": 20
+ },
+ {
+ "uniqueID": "showHitCounts",
+ "type": "checkbox",
+ "title": "Show Hit Counts",
+ "description": "Display the hit count numbers below the bar.",
+ "options": [],
+ "value": true
+ },
+ {
+ "uniqueID": "hitCountsFontSize",
+ "type": "number",
+ "title": "Hit Counts Font Size (px)",
+ "description": "",
+ "options": [],
+ "value": 40
+ },
+ {
+ "uniqueID": "hitCountsOffsetY",
+ "type": "number",
+ "title": "Hit Counts Y-Offset (px)",
+ "description": "Move the hit counts down (positive) or up (negative).",
+ "options": [],
+ "value": 0
+ },
+ {
+ "uniqueID": "hitCountsOnTop",
+ "type": "checkbox",
+ "title": "Hit Counts Position on Top",
+ "description": "Move hit counts above to top. Disable for bottom.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "hideArrow",
+ "type": "checkbox",
+ "title": "Hide Hit Error Arrow",
+ "description": "Hides the median hit error arrow.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "hideHitErrorBar",
+ "type": "checkbox",
+ "title": "Hide Hit Error Bar",
+ "description": "Hides the colored timing bar and hit ticks.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "arrowOnTop",
+ "type": "checkbox",
+ "title": "Arrow Position on Top",
+ "description": "Move arrow hit error to top. Disable for bottom.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "showBg",
+ "type": "checkbox",
+ "title": "Show Bar Background",
+ "description": "Draws a background box behind the hit error visualizer.",
+ "options": [],
+ "value": false
+ },
+ {
+ "uniqueID": "bgColor",
+ "type": "color",
+ "title": "Background Color",
+ "description": "",
+ "options": [],
+ "value": "#000000"
+ },
+ {
+ "uniqueID": "bgOpacity",
+ "type": "number",
+ "title": "Background Opacity (%)",
+ "description": "0 = Transparent, 100 = Solid Color",
+ "options": [],
+ "value": 50
+ },
+ {
+ "uniqueID": "bgPadding",
+ "type": "number",
+ "title": "Background Padding (px)",
+ "description": "Adds extra width/height so the background is larger than the ticks/bar.",
+ "options": [],
+ "value": 10
+ }
+]
\ No newline at end of file