diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..34c12be --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +build/ +out/ + +# Environment variables +.env +.env.local +.env.*.local +!.env.example + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Caches +.npm +.eslintcache +.cache/ +coverage/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 0000000..b38f190 --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# 20260912 - Hyperling +# "Install" script to get this program runnable from anywhere on the system. + +## Variables ## + +DIR="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +PROG="$(basename -- "${BASH_SOURCE[0]}")" +LOCAL=~/bin +GLOBAL=/usr/local/bin +GLOBAL_SRC=/usr/local/src + +name_local="prototype.js" +name_short="nomad" +name_long="nomadic-weather" +name_abs="weather-list" + +## Functions ## + +function usage { + cat <<- EOF + $PROG [-h] [-g] [a|l|s] [-r] + + Install $name_local to the system. Can be local, global, or removed. + + Parameters) + h) Display this message. + g) Install globally ($GLOBAL), otherwise local ($LOCAL). + a) Abstract name, unrelated to being nomadic. ($name_abs) + l) Long name, the full name of the project. ($name_long) + s) Short name, the full name of the project. ($name_short) + r) Remove / uninstall the project according to the above flags. + + Examples) + - Install to the local user with the abstract name. + $PROG + - Install to all users with all names. + $PROG -alsg + - Remove all instances. + $PROG -alsr && $PROG -alsrg + + Happy weather watching! :) + + ~ Hyperling + EOF + exit $1 +} + +## Parameters ## + +while getopts ":gslarh" arg; do + case $arg in + g) global="Y" ;; + s) short="Y" ;; + l) long="Y" ;; + a) abstract="Y" ;; + r) remove="Y" ;; + h) usage ;; + *) echo "ERROR: Parameter $OPTARG was not recognized." && usage 1 ;; + esac +done + +## Processing ## + +name="" +if [[ "$short" == "Y" ]]; then + name="$name $name_short" +fi +if [[ "$long" == "Y" ]]; then + name="$name $name_long" +fi +if [[ "$abstract" == "Y" || ( -z "$short" && -z "$long" ) ]]; then + name="$name $name_abs" +fi + +cmd="" +dir="$LOCAL" +src="$DIR" +if [[ "$global" == "Y" ]]; then + dir="$GLOBAL" + cmd="sudo" + echo "Requesting sudo privileges... " + sudo echo "Success!" + mkdir -pv /usr/local/src + sudo cp -Rv "$DIR" $GLOBAL_SRC/ + src="$GLOBAL_SRC/`basename "$DIR"`" +fi + +if [[ -z "$remove" ]]; then + cmd="$cmd ln -svf $src/$name_local" +else + cmd="$cmd rm -v" +fi + +## Main ## + +# Untested +#if [[ "$global" == "Y" && "$remove" == "Y" ]]; then +# sudo rm -rfv "$src" +#fi + +for prog in $name; do + mkdir -pv "$dir" + $cmd "$dir/$prog" +done + +## Finish ## + +echo "Done!" +exit 0 diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..0192f38 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,203 @@ +{ + "name": "nomadic-weather", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "yargs": "^18.1.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..97e6df0 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "yargs": "^18.1.0" + } +} diff --git a/cli/prototype.js b/cli/prototype.js new file mode 100755 index 0000000..ab9fcd3 --- /dev/null +++ b/cli/prototype.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +"use strict"; +// 2026-09-12 - Hyperling +/* Prototype mostly generated by Ask Brave from prompt: + * node.js script which has 8 location variables including name, lat, lon, and elevation. + * loops over the locations sending them to open-meteo requesting temperature min and max for 7 days, no hourly. + * uses units for USA such as fahrenheit, inches, mph. + * prints the location name and elevation with the average high and low for the week. + * */ +// Maybe AI is actually getting better than I thought. ;D + +/* AI Updates: + * - highlight highs between 60 and 75, highlight lows between 40 and 55 + * - order locations by elevation ascending + * */ + +/* User Updates + * - fix spacing in table + * - move temps to be right aligned + * - send elevation to API + * - new highlighting function with many other colors + * - added apparent (feels like) + * - added args, basically took over the program without any incremental commits + * */ + +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); + +const version = '0.0.1'; + +const days_fore_max = 16; +const days_fore_default = 12; +const days_past_default = 2; + +const ft_to_m = 0.3048; + +const argv = yargs(hideBin(process.argv)) + .option('days_fore', { + alias: 'f', + describe: 'Set forecast days, such as "-f 10". Maximum of 16.', + type: 'number', + requiresArg: true, + }) + .option('days_past', { + alias: 'p', + describe: 'Set past days, such as "-p 3"', + type: 'number', + requiresArg: true, + }) + .option('calc_elevation', { + alias: 'e', + describe: 'Set to calculate elevation at lat/long.', + type: 'boolean', + }) + .check((argv) => { + if (argv.f !== undefined && (isNaN(argv.f) || argv.f < 0)) { + throw new Error('Forecast days must be a non-negative number'); + } + if (argv.p !== undefined && (isNaN(argv.p) || argv.p < 0)) { + throw new Error('Past days must be a non-negative number'); + } + return true; + }) + //.demandOption(['timeout', 'debug']) // Optional: require these arguments + .strict() + .help() + .version(version) + .argv +; + +function max (value, upper) { + return (value > upper) ? upper : value; +} +const days_fore = max(argv.hasOwnProperty("days_fore") ? argv.days_fore : days_fore_default, days_fore_max); +const days_past = argv.hasOwnProperty("days_past") ? argv.days_past : days_past_default; +const calc_elevation = argv.calc_elevation ? true : false; + +const days = days_fore + days_past; + +console.log(`Averaging for ${days} days: ${days_fore} forecasted, ${days_past} past.`); +if (calc_elevation) console.log("Calculating elevation rather than using provided values."); +else console.log("Using user-provided elevations when they exist."); + +const locations = [ + //{ name: "Flagstaff, AZ", lat: 35.1983, lon: -111.6513, elevation: 6903 }, + //{ name: "Flagstaff: NG", lat: 35.1983, lon: -111.6513, elevation: 6931 }, + { name: "Flagstaff: ++", lat: 35.1983, lon: -111.6513, elevation: 8200 }, + //{ name: "Tucson, AZ", lat: 32.2226, lon: -110.9747, elevation: 2490 }, + //{ name: "Tucson: NG", lat: 32.2226, lon: -110.9747, elevation: 2483 }, + //{ name: "Tucson: GV", lat: 32.2226, lon: -110.9747, elevation: 2371 }, + { name: "Tucson: ++", lat: 32.2226, lon: -110.9747, elevation: 2150 }, + //{ name: "Prescott, AZ", lat: 34.5400, lon: -112.4685, elevation: 5374 }, + //{ name: "Prescott: NG", lat: 34.5400, lon: -112.4685, elevation: 5496 }, + { name: "Prescott: ++", lat: 34.5400, lon: -112.4685, elevation: 6400 }, + //{ name: "Frisco, CO", lat: 39.5744, lon: -106.0975, elevation: 9075 }, + //{ name: "Frisco: NG", lat: 39.5744, lon: -106.0975, elevation: 9067 }, + { name: "Frisco: ++", lat: 39.5744, lon: -106.0975, elevation: 10200 }, + //{ name: "Greer, AZ", lat: 34.0100, lon: -109.4587, elevation: 8356 }, + { name: "Greer: ++", lat: 34.0100, lon: -109.4587, elevation: 9000 }, + //{ name: "Sedona, AZ", lat: 34.8697, lon: -111.7610, elevation: 4350 }, + { name: "Sedona: ++", lat: 34.8697, lon: -111.7610, elevation: 4500 }, + { name: "Cottonwood: ++", lat: 34.7392, lon: -112.0099, elevation: 3600 }, + { name: "Cherry: ++", lat: 34.5781, lon: -112.0677, elevation: 5300 }, + //{ name: "Sedona: NG", lat: 34.8697, lon: -111.7610, elevation: 4420 }, + //{ name: "Ehrenberg, AZ", lat: 33.9897, lon: -114.3550, elevation: 333 }, + { name: "Ehrenberg: ++", lat: 33.9897, lon: -114.3550, elevation: 300 }, + { name: "Quartzsite: ++", lat: 33.6763, lon: -114.2104, elevation: 900 }, +]; +locations.sort((a, b) => a.elevation - b.elevation); + +const RED = "\x1b[31m"; +const GRN = "\x1b[32m"; +const YLW = "\x1b[33m"; +const BLU = "\x1b[34m"; +const MGN = "\x1b[35m"; +const CYN = "\x1b[36m"; +const WHT = "\x1b[37m"; +const RESET = "\x1b[0m"; + +function highlight(value, min, max) { + const n = parseFloat(value); + return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value; +} + +const pref_high_upper = 75; +const pref_high_lower = 60; +const pref_low_upper = 55; +const pref_low_lower = 40; + +const tolerance_light = 10; +const tolerance_heavy = 20; + +function check (value, min, max) { + const n = parseFloat(value); + + var CLR = ""; + + if (n >= max + tolerance_heavy) + CLR = MGN; + else if (n >= max + tolerance_light) + CLR = RED; + else if (n >= max) + CLR = YLW; + else if (n <= max && n >= min) + CLR = GRN; + else if (n <= min - tolerance_heavy) + CLR = WHT + else if (n <= min - tolerance_light) + CLR = CYN + else if (n <= min) + CLR = BLU; + else + CLR = RESET; + + return `${CLR}${value}${RESET}`; + //return (n >= min && n <= max) ? `${GRN}${value}${RESET}` : value; +} + +function pad(str, width) { + const visible = str.replace(/\x1b\[\d+m/g, '').length; + return ' '.repeat(Math.max(0, width - visible)) + str; +} + +async function getForecast(loc) { + const url = new URL("https://api.open-meteo.com/v1/forecast"); + url.searchParams.set("latitude", loc.lat); + url.searchParams.set("longitude", loc.lon); + if (!calc_elevation && loc.elevation) url.searchParams.set("elevation", loc.elevation * ft_to_m); + url.searchParams.set("daily", "temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min"); + url.searchParams.set("temperature_unit", "fahrenheit"); + url.searchParams.set("precipitation_unit", "inch"); + url.searchParams.set("wind_speed_unit", "mph"); + url.searchParams.set("timezone", "auto"); + url.searchParams.set("forecast_days", days_fore); + url.searchParams.set("past_days", days_past); + + const res = await fetch(url); + const data = await res.json(); + + const highs = data.daily.temperature_2m_max; + const lows = data.daily.temperature_2m_min; + + const avgHigh = (highs.reduce((a, b) => a + b, 0) / highs.length).toFixed(1); + const avgLow = (lows.reduce((a, b) => a + b, 0) / lows.length).toFixed(1); + + const highs2 = data.daily.apparent_temperature_max; + const lows2 = data.daily.apparent_temperature_min; + + const avgHigh2 = (highs2.reduce((a, b) => a + b, 0) / highs2.length).toFixed(1); + const avgLow2 = (lows2.reduce((a, b) => a + b, 0) / lows2.length).toFixed(1); + + const elev = (!calc_elevation && loc.elevation) ? loc.elevation : (data.elevation / ft_to_m).toFixed(0); + + return { name: loc.name, elevation: elev, avgHigh, avgLow, avgHigh2, avgLow2 }; +} + +(async () => { + console.log("Location | Elev (ft) | High (°F) | Low (°F) | FeelH (°F) | FeelL (°F)"); + console.log("----------------------+-----------+-----------+----------+------------+------------"); + + for (const loc of locations) { + try { + const r = await getForecast(loc); + const hi = check(r.avgHigh, pref_high_lower, pref_high_upper); + const lo = check(r.avgLow, pref_low_lower, pref_low_upper); + const hi2 = check(r.avgHigh2, pref_high_lower, pref_high_upper); + const lo2 = check(r.avgLow2, pref_low_lower, pref_low_upper); + console.log( + `${pad(r.name, 21)} | ${pad(String(r.elevation), 9)} | ${pad(hi, 9)} | ${pad(lo, 8)} | ${pad(hi2, 10)} | ${pad(lo2, 10)}` + ); + } catch (e) { + console.log(`${loc.name}: error – ${e.message}`); + } + } +})();