Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)
* Move Elsewhen into Omarchy as omarchy.elsewhen The world clock ships in the shell tree instead of its own package. A migration renames existing bar entries with their settings and removes the retired package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove Elsewhen's shelved Earth row, overlap band and sky tint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tidy Elsewhen's models, data helper and docs, and drop currency Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restructure Elsewhen's panel and globe on the shared shell components Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show Elsewhen's bar globe upright Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use the shared city search for Elsewhen's globe jump bar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep legacy Elsewhen entries single and retry offline geocodes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fetch Elsewhen's weather with curl instead of a Python helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c7cef1e78
commit
e7218e36f2
37 files changed
+6780
-155
No files matched your search
@@ -0,0 +1,925 @@
|
||||
// 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, " ")
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user