🌈️ apply a wallpaper based on the weather outside
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement matching for a suitable wallpaper

Angel Wang (Jun 20, 2026, 8:20 PM -0600) c5823dad 16f3a6a6

+224 -82
-3
.editorconfig
··· 1 - [*.mts] 2 - indent_size = 4 3 - indent_style = tab
+2 -1
deno.json
··· 7 7 "@std/assert": "jsr:@std/assert@1", 8 8 "suncalc": "npm:suncalc@^2.0.0", 9 9 "zx": "npm:zx@^8.8.5" 10 - } 10 + }, 11 + "fmt": { "semiColons": false, "indentWidth": 4, "useTabs": true, "lineWidth": 120 } 11 12 }
+50 -25
utils.mts
··· 1 - export const configDir = process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/rainwall` : `${os.homedir()}/.config/rainwall` 2 - export const cacheDir = process.env.XDG_CACHE_HOME ? `${process.env.XDG_CACHE_HOME}/rainwall` : `${os.homedir()}/.cache/rainwall` 3 1 4 - if (!fs.statSync(configDir)){ 5 - fs.mkdirSync(configDir); 2 + export const configDir = process.env.XDG_CONFIG_HOME 3 + ? `${process.env.XDG_CONFIG_HOME}/rainwall` 4 + : `${os.homedir()}/.config/rainwall` 5 + export const cacheDir = process.env.XDG_CACHE_HOME 6 + ? `${process.env.XDG_CACHE_HOME}/rainwall` 7 + : `${os.homedir()}/.cache/rainwall` 8 + 9 + if (!fs.statSync(configDir)) { 10 + fs.mkdirSync(configDir) 6 11 } 7 12 8 - if (!fs.statSync(cacheDir)){ 9 - fs.mkdirSync(cacheDir); 13 + if (!fs.statSync(cacheDir)) { 14 + fs.mkdirSync(cacheDir) 10 15 } 11 16 12 17 export async function loadConfig(pathToConfig: string, defaultConfig: object) { 13 - let config: object | undefined 14 - try { 15 - const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { encoding: 'utf8' }) 16 - if (stringConfig == undefined) { 17 - console.warn(`Current config blank, generating a new one... you may need to stop this process and edit the config file.`) 18 - config = defaultConfig 19 - writeDefaultConfig(pathToConfig, defaultConfig) 20 - } else { 21 - config = JSON.parse(stringConfig) 22 - } 23 - } catch (err) { 24 - console.warn(`Got ${(err as Error).message} when trying to load config, generating a new one... you may need to stop this process and edit the config file.`) 25 - config = defaultConfig 26 - writeDefaultConfig(pathToConfig, defaultConfig) 27 - } 28 - console.info(`Loaded config from ${pathToConfig}!`) 29 - return config 18 + let config: object | undefined 19 + try { 20 + const stringConfig = await fs.promises.readFile(`${pathToConfig}`, { 21 + encoding: "utf8" 22 + }) 23 + if (stringConfig == undefined) { 24 + console.warn( 25 + `Current config blank, generating a new one... you may need to stop this process and edit the config file.` 26 + ) 27 + config = defaultConfig 28 + writeDefaultConfig(pathToConfig, defaultConfig) 29 + } else { 30 + config = JSON.parse(stringConfig) 31 + } 32 + } catch (err) { 33 + console.warn( 34 + `Got ${ 35 + (err as Error).message 36 + } when trying to load config, generating a new one... you may need to stop this process and edit the config file.` 37 + ) 38 + config = defaultConfig 39 + writeDefaultConfig(pathToConfig, defaultConfig) 40 + } 41 + console.info(`Loaded config from ${pathToConfig}!`) 42 + return config 30 43 } 31 44 32 45 async function writeDefaultConfig(path: string, data: object) { 33 - await fs.writeFile(`${path}`, JSON.stringify(data, null, 4)) 34 - console.info(`Generated default config at ${path}!`) 46 + await fs.writeFile(`${path}`, JSON.stringify(data, null, 4)) 47 + console.info(`Generated default config at ${path}!`) 48 + } 49 + 50 + export function floorToStep(num: number, step: number) { 51 + return Math.floor(num / step) * step 52 + } 53 + 54 + export function clamp(number: number, min: number, max: number) { 55 + return Math.max(min, Math.min(number, max)) 56 + } 57 + 58 + export function map(number: number, min: number, max: number, newMin: number, newMax: number) { 59 + return (((number - min) / (max - min)) * (newMax - newMin)) + newMin 35 60 }
+7 -7
analyze/analysis.mts
··· 1 1 #!/usr/bin/env zx 2 + 3 + import { floorToStep } from "../utils.mts"; 4 + 2 5 /// <reference types="zx/globals" /> 3 6 4 7 export interface AnalysisConfig { ··· 32 35 } 33 36 } 34 37 35 - export const defaultConfig: AnalysisConfig = { 36 - imageDir: `${os.homedir()}/Pictures`, 37 - lightnessStep: 10, 38 - chromaStep: 1, 38 + export const defaultConfig: AnalysisConfig = { 39 + imageDir: `${os.homedir()}/Pictures`, 40 + lightnessStep: 10, 41 + chromaStep: 1, 39 42 hueStep: 90, 40 43 preAnalysisCommands: [] 41 44 } ··· 131 134 } 132 135 } 133 136 134 - function floorToStep(num: number, step: number) { 135 - return Math.floor(num / step) * step 136 - }
+93 -37
apply/application.mts
··· 1 1 #!/usr/bin/env zx 2 + 3 + import { ImageAnalysisData } from "../analyze/analysis.mts" 4 + import _ from "npm:underscore" 5 + 2 6 /// <reference types="zx/globals" /> 3 7 4 8 export interface ApplicationConfig { 5 - latitude: number, 6 - longitude: number, 7 - weatherModel: string, 8 - applyWallpaperCommand: string 9 + latitude: number 10 + longitude: number 11 + weatherModel: string 12 + lightnessRange: { start: number; end: number } 13 + chromaRange: { start: number; end: number } 14 + hueRange: { start: number; end: number } 15 + applyWallpaperCommand: string 9 16 } 10 17 11 - export const defaultConfig: ApplicationConfig = { 18 + export const defaultConfig: ApplicationConfig = { 12 19 latitude: 0, 13 - longitude: 0, 14 - weatherModel: "best_match", 15 - applyWallpaperCommand: "hyprctl hyprpaper wallpaper , %s" 20 + longitude: 0, 21 + weatherModel: "best_match", 22 + lightnessRange: { 23 + start: 0, 24 + end: 100, 25 + }, 26 + chromaRange: { 27 + start: 0, 28 + end: 100, 29 + }, 30 + hueRange: { 31 + start: 0, 32 + end: 360, 33 + }, 34 + applyWallpaperCommand: "hyprctl hyprpaper wallpaper , %s", 16 35 } 17 36 18 - export async function getOpenMeteoData(latitude: number, longitude: number, weatherModel: string = "best_match") { 19 - const openMeteoResponse = await fetch("https://api.open-meteo.com/v1/forecast", { 20 - method: "POST", 21 - body: new URLSearchParams({ 22 - latitude: latitude.toString(), 23 - longitude: longitude.toString(), 24 - models: weatherModel, 25 - current: "cloud_cover", 26 - minutely_15: "shortwave_radiation_instant" 27 - }) 28 - }) 37 + export async function getOpenMeteoData( 38 + latitude: number, 39 + longitude: number, 40 + weatherModel: string = "best_match", 41 + ) { 42 + const openMeteoResponse = await fetch( 43 + "https://api.open-meteo.com/v1/forecast", 44 + { 45 + method: "POST", 46 + body: new URLSearchParams({ 47 + latitude: latitude.toString(), 48 + longitude: longitude.toString(), 49 + models: weatherModel, 50 + current: "cloud_cover", 51 + minutely_15: "shortwave_radiation_instant", 52 + }), 53 + }, 54 + ) 29 55 30 - if (!openMeteoResponse.ok) { 31 - throw new Error(`Open-Meteo sent back an error: ${openMeteoResponse.status}`) 32 - } 56 + if (!openMeteoResponse.ok) { 57 + throw new Error( 58 + `Open-Meteo sent back an error: ${openMeteoResponse.status}`, 59 + ) 60 + } 33 61 34 - const openMeteoData = await openMeteoResponse.json() 62 + const openMeteoData = await openMeteoResponse.json() 35 63 36 - const cloudCover = openMeteoData.current.cloud_cover 64 + const cloudCover: string = openMeteoData.current.cloud_cover 37 65 38 - let index = 0 39 - openMeteoData.minutely_15.time.forEach((time: string) => { 40 - if (Date.parse(time) > Date.now()) { 41 - // if the data point has a time greater than right now, subtract one to find the one just before the current 42 - // time 43 - index = openMeteoData.minutely_15.time.indexOf(time) - 1 44 - } 45 - }) 46 - const shortwaveRadiation = openMeteoData.minutely_15.shortwave_radiation_instant[index]; 66 + let index = 0 67 + openMeteoData.minutely_15.time.forEach((time: string) => { 68 + if (Date.parse(time) > Date.now()) { 69 + // if the data point has a time greater than right now, subtract one to find the one just before the current 70 + // time 71 + index = openMeteoData.minutely_15.time.indexOf(time) - 1 72 + } 73 + }) 74 + const shortwaveRadiation: string = openMeteoData.minutely_15.shortwave_radiation_instant[index] 47 75 48 - return { 49 - cloudCover, shortwaveRadiation 50 - } 51 - } 76 + return { 77 + cloudCover, 78 + shortwaveRadiation, 79 + } 80 + } 81 + 82 + export function findMatchingImages( 83 + imagesData: ImageAnalysisData, 84 + hueValue: number, 85 + chromaValue: number, 86 + lightnessValue: number, 87 + ) { 88 + let result = [] 89 + let i = 0 90 + const chromaIndex = imagesData.chromaData.indexOf( 91 + imagesData.chromaData.find((data) => data.chroma === chromaValue)!, 92 + ) 93 + const lightnessIndex = imagesData.lightnessData.indexOf( 94 + imagesData.lightnessData.find((data) => data.lightness === lightnessValue)!, 95 + ) 96 + 97 + do { 98 + const matches = [ 99 + imagesData.chromaData[chromaIndex + i].paths || imagesData.chromaData[chromaIndex - i].paths, 100 + imagesData.lightnessData[lightnessIndex + i].paths || imagesData.lightnessData[lightnessIndex - i].paths, 101 + ] 102 + console.log(matches) 103 + result = matches.reduce((a, b) => a.filter((c) => b.includes(c))) 104 + 105 + i++ 106 + } while (result.length === 0) 107 + }
+72 -9
apply/index.mts
··· 1 1 #!/usr/bin/env zx 2 2 /// <reference types="zx/globals" /> 3 3 4 - import { configDir, loadConfig } from "../utils.mts"; 5 - import { ApplicationConfig, defaultConfig, getOpenMeteoData } from "./application.mts"; 6 - import * as SunCalc from "npm:suncalc" 4 + import { AnalysisConfig, defaultConfig as analysisDefaultConfig, ImageAnalysisData } from "../analyze/analysis.mts" 5 + import { cacheDir, configDir, floorToStep, loadConfig, map } from "../utils.mts" 6 + import { ApplicationConfig, defaultConfig, findMatchingImages, getOpenMeteoData } from "./application.mts" 7 + import * as SunCalc from "npm:suncalc" 7 8 8 9 const pathToConfig = `${configDir}/apply-config.json` 10 + const pathToAnalysisConfig = `${configDir}/analyze-config.json` 11 + const pathToCache = `${cacheDir}/analysis.json` 9 12 10 - console.debug(`Trying to load config from ${pathToConfig}...`) 11 - const config = await loadConfig(pathToConfig, defaultConfig) as ApplicationConfig 13 + console.debug(`Trying to load application config from ${pathToConfig}...`) 14 + const config = await loadConfig( 15 + pathToConfig, 16 + defaultConfig, 17 + ) as ApplicationConfig 12 18 if (config == undefined) { 13 - throw new Error("Couldn't initialize config!") 19 + throw new Error("Couldn't initialize config!") 20 + } 21 + 22 + // we also need the analysis config to load the steps 23 + console.debug(`Trying to load analysis config from ${pathToAnalysisConfig}...`) 24 + const analysisConfig = await loadConfig( 25 + pathToAnalysisConfig, 26 + analysisDefaultConfig, 27 + ) as AnalysisConfig 28 + if (analysisConfig == undefined) { 29 + throw new Error("Couldn't initialize config!") 14 30 } 15 31 16 32 console.debug(`Config: \n${JSON.stringify(config, null, 4)}`) 17 33 18 - const openMeteoData = await getOpenMeteoData(config.latitude, config.longitude, config.weatherModel) 19 - const sunCalcData = SunCalc.getPosition(new Date(), config.latitude, config.longitude) 34 + const openMeteoData = await getOpenMeteoData( 35 + config.latitude, 36 + config.longitude, 37 + config.weatherModel, 38 + ) 39 + const sunCalcData = SunCalc.getPosition( 40 + new Date(), 41 + config.latitude, 42 + config.longitude, 43 + ) 44 + 20 45 console.info(`Current cloud cover percentage is ${openMeteoData.cloudCover}%!`) 21 - console.info(`Current shortwave radiation is ${openMeteoData.shortwaveRadiation} W/m²!`) 46 + console.info( 47 + `Current shortwave radiation is ${openMeteoData.shortwaveRadiation} W/m²!`, 48 + ) 22 49 console.info(`Current sun altitude is ${sunCalcData.altitude}°!`) 23 50 51 + // const hueValue = clamp() 52 + const chromaValue = floorToStep( 53 + map( 54 + Number(openMeteoData.cloudCover), 55 + 0, 56 + 100, 57 + config.chromaRange.end, 58 + config.chromaRange.start, 59 + ), 60 + analysisConfig.chromaStep, 61 + ) 62 + 63 + const lightnessValue = floorToStep( 64 + map( 65 + Number(openMeteoData.shortwaveRadiation), 66 + 0, 67 + 1000, 68 + config.lightnessRange.start, 69 + config.lightnessRange.end, 70 + ), 71 + analysisConfig.lightnessStep, 72 + ) 73 + 74 + console.info(`Got chroma value ${chromaValue}!`) 75 + console.info(`Got lightness value ${lightnessValue}!`) 76 + 77 + console.debug(`Trying to open cache file ${pathToCache}...`) 78 + const cacheFile = await fs.promises.readFile(`${pathToCache}`, { 79 + encoding: "utf8", 80 + }) 81 + const imagesData = JSON.parse(cacheFile) as ImageAnalysisData 82 + console.debug(`Opened cache file ${pathToCache}!`) 83 + 84 + 85 + 86 + findMatchingImages(imagesData, 0, chromaValue, lightnessValue)