您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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 }; } })();