296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
// weather.json holds {"name": ..., "latitude": ..., "longitude": ...} (see
|
|
// omarchy-weather-location, which owns the format). Missing, blank, or
|
|
// unparseable means the location is auto-detected from the IP address.
|
|
function parseLocationFile(raw) {
|
|
var unset = { name: "", latitude: null, longitude: null }
|
|
try {
|
|
var data = JSON.parse(String(raw || ""))
|
|
if (!data || typeof data !== "object") return unset
|
|
|
|
var latitude = parseFloat(data.latitude)
|
|
var longitude = parseFloat(data.longitude)
|
|
var hasCoordinates = !isNaN(latitude) && !isNaN(longitude)
|
|
return {
|
|
name: typeof data.name === "string" ? data.name.replace(/^\s+|\s+$/g, "") : "",
|
|
latitude: hasCoordinates ? latitude : null,
|
|
longitude: hasCoordinates ? longitude : null
|
|
}
|
|
} catch (e) {
|
|
return unset
|
|
}
|
|
}
|
|
|
|
// wttr.in path segment for a configured location: exact coordinates when
|
|
// both are present, the URL-encoded name as a fallback (hand-edited
|
|
// weather.loc files may only carry a name), empty for IP auto-detect.
|
|
function wttrLocationQuery(location, latitude, longitude) {
|
|
var lat = parseFloat(String(latitude))
|
|
var lon = parseFloat(String(longitude))
|
|
if (!isNaN(lat) && !isNaN(lon)) return lat + "," + lon
|
|
|
|
var name = String(location || "").replace(/^\s+|\s+$/g, "")
|
|
return name === "" ? "" : encodeURIComponent(name)
|
|
}
|
|
|
|
// Open-Meteo geocoding response → suggestion rows for the location picker.
|
|
function parseGeocodingResults(raw) {
|
|
try {
|
|
var data = JSON.parse(String(raw || "{}"))
|
|
var results = data.results
|
|
if (!results || !results.length) return []
|
|
|
|
var out = []
|
|
for (var i = 0; i < results.length; i++) {
|
|
var r = results[i]
|
|
if (!r || !r.name || r.latitude === undefined || r.longitude === undefined) continue
|
|
var region = [r.admin1, r.country].filter(function(part) { return !!part }).join(", ")
|
|
out.push({
|
|
name: String(r.name),
|
|
description: region,
|
|
latitude: r.latitude,
|
|
longitude: r.longitude
|
|
})
|
|
}
|
|
return out
|
|
} catch (e) {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function locationCommit(text, suggestions, selectedIndex) {
|
|
var name = String(text || "").replace(/^\s+|\s+$/g, "")
|
|
if (name === "") return { name: "", latitude: null, longitude: null }
|
|
|
|
var choices = suggestions || []
|
|
var index = Math.max(0, Math.min(parseInt(selectedIndex, 10) || 0, choices.length - 1))
|
|
var suggestion = choices[index]
|
|
if (suggestion) return suggestion
|
|
|
|
return { name: name, latitude: null, longitude: null }
|
|
}
|
|
|
|
function isFutureForecastDate(dateString, todayString) {
|
|
if (!dateString) return false
|
|
return String(dateString).slice(0, 10) > String(todayString || "")
|
|
}
|
|
|
|
function roundedTemp(value) {
|
|
if (value === undefined || value === null || value === "") return ""
|
|
var n = parseFloat(String(value))
|
|
return isNaN(n) ? "" : String(Math.round(n))
|
|
}
|
|
|
|
function celsiusToFahrenheit(value) {
|
|
if (value === undefined || value === null || value === "") return ""
|
|
var n = parseFloat(String(value))
|
|
return isNaN(n) ? "" : (n * 9 / 5) + 32
|
|
}
|
|
|
|
function formatTemp(value, useImperial) {
|
|
if (value === undefined || value === null || value === "") return ""
|
|
return value + "°" + (useImperial ? "F" : "C")
|
|
}
|
|
|
|
function normalizedUnit(value) {
|
|
return String(value || "").replace(/^\s+|\s+$/g, "").toLowerCase()
|
|
}
|
|
|
|
function localeUsesImperial(localeName) {
|
|
var name = String(localeName || "").replace(".", "_")
|
|
return /^en[_-]US($|[_.-])/.test(name) || /^en[_-]LR($|[_.-])/.test(name) || /^my($|[_.-])/.test(name)
|
|
}
|
|
|
|
function countryUsesImperial(countryName) {
|
|
var country = String(countryName || "")
|
|
.replace(/^\s+|\s+$/g, "")
|
|
.replace(/[._-]+/g, " ")
|
|
.toLowerCase()
|
|
if (!country) return null
|
|
if (country === "us" || country === "usa" || country === "united states" || country === "united states of america") return true
|
|
if (country === "liberia" || country === "myanmar" || country === "burma") return true
|
|
return false
|
|
}
|
|
|
|
function shouldUseImperial(unitOverride, localeName, countryName) {
|
|
var unit = normalizedUnit(unitOverride)
|
|
if (unit === "imperial") return true
|
|
if (unit === "metric") return false
|
|
|
|
var countryPreference = countryUsesImperial(countryName)
|
|
if (countryPreference !== null) return countryPreference
|
|
|
|
return localeUsesImperial(localeName)
|
|
}
|
|
|
|
function dayName(dateString, formatter) {
|
|
if (!dateString) return ""
|
|
var d = new Date(dateString + "T12:00:00")
|
|
if (isNaN(d.getTime())) return ""
|
|
if (formatter) return formatter(d)
|
|
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]
|
|
}
|
|
|
|
function openMeteoForecastDays(dailyForecastReport, todayString) {
|
|
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
|
|
if (!daily || !daily.time) return []
|
|
|
|
var result = []
|
|
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
|
|
var date = daily.time[i]
|
|
if (!isFutureForecastDate(date, todayString)) continue
|
|
|
|
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
|
|
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
|
|
result.push({
|
|
date: date,
|
|
maxtempC: roundedTemp(maxC),
|
|
mintempC: roundedTemp(minC),
|
|
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
|
|
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
|
|
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Open-Meteo bundles current conditions with the daily forecast request and
|
|
// answers far faster than wttr.in. Normalize them to wttr's
|
|
// current_condition shape so the panel can use either source
|
|
// interchangeably. Open-Meteo reports metric (°C, km/h).
|
|
function openMeteoCurrentCondition(dailyForecastReport) {
|
|
var current = dailyForecastReport && dailyForecastReport.current ? dailyForecastReport.current : null
|
|
if (!current || current.temperature_2m === undefined || current.temperature_2m === null) return null
|
|
return {
|
|
temp_C: roundedTemp(current.temperature_2m),
|
|
temp_F: roundedTemp(celsiusToFahrenheit(current.temperature_2m)),
|
|
FeelsLikeC: roundedTemp(current.apparent_temperature),
|
|
FeelsLikeF: roundedTemp(celsiusToFahrenheit(current.apparent_temperature)),
|
|
windspeedKmph: roundedTemp(current.wind_speed_10m),
|
|
windspeedMiles: roundedTemp(current.wind_speed_10m * 0.621371),
|
|
humidity: roundedTemp(current.relative_humidity_2m),
|
|
openMeteoWeatherCode: current.weather_code,
|
|
isDay: current.is_day
|
|
}
|
|
}
|
|
|
|
function currentIcon(current, fallback) {
|
|
if (!current) return fallback || ""
|
|
if (current.openMeteoWeatherCode !== undefined && current.openMeteoWeatherCode !== null)
|
|
return iconForOpenMeteoCode(current.openMeteoWeatherCode, Number(current.isDay) === 0)
|
|
if (current.weatherCode !== undefined && current.weatherCode !== null)
|
|
return iconForCode(current.weatherCode, false)
|
|
return fallback || ""
|
|
}
|
|
|
|
// wttr.in has no day/night flag. Use its icon only to fill an empty initial
|
|
// state, never to replace a day/night-aware icon resolved by Open-Meteo.
|
|
function provisionalCurrentIcon(current, resolvedIcon) {
|
|
return resolvedIcon || currentIcon(current, "")
|
|
}
|
|
|
|
function weatherResponseCompletesSave(hasConfiguredCoordinates, source) {
|
|
return hasConfiguredCoordinates ? source === "open-meteo" : source === "wttr"
|
|
}
|
|
|
|
function wttrNextForecastDays(report, todayString) {
|
|
var days = report && report.weather ? report.weather : []
|
|
var result = []
|
|
for (var i = 0; i < days.length && result.length < 3; ++i) {
|
|
if (isFutureForecastDate(days[i].date, todayString)) result.push(days[i])
|
|
}
|
|
return result
|
|
}
|
|
|
|
function buildForecastDays(report, dailyForecastReport, todayString) {
|
|
var days = openMeteoForecastDays(dailyForecastReport, todayString)
|
|
return days.length > 0 ? days : wttrNextForecastDays(report, todayString)
|
|
}
|
|
|
|
function bareTempForDay(day, kind, useImperial) {
|
|
if (!day) return ""
|
|
var v = useImperial
|
|
? (kind === "max" ? day.maxtempF : day.mintempF)
|
|
: (kind === "max" ? day.maxtempC : day.mintempC)
|
|
if (v === undefined || v === null || v === "") return ""
|
|
return v + "°"
|
|
}
|
|
|
|
function dayIcon(day) {
|
|
if (!day) return ""
|
|
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null)
|
|
return iconForOpenMeteoCode(day.openMeteoWeatherCode)
|
|
if (!day.hourly || day.hourly.length === 0) return ""
|
|
|
|
var best = day.hourly[0]
|
|
var bestDist = 9999
|
|
for (var i = 0; i < day.hourly.length; ++i) {
|
|
var t = parseInt(String(day.hourly[i].time || "0"), 10)
|
|
var dist = Math.abs(t - 1200)
|
|
if (dist < bestDist) {
|
|
bestDist = dist
|
|
best = day.hourly[i]
|
|
}
|
|
}
|
|
return iconForCode(best.weatherCode, false)
|
|
}
|
|
|
|
function iconForOpenMeteoCode(code, night) {
|
|
var c = parseInt(String(code || "0"), 10)
|
|
if (c === 0) return iconForCode(113, night)
|
|
if (c === 1 || c === 2) return iconForCode(116, night)
|
|
if (c === 3) return iconForCode(119, night)
|
|
if (c === 45 || c === 48) return iconForCode(143, night)
|
|
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, night)
|
|
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, night)
|
|
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, night)
|
|
if (c === 95 || c === 96 || c === 99) return iconForCode(389, night)
|
|
return iconForCode(119, night)
|
|
}
|
|
|
|
function iconForCode(code, night) {
|
|
var c = parseInt(String(code || "0"), 10)
|
|
switch (c) {
|
|
case 113: return night ? "" : ""
|
|
case 116: return night ? "" : ""
|
|
case 119: case 122: return ""
|
|
case 143: case 248: case 260: return night ? "\ue346" : "\ue313"
|
|
case 176: case 263: case 353: return night ? "" : ""
|
|
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
|
|
case 182: case 185: case 281: case 284: case 311: case 314:
|
|
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
|
|
case 200: case 386: case 389: case 392: case 395: return ""
|
|
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
|
|
case 329: case 332: case 335: case 338: case 371: return ""
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
parseLocationFile: parseLocationFile,
|
|
wttrLocationQuery: wttrLocationQuery,
|
|
parseGeocodingResults: parseGeocodingResults,
|
|
locationCommit: locationCommit,
|
|
isFutureForecastDate: isFutureForecastDate,
|
|
roundedTemp: roundedTemp,
|
|
celsiusToFahrenheit: celsiusToFahrenheit,
|
|
formatTemp: formatTemp,
|
|
normalizedUnit: normalizedUnit,
|
|
localeUsesImperial: localeUsesImperial,
|
|
countryUsesImperial: countryUsesImperial,
|
|
shouldUseImperial: shouldUseImperial,
|
|
dayName: dayName,
|
|
openMeteoForecastDays: openMeteoForecastDays,
|
|
openMeteoCurrentCondition: openMeteoCurrentCondition,
|
|
currentIcon: currentIcon,
|
|
provisionalCurrentIcon: provisionalCurrentIcon,
|
|
weatherResponseCompletesSave: weatherResponseCompletesSave,
|
|
wttrNextForecastDays: wttrNextForecastDays,
|
|
buildForecastDays: buildForecastDays,
|
|
bareTempForDay: bareTempForDay,
|
|
dayIcon: dayIcon,
|
|
iconForOpenMeteoCode: iconForOpenMeteoCode,
|
|
iconForCode: iconForCode
|
|
}
|
|
}
|