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