// ==UserScript==
// @name Internet Roadtrip - Combined Votes Counts UI
// @description Moves the vote counts in neal.fun/internet-roadtrip from the top right panel to be alongside the arrows, on the wheel, and in the radio
// @namespace me.netux.site/user-scripts/internet-roadtrip/combined-votes-counts-ui
// @version 1.4.1
// @author netux
// @license MIT
// @match https://neal.fun/internet-roadtrip/*
// @icon https://neal.fun/favicons/internet-roadtrip.png
// @run-at document-start
// @grant GM_addStyle
// @grant GM.getValue
// @grant GM.setValue
// @require https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==
/* globals IRF */
(async () => {
const CSS_PREFIX = `cvcui-`;
const cssClass = (names) => (Array.isArray(names) ? names : [names]).map((name) => `${CSS_PREFIX}${name}`).join(' ');
await IRF.dom.container;
GM_addStyle(`
.container {
& .results {
top: 50px;
right: 10px;
width: fit-content;
min-width: 200px;
padding: 7px 10px;
&::after {
/* annoying... */
pointer-events: none;
}
& .results-content {
padding-bottom: 6px;
display: none;
}
& .${cssClass('results-content-toggle-button')} {
width: 100%;
height: 0.6rem;
margin-block: 0.3rem 0.1rem;
background-image: url("https://www.svgrepo.com/show/257732/up-arrow.svg");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
display: block;
}
&.${cssClass('results-content-open')} {
& .${cssClass('results-content-toggle-button')} {
rotate: 180deg;
}
& .results-content {
display: revert;
}
}
}
.${cssClass('vote-count')} {
position: absolute;
font-family: "Roboto", sans-serif;
color: white;
text-shadow: ${[[0, 1], [0, -1], [1, 0], [-1, 0]].map(([x, y]) => `${x}px ${y}px 2px black`).join(', ')};
pointer-events: none;
white-space: nowrap;
}
& .options {
cursor: pointer;
& .${cssClass('vote-count')} {
bottom: -0.4em;
left: 0;
width: 100%;
text-align: center;
font-size: 12px;
}
}
& .wheel-container {
& .${cssClass('vote-count')} {
top: 22%;
left: 50%;
translate: -50%;
font-size: 20px;
user-select: none;
}
}
}
body:not(.${cssClass('reduce-arrow-motion')}) .container :is(
.option .option-arrow,
.option .${cssClass('vote-count')}
) {
transition: translate 0.1s linear;
translate: 0 calc(-20px * var(--${CSS_PREFIX}vote-count-percentage));
}
@media (max-width: 900px) {
.container {
& .results {
top: 41px;
right: 5px;
}
}
}
`);
const containerVDOM = await IRF.vdom.container;
const resultsEl = await IRF.dom.results;
const resultsVDOM = await IRF.vdom.results;
const optionsContainerEl = await IRF.dom.options;
const wheelContainerEl = await IRF.dom.wheel;
const radioEl = await IRF.dom.radio;
const mapSound = await IRF.vdom.map.then((map) => map.data.mapSound); // yoink
const wheelHonkVotesEl = document.createElement('span');
const radioSeekVotesTextNode = document.createTextNode('0');
function ensureOptionVotesEl(optionEl) {
let votesEl = optionEl._votesEl;
if (!votesEl) {
votesEl = document.createElement('span');
votesEl.className = cssClass('vote-count');
votesEl.textContent = `0 (0%)`;
optionEl.appendChild(votesEl);
optionEl._votesEl = votesEl;
}
return votesEl;
}
function updateVotes(votes) {
const totalVotes = Object.values(votes).reduce((total, count) => total + count, 0);
const optionEls = optionsContainerEl.querySelectorAll('.option');
for (const [voteStr, votesCount] of Object.entries(votes)) {
const percentage = totalVotes !== 0 ? (votesCount / totalVotes) : 0;
const percentageStr = `${Math.floor(percentage * 100)}`;
switch (voteStr) {
case "-2": {
wheelHonkVotesEl.textContent = `${votesCount} (${percentageStr}%)`;
break;
}
case "-1": {
radioSeekVotesTextNode.textContent = votesCount;
break;
}
default: {
const voteIndex = parseInt(voteStr, 10);
const optionEl = optionEls[voteIndex];
if (!optionEl) {
continue;
}
const votesEl = ensureOptionVotesEl(optionEl);
votesEl.textContent = `${votesCount} (${percentageStr}%)`;
optionEl.style.setProperty(`--${CSS_PREFIX}vote-count-percentage`, percentage);
}
}
}
}
{
const { set: voteCountsSetter } = Object.getOwnPropertyDescriptor(resultsVDOM.state._props, 'voteCounts');
Object.defineProperty(resultsVDOM.state._props, 'voteCounts', {
set(newVoteCounts) {
updateVotes(newVoteCounts);
return voteCountsSetter.call(this, newVoteCounts);
},
configurable: true,
enumerable: true,
});
}
const settings = {
'results-content-open': false,
'reduce-arrow-motion': false
};
for (const key in settings) {
const value = await GM.getValue(key, settings[key]);
settings[key] = value;
}
async function updateDomFromSettings() {
document.body.classList.toggle(cssClass('reduce-arrow-motion'), settings['reduce-arrow-motion']);
resultsEl.classList.toggle(cssClass('results-content-open'), settings['results-content-open']);
}
updateDomFromSettings();
async function saveSettings() {
for (const key in settings) {
await GM.setValue(key, settings[key]);
}
}
{
const optionsContainerMutationObserver = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== "childList") {
continue;
}
for (const addedOptionEl of record.addedNodes) {
if (!addedOptionEl.classList?.contains('option')) {
continue;
}
ensureOptionVotesEl(addedOptionEl);
}
}
});
optionsContainerMutationObserver.observe(optionsContainerEl, {
childList: true
});
const wheelClickArealEl = wheelContainerEl.querySelector('.wheel-click-area');
wheelHonkVotesEl.className = cssClass('vote-count');
wheelClickArealEl.appendChild(wheelHonkVotesEl);
const radioSeekButtonLabelEl = radioEl.querySelector('.control-button .button-label');
radioSeekButtonLabelEl.append(
document.createTextNode(' ('),
radioSeekVotesTextNode,
document.createTextNode(')'),
);
const resultsContentToggleEl = document.createElement('div');
resultsContentToggleEl.className = cssClass('results-content-toggle-button');
resultsContentToggleEl.addEventListener('click', async () => {
mapSound?.play();
settings['results-content-open'] = !settings['results-content-open'];
await saveSettings();
updateDomFromSettings();
});
const resultsContentEl = resultsEl.querySelector('.results-content');
resultsContentEl.insertAdjacentElement('afterend', resultsContentToggleEl);
}
{
const tab = IRF.ui.panel.createTabFor(
{
... GM.info,
script: {
... GM.info.script,
name: GM.info.script.name.replace('Internet Roadtrip - ', '')
}
},
{
tabName: 'Combine Votes Counts UI',
style: `
.${cssClass('settings-tab-content')} {
& *, *::before, *::after {
box-sizing: border-box;
}
& .${cssClass('field-group')} {
display: flex;
align-items: center;
justify-content: space-between;
}
}
`,
className: cssClass('settings-tab-content'),
}
);
function makeFieldGroup({ id, label }, renderInput) {
const fieldGroupEl = document.createElement('div');
fieldGroupEl.className = cssClass('field-group');
const labelEl = document.createElement('label');
labelEl.textContent = label;
fieldGroupEl.appendChild(labelEl);
const inputEl = renderInput({ id });
fieldGroupEl.appendChild(inputEl);
return fieldGroupEl;
}
tab.container.append(
makeFieldGroup({ id: `${CSS_PREFIX}reduce-arrow-motion`, label: 'Reduce Arrow Motion' }, ({ id }) => {
const inputEl = document.createElement('input');
inputEl.id = id;
inputEl.type = 'checkbox';
inputEl.className = IRF.ui.panel.styles.toggle;
inputEl.checked = settings['reduce-arrow-motion'];
inputEl.addEventListener('change', async () => {
settings['reduce-arrow-motion'] = inputEl.checked;
await saveSettings();
updateDomFromSettings();
});
return inputEl;
})
);
}
})();