Files
omarchy/shell/plugins/panels/elsewhen/Model.js
T
David Heinemeier HanssonandClaude Opus 5.5 773e19e145 Drive Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E (#13445)
* Navigate Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E

Up and down walk home and the cities, lighting the picked row and turning
the globe to it. Left and right move the clocks an hour, held until Escape
or close. Escape now clears a shifted time before leaving the globe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reach Add a city from the keyboard and shift time past a day

Down past the last city selects Add a city, where Return or Space opens the
search. The arrow keys no longer stop at twelve hours, and shifts past a day
read in days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete the picked Elsewhen city from the keyboard

Delete (or x) removes the picked city and moves the cursor to the one that
takes its place. The picked row shows its × so the target is plain. Delete
now reaches every panel's deleteRequested, as it already does in the
clipboard and the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Center bar glyphs on the open-panel underline

The 16px icon canvas in the 27px slot was snapped from 5.5 to 6, and the
glyph's fractional centering correction was snapped too, so glyphs sat up to
0.9px right of the underline, which centers on the slot. Both are now placed
exactly, and the geometry test measures against the slot to within 0.05px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:05:41 +02:00

943 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)
// The arrow keys can run past a day, where hours alone stop reading well.
var d = Math.floor(a / DAY_MINUTES), h = Math.floor(a % DAY_MINUTES / 60), rem = a % 60
var parts = []
if (d) parts.push(d + "d")
if (h) parts.push(h + "h")
if (rem) parts.push(rem + "m")
return sign + parts.join(" ")
}
// ---- 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
}
}