Internet Roadtrip - Thermometer

Display a thermometer widget with the temperature and humidity of the current location of the neal.fun/internet-roadtrip vehicle

// ==UserScript==
// @name         Internet Roadtrip - Thermometer
// @description  Display a thermometer widget with the temperature and humidity of the current location of the neal.fun/internet-roadtrip vehicle
// @namespace    me.netux.site/user-scripts/internet-roadtrip/thermometer
// @version      1.6.1
// @author       netux
// @license      MIT
// @match        https://neal.fun/internet-roadtrip/
// @icon         https://cloudy.netux.site/neal_internet_roadtrip/Thermometer%20logo.png
// @grant        GM.xmlHttpRequest
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM_addStyle
// @run-at       document-start
// @require      https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
// @require      https://cdn.jsdelivr.net/npm/[email protected]
// @connect      api.open-meteo.com
// @noframes
// ==/UserScript==

/* globals IRF, VM */

(async () => {
  const MOD_NAME = GM.info.script.name.replace('Internet Roadtrip - ', '');
  const MOD_DOM_SAFE_PREFIX = 'thermometer-';
  const MOD_LOG_PREFIX = `[${MOD_NAME}]`;

  const RAD_TO_DEG = 180 / Math.PI;

  const cssClass = (... names) => names.map((name) => `${MOD_DOM_SAFE_PREFIX}${name}`).join(' ');

  const zeroOne = (value, min, max) => (value - min) / (max - min);

  const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  function GM_fetch(details) {
    return new Promise((resolve, reject) => {
      GM.xmlHttpRequest({
        ...details,
        onload: (response) => resolve(response),
        onerror: (err) => reject(err)
      });
    });
  }

  const DEFAULT_TEMPERATURE_GRADIENT = [
    { temperatureCelsius: -20, color: '#f5f5f5'},
    { temperatureCelsius: -10, color: '#82cdff'},
    { temperatureCelsius: 0, color: '#0c9eff'},
    { temperatureCelsius: 15, color: '#043add'},
    { temperatureCelsius: 18.5, color: '#c0c23d'},
    { temperatureCelsius: 22.5, color: '#ffd86d'},
    { temperatureCelsius: 27.5, color: '#ffa538'},
    { temperatureCelsius: 32.5, color: '#c92626'},
    { temperatureCelsius: 40, color: '#6a0b39'},
  ];

  const {
    min: DEFAULT_TEMPERATURE_GRADIENT_MIN_TEMPERATURE,
    max: DEFAULT_TEMPERATURE_GRADIENT_MAX_TEMPERATURE
  } = DEFAULT_TEMPERATURE_GRADIENT.reduce(
    ({ min: previousMin, max: previousMax }, { temperatureCelsius }) => ({
      min: Math.min(temperatureCelsius, previousMin),
      max: Math.max(temperatureCelsius, previousMax),
    }),
    { min: 0, max: 0 }
  );

  const TEMPERATURE_UNITS = {
    celsius: {
      label: 'Celsius',
      unit: '°C',
      fromCelsius: (celsius) => celsius
    },
    fahrenheit: {
      label: 'Fahrenheit',
      unit: '°F',
      fromCelsius: (celsius) => (celsius * 1.8) + 32
    },
    felsius: {
      label: 'Felsius (xkcd #1923)',
      unit: '°Є',
      fromCelsius: (celsius) => 7 * celsius / 5 + 16
    },
    kelvin: {
      label: 'Kelvin',
      unit: 'K',
      fromCelsius: (celsius) => celsius + 273
    }
  };

  const DEFAULT_OVERLAY_POSITION = {
    x: 0.5,
    y: 0.5
  };

  const waitForDocumentBody = new Promise((resolve) => {
    if (document.body) {
      resolve(document.body);
      return;
    }

    const observer = new MutationObserver(() => {
      if (document.body) {
        observer.disconnect();
        resolve(document.body);
      }
    });
    observer.observe(document.documentElement, { childList: true });
  });

  /*
   * Yoinked from https://github.com/violentmonkey/vm-ui/blob/00592622a01e48a4ac27a743254d82b1ebcd6d02/src/util/movable.ts
   * Modified to:
   * - Make it an EventTarget
   * - Add move-start, moving, and move-end events
   * - Add handler elements
   * - Add methods to retrieve and set position
   * - Support for touch
   */
  class Movable extends EventTarget {
    static defaultOptions = {
      origin: { x: 'auto', y: 'auto' },
    };

    el = null;
    options = null;

    constructor(el, options) {
      super();

      this.el = el;
      this.setOptions(options);
    }

    setOptions(options) {
      this.options = {
        ...Movable.defaultOptions,
        ...options,
      };
    }

    applyOptions(newOptions) {
      this.options = {
        ...this.options,
        ...newOptions,
      };
    }

    isTouchEvent = (e) => e.type.startsWith('touch');
    getEventPointerPosition = (e) => {
      if (this.isTouchEvent(e)) {
        const { clientX, clientY } = e.touches[this.touchIdentifier];
        return { clientX, clientY };
      } else {
        const { clientX, clientY } = e;
        return { clientX, clientY };
      }
    }

    onMouseDown = (e) => {
      if (this.isTouchEvent(e)) {
        this.touchIdentifier = e.changedTouches?.[0]?.identifier;
      }

      const { handlerElements = [] } = this.options;
      if (
        handlerElements.length > 0 &&
        !handlerElements.some((handlerEl) => e.target === handlerEl || handlerEl.contains(e.target))
      ) {
        return;
      }

      e.preventDefault();
      e.stopPropagation();

      const { x, y } = this.el.getBoundingClientRect();
      const { clientX, clientY } = this.getEventPointerPosition(e);
      this.dragging = { x: clientX - x, y: clientY - y };
      document.addEventListener('mousemove', this.onMouseMove);
      document.addEventListener('touchmove', this.onMouseMove);
      document.addEventListener('mouseup', this.onMouseUp);
      document.addEventListener('touchend', this.onMouseUp);

      this.dispatchEvent(new Event('move-start'));
    };

    onMouseMove = (e) => {
      if (
        this.isTouchEvent(e) &&
        this.touchIdentifier != null &&
        !Array.from(event.changedTouches).some((touch) => this.touchIdentifier === touch.identifier)
      ) return;
      if (!this.dragging) return;
      const { x, y } = this.dragging;
      const { clientX, clientY } = this.getEventPointerPosition(e);

      this.setPosition(clientX - x, clientY - y);
    };

    onMouseUp = (e) => {
      if (
        this.isTouchEvent(e) &&
        this.touchIdentifier != null &&
        !Array.from(event.changedTouches).some((touch) => this.touchIdentifier === touch.identifier)
      ) return;

      this.dragging = null;
      this.touchIdentifier = null;

      document.removeEventListener('mousemove', this.onMouseMove);
      document.removeEventListener('touchmove', this.onMouseMove);
      document.removeEventListener('mouseup', this.onMouseUp);
      document.removeEventListener('touchend', this.onMouseUp);

      this.dispatchEvent(new Event('move-end'));
    };

    enable() {
      this.el.addEventListener('mousedown', this.onMouseDown);
			this.el.addEventListener('touchstart', this.onMouseDown);
    }

    disable() {
      this.dragging = undefined;
      this.el.removeEventListener('mousedown', this.onMouseDown);
			this.el.removeEventListener('touchstart', this.onMouseDown);
      document.removeEventListener('mousemove', this.onMouseMove);
      document.removeEventListener('touchmove', this.onMouseUp);
      document.removeEventListener('mouseup', this.onMouseUp);
      document.removeEventListener('touchend', this.onMouseUp);
    }

    setPosition(x, y) {
      const { origin } = this.options;

      const { offsetWidth: width, offsetHeight: height } = this.el;
      const { clientWidth, clientHeight } = document.documentElement;
      const left = Math.max(0, Math.min(x, clientWidth - width));
      const top = Math.max(0, Math.min(y, clientHeight - height));

      const position = {
        top: 'auto',
        left: 'auto',
        right: 'auto',
        bottom: 'auto',
      };

      if (
        origin.x === 'start' ||
        (origin.x === 'auto' && left + left + width < clientWidth)
      ) {
        position.left = `${left}px`;
      } else {
        position.right = `${clientWidth - left - width}px`;
      }
      if (
        origin.y === 'start' ||
        (origin.y === 'auto' && top + top + height < clientHeight)
      ) {
        position.top = `${top}px`;
      } else {
        position.bottom = `${clientHeight - top - height}px`;
      }

      Object.assign(this.el.style, position);

      this.dispatchEvent(new Event('moving'));
    }

    getPosition() {
      const { left, top } = panel.wrapperEl.getBoundingClientRect();

      return { left, top };
    }
  }

  /*
   * Based on VM UI's VM.getPanel() https://github.com/violentmonkey/vm-ui/blob/00592622a01e48a4ac27a743254d82b1ebcd6d02/src/panel/index.tsx#L71
   */
  function makePanel(config) {
    const hostEl = VM.hm(`${MOD_DOM_SAFE_PREFIX}host`, {});
    const shadowRoot = hostEl.attachShadow({ mode: 'open' });

    shadowRoot.append(VM.hm('style', {}, `
    ${MOD_DOM_SAFE_PREFIX}wrapper {
      position: fixed;
      z-index: ${config.zIndex ?? (Number.MAX_SAFE_INTEGER - 1)}
    }
    `));
    if (config.style) {
      shadowRoot.append(VM.hm('style', {}, config.style));
    }

    const bodyEl = VM.hm(`${MOD_DOM_SAFE_PREFIX}body`, {});
    const wrapperEl = VM.hm(`${MOD_DOM_SAFE_PREFIX}wrapper`, {}, bodyEl);
    shadowRoot.append(wrapperEl);

    const movable = new Movable(wrapperEl);

    return {
      hostEl,
      wrapperEl,
      bodyEl,
      movable,
      show() {
        document.body.append(hostEl);
      },
      hide() {
        hostEl.remove();
      }
    };
  }

  class Pet extends EventTarget {
    #interval = null;

    #mousePositionInsidePet = null;
    #lastMousePositionInsidePet = null;
    #lastAngle = null;

    #scratchCounter = 0;
    #samplesBeingPet = 0;
    #lastEventDispatchWasPettingStart = false;

    constructor(petEl, options) {
      super();

      this.petEl = petEl;
      this.options = options;

      this.start();
    }

    start() {
      this.#interval = setInterval(this.#sample.bind(this), this.options.sampleRate);

      this.#mousePositionInsidePet = null;
      this.petEl.addEventListener('mousemove', this.#handlePetElMouseMove.bind(this));
      this.petEl.addEventListener('mouseleave', this.#handlePetElMouseLeave.bind(this));
    }

    stop() {
      clearInterval(this.#interval);
      this.#interval = null;

      this.petEl.removeEventListener('mousemove', this.#handlePetElMouseMove);
      this.petEl.removeEventListener('mouseleave', this.#handlePetElMouseLeave);
    }

    #handlePetElMouseMove = (event) => {
      const { clientX, clientY } = event;
      this.#mousePositionInsidePet = {
        x: clientX,
        y: clientY
      };
    }

    #handlePetElMouseLeave = (event) => {
      this.#mousePositionInsidePet = null;
    }

    #sample() {
      if (this.#lastMousePositionInsidePet != null && this.#mousePositionInsidePet != null) {
        const normalizedX = this.#mousePositionInsidePet.x - this.#lastMousePositionInsidePet.x;
        const normalizedY = this.#mousePositionInsidePet.y - this.#lastMousePositionInsidePet.y;

        const angle = Math.atan2(normalizedX, normalizedY) * RAD_TO_DEG;

        if (this.#lastAngle != null) {
          const anglesDistance =  ((this.#lastAngle - angle) + 180) % 360 - 180;
          const absAnglesDistance = Math.abs(anglesDistance);

          // console.debug(MOD_LOG_PREFIX, 'absAnglesDistance:', absAnglesDistance);

          if (absAnglesDistance > (this.options.angleMin + 180) && absAnglesDistance < (this.options.angleMax + 180)) {
            this.#scratchCounter = Math.min(this.#scratchCounter + 1, this.options.max);
          }

          const isBeingPet = this.#scratchCounter > this.options.threshold;
          if (isBeingPet) {
            this.#samplesBeingPet = Math.min(this.#samplesBeingPet + 1, this.options.activation);
          } else {
            this.#samplesBeingPet = Math.max(0, this.#samplesBeingPet - 1);
          }

          // console.debug(MOD_LOG_PREFIX, 'isBeingPet:', isBeingPet);
          // console.debug(MOD_LOG_PREFIX, 'samplesBeingPet:', this.#samplesBeingPet);

          if (isBeingPet && this.#samplesBeingPet >= this.options.activation) {
            if (!this.#lastEventDispatchWasPettingStart) {
              this.dispatchEvent(new Event('petting-start'));
              this.#lastEventDispatchWasPettingStart = true;
            }
          } else {
            if (this.#lastEventDispatchWasPettingStart) {
              this.dispatchEvent(new Event('petting-end'));
              this.#lastEventDispatchWasPettingStart = false;
            }
          }
        }

        this.#lastAngle = angle;
      } else {
        this.#lastAngle = null;

        this.#samplesBeingPet = Math.max(0, this.#samplesBeingPet - 0.75);
        if (this.#samplesBeingPet === 0 && this.#lastEventDispatchWasPettingStart) {
          this.dispatchEvent(new Event('petting-end'));
          this.#lastEventDispatchWasPettingStart = false;
        }
      }

      this.#lastMousePositionInsidePet = this.#mousePositionInsidePet;
      this.#scratchCounter = Math.max(0, this.#scratchCounter - this.options.neglect);
    }
  }

  let lastForecast = null;

  const getDefaultTemperatureGradientSettings = () => ({
    temperatureGradient: DEFAULT_TEMPERATURE_GRADIENT
      .map(({ temperatureCelsius, color }) => ({
        percent: zeroOne(temperatureCelsius, DEFAULT_TEMPERATURE_GRADIENT_MIN_TEMPERATURE, DEFAULT_TEMPERATURE_GRADIENT_MAX_TEMPERATURE),
        color
      })),
    temperatureGradientMinCelsius: DEFAULT_TEMPERATURE_GRADIENT_MIN_TEMPERATURE,
    temperatureGradientMaxCelsius: DEFAULT_TEMPERATURE_GRADIENT_MAX_TEMPERATURE
  });

  const settings = {
    overlayPosition: null,
    temperatureUnit: 'celsius',
    ... getDefaultTemperatureGradientSettings()
  };

  for (const key in settings) {
    settings[key] = await GM.getValue(key, settings[key]);
  }

  // Migration from <=1.2.2, when the overlay position was null by default
  if (settings.overlayPosition == null) {
    settings.overlayPosition = DEFAULT_OVERLAY_POSITION;
  }

  async function saveSettings() {
    for (const key in settings) {
      GM.setValue(key, settings[key]);
    }
  }
  await saveSettings();

  const temperatureCanvasGradientCtx = document.createElement('canvas').getContext('2d');

  function sampleTemperatureGradient(temperatureCelsius) {
    const percent = zeroOne(temperatureCelsius, settings.temperatureGradientMinCelsius, settings.temperatureGradientMaxCelsius);
    const clampedPercent = Math.max(0, Math.min(percent, 1));

    const x = clampedPercent * temperatureCanvasGradientCtx.canvas.width - 1;

    const rgb = temperatureCanvasGradientCtx.getImageData(x, 0, 1, 1).data.slice(0, 3);
    return '#' + [... rgb].map((c) => c.toString(16).padStart(2, '0')).join('');
  }

  const temperatureAtGradient = (x) => settings.temperatureGradientMinCelsius + (settings.temperatureGradientMaxCelsius - settings.temperatureGradientMinCelsius) * x;

  function redrawTemperatureCanvas() {
    temperatureCanvasGradientCtx.canvas.width = Math.abs(settings.temperatureGradientMaxCelsius - settings.temperatureGradientMinCelsius);
    temperatureCanvasGradientCtx.canvas.height = 1;

    const temperatureCanvasGradient = temperatureCanvasGradientCtx.createLinearGradient(0, 0, temperatureCanvasGradientCtx.canvas.width, 0);
    for (const { percent, color } of settings.temperatureGradient) {
      temperatureCanvasGradient.addColorStop(percent, color);
    }

    temperatureCanvasGradientCtx.fillStyle = temperatureCanvasGradient;
    temperatureCanvasGradientCtx.fillRect(0, 0, temperatureCanvasGradientCtx.canvas.width, temperatureCanvasGradientCtx.canvas.height);
  }

  await waitForDocumentBody;

  const tempGraphicEl = VM.hm('svg', {});

  const temperatureInfoEl = VM.hm('p', { className: 'thermometer__temperature-info' });
  const humidityInfoEl = VM.hm('p', { className: 'thermometer__humidity-info' });
  const infoContainerEl = VM.hm('div', { className: 'thermometer__info' }, [
    VM.hm('div', { className: 'thermometer__error-info' }, [
      VM.hm('h2', {}, ':('),
      VM.hm('p', {}, 'Contact @netux about this')
    ]),
    temperatureInfoEl,
    humidityInfoEl
  ]);

  const panel = makePanel({
    style: `
    ${MOD_DOM_SAFE_PREFIX}-wrapper {
      pointer-events: none;
    }

    .thermometer {
      /* Theme: transparent */
      background-color: transparent;
      border: none;
      box-shadow: none;

      /* Fix for loading indicator making the container super big */
      width: fit-content;
      height: fit-content;

      padding: 0;
      display: flex;
      flex-direction: row;

      & > * {
        pointer-events: initial;
      }

      & .thermometer__graphic {
        cursor: grab;

        padding: 0.5rem;
        align-self: center;

        &.thermometer__graphic--grabbed {
          cursor: grabbing;
        }

        & #fill, #bottom-fill {
          transition: ${['height', 'color'].map((property) => `${property} 1s linear`)};
        }

        .thermometer--loading & {
          animation: 1s linear infinite alternate thermometer-loading;

          & #fill, #bottom-fill {
            transition: none;
          }
        }
      }

      & .thermometer__info {
        min-width: 8ch;
        margin-block: 0.5rem;
        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(', ')};
        text-align: right;

        & :is(p, ${new Array(6).fill(null).map((_, i) => `h${i + 1}`).join(', ')}) {
          margin: 0;
        }

        .thermometer--loading & {
          visibility: hidden;
        }
      }

      & .thermometer__error-info {
        color: #ff4141;
        display: none;

        .thermometer--error & {
          display: initial;
        }
      }

      &.thermometer--info-on-the-right {
        flex-direction: row-reverse;

        & .thermometer__info {
          text-align: left;
        }
      }
    }

    @keyframes thermometer-loading {
      0% {
        color: lightgray;
      }
      100% {
        color: darkgray;
      }
    }
    `,
    zIndex: 200
  });

  panel.movable.addEventListener('move-end', async () => {
    const { left, top } = panel.movable.getPosition();

    settings.overlayPosition = {
      x: left / window.innerWidth,
      y: top / window.innerHeight
    }

    await saveSettings();
  });
  panel.movable.addEventListener('moving', () => {
    const { left, width } = panel.wrapperEl.getBoundingClientRect();

    panel.bodyEl.classList.toggle('thermometer--info-on-the-right', left + width / 2 < (window.innerWidth / 2));
  });
  panel.movable.enable();

  panel.bodyEl.classList.add('thermometer');
  panel.bodyEl.classList.add('thermometer--loading');
  panel.bodyEl.append(infoContainerEl, tempGraphicEl);

  // The element has to have a parent to be able to set outerHTML
  tempGraphicEl.outerHTML = `
  <svg
    class="thermometer__graphic"
    width="22"
    height="100"
    viewBox="0 0 22 100"
    version="1.1"
    xmlns="http://www.w3.org/2000/svg"
    xmlns:svg="http://www.w3.org/2000/svg"
  >
    <rect
      id="background"
      style="fill: rgb(206 251 250 / 50%); fill-opacity: 1; opacity: 1; stroke: none; stroke-dasharray: none; stroke-linecap: round; stroke-linejoin: round; stroke-opacity: 1; stroke-width: 2;"
      width="9.67380575"
      height="84.6300375"
      x="5.841754249999994"
      y="1.9680687500000005"
      rx="4.5632865016684"
    />
    <rect
      id="fill"
      style="fill: currentcolor; fill-opacity: 1; opacity: 1; stroke: none; stroke-dasharray: none; stroke-linecap: round; stroke-linejoin: round; stroke-opacity: 1; stroke-width: 2; rotate: 180deg; transform-origin: center;"
      width="9.67380575"
      height="0"
      x="5.841754249999994"
      y="14"
      rx="4.5632865016684"
    />
    <g>
      <ellipse
        id="bottom-fill"
        style="opacity: 1; fill: currentcolor; fill-opacity: 1; stroke-width: 2.98623; stroke-linecap: round; stroke-linejoin: round"
        cx="10.81210005114999"
        cy="89.515713524548"
        rx="9.596004408359999"
        ry="9.3861523188054"
      />
      <path
        id="shimmer"
        style="fill: none; opacity: 0.75; stroke: #ffffff; stroke-dasharray: none; stroke-linecap: round; stroke-linejoin: miter; stroke-opacity: 1; stroke-width: 1.5;"
        d="M 3.68829 88.8769 C 3.68829 88.8769 3.94517 91.2262 5.70701 93.1668 C 7.46886 95.1075 9.59383 95.4109 9.59383 95.4109"
      />
    </g>
    <path
      id="outline"
      style="display: inline; fill: none; stroke: #000000; stroke-dasharray: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 2;"
      d="M 10.6362 1.09869 C 7.89534 1.09869 5.69054 3.76255 5.69055 7.07108 L 5.69055 80.5387 C 2.8139 82.2685 0.910186 85.417 0.910186 89.0143 C 0.910186 94.4748 5.34273 98.9026 10.8115 98.9026 C 16.2803 98.9026 20.7148 94.4748 20.7148 89.0143 C 20.7148 85.2876 18.6337 82.0426 15.5838 80.3576 L 15.5838 7.07108 C 15.5838 3.76255 13.377 1.09869 10.6362 1.09869 Z"
    />
  </svg>
  `;

  // Setting outerHTML completely replaces the element (making tempGraphicEl point an element that isn't there anymore).
  // So we must get the element again from its known parent.
  const graphicEl = panel.bodyEl.querySelector('.thermometer__graphic');

  const maxFillHeight = Number.parseFloat(graphicEl.querySelector('#background').getAttribute('height'));
  const fillPathEl = graphicEl.querySelector('#fill');
  fillPathEl.setAttribute('height', maxFillHeight);

  panel.movable.addEventListener('move-start', () => {
    graphicEl.classList.add('thermometer__graphic--grabbed');
  });
  panel.movable.addEventListener('move-end', () => {
    graphicEl.classList.remove('thermometer__graphic--grabbed');
  });

  panel.movable.applyOptions({
    handlerElements: [graphicEl]
  });

  panel.show();

  panel.movable.setPosition(
    settings.overlayPosition.x * window.innerWidth,
    settings.overlayPosition.y * window.innerHeight
  );

  {
    class Heart {
      constructor({
        initialPosX,
        initialPosY,
        velocityX,
        velocityY,
        maxLifetime
      }) {
        this.el = document.createElement('div');
        document.body.append(this.el);

        this.maxLifetime = maxLifetime;
        this.lifetime = this.maxLifetime;

        this.x = initialPosX;
        this.y = initialPosY;
        this.velocityX = velocityX;
        this.velocityY = velocityY;

        this.lastUpdateTimestamp = Date.now();
        this.update();
      }

      update() {
        this.lifetime -= Date.now() - this.lastUpdateTimestamp;
        if (this.lifetime <= 0) {
         this.el.remove();
         return;
        }

        this.x += this.velocityX;
        this.y += this.velocityY;

        this.el.style.left = `${this.x}px`;
        this.el.style.top = `${this.y}px`;
        this.el.style.scale = this.lifetime / this.maxLifetime;
        this.el.style.opacity = 1.5 * this.lifetime / this.maxLifetime;

        this.lastUpdateTimestamp = Date.now();
        requestAnimationFrame(this.update.bind(this));
      }
    }

    GM_addStyle(`
    @keyframes ${MOD_DOM_SAFE_PREFIX}petting-heart-movement {
       0% {
          translate: 0 0;
          rotate: 0deg;
       }
       25% {
          translate: -10% 0;
          rotate: 25deg;
       }
       50% {
          translate: 0% 0;
          rotate: 0deg;
       }
       75% {
          translate: 10% 0;
          rotate: -25deg;
       }
    }

    /* https://css-tricks.com/hearts-in-html-and-css/#aa-css-shape */
    .${cssClass('petting-heart')} {
      --size: 10px;

      position: fixed;
      background-color: red;
      margin: 0 calc(var(--size) / 3);
      width: var(--size);
      aspect-ratio: 1;
      display: inline-block;
      transform: translate(-50%, -50%) rotate(-45deg);
      animation: ${MOD_DOM_SAFE_PREFIX}petting-heart-movement 2s linear infinite;
      z-index: 0;

      &::before,
      &::after {
        content: "";
        position: absolute;
        width: var(--size);
        aspect-ratio: 1;
        border-radius: 50%;
        background-color: red;
      }

      &::before {
        top: calc(var(--size) / -2);
        left: 0;
      }

      &::after {
        left: calc(var(--size) / 2);
        top: 0;
      }
    }
    `)

    const thermometerPet = new Pet(graphicEl, {
      sampleRate: 100,
      max: 10,
      threshold: 2.5,
      activation: 3,
      neglect: 0.5,
      angleMin: -20,
      angleMax: 20,
    });

    let spawnHeartsInterval;

    thermometerPet.addEventListener('petting-start', () => {
      let spawnNextHeartGoingRight = false;
      spawnHeartsInterval = setInterval(() => {
        const boundingBox = thermometerPet.petEl.getBoundingClientRect();

        const heart = new Heart({
          initialPosX: boundingBox.left + boundingBox.width * (spawnNextHeartGoingRight ? 2/3 : 1/3),
          initialPosY: boundingBox.top + boundingBox.height / 3,
          velocityX: 0.25 * (spawnNextHeartGoingRight ? 1 : -1),
          velocityY: -0.5,
          maxLifetime: 5000
        });
        heart.el.classList.add(cssClass('petting-heart'));

        spawnNextHeartGoingRight = !spawnNextHeartGoingRight;
      }, 500);
    });

    thermometerPet.addEventListener('petting-end', () => {
       clearInterval(spawnHeartsInterval);
    });
  }

  let irfPanelTabTemperatureMarkerEl = null;

  const containerVDOM = await IRF.vdom.container;

  const waitForCoordinatesToBeSetAtLeastOnce = new Promise(async (resolve) => {
    containerVDOM.state.changeStop = new Proxy(containerVDOM.state.changeStop, {
      apply(ogChangeStop, thisArg, args) {
        const returnedValue = ogChangeStop.apply(thisArg, args);

        resolve();

        return returnedValue;
      }
    });
  });

  function updateGraphicFromForecast(forecast) {
    const temperatureCelsius = forecast?.current.temperature_2m;

    let fillPercentage = temperatureCelsius != null
      ? zeroOne(temperatureCelsius, settings.temperatureGradientMinCelsius, settings.temperatureGradientMaxCelsius)
      : 0;
    fillPercentage = Math.min(fillPercentage, 1);

    fillPathEl.setAttribute('height', (fillPercentage * maxFillHeight).toString());

    let color = '';
    if (temperatureCelsius != null) {
      color = sampleTemperatureGradient(temperatureCelsius);
    }
    graphicEl.style.color = color;
  }

  function updateInfoFromForecast(forecast) {
    if (forecast) {
      const userTemperatureUnit = TEMPERATURE_UNITS[settings.temperatureUnit];
      const temperatureInUserUnit = userTemperatureUnit.fromCelsius(forecast.current.temperature_2m);
      temperatureInfoEl.textContent = `T: ${Math.round(temperatureInUserUnit)}${userTemperatureUnit.unit}`;

      const relativeHumidityPercentage = forecast.current.relative_humidity_2m;
      humidityInfoEl.textContent = `H: ${relativeHumidityPercentage}%`;
    }

    if (irfPanelTabTemperatureMarkerEl != null) {
      if (forecast) {
        // TODO(netux): display in user temperature
        irfPanelTabTemperatureMarkerEl.textContent = `${Math.round(forecast.current.temperature_2m)}${TEMPERATURE_UNITS.celsius.unit}`;
        irfPanelTabTemperatureMarkerEl.style.left = `${zeroOne(forecast.current.temperature_2m, settings.temperatureGradientMinCelsius, settings.temperatureGradientMaxCelsius) * 100}%`;
        irfPanelTabTemperatureMarkerEl.style.display = '';
      } else {
        irfPanelTabTemperatureMarkerEl.style.display = 'none';
      }
    }
  }

  {
    const tab = IRF.ui.panel.createTabFor(
      { ... GM.info, script: { ... GM.info.script, name: MOD_NAME } },
      {
        tabName: MOD_NAME,
        style: `
        .${cssClass('tab-content')} {
          & *, *::before, *::after {
            box-sizing: border-box;
          }

          & .${cssClass('field-group')} {
            margin-block: 1rem;
            gap: 0.25rem;
            display: flex;
            align-items: center;
            justify-content: space-between;

            & label > small {
              color: lightgray;
              display: block;
            }

            & .${cssClass('field-group__label-container')},
            & .${cssClass('field-group__input-container')} {
              width: 100%;
              display: flex;
              flex-direction: row;
              flex-wrap: nowrap;
              align-items: center;
              gap: 1ch;
            }

            & .${cssClass('field-group__input-container')} {
              justify-content: end;
              white-space: nowrap;
            }
          }

          & input:is(:not([type]), [type="text"], [type="number"]),
          & select {
            --padding-inline: 0.5rem;
            --border-radius: 0.8rem;
            --border-color: #848e95;

            width: calc(100% - 2 * var(--padding-inline));
            min-height: 1.5rem;
            margin: 0;
            padding-inline: var(--padding-inline);
            color: white;
            background: transparent;
            border: 1px solid var(--border-color);
            font-size: 100%;
            border-radius: var(--border-radius);
          }

          & option {
            background-color: black;
          }

          @supports (appearance: base-select) {
            & select, & ::picker(select) {
              appearance: base-select;
              font-size: 0.9rem;
            }

            & select::picker-icon {
                scale: 0.9;
                margin-right: 0.125rem;
            }

            & select:open {
              border-bottom-left-radius: 0;
              border-bottom-right-radius: 0;
              border-bottom-color: transparent;
            }

            & ::picker(select) {
              margin-top: -1px;
              border: none;
              background-color: black;
              border-bottom-left-radius: var(--border-radius);
              border-bottom-right-radius: var(--border-radius);
              border: 1px solid var(--border-color);
              border-top-color: transparent;
            }

            & option {
              color: white;
              background-color: black;

              &::checkmark {
                display: none;
              }

              &:checked {
                background-color: rgb(255 255 255 / 10%);
              }

              &:hover {
                background-color: rgb(255 255 255 / 20%);
              }
            }
          }

          & button {
            padding: 0.25rem;
            margin-left: 0.125rem;
            gap: 0.25rem;
            cursor: pointer;
            border: none;
            font-size: 0.9rem;
            align-items: center;
            justify-content: center;
            background-color: white;
            display: inline-flex;

            &:hover {
              background-color: #d3d3d3;
            }

            & > img {
              width: 1rem;
              aspect-ratio: 1;
            }
          }

          & .${cssClass('gradient-field-group')} {
            position: relative;
            flex-direction: column;
            align-items: initial;

            & canvas {
              width: 100%;
              height: 20px;
            }

            & .${cssClass('gradient-range')} {
              display: flex;
              justify-content: space-between;

              /* Dotted line */
              background:
                /* Dotted line */
                repeating-linear-gradient(
                  to right,
                  #b9b9b973 0 3px,
                  transparent 6px 9px
                ),
                /* Mask */
                linear-gradient(
                  to right,
                  black 0%, black 20%,
                  transparent 30%, transparent 70%,
                  black 80%, black 100%
                );
              background-size: auto 3px;
              background-blend-mode: multiply;
              background-repeat: repeat-x;
              background-position: center;

              & .${cssClass('gradient-range__input-container')} {
                display: flex;
                align-items: center;
                gap: 0.5ch;

                & input {
                  width: 8ch;
                  text-align: right;

                  -moz-appearance: textfield;
                  &::-webkit-inner-spin-button,
                  &::-webkit-inner-spin-button {
                    display: none;
                  }
                }
              }
            }

            & .${cssClass('gradient-container')} {
              position: relative;
              padding-top: 0.33rem;
              margin-inline: 0.5rem;
              cursor: pointer;
            }

            & .${cssClass('gradient-marker')} {
              --marker-color: pink;

              position: absolute;
              top: 0;
              translate: -50% -133%;
              text-align: center;
              white-space: nowrap;
              font-size: 75%;
              text-shadow: 0 0 3px black;
              pointer-events: none;

              &.${cssClass('gradient-marker--current')} {
                --marker-color: gray;
              }

              &.${cssClass('gradient-marker--cursor')} {
                --marker-color: red;
              }

              &::after {
                content: "";
                position: absolute;
                translate: -50% 100%;
                left: 50%;
                bottom: 0;
                width: 1rem;
                height: 0.5rem;
                background-color: var(--marker-color);
                clip-path: polygon(50% 100%, 0 0, 100% 0);
              }
            }

            & .${cssClass('gradient-stops-container')} {
              --stop-height: 0.85rem;
              --stop-tip-height: 7px;
              --stop-border-size: 2px;

              position: relative;
              min-height: calc(var(--stop-tip-height) + var(--stop-height) + 2 * var(--stop-border-size));

              & .${cssClass('gradient-stop')} {
                position: absolute;
                background-color: transparent;
                translate: -50% 0;
                height: var(--stop-height);
                aspect-ratio: 0.9;
                display: inline-block;
                border-radius: 4px 4px 2px 2px;
                border: var(--stop-border-size) solid white;
                margin-top: var(--stop-tip-height);
                cursor: grab;

                &::before {
                  content: "";
                  background-color: white;
                  width: var(--stop-tip-height);
                  aspect-ratio: 1;
                  position: absolute;
                  top: 0;
                  left: 50%;
                  translate: -50% -100%;
                  clip-path: polygon(50% 0, 100% 100%, 0 100%);
                }

                & input[type="color"] {
                  pointer-events: none;
                  width: 1px;
                  aspect-ratio: 1;
                  opacity: 0.01;
                }
              }
            }
          }
        }
        `,
        className: cssClass('tab-content')
      }
    );

    function makeFieldGroup({ id, label }, renderInput) {
      const renderInputOutput = renderInput({ id });

      const fieldGroupEl = VM.hm('div', { className: cssClass('field-group') }, [
        VM.hm('div', { className: cssClass('field-group__label-container') }, [
          VM.hm('label', {}, label)
        ]),
        VM.hm('div', { className: cssClass('field-group__input-container') }, renderInputOutput),
      ]);

      return fieldGroupEl;
    }

    tab.container.append(
      makeFieldGroup(
        {
          id: `${MOD_DOM_SAFE_PREFIX}temperature-unit`,
          label: 'Temperature Unit'
        },
        ({ id }) => {
          const selectEl = VM.hm(
            'select',
            { id },
            Object.entries(TEMPERATURE_UNITS)
              .map(([value, { label }]) => VM.hm('option', { value }, label))
          );
          selectEl.value = settings.temperatureUnit;

          selectEl.addEventListener('change', async () => {
            settings.temperatureUnit = selectEl.value;
            await saveSettings();

            updateGraphicFromForecast(lastForecast);
            updateInfoFromForecast(lastForecast);
          });

          return selectEl;
        }
      ),
    );

    {
      const currentTemperatureMarkerEl = VM.hm('span', { className: cssClass('gradient-marker', 'gradient-marker--current') });

      currentTemperatureMarkerEl.style.display = 'none';
      irfPanelTabTemperatureMarkerEl = currentTemperatureMarkerEl

      const cursorTemperatureMarkerEl = VM.hm('span', { className: cssClass('gradient-marker', 'gradient-marker--cursor') });
      cursorTemperatureMarkerEl.style.display = 'none';

      const gradientStopsContainerEl = VM.hm('div', { className: cssClass('gradient-stops-container') });
      const gradientContainerEl = VM.hm('div', { className: cssClass('gradient-container') }, [
        currentTemperatureMarkerEl,
        cursorTemperatureMarkerEl,
        temperatureCanvasGradientCtx.canvas,
        gradientStopsContainerEl
      ]);

      gradientContainerEl.addEventListener('mouseenter', (event) => {
        cursorTemperatureMarkerEl.style.display = '';
      });
      gradientContainerEl.addEventListener('mouseleave', (event) => {
        cursorTemperatureMarkerEl.style.display = 'none';
      });
      gradientContainerEl.addEventListener('mousemove', (event) => {
        const { left: containerClientX, width: containerWidth } = gradientContainerEl.getBoundingClientRect();
        const containerOffsetX = event.clientX - containerClientX;

        const percent = containerOffsetX / containerWidth;
        const temperatureInIncrementsOf05 = (Math.round(temperatureAtGradient(percent) * 2) / 2).toFixed(1);
        cursorTemperatureMarkerEl.textContent = `${temperatureInIncrementsOf05}${TEMPERATURE_UNITS.celsius.unit}`;
        cursorTemperatureMarkerEl.style.left = `${percent * 100}%`;
      }, { capture: true });

      class GradientColorStop extends EventTarget {
        constructor(entry) {
          super();

          this.inputEl = VM.hm('input', { type: 'color' });
          this.el = VM.hm('div', { className: cssClass('gradient-stop') }, [this.inputEl]);
          this.entry = entry;

          this.el.addEventListener('contextmenu', async (event) => {
            event.preventDefault();

            settings.temperatureGradient.splice(settings.temperatureGradient.indexOf(entry), 1);
            this.el.remove();

            redrawTemperatureCanvas();
            updateGraphicFromForecast(lastForecast);
            updateInfoFromForecast(lastForecast);

            await saveSettings();
          });

          this.inputEl.addEventListener('input', async () => {
            entry.color = this.inputEl.value;
            redrawTemperatureCanvas();

            this.updateDOM();

            await saveSettings();
          });

          let lastDragStartPos = null;

          this.el.addEventListener('mousedown', (event) => {
            event.preventDefault();

            this.isDragging = true;
            lastDragStartPos = { x: event.clientX, y: event.clientY };
          });

          document.addEventListener('mouseup', (event) => {
            if (!this.isDragging) {
              return;
            }

            event.preventDefault();

            this.isDragging = false;

            if (Math.abs(lastDragStartPos.x - event.clientX) + Math.abs(lastDragStartPos.y - event.clientY) < 5) {
              this.inputEl.click();
            }
          });

          document.addEventListener('mousemove', async (event) => {
            if (!this.isDragging) {
              return;
            }

            event.preventDefault();

            const gradientStopBoundingBox = gradientStopsContainerEl.getBoundingClientRect();

            const percent = (event.clientX - gradientStopBoundingBox.left) / gradientStopBoundingBox.width;
            const clampedPercent = Math.max(0, Math.min(percent, 1));

            this.entry.percent = clampedPercent;

            this.updateDOM();

            await saveSettings();
          });

          this.updateDOM();
        }

        updateDOM() {
          const { percent, color } = this.entry;

          redrawTemperatureCanvas();
          updateGraphicFromForecast(lastForecast);
          updateInfoFromForecast(lastForecast);

          this.el.style.left = `${percent * 100}%`;
          this.el.style.backgroundColor = color;
          this.el.style.zIndex = Math.round(percent * 100);
          this.inputEl.value = color;
        }
      }

      function recreateGradientStops() {
        while (gradientStopsContainerEl.firstChild != null) {
          gradientStopsContainerEl.firstChild.remove();
        }
        for (const entry of settings.temperatureGradient) {
          const gradientStop = new GradientColorStop(entry);

          gradientStopsContainerEl.append(gradientStop.el);
        }
      }
      recreateGradientStops();

      gradientContainerEl.addEventListener('dblclick', async (event) => {
        event.preventDefault();
        event.stopPropagation();

        const percent = event.offsetX / gradientStopsContainerEl.clientWidth;
        const color = sampleTemperatureGradient(temperatureAtGradient(percent));

        const entry = { percent, color };
        settings.temperatureGradient.push(entry);

        const gradientStop = new GradientColorStop(entry);

        gradientStopsContainerEl.append(gradientStop.el);

        redrawTemperatureCanvas();

        await saveSettings();
      }, { capture: true });

      const gradientMinInputEl = VM.hm('input', {
        id: `${MOD_DOM_SAFE_PREFIX}gradient-temperature-min`,
        type: 'number'
      });
      gradientMinInputEl.value = settings.temperatureGradientMinCelsius;
      gradientMinInputEl.addEventListener('change', async (event) => {
        const numberValue = parseFloat(event.target.value);
        if (Number.isNaN(numberValue)) {
          return;
        }

        settings.temperatureGradientMinCelsius = numberValue;
        redrawTemperatureCanvas();
        updateGraphicFromForecast(lastForecast);
        updateInfoFromForecast(lastForecast);

        await saveSettings();
      });

      const gradientMaxInputEl = VM.hm('input', {
        id: `${MOD_DOM_SAFE_PREFIX}gradient-temperature-max`,
        type: 'number'
      });
      gradientMaxInputEl.value = settings.temperatureGradientMaxCelsius;
      gradientMaxInputEl.addEventListener('change', async (event) => {
        const numberValue = parseFloat(event.target.value);
        if (Number.isNaN(numberValue)) {
          return;
        }

        settings.temperatureGradientMaxCelsius = numberValue;
        redrawTemperatureCanvas();
        updateGraphicFromForecast(lastForecast);
        updateInfoFromForecast(lastForecast);

        await saveSettings();
      });

      const resetSettingsButtonEl = VM.hm('button', {}, VM.hm('img', { src: 'https://www.svgrepo.com/show/511181/undo.svg' }));
      resetSettingsButtonEl.addEventListener('click', async () => {
        if (!confirm([
          'This will reset the thermometer gradient range and color stops to their default values.',
          'Are you sure you want to continue?'
        ].join('\n'))) {
          return;
        }

        Object.assign(settings, getDefaultTemperatureGradientSettings());

        gradientMinInputEl.value = settings.temperatureGradientMinCelsius;
        gradientMaxInputEl.value = settings.temperatureGradientMaxCelsius;

        redrawTemperatureCanvas();
        updateGraphicFromForecast(lastForecast);
        updateInfoFromForecast(lastForecast);
        recreateGradientStops();

        await saveSettings();
      });

      const gradientFieldGroupEl = VM.hm('div', { className: cssClass('field-group', 'gradient-field-group') }, [
        VM.hm('div', { className: cssClass('field-group__label-container') }, [
          VM.hm('label', {}, 'Temperature Gradient'),
          resetSettingsButtonEl,
        ]),
        VM.hm('div', { className: cssClass('gradient-range') }, [
          VM.hm('div', { className: cssClass('gradient-range__input-container') }, [
            gradientMinInputEl,
            TEMPERATURE_UNITS.celsius.unit
          ]),
          VM.hm('div', { className: cssClass('gradient-range__input-container') }, [
            gradientMaxInputEl,
            TEMPERATURE_UNITS.celsius.unit
          ])
        ]),
        gradientContainerEl
      ]);
      tab.container.append(gradientFieldGroupEl);
    }

    {
      const resetPositionButtonEl = VM.hm('button', {}, 'Reset position');
      resetPositionButtonEl.addEventListener('click', () => {
        panel.movable.setPosition(DEFAULT_OVERLAY_POSITION.x * window.innerWidth, DEFAULT_OVERLAY_POSITION.y * window.innerHeight);
      })
      tab.container.append(resetPositionButtonEl);
    }
  }

  async function fetchForecast([latitude, longitude]) {
    const forecastApiUrl = `https://api.open-meteo.com/v1/forecast?${[
      `latitude=${latitude}`,
      `longitude=${longitude}`,
      `current=${[
        'temperature_2m',
        'relative_humidity_2m'
      ].join(',')}`,
      `temperature_unit=celsius`
    ].join('&')}`;

    const { status, response: forecastStr } = await GM_fetch({
      url: forecastApiUrl,
      headers: {
        'content-type': 'application/json'
      },
      timeout: 10_000
    });

    if (status !== 200) {
      throw new Error(`Got a ${status} status code when requesting forecast information`);
    }

		if (forecastStr == null) {
      throw new Error(`For some reason the forecast information was nullish`);
    }

    let forecast;
    try {
      forecast = JSON.parse(forecastStr);
    } catch (error) {
      throw new Error(`Could not parse forecast JSON: ${error.toString()}`);
    }

    return forecast;
  }

  async function tickForecast() {
    let forecast;
    try {
      forecast = await fetchForecast([containerVDOM.state.currentCoords.lat, containerVDOM.state.currentCoords.lng]);
    } catch (error) {
      panel.bodyEl.classList.add('thermometer--error');
      console.error(MOD_LOG_PREFIX, 'Could not fetch forecast', error);
      return;
    }

    console.debug(MOD_LOG_PREFIX, 'New forecast received:', forecast);

    lastForecast = forecast;

    panel.bodyEl.classList.remove('thermometer--loading');
    panel.bodyEl.classList.remove('thermometer--error');

    updateGraphicFromForecast(forecast);
    updateInfoFromForecast(forecast);
  }

  waitForCoordinatesToBeSetAtLeastOnce.then(() => {
    setInterval(tickForecast, 15 * 60_000 /* every 15 minutes */);
    tickForecast();
  });

  if (typeof unsafeWindow !== 'undefined') {
    unsafeWindow.irtThermometer = {
      panel,
      fetchForecast,
      updateGraphicFromForecast,
      updateInfoFromForecast,
      tickForecast
    };
  }
})();