Files
omarchy/shell/plugins/panels/elsewhen/Model.js
T
5929a9b80c Set Elsewhen's here by tapping a city on the globe (#13438)
A city on this machine's zone becomes homeCity, the zone's own city clears
it, and a city on another zone leaves it alone. Ported from omacom/elsewhen#5.

Co-authored-by: Shawn Yeager <shawn@shawnyeager.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:26:00 +02:00

939 lines
33 KiB
JavaScript

// QML has no Intl, so each zone's UTC offset comes from a `date` probe and the
// clocks tick locally against it.
var DEFAULT_ZONES = "Los Angeles|America/Los_Angeles, Paris|Europe/Paris, Tokyo|Asia/Tokyo"
var WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
// Keeps the list free of entries that could never name a zone.
var ZONE_ID = /^[A-Za-z0-9_+\-\/]+$/
// Labels live in a "Label|Zone, Label|Zone" string, so they may not carry either delimiter.
function cleanLabel(label) {
return String(label || "").replace(/[,|]/g, " ").replace(/\s+/g, " ").trim()
}
// "Los Angeles|America/Los_Angeles, Asia/Tokyo" -> [{label, id}, ...]. Empty means a fresh install.
function parseZones(spec) {
var text = String(spec === undefined || spec === null ? "" : spec)
var out = []
var parts = text.split(",")
for (var i = 0; i < parts.length; i++) {
var entry = parts[i].trim()
if (entry === "") continue
var fields = entry.split("|")
var label = ""
var id = ""
if (fields.length >= 2) {
label = fields[0].trim()
id = fields[1].trim()
} else {
id = entry
label = entry.split("/").pop().replace(/_/g, " ")
}
if (id === "" || !ZONE_ID.test(id)) continue
if (label === "") label = id.split("/").pop().replace(/_/g, " ")
out.push({ label: label, id: id })
}
return out
}
// ---- the date probe
// argv for one `date` per zone, printing "Asia/Tokyo|JST|+0900". Zones are
// arguments, never script text. `withLocal` adds "LOCAL|<zone>" plus that zone's own line.
function probeCommand(ids, withLocal) {
var script = "for z in \"$@\"; do TZ=\"$z\" date \"+$z|%Z|%z\"; done"
if (withLocal)
script = "tz=$(timedatectl show -p Timezone --value); "
+ "printf 'LOCAL|%s\\n' \"$tz\"; TZ=\"$tz\" date \"+$tz|%Z|%z\"; " + script
return ["bash", "-c", script, "bash"].concat(ids || [])
}
// "-0700" -> -420, or null.
function parseOffset(text) {
var m = /^([+-])(\d{2})(\d{2})$/.exec(String(text || "").trim())
if (!m) return null
var minutes = parseInt(m[2], 10) * 60 + parseInt(m[3], 10)
return m[1] === "-" ? -minutes : minutes
}
function parseProbeLine(line) {
var fields = String(line || "").split("|")
if (fields.length < 3) return null
var id = fields[0].trim()
var offset = parseOffset(fields[2])
if (id === "" || offset === null) return null
return { id: id, abbr: fields[1].trim(), offsetMinutes: offset }
}
function localZoneFromProbe(text) {
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var f = lines[i].split("|")
if (f.length >= 2 && f[0].trim() === "LOCAL") return f[1].trim()
}
return ""
}
// Probe stdout -> { "America/Los_Angeles": {abbr, offsetMinutes}, ... }
function parseProbe(text) {
var map = {}
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var parsed = parseProbeLine(lines[i])
if (parsed) map[parsed.id] = { abbr: parsed.abbr, offsetMinutes: parsed.offsetMinutes }
}
return map
}
// Absolute offset: "UTC+2", "UTC-3:30", "UTC". Minutes only when they are not zero.
function utcOffsetLabel(minutes) {
if (minutes === undefined || minutes === null) return ""
var total = Number(minutes)
if (!isFinite(total)) return ""
total = Math.round(total)
if (total === 0) return "UTC"
var abs = Math.abs(total)
var hours = Math.floor(abs / 60)
var mins = abs % 60
return "UTC" + (total < 0 ? "-" : "+") + hours
+ (mins === 0 ? "" : ":" + (mins < 10 ? "0" : "") + mins)
}
// ---- clock faces
// Shifting the instant by the offset and reading UTC getters gives the zone's wall clock.
function zoneParts(nowMs, offsetMinutes) {
var d = new Date(Number(nowMs) + Number(offsetMinutes) * 60000)
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth(),
day: d.getUTCDate(),
weekday: d.getUTCDay(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes()
}
}
function localParts(nowMs) {
var d = new Date(Number(nowMs))
return {
year: d.getFullYear(),
month: d.getMonth(),
day: d.getDate(),
weekday: d.getDay(),
hour: d.getHours(),
minute: d.getMinutes()
}
}
// Calendar fields only, so DST cannot skew the count.
function dayDelta(parts, reference) {
var a = Date.UTC(parts.year, parts.month, parts.day)
var b = Date.UTC(reference.year, reference.month, reference.day)
return Math.round((a - b) / 86400000)
}
function dayLabel(delta) {
if (delta === 0) return ""
if (delta === 1) return "Tomorrow"
if (delta === -1) return "Yesterday"
return delta > 0 ? "+" + delta + " days" : delta + " days"
}
function pad2(n) {
return (n < 10 ? "0" : "") + n
}
function formatTime(parts, hour24) {
if (hour24) return pad2(parts.hour) + ":" + pad2(parts.minute)
var h = parts.hour % 12
if (h === 0) h = 12
return h + ":" + pad2(parts.minute)
}
function meridiem(parts) {
return parts.hour < 12 ? "AM" : "PM"
}
// "14:05" or "2:05 PM" at a zone offset; "" before the offset is known.
function formatClock(nowMs, offsetMinutes, hour24) {
if (offsetMinutes === undefined || offsetMinutes === null) return ""
var parts = zoneParts(nowMs, offsetMinutes)
return formatTime(parts, hour24) + (hour24 ? "" : " " + meridiem(parts))
}
function formatDate(parts) {
return WEEKDAYS[parts.weekday].slice(0, 3) + " " + MONTHS[parts.month] + " " + parts.day
}
function isDaytime(parts) {
return parts.hour >= 6 && parts.hour < 18
}
// Fixed civil hours, used until a city's real sunrise is known.
var DAY_START = 6
var DAY_END = 18
var DAWN_END = 8
var DUSK_START = 17
function phaseFor(parts) {
var h = parts.hour
if (h < DAY_START || h >= DAY_END + 2) return "night"
if (h < DAWN_END) return "dawn"
if (h < DUSK_START) return "day"
return "dusk"
}
function phaseLabel(phase) {
if (phase === "dawn") return "sunrise"
if (phase === "dusk") return "sunset"
if (phase === "night") return "night"
return "daytime"
}
function dayProgress(parts) {
return (parts.hour * 60 + parts.minute) / 1440
}
function daylightStart() { return DAY_START / 24 }
function daylightEnd() { return DAY_END / 24 }
// Geometric, not phaseFor(): "dusk" runs past the end of the lit band.
function inDaylight(progress) {
return progress >= daylightStart() && progress < daylightEnd()
}
// "+9h" or "-3.5h" from the viewer's clock; "" on the viewer's own offset.
function relativeOffsetLabel(zoneOffsetMinutes, localOffsetMinutes) {
var diff = Number(zoneOffsetMinutes) - Number(localOffsetMinutes)
if (diff === 0) return ""
var hours = diff / 60
var text = (Math.round(hours * 10) / 10).toString()
return (diff > 0 ? "+" : "") + text + "h"
}
// Everything a row needs; `ready` is false until the zone has been probed.
function rowFor(zone, probe, nowMs, localOffsetMinutes, hour24) {
var info = probe ? probe[zone.id] : null
if (!info) return { label: zone.label, id: zone.id, ready: false }
var parts = zoneParts(nowMs, info.offsetMinutes)
var here = localParts(nowMs)
var delta = dayDelta(parts, here)
return {
label: zone.label,
id: zone.id,
ready: true,
abbr: info.abbr,
offsetMinutes: info.offsetMinutes,
time: formatTime(parts, hour24),
hour: parts.hour,
meridiem: hour24 ? "" : meridiem(parts),
date: formatDate(parts),
dayLabel: dayLabel(delta),
daytime: isDaytime(parts),
phase: phaseFor(parts),
lit: inDaylight(dayProgress(parts)),
phaseLabel: phaseLabel(phaseFor(parts)),
progress: dayProgress(parts),
relative: relativeOffsetLabel(info.offsetMinutes, localOffsetMinutes)
}
}
function rows(zones, probe, nowMs, localOffsetMinutes, hour24) {
var out = []
for (var i = 0; i < zones.length; i++)
out.push(rowFor(zones[i], probe, nowMs, localOffsetMinutes, hour24))
return out
}
// ---- editing
// Rows and globe cities share no index space, so they meet on (label, zone id).
function indexOfZone(zones, label, id) {
var list = zones || []
for (var i = 0; i < list.length; i++)
if (list[i].label === label && list[i].id === id) return i
return -1
}
// Focus is held as a key so it follows the city when `zones` is replaced.
function indexOfZoneKey(zones, key) {
var list = zones || []
if (!key) return -1
for (var i = 0; i < list.length; i++)
if (factsKey(list[i]) === key) return i
return -1
}
function labelForZoneId(id) {
return String(id || "").split("/").pop().replace(/_/g, " ")
}
// Tapping a city on this machine's clock makes it "here", and the zone's own
// city clears the choice. The new homeCity setting, or null to leave it.
function homeCityAfterTap(label, zoneId, homeOverride, localZone) {
var name = String(label || "").trim()
var local = String(localZone || "")
if (name === "" || local === "" || String(zoneId || "") !== local) return null
var override = String(homeOverride || "").trim()
var zoneCity = labelForZoneId(local)
if (name === (override === "" ? zoneCity : override)) return null
return name === zoneCity ? "" : name
}
function serializeZones(zones) {
var parts = []
for (var i = 0; i < zones.length; i++) {
var z = zones[i]
if (!z || !z.id) continue
parts.push(z.label && z.label !== "" ? z.label + "|" + z.id : z.id)
}
return parts.join(", ")
}
// Two cities may share a zone, so an entry is its label and zone together.
function hasEntry(zones, id, label) {
for (var i = 0; i < zones.length; i++)
if (zones[i].id === id && zones[i].label === label) return true
return false
}
// Returns the same array when nothing changes, so callers can skip the write.
function addZone(zones, id, label) {
var zoneId = String(id || "").trim()
if (zoneId === "" || !ZONE_ID.test(zoneId)) return zones
var name = cleanLabel(label)
if (name === "") name = labelForZoneId(zoneId)
if (hasEntry(zones, zoneId, name)) return zones
var out = zones.slice()
out.push({ label: name, id: zoneId })
return out
}
// By position: with two rows on one zone, an id cannot say which was clicked.
function removeZoneAt(zones, index) {
if (zones.length <= 1 || index < 0 || index >= zones.length) return zones
var out = zones.slice()
out.splice(index, 1)
return out
}
function moveZone(zones, from, to) {
var n = zones.length
if (from === to || from < 0 || from >= n || to < 0 || to >= n) return zones
var out = zones.slice()
out.splice(to, 0, out.splice(from, 1)[0])
return out
}
// The last city is never removed.
function removeZone(zones, id) {
if (zones.length <= 1) return zones
var out = []
for (var i = 0; i < zones.length; i++) if (zones[i].id !== id) out.push(zones[i])
return out.length === zones.length ? zones : out
}
// `timedatectl list-timezones` plus CITY_ALIASES -> picker options, minus `existing`.
function zoneOptions(text, existing) {
var lines = String(text || "").split("\n")
var seen = {}
var out = []
function offer(id, label) {
var key = label + "\u0000" + id
if (seen[key]) return
if (existing && hasEntry(existing, id, label)) return
seen[key] = true
out.push({ value: id, label: label, description: id })
}
for (var i = 0; i < lines.length; i++) {
var id = lines[i].trim()
if (id === "" || id.indexOf("/") === -1) continue
offer(id, labelForZoneId(id))
}
for (var j = 0; j < CITY_ALIASES.length; j++)
offer(CITY_ALIASES[j].id, CITY_ALIASES[j].label)
out.sort(function(a, b) { return a.label < b.label ? -1 : (a.label > b.label ? 1 : 0) })
return out
}
// Matches city name or IANA id; city-name prefixes sort first.
function searchZones(options, query, limit) {
var q = String(query || "").trim().toLowerCase()
var max = limit === undefined ? 6 : limit
var starts = []
var contains = []
for (var i = 0; i < options.length; i++) {
var o = options[i]
var label = String(o.label).toLowerCase()
var id = String(o.value).toLowerCase()
if (q === "") { starts.push(o); }
else if (label.indexOf(q) === 0) starts.push(o)
else if (label.indexOf(q) !== -1 || id.indexOf(q) !== -1) contains.push(o)
if (starts.length >= max && q === "") break
}
return starts.concat(contains).slice(0, max)
}
// Keyboard selection in a result list, wrapping at both ends.
function moveSelection(index, delta, count) {
if (count === 0) return 0
return ((index + delta) % count + count) % count
}
// Globe cities as [name, zone, lat, lon, rank]: home first, then the rest,
// first name wins. Tracked and session cities need coordinates.
function mergeCities(home, builtIn, tracked, session) {
var out = []
var seen = {}
function add(city, needsCoords) {
if (needsCoords && (city[2] === null || city[2] === undefined)) return
var key = String(city[0]).toLowerCase()
if (seen[key]) return
seen[key] = true
out.push(city)
}
if (home && home.length > 0 && home[2] !== undefined) add(home, false)
var i
for (i = 0; i < (builtIn || []).length; i++) add(builtIn[i], false)
for (i = 0; i < (tracked || []).length; i++) add(tracked[i], true)
for (i = 0; i < (session || []).length; i++) add(session[i], true)
return out
}
// Places people think of that the tz database files under another city's zone.
var CITY_ALIASES = [
// US Eastern
{ label: "Miami", id: "America/New_York" },
{ label: "Boca Raton", id: "America/New_York" },
{ label: "Boston", id: "America/New_York" },
{ label: "Philadelphia", id: "America/New_York" },
{ label: "Washington DC", id: "America/New_York" },
{ label: "Atlanta", id: "America/New_York" },
{ label: "Orlando", id: "America/New_York" },
{ label: "Tampa", id: "America/New_York" },
{ label: "Charlotte", id: "America/New_York" },
{ label: "Pittsburgh", id: "America/New_York" },
{ label: "Cleveland", id: "America/New_York" },
// US Central
{ label: "Chicago", id: "America/Chicago" },
{ label: "Austin", id: "America/Chicago" },
{ label: "Dallas", id: "America/Chicago" },
{ label: "Houston", id: "America/Chicago" },
{ label: "San Antonio", id: "America/Chicago" },
{ label: "Nashville", id: "America/Chicago" },
{ label: "New Orleans", id: "America/Chicago" },
{ label: "Minneapolis", id: "America/Chicago" },
{ label: "Kansas City", id: "America/Chicago" },
{ label: "St. Louis", id: "America/Chicago" },
{ label: "Memphis", id: "America/Chicago" },
// US Mountain
{ label: "Salt Lake City", id: "America/Denver" },
{ label: "Albuquerque", id: "America/Denver" },
{ label: "Colorado Springs", id: "America/Denver" },
{ label: "Boulder", id: "America/Denver" },
// Arizona does not observe DST, so it is its own zone.
{ label: "Tucson", id: "America/Phoenix" },
{ label: "Scottsdale", id: "America/Phoenix" },
// US Pacific
{ label: "San Francisco", id: "America/Los_Angeles" },
{ label: "San Diego", id: "America/Los_Angeles" },
{ label: "San Jose", id: "America/Los_Angeles" },
{ label: "Oakland", id: "America/Los_Angeles" },
{ label: "Sacramento", id: "America/Los_Angeles" },
{ label: "Seattle", id: "America/Los_Angeles" },
{ label: "Portland", id: "America/Los_Angeles" },
{ label: "Las Vegas", id: "America/Los_Angeles" },
// Canada
{ label: "Montreal", id: "America/Toronto" },
{ label: "Ottawa", id: "America/Toronto" },
{ label: "Calgary", id: "America/Edmonton" },
{ label: "Victoria", id: "America/Vancouver" },
// Latin America
{ label: "Rio de Janeiro", id: "America/Sao_Paulo" },
{ label: "Brasilia", id: "America/Sao_Paulo" },
{ label: "Guadalajara", id: "America/Mexico_City" },
// UK and Ireland
{ label: "Manchester", id: "Europe/London" },
{ label: "Edinburgh", id: "Europe/London" },
{ label: "Glasgow", id: "Europe/London" },
{ label: "Birmingham", id: "Europe/London" },
{ label: "Cambridge", id: "Europe/London" },
{ label: "Oxford", id: "Europe/London" },
// Continental Europe
{ label: "Munich", id: "Europe/Berlin" },
{ label: "Frankfurt", id: "Europe/Berlin" },
{ label: "Hamburg", id: "Europe/Berlin" },
{ label: "Cologne", id: "Europe/Berlin" },
{ label: "Lyon", id: "Europe/Paris" },
{ label: "Marseille", id: "Europe/Paris" },
{ label: "Nice", id: "Europe/Paris" },
{ label: "Barcelona", id: "Europe/Madrid" },
{ label: "Valencia", id: "Europe/Madrid" },
{ label: "Seville", id: "Europe/Madrid" },
{ label: "Milan", id: "Europe/Rome" },
{ label: "Naples", id: "Europe/Rome" },
{ label: "Florence", id: "Europe/Rome" },
{ label: "Venice", id: "Europe/Rome" },
{ label: "Turin", id: "Europe/Rome" },
{ label: "Rotterdam", id: "Europe/Amsterdam" },
{ label: "Geneva", id: "Europe/Zurich" },
{ label: "Basel", id: "Europe/Zurich" },
{ label: "Gothenburg", id: "Europe/Stockholm" },
{ label: "Porto", id: "Europe/Lisbon" },
{ label: "Krakow", id: "Europe/Warsaw" },
{ label: "St Petersburg", id: "Europe/Moscow" },
// Asia
{ label: "Beijing", id: "Asia/Shanghai" },
{ label: "Shenzhen", id: "Asia/Shanghai" },
{ label: "Guangzhou", id: "Asia/Shanghai" },
{ label: "Osaka", id: "Asia/Tokyo" },
{ label: "Kyoto", id: "Asia/Tokyo" },
{ label: "Yokohama", id: "Asia/Tokyo" },
{ label: "Nagoya", id: "Asia/Tokyo" },
{ label: "Busan", id: "Asia/Seoul" },
{ label: "Mumbai", id: "Asia/Kolkata" },
{ label: "Delhi", id: "Asia/Kolkata" },
{ label: "New Delhi", id: "Asia/Kolkata" },
{ label: "Bangalore", id: "Asia/Kolkata" },
{ label: "Bengaluru", id: "Asia/Kolkata" },
{ label: "Chennai", id: "Asia/Kolkata" },
{ label: "Hyderabad", id: "Asia/Kolkata" },
{ label: "Pune", id: "Asia/Kolkata" },
{ label: "Abu Dhabi", id: "Asia/Dubai" },
{ label: "Tel Aviv", id: "Asia/Jerusalem" },
// Oceania and Africa
{ label: "Canberra", id: "Australia/Sydney" },
{ label: "Cape Town", id: "Africa/Johannesburg" },
{ label: "Durban", id: "Africa/Johannesburg" },
{ label: "Alexandria", id: "Africa/Cairo" }
]
// ---- weather
// Coordinates and weather are keyed by "label|zone".
function factsKey(zone) {
return String(zone.label) + "|" + String(zone.id)
}
var WEATHER_TTL_MS = 20 * 60 * 1000
function geocodeUrl(label) {
return "https://geocoding-api.open-meteo.com/v1/search?count=10&language=en&format=json&name="
+ encodeURIComponent(label)
}
// { place, answered }: a place in the row's own zone beats the top result, and
// `answered` is false when the request itself failed.
function pickGeocode(text, zone) {
var results
try { results = JSON.parse(text).results || [] } catch (e) { return { place: null, answered: false } }
var best = null
for (var i = 0; i < results.length && !best; i++) if (results[i].timezone === zone) best = results[i]
if (!best && results.length > 0) best = results[0]
return { place: best ? { lat: best.latitude, lon: best.longitude } : null, answered: true }
}
// The zone's representative city from zone1970.tab (ISO 6709), the offline fallback.
function zoneTabCoords(tabText, zone) {
var lines = String(tabText || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].split("\t")
if (lines[i].charAt(0) === "#" || parts.length < 3 || parts[2] !== zone) continue
var m = /^([+-])(\d{2})(\d{2})(\d{2})?([+-])(\d{3})(\d{2})(\d{2})?$/.exec(parts[1])
if (!m) return null
function dec(sign, d, mm, ss) { return (sign === "-" ? -1 : 1) * (Number(d) + Number(mm) / 60 + Number(ss || 0) / 3600) }
return { lat: Math.round(dec(m[1], m[2], m[3], m[4]) * 1e4) / 1e4, lon: Math.round(dec(m[5], m[6], m[7], m[8]) * 1e4) / 1e4 }
}
return null
}
function forecastUrl(points) {
return "https://api.open-meteo.com/v1/forecast?current=temperature_2m,weather_code&temperature_unit=celsius"
+ "&latitude=" + points.map(function(p) { return p.lat }).join(",")
+ "&longitude=" + points.map(function(p) { return p.lon }).join(",")
}
// Open-Meteo answers one point as an object and several as an array, in request order.
function parseForecast(text, keys, nowMs) {
var payload
try { payload = JSON.parse(text) } catch (e) { return {} }
if (!Array.isArray(payload)) payload = [payload]
var out = {}
for (var i = 0; i < keys.length && i < payload.length; i++) {
var current = (payload[i] && payload[i].current) || {}
if (current.temperature_2m === undefined || current.temperature_2m === null) continue
out[keys[i]] = { c: Math.round(current.temperature_2m * 10) / 10, w: current.weather_code, at: nowMs }
}
return out
}
function weatherStale(entry, nowMs) {
return !entry || nowMs - entry.at >= WEATHER_TTL_MS
}
// { "label|zone": { lat, lon, c, w } }, each field only when known.
function mergeFacts(keys, coords, weather) {
var out = {}
keys.forEach(function(key) {
var entry = {}
if (coords[key]) { entry.lat = coords[key].lat; entry.lon = coords[key].lon }
if (weather[key]) {
entry.c = weather[key].c
if (weather[key].w !== undefined && weather[key].w !== null) entry.w = weather[key].w
}
out[key] = entry
})
return out
}
// An explicit "C" or "F" wins; anything else follows the system.
function resolveUnits(setting, auto) {
var explicit = String(setting === undefined || setting === null ? "" : setting)
.trim().toUpperCase()
if (explicit === "C" || explicit === "F") return explicit
return String(auto).toUpperCase() === "F" ? "F" : "C"
}
// Only a 12-hour pattern has an AM/PM designator once quoted literals are stripped.
function usesTwentyFourHour(timeFormat) {
var pattern = String(timeFormat === undefined || timeFormat === null ? "" : timeFormat)
.replace(/'[^']*'/g, "")
return !/[Aa]/.test(pattern)
}
// An explicit setting wins, and a stored `false` is explicit.
function resolveHour24(setting, auto) {
if (setting === true || setting === false) return setting
var text = String(setting === undefined || setting === null ? "" : setting)
.trim().toLowerCase()
if (text === "true" || text === "24") return true
if (text === "false" || text === "12") return false
return auto === true
}
function formatTemp(celsius, units) {
if (celsius === undefined || celsius === null) return ""
if (String(units).toUpperCase() === "C") return Math.round(celsius) + "°C"
return Math.round(celsius * 9 / 5 + 32) + "°F"
}
function tempLabel(facts, units) {
return facts ? formatTemp(facts.c, units) : ""
}
// WMO present-weather codes collapsed to the five glyphs a row can show.
function weatherKind(code) {
// Number(null) is 0, which means "clear".
if (code === null || code === undefined || code === "") return ""
var c = Number(code)
if (!isFinite(c)) return ""
if (c <= 1) return "sunny"
if (c === 2) return "partly"
if (c === 3 || c === 45 || c === 48) return "cloudy"
if (c >= 71 && c <= 77) return "snow"
if (c === 85 || c === 86) return "snow"
if (c >= 51 && c <= 67) return "rain"
if (c >= 80 && c <= 82) return "rain"
if (c >= 95 && c <= 99) return "rain"
return ""
}
// ---- scrubbing
var DAY_MINUTES = 1440
// The nearest occurrence of the local time under the pointer, in (-720, 720].
function scrubDeltaMinutes(fraction, cityLocalMinutes) {
var target = Math.max(0, Math.min(1, fraction)) * DAY_MINUTES
var delta = target - cityLocalMinutes
while (delta > DAY_MINUTES / 2) delta -= DAY_MINUTES
while (delta <= -DAY_MINUTES / 2) delta += DAY_MINUTES
return delta
}
// Round before splitting into hour and minute, and wrap after, so 419.8 is "7:00".
function formatMinuteOfDay(minute, hour24) {
var m = Math.round(Number(minute))
m = ((m % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES
var hour = Math.floor(m / 60)
return formatTime({ hour: hour, minute: m % 60 }, hour24)
+ (hour24 ? "" : (hour < 12 ? " AM" : " PM"))
}
// "+3h", "-45m", "" for now.
function formatScrubDelta(minutes) {
var m = Math.round(minutes)
if (m === 0) return ""
var sign = m > 0 ? "+" : "-"
var a = Math.abs(m)
if (a < 60) return sign + a + "m"
var h = Math.floor(a / 60), rem = a % 60
return sign + h + "h" + (rem ? " " + rem + "m" : "")
}
// ---- the strip's sunrise arrows
function arrowBox(fraction, barWidth, boxWidth, tuck, rising) {
var at = barWidth * fraction
var pos = rising ? at - boxWidth + tuck : at - tuck
return Math.round(Math.max(0, Math.min(barWidth - boxWidth, pos)))
}
function arrowCovered(boxX, boxWidth, markerCenter, markerWidth, slack) {
return Math.abs(markerCenter - (boxX + boxWidth / 2)) < markerWidth / 2 + slack
}
// One chip shows at a time. Both rules read what showed at press time, so a
// click comes out the same whichever handler Qt runs first.
var NO_CHIP = -1
function chipAfterTap(shownAtPress, slot) {
return shownAtPress === slot ? NO_CHIP : slot
}
// Clears the chip unless something else changed it during this press.
function chipAfterRelease(shownNow, shownAtPress) {
return shownNow === shownAtPress ? NO_CHIP : shownNow
}
// ---- globe transition and row drag
// Rows are knocked aside one after another as `zoom` runs 0..1, alternating sides.
var KNOCK_STAGGER = 0.09
var KNOCK_MAX_LEAD = 0.5
var KNOCK_TILT = 14
var KNOCK_SHRINK = 0.18
function knockAt(index, zoom) {
var lead = Math.min(KNOCK_MAX_LEAD, index * KNOCK_STAGGER)
return Math.max(0, Math.min(1, (zoom - lead) / (1 - lead)))
}
function knockSide(index) { return index % 2 === 0 ? -1 : 1 }
function knockX(index, zoom, throwX) { return knockAt(index, zoom) * knockSide(index) * throwX }
// Squared, so rows accelerate as they fall.
function knockY(index, zoom, fall) {
var p = knockAt(index, zoom)
return p * p * fall
}
function knockTilt(index, zoom) { return knockAt(index, zoom) * knockSide(index) * KNOCK_TILT }
function knockShrink(index, zoom) { return 1 - KNOCK_SHRINK * knockAt(index, zoom) }
// Rows the pointer must travel past the held slot before the target moves.
var DRAG_HYSTERESIS = 0.6
function dragTargetFor(dragIndex, dragOffset, rowPitch, heldTarget, count) {
if (dragIndex < 0 || rowPitch <= 0) return -1
var raw = dragOffset / rowPitch
var held = heldTarget < 0 ? 0 : heldTarget - dragIndex
var next = Math.abs(raw - held) >= DRAG_HYSTERESIS ? Math.round(raw) : held
return Math.max(0, Math.min(count - 1, dragIndex + next))
}
// The dragged row follows the pointer; rows it has passed step aside by one pitch.
function rowShift(index, dragIndex, dragTarget, dragOffset, rowPitch) {
if (dragIndex < 0) return 0
if (index === dragIndex) return dragOffset
if (dragIndex < dragTarget && index > dragIndex && index <= dragTarget) return -rowPitch
if (dragIndex > dragTarget && index >= dragTarget && index < dragIndex) return rowPitch
return 0
}
// Opaque blend of two colors: a Qt color inside QML, {r, g, b, a} under node.
function mix(from, to, t) {
var r = from.r + (to.r - from.r) * t
var g = from.g + (to.g - from.g) * t
var b = from.b + (to.b - from.b) * t
return typeof Qt !== "undefined" ? Qt.rgba(r, g, b, 1) : { r: r, g: g, b: b, a: 1 }
}
// ---- first run
// Home plus four well-known cities spread round the clock from it. Rank breaks ties.
var SEED_CANDIDATES = [
{ label: "Honolulu", id: "Pacific/Honolulu", rank: 3 },
{ label: "Anchorage", id: "America/Anchorage", rank: 3 },
{ label: "Los Angeles", id: "America/Los_Angeles", rank: 2 },
{ label: "Vancouver", id: "America/Vancouver", rank: 3 },
{ label: "Mexico City", id: "America/Mexico_City", rank: 3 },
{ label: "Chicago", id: "America/Chicago", rank: 3 },
{ label: "New York", id: "America/New_York", rank: 1 },
{ label: "Sao Paulo", id: "America/Sao_Paulo", rank: 2 },
{ label: "Buenos Aires", id: "America/Argentina/Buenos_Aires", rank: 3 },
{ label: "Reykjavik", id: "Atlantic/Reykjavik", rank: 3 },
{ label: "London", id: "Europe/London", rank: 1 },
{ label: "Lisbon", id: "Europe/Lisbon", rank: 3 },
{ label: "Paris", id: "Europe/Paris", rank: 1 },
{ label: "Berlin", id: "Europe/Berlin", rank: 3 },
{ label: "Madrid", id: "Europe/Madrid", rank: 3 },
{ label: "Rome", id: "Europe/Rome", rank: 3 },
{ label: "Cairo", id: "Africa/Cairo", rank: 3 },
{ label: "Johannesburg", id: "Africa/Johannesburg", rank: 3 },
{ label: "Istanbul", id: "Europe/Istanbul", rank: 3 },
{ label: "Moscow", id: "Europe/Moscow", rank: 3 },
{ label: "Nairobi", id: "Africa/Nairobi", rank: 3 },
{ label: "Dubai", id: "Asia/Dubai", rank: 2 },
{ label: "Karachi", id: "Asia/Karachi", rank: 3 },
{ label: "Delhi", id: "Asia/Kolkata", rank: 2 },
{ label: "Bangkok", id: "Asia/Bangkok", rank: 3 },
{ label: "Jakarta", id: "Asia/Jakarta", rank: 3 },
{ label: "Singapore", id: "Asia/Singapore", rank: 2 },
{ label: "Hong Kong", id: "Asia/Hong_Kong", rank: 2 },
{ label: "Shanghai", id: "Asia/Shanghai", rank: 2 },
{ label: "Perth", id: "Australia/Perth", rank: 3 },
{ label: "Seoul", id: "Asia/Seoul", rank: 3 },
{ label: "Tokyo", id: "Asia/Tokyo", rank: 1 },
{ label: "Sydney", id: "Australia/Sydney", rank: 2 },
{ label: "Auckland", id: "Pacific/Auckland", rank: 3 }
]
function seedCandidateZones() {
var out = []
for (var i = 0; i < SEED_CANDIDATES.length; i++) out.push(SEED_CANDIDATES[i].id)
return out
}
// Minutes east of home, wrapped to one turn of the dial.
function eastOf(offsetMinutes, homeMinutes) {
var d = (offsetMinutes - homeMinutes) % DAY_MINUTES
return d < 0 ? d + DAY_MINUTES : d
}
function dialDistance(a, b) {
var d = Math.abs(a - b) % DAY_MINUTES
return Math.min(d, DAY_MINUTES - d)
}
// Best-ranked candidates first, each at least `gap` minutes from home and from every pick.
function pickSeedAt(home, offsets, count, gap) {
var homeOff = offsets[home.id]
var homeLabel = String(home.label || "").toLowerCase()
var picked = []
for (var r = 1; r <= 3; r++) {
for (var j = 0; j < SEED_CANDIDATES.length && picked.length < count; j++) {
var c = SEED_CANDIDATES[j]
if (c.rank !== r) continue
var off = offsets[c.id]
if (off === undefined || off === null) continue
if (c.id === home.id) continue
if (String(c.label).toLowerCase() === homeLabel) continue
var east = eastOf(off, homeOff)
if (Math.min(east, DAY_MINUTES - east) < gap) continue
var clash = false
for (var k = 0; k < picked.length; k++)
if (dialDistance(picked[k].east, east) < gap) { clash = true; break }
if (clash) continue
picked.push({ label: c.label, id: c.id, east: east })
}
}
return picked
}
// `offsets` maps zone id to minutes east of UTC and must include home's.
function pickSeedZones(home, offsets, count) {
var n = count === undefined ? 4 : count
if (!home || !home.id || !offsets) return []
if (offsets[home.id] === undefined || offsets[home.id] === null) return []
// Relax the gap where half the world sits within a couple of hours of home.
var picked = []
var gaps = [180, 120, 60]
for (var g = 0; g < gaps.length; g++) {
picked = pickSeedAt(home, offsets, n, gaps[g])
if (picked.length >= n) break
}
picked.sort(function (a, b) { return a.east - b.east })
var out = []
for (var m = 0; m < picked.length; m++)
out.push({ label: picked[m].label, id: picked[m].id })
return out
}
function seedZones(home, offsets, count) {
if (!home || !home.id) return []
var out = [{ label: home.label, id: home.id }]
var rest = pickSeedZones(home, offsets, count)
for (var i = 0; i < rest.length; i++) out.push(rest[i])
return out
}
if (typeof module !== "undefined") {
module.exports = {
DEFAULT_ZONES: DEFAULT_ZONES,
NO_CHIP: NO_CHIP,
SEED_CANDIDATES: SEED_CANDIDATES,
parseZones: parseZones,
probeCommand: probeCommand,
parseOffset: parseOffset,
parseProbe: parseProbe,
localZoneFromProbe: localZoneFromProbe,
utcOffsetLabel: utcOffsetLabel,
relativeOffsetLabel: relativeOffsetLabel,
zoneParts: zoneParts,
localParts: localParts,
formatTime: formatTime,
meridiem: meridiem,
formatClock: formatClock,
daylightStart: daylightStart,
daylightEnd: daylightEnd,
rows: rows,
indexOfZone: indexOfZone,
indexOfZoneKey: indexOfZoneKey,
labelForZoneId: labelForZoneId,
homeCityAfterTap: homeCityAfterTap,
serializeZones: serializeZones,
addZone: addZone,
removeZoneAt: removeZoneAt,
moveZone: moveZone,
removeZone: removeZone,
zoneOptions: zoneOptions,
searchZones: searchZones,
moveSelection: moveSelection,
mergeCities: mergeCities,
factsKey: factsKey,
geocodeUrl: geocodeUrl,
pickGeocode: pickGeocode,
zoneTabCoords: zoneTabCoords,
forecastUrl: forecastUrl,
parseForecast: parseForecast,
weatherStale: weatherStale,
mergeFacts: mergeFacts,
resolveUnits: resolveUnits,
usesTwentyFourHour: usesTwentyFourHour,
resolveHour24: resolveHour24,
formatTemp: formatTemp,
tempLabel: tempLabel,
weatherKind: weatherKind,
scrubDeltaMinutes: scrubDeltaMinutes,
formatMinuteOfDay: formatMinuteOfDay,
formatScrubDelta: formatScrubDelta,
arrowBox: arrowBox,
arrowCovered: arrowCovered,
chipAfterTap: chipAfterTap,
chipAfterRelease: chipAfterRelease,
knockAt: knockAt,
knockX: knockX,
knockY: knockY,
knockTilt: knockTilt,
knockShrink: knockShrink,
dragTargetFor: dragTargetFor,
rowShift: rowShift,
mix: mix,
seedCandidateZones: seedCandidateZones,
pickSeedZones: pickSeedZones,
seedZones: seedZones
}
}