Internet Roadtrip - Combined Votes Counts UI

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

// ==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;
      })
    );
  }
})();