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:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 21:15:00 +02:00
1 parent 5c7cef1e78
commit e7218e36f2
37 files changed
+6780 -155

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ User-installed plugins live alongside these conceptually but on disk under
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
| Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
| Clock | `omarchy.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
| Elsewhen | `omarchy.elsewhen` | `bar-widget` | `panels/elsewhen/Panel.qml` |
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
+52
View File
@@ -0,0 +1,52 @@
// A line of text laid along a shallow circular arc. `widths` are character
// advances; `rise` is how far the ends sit from the middle, in pixels; `smile`
// bends the ends up. Returns { width, height, chars: [{ x, y, rotation }] }
// with each box's top-left and its turn in degrees about its center.
function layout(widths, rise, smile) {
var chars = []
var total = 0
var i
for (i = 0; i < widths.length; i++) total += widths[i]
if (total <= 0 || rise <= 0) {
var x = 0
for (i = 0; i < widths.length; i++) {
chars.push({ x: x, y: 0, rotation: 0 })
x += widths[i]
}
return { width: total, height: 0, chars: chars }
}
// Keeps the trig in range for absurd rises.
var sag = Math.min(rise, total / 4)
// Shallow-arc sagitta: R = w^2 / 8s.
var radius = (total * total) / (8 * sag)
var halfAngle = total / (2 * radius)
var maxDrop = radius * (1 - Math.cos(halfAngle))
var chordWidth = 2 * radius * Math.sin(halfAngle)
var travelled = 0
var left = 0, right = chordWidth
for (i = 0; i < widths.length; i++) {
var w = widths[i]
var a = (travelled + w / 2 - total / 2) / radius
var drop = radius * (1 - Math.cos(a))
var x = chordWidth / 2 + radius * Math.sin(a) - w / 2
chars.push({
x: x,
y: smile ? maxDrop - drop : drop,
rotation: (smile ? -a : a) * 180 / Math.PI
})
left = Math.min(left, x)
right = Math.max(right, x + w)
travelled += w
}
// End boxes hang past the chord; shift so x starts at zero and width covers them.
for (i = 0; i < chars.length; i++) chars[i].x -= left
return { width: right - left, height: maxDrop, chars: chars }
}
if (typeof module !== "undefined") module.exports = { layout: layout }
+70
View File
@@ -0,0 +1,70 @@
import QtQuick
import qs.Commons
import "Arc.js" as Arc
// One line of text arched over what sits below it, each character turned to
// follow the curve. A Text per character keeps rendering identical to the
// neighbouring Text items, which a Canvas would not. No eliding.
Item {
id: root
property string text: ""
// How far the ends drop below the middle.
property real rise: 6
property string fontFamily: Style.font.family
property int pixelSize: Style.font.caption
property color color: Color.foreground
FontMetrics {
id: metrics
font.family: root.fontFamily
font.pixelSize: root.pixelSize
}
readonly property var glyphs: text.split("")
readonly property var placed: {
// advanceWidth does not register the font as a dependency.
var _ = metrics.font.family + metrics.font.pixelSize
var widths = []
for (var i = 0; i < glyphs.length; i++) widths.push(metrics.advanceWidth(glyphs[i]))
return Arc.layout(widths, rise, false)
}
// Room for the end characters' corners once they are turned.
readonly property real slack: Math.round(metrics.height / 2)
implicitWidth: Math.ceil(placed.width) + 2 * slack
implicitHeight: Math.ceil(placed.height + metrics.height)
readonly property real originX: (width - placed.width) / 2
Repeater {
model: root.glyphs.length
Text {
required property int index
// The count changes a beat before the arrays behind it do.
readonly property string glyph: root.glyphs[index] || ""
readonly property var spot: root.placed.chars[index] || { x: 0, y: 0, rotation: 0 }
// Exactly one advance wide, so the glyph's middle is where the arc put it.
width: metrics.advanceWidth(glyph)
height: metrics.height
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
x: root.originX + spot.x
y: spot.y
rotation: spot.rotation
transformOrigin: Item.Center
textFormat: Text.PlainText
text: glyph
color: root.color
font.family: root.fontFamily
font.pixelSize: root.pixelSize
renderType: Text.QtRendering // native rendering ignores the rotation
}
}
}
@@ -0,0 +1,86 @@
import QtQuick
import qs.Commons
import qs.Ui
// The matches for a CitySearch; hosts that overlay results place this themselves.
Column {
id: list
required property var citySearch
spacing: Style.space(2)
Repeater {
model: list.citySearch.matches
CursorSurface {
id: match
required property var modelData
required property int index
width: parent.width
implicitHeight: Style.spacing.popupRowHeight
foreground: list.citySearch.foreground
hasCursor: list.citySearch.selectedIndex === index
Text {
anchors.left: parent.left
anchors.leftMargin: Style.spacing.xl
anchors.right: matchZone.left
anchors.rightMargin: Style.spacing.lg
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: match.modelData.label
color: list.citySearch.foreground
font.family: list.citySearch.fontFamily
font.pixelSize: list.citySearch.fontSize
elide: Text.ElideRight
}
// Two cities in one zone differ by name, two zones with one name by offset.
Row {
id: matchZone
anchors.right: parent.right
anchors.rightMargin: Style.spacing.xl
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spacing.md
Caption {
text: match.modelData.value
color: list.citySearch.fainter
}
Caption {
text: list.citySearch.offsetLabel(match.modelData.value)
visible: text !== ""
}
}
// Only movement moves the selection, so a resting pointer cannot
// steal it from the arrow keys as the list rebuilds.
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: list.citySearch.selectedIndex = match.index
onClicked: list.citySearch.pick(match.modelData)
}
}
}
Caption {
visible: list.citySearch.matches.length === 0
width: parent.width
horizontalAlignment: Text.AlignHCenter
topPadding: Style.spacing.md
text: list.citySearch.loading ? list.citySearch.loadingText : "No matches"
color: list.citySearch.fainter
}
component Caption: Text {
textFormat: Text.PlainText
color: list.citySearch.dim
font.family: list.citySearch.fontFamily
font.pixelSize: Style.font.caption
}
}
+366
View File
@@ -0,0 +1,366 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "Model.js" as Model
import "Greetings.js" as Greet
// One city: name, weather, date (or a local greeting on hover) and the time,
// over its daylight strip. Drag the body to reorder, click it to turn the
// globe there. Every toggle on the row flips the setting for the whole list.
Rectangle {
id: row
required property var modelData
required property int index
// The owning Panel: the row reads its clock, facts and drag state.
property var panel
readonly property color foreground: panel.foreground
readonly property color dim: panel.dim
readonly property color fainter: panel.fainter
readonly property string fontFamily: panel.fontFamily
readonly property var rowData: panel.clockRows[index]
|| ({ ready: false, label: modelData.label, id: modelData.id })
readonly property bool ready: rowData.ready
readonly property var greeting: Greet.greeting(rowData.id, ready ? rowData.hour : 0)
readonly property bool removable: panel.clockRows.length > 1
readonly property bool dragged: panel.dragIndex === index
// Lightest at midday, darkest at night, so the list dims into the small hours.
readonly property real phaseFill: {
var p = ready ? rowData.phase : "day"
if (p === "day") return 0.10
if (p === "night") return 0.035
return 0.07
}
readonly property int pad: Style.space(15)
readonly property int stripGap: Style.space(9)
width: parent.width
// The top pad is measured to the cap height, not the line box, so the row
// looks as tall above the name as below the strip.
implicitHeight: (pad - panel.capGap) + rowLabels.implicitHeight + stripGap + strip.trackHeight + pad
radius: Style.cornerRadius
// Opaque: knocked-aside rows pass over one another and over the globe.
color: Model.mix(Color.popups.background, foreground, rowHover.hovered ? phaseFill + 0.05 : phaseFill)
// Transforms leave the Column's layout alone: the knock that clears the
// globe's way, then the drag offset.
transform: [
Translate {
x: Model.knockX(row.index, row.panel.zoom, Style.space(95))
y: Model.knockY(row.index, row.panel.zoom, row.panel.knockFall)
},
Rotation {
origin.x: row.width / 2
origin.y: row.height / 2
angle: Model.knockTilt(row.index, row.panel.zoom)
},
Scale {
origin.x: row.width / 2
origin.y: row.height / 2
xScale: Model.knockShrink(row.index, row.panel.zoom)
yScale: Model.knockShrink(row.index, row.panel.zoom)
},
Translate {
y: Model.rowShift(row.index, row.panel.dragIndex, row.panel.dragTarget,
row.panel.dragOffset, row.panel.rowPitch)
// The dragged row follows the pointer one-to-one; the others ease aside.
Behavior on y {
enabled: !row.dragged
NumberAnimation { duration: 130; easing.type: Easing.OutCubic }
}
}
]
z: dragged ? 2 : 0
opacity: dragged ? 0.9 : 1
Behavior on opacity { NumberAnimation { duration: 120 } }
HoverHandler { id: rowHover }
// The row body is the reorder grab; later siblings keep their own taps.
MouseArea {
id: grab
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: strip.top
preventStealing: true
cursorShape: row.dragged ? Qt.ClosedHandCursor : Qt.OpenHandCursor
property real pressY: 0
property bool armed: false
property int chipAtPress: Model.NO_CHIP
// Measured in the Column, which does not move; the row's own frame slides
// with the drag and would feed the offset back into itself.
function pointerY(mouse) { return grab.mapToItem(row.parent, 0, mouse.y).y }
function finish() {
armed = false
strip.dismissUnlessChanged(chipAtPress)
}
onPressed: function(mouse) {
pressY = pointerY(mouse)
armed = false
chipAtPress = strip.shownChip
}
onPositionChanged: function(mouse) {
var dy = pointerY(mouse) - pressY
// A few pixels of slack, so a click is never a reorder.
if (!armed) {
if (Math.abs(dy) < Style.space(4)) return
armed = true
row.panel.beginRowDrag(row.index, row.height + row.parent.spacing)
}
row.panel.moveRowDrag(dy)
}
onReleased: {
if (armed) row.panel.releaseRowDrag()
else row.panel.focusOn(row.index)
finish()
}
onCanceled: {
row.panel.cancelRowDrag()
finish()
}
}
Column {
id: rowLabels
anchors.left: parent.left
anchors.leftMargin: Style.spacing.rowPaddingX
anchors.right: timeBlock.left
anchors.rightMargin: Style.spacing.xl
anchors.top: parent.top
anchors.topMargin: row.pad - row.panel.capGap
spacing: Style.space(2)
// A layout so the name is what gives way when the line is tight.
RowLayout {
width: parent.width
spacing: Style.space(7)
// Caps the name at its unelided width; the Text's own implicitWidth
// shrinks as it elides, which would make the cap circular.
TextMetrics {
id: nameMetrics
font.family: row.fontFamily
font.pixelSize: Style.font.subtitle
font.weight: Font.DemiBold
text: row.rowData.label
}
Text {
Layout.fillWidth: true
Layout.maximumWidth: Math.ceil(nameMetrics.advanceWidth) + 2
Layout.minimumWidth: 0
Layout.alignment: Qt.AlignBaseline
textFormat: Text.PlainText
text: row.rowData.label
color: row.foreground
font.family: row.fontFamily
font.pixelSize: Style.font.subtitle
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Caption {
Layout.alignment: Qt.AlignBaseline
text: Model.tempLabel(row.panel.facts[Model.factsKey(row.rowData)], row.panel.units)
visible: text !== ""
color: tempHover.hovered ? row.foreground : row.dim
HoverHandler {
id: tempHover
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: { row.panel.toggleUnits(); strip.dismissChips() } }
}
Caption {
Layout.alignment: Qt.AlignBaseline
text: row.panel.weatherGlyph(row.rowData)
visible: text !== ""
font.pixelSize: Style.font.bodySmall
}
}
// The date, and the greeting in its place while hovered. Stacked in one
// slot so the row never changes size under the pointer.
Item {
width: parent.width
implicitHeight: dateLine.implicitHeight
// No greeting before the probe lands: a wrong one is worse than none.
readonly property bool greeting: row.ready && rowHover.hovered
Row {
id: dateLine
spacing: Style.spacing.sm
opacity: parent.greeting ? 0 : 1
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 110 } }
Caption {
text: row.ready ? row.rowData.date : "\u2026"
color: row.ready && row.rowData.dayLabel !== "" ? row.dim : row.fainter
}
Caption {
text: "\u00b7"
visible: row.ready && row.rowData.dayLabel !== ""
color: row.fainter
}
Caption {
text: row.ready ? row.rowData.dayLabel : ""
visible: text !== ""
}
}
Row {
spacing: Style.space(5)
opacity: parent.greeting ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 110 } }
// Non-Latin scripts fall back per character through fontconfig.
Caption {
text: row.greeting.text
color: row.foreground
}
// A pronunciation, not a translation; absent for Latin scripts.
Caption {
text: row.greeting.roman
visible: text !== ""
color: row.fainter
}
}
}
}
DaylightStrip {
id: strip
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Style.spacing.rowPaddingX
anchors.rightMargin: Style.spacing.rowPaddingX
anchors.verticalCenter: parent.bottom
anchors.verticalCenterOffset: -(row.pad + trackHeight / 2)
ready: row.ready
offsetMinutes: row.ready ? row.rowData.offsetMinutes : 0
progress: row.ready ? row.rowData.progress : 0
fallbackLit: row.ready && row.rowData.lit
place: row.panel.facts[Model.factsKey(row.modelData)] || null
nowMs: row.panel.effectiveMs
hovered: rowHover.hovered
hour24: row.panel.hour24
moonPhase: row.panel.moonPhase
foreground: row.foreground
fainter: row.fainter
daylightMarker: row.panel.daylightMarker
fontFamily: row.fontFamily
onScrubStarted: function(fraction) { row.panel.beginScrub(row.rowData, fraction) }
onScrubMoved: function(fraction) { row.panel.scrubTo(row.rowData, fraction) }
onScrubEnded: row.panel.endScrub()
onMoonShowRequested: row.panel.startMoonShow()
}
// The last city stays; removing it would leave no way back.
PanelActionButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(1)
iconText: "\u00d7"
tooltipText: "Remove"
foreground: row.fainter
hoverColor: row.foreground
fontFamily: row.fontFamily
fontSize: Style.font.bodySmall
enabled: row.removable
opacity: row.removable && rowHover.hovered ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 120 } }
onClicked: row.panel.removeCityAt(row.index)
}
Column {
id: timeBlock
anchors.right: parent.right
anchors.rightMargin: Style.spacing.rowPaddingX
anchors.verticalCenter: rowLabels.verticalCenter
spacing: Style.space(1)
// The hour is already the brightest thing on the row, so hover lifts the meridiem.
LinkRow {
id: timeLine
anchors.right: parent.right
spacing: Style.spacing.xs
onClicked: { row.panel.toggleHour24(); strip.dismissChips() }
Text {
id: bigTime
textFormat: Text.PlainText
text: row.ready ? row.rowData.time : "--:--"
color: row.foreground
font.family: row.fontFamily
font.pixelSize: Style.font.heading
font.weight: Font.DemiBold
}
Caption {
anchors.baseline: bigTime.baseline
text: row.rowData.meridiem || ""
visible: text !== ""
color: timeLine.hovered ? row.foreground : row.dim
}
}
LinkRow {
id: offsetLine
anchors.right: parent.right
spacing: Style.space(5)
visible: row.ready
onClicked: { row.panel.toggleOffsetMode(); strip.dismissChips() }
Caption {
text: row.ready ? row.rowData.abbr : ""
color: offsetLine.hovered ? row.dim : row.fainter
}
Caption {
text: row.panel.offsetTextFor(row.rowData)
visible: text !== ""
color: offsetLine.hovered ? row.foreground : row.fainter
}
}
}
component Caption: Text {
textFormat: Text.PlainText
color: row.dim
font.family: row.fontFamily
font.pixelSize: Style.font.caption
}
component LinkRow: Row {
id: link
signal clicked()
readonly property bool hovered: linkHover.hovered
HoverHandler {
id: linkHover
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: link.clicked() }
}
}
@@ -0,0 +1,98 @@
import QtQuick
import qs.Commons
import qs.Ui
import "Model.js" as Model
// A button that opens into a city search: a field, then the matching cities
// with their zone and offset. Arrows move the selection, Return picks it,
// Escape backs out. Inline rather than a dropdown so the results grow the
// panel instead of running off the bottom of the screen.
Column {
id: search
// [{ label, value }] from Model.zoneOptions.
property var options: []
property int limit: 6
// Zone id -> offset text; read inside a binding, so its dependencies track.
property var offsetLabel: function(zoneId) { return "" }
property bool loading: false
property string buttonText: "+ Add a city"
property string placeholderText: "Search cities\u2026"
property string loadingText: "Loading zones\u2026"
property real fontSize: Style.font.bodySmall
property color foreground: Color.foreground
property color dim: Qt.darker(foreground, 1.55)
property color fainter: Qt.darker(foreground, 2.1)
property string fontFamily: Style.font.family
// False when the host shows CityMatches elsewhere.
property bool inlineResults: true
property bool active: false
readonly property string query: field.text
readonly property var matches: active ? Model.searchZones(options, query, limit) : []
// An index, not the match itself: the list is rebuilt on every keystroke.
property int selectedIndex: 0
signal picked(string label, string id)
signal dismissed()
onQueryChanged: selectedIndex = 0
onMatchesChanged: if (selectedIndex >= matches.length) selectedIndex = 0
function start() {
selectedIndex = 0
active = true
Qt.callLater(function() { field.text = ""; field.forceActiveFocus() })
}
// The hidden field would keep the keyboard, so the host takes it back on dismissed.
function stop() {
active = false
field.text = ""
dismissed()
}
function pick(match) {
picked(match.label, match.value)
stop()
}
function pickSelected() {
if (matches.length > 0) pick(matches[Util.clamp(selectedIndex, 0, matches.length - 1)])
}
spacing: Style.spacing.md
Button {
width: parent.width
// Matches the field, so opening the search does not shift the layout.
height: field.implicitHeight
visible: !search.active
text: search.buttonText
fontSize: search.fontSize
foreground: search.foreground
fontFamily: search.fontFamily
bordered: true
onClicked: search.start()
}
TextField {
id: field
visible: search.active
width: parent.width
placeholderText: search.placeholderText
foreground: search.foreground
Keys.onEscapePressed: search.stop()
Keys.onReturnPressed: search.pickSelected()
Keys.onEnterPressed: search.pickSelected()
Keys.onUpPressed: search.selectedIndex = Model.moveSelection(search.selectedIndex, -1, search.matches.length)
Keys.onDownPressed: search.selectedIndex = Model.moveSelection(search.selectedIndex, 1, search.matches.length)
}
CityMatches {
width: parent.width
visible: search.active && search.inlineResults
citySearch: search
}
}
@@ -0,0 +1,223 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
import qs.Ui
import "Model.js" as Model
import "Sun.js" as Sun
// A city's 24 hours, midnight to midnight: its real daylight lit, a marker at
// now (the sun by day, tonight's moon by night), and sunrise/sunset arrows
// while the row is hovered. Dragging along it scrubs every clock.
Item {
id: strip
property bool ready: false
property int offsetMinutes: 0
property real progress: 0
// The fixed civil band's answer, for a city with no coordinates yet.
property bool fallbackLit: false
// { lat, lon } from the fetcher's geocode, or null.
property var place: null
property double nowMs: 0
property bool hovered: false
property bool hour24: false
property real moonPhase: 0
property color foreground: Color.foreground
property color fainter: Qt.darker(foreground, 2.1)
property color daylightMarker: "#E5C736"
property string fontFamily: Style.font.family
readonly property int trackHeight: Math.max(2, Style.space(3))
// Which arrow's time is showing: its index, or Model.NO_CHIP.
property int shownChip: Model.NO_CHIP
signal scrubStarted(real fraction)
signal scrubMoved(real fraction)
signal scrubEnded()
signal moonShowRequested()
function dismissChips() { shownChip = Model.NO_CHIP }
// Decided against the value read on press, so either delivery order of a
// tap and the release underneath it lands the same way.
function dismissUnlessChanged(atPress) {
shownChip = Model.chipAfterRelease(shownChip, atPress)
}
onHoveredChanged: if (!hovered) dismissChips()
height: Style.space(18)
// Keyed to local midnight, not the ticking clock, so the sun and the
// Repeaters below rebuild once a day rather than once a second.
readonly property double dayMs: ready ? Sun.localMidnightMs(nowMs, offsetMinutes) : 0
readonly property var sun: {
if (!ready || !place || place.lat === undefined || place.lon === undefined) return null
// Local noon: the instant furthest from either edge of the day.
return Sun.sunTimes(place.lat, place.lon, dayMs + 43200000, offsetMinutes)
}
// Empty in polar day and night, when the sun does not cross the horizon.
readonly property var sunMarks: {
if (!sun || sun.kind !== "normal") return []
var out = []
var up = Sun.eventMark(sun.riseMinutes)
var down = Sun.eventMark(sun.setMinutes)
if (up !== null) out.push({ x: up, minutes: sun.riseMinutes, rising: true, name: "Sunrise" })
if (down !== null) out.push({ x: down, minutes: sun.setMinutes, rising: false, name: "Sunset" })
return out
}
// Read from the band that is drawn, so the sun never sits in the dark.
readonly property bool litNow: sun ? Sun.litAt(sun, ready ? progress * 1440 : 0)
: (ready && fallbackLit)
Rectangle {
id: track
visible: strip.ready
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: strip.trackHeight
radius: height / 2
color: Util.alpha(strip.foreground, 0.10)
// Polar night has no band at all, rather than a zero-width one.
Repeater {
model: strip.sun ? Sun.litSpans(strip.sun)
: [{ x0: Model.daylightStart(), x1: Model.daylightEnd() }]
Rectangle {
required property var modelData
x: parent.width * modelData.x0
width: Math.max(1, parent.width * (modelData.x1 - modelData.x0))
height: parent.height
radius: parent.radius
color: Util.alpha(strip.foreground, 0.28)
}
}
Rectangle {
id: nowMarker
width: Math.max(8, Style.space(10))
height: width
radius: width / 2
x: Math.round(parent.width * (strip.ready ? strip.progress : 0) - width / 2)
y: (parent.height - height) / 2
color: strip.litNow ? strip.daylightMarker : "transparent"
MoonDot {
anchors.fill: parent
visible: !strip.litNow
phase: strip.moonPhase
color: strip.foreground
}
Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.OutCubic } }
}
}
// Scrub, or Shift-click the moon to run its phase show. The arrows below are
// later siblings, so their taps never reach this.
MouseArea {
anchors.fill: parent
cursorShape: Qt.SizeHorCursor
preventStealing: true
enabled: strip.ready
property bool showing: false
property int chipAtPress: Model.NO_CHIP
function onMoon(mouse) {
if (strip.litNow) return false
return Math.abs(mouse.x - (nowMarker.x + nowMarker.width / 2)) <= nowMarker.width
}
function finish() {
if (!showing) strip.scrubEnded()
strip.dismissUnlessChanged(chipAtPress)
showing = false
}
onPressed: function(mouse) {
showing = (mouse.modifiers & Qt.ShiftModifier) !== 0 && onMoon(mouse)
chipAtPress = strip.shownChip
if (showing) strip.moonShowRequested()
else strip.scrubStarted(mouse.x / width)
}
onPositionChanged: function(mouse) {
if (!showing) strip.scrubMoved(mouse.x / width)
}
onReleased: finish()
onCanceled: finish()
}
// Sunrise and sunset, just outside the lit band they point at.
Repeater {
model: strip.sunMarks
Item {
id: arrow
required property var modelData
required property int index
readonly property bool rising: modelData.rising
// A finger-sized box around a caption-sized glyph, tucked towards the band.
width: Style.space(14)
height: Style.space(16)
anchors.verticalCenter: parent.verticalCenter
x: Model.arrowBox(arrow.modelData.x, strip.width, arrow.width, Style.space(3), arrow.rising)
// Hidden while the now marker stands on it. nowMarker.x, not progress,
// so it reappears exactly as the eased marker clears.
readonly property bool covered: {
var markerX = nowMarker.x
if (!strip.ready) return false
return Model.arrowCovered(arrow.x, arrow.width, markerX + nowMarker.width / 2,
nowMarker.width, Style.space(4))
}
// Gone rather than faded, so a hidden arrow is also untappable.
opacity: strip.hovered && !arrow.covered ? 1 : 0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 160 } }
// Sunrise sits a little high and sunset a little low: a cue that needs no reading.
transform: Translate { y: arrow.rising ? -Style.space(2) : Style.space(2) }
onCoveredChanged: if (arrow.covered && strip.shownChip === arrow.index) strip.dismissChips()
Text {
anchors.centerIn: parent
textFormat: Text.PlainText
text: arrow.rising ? "\u2191" : "\u2193"
color: arrowHover.hovered || strip.shownChip === arrow.index ? strip.foreground : strip.fainter
font.family: strip.fontFamily
font.pixelSize: Style.font.caption
}
HoverHandler {
id: arrowHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
// Read on press, so a dismissal that already ran underneath cannot
// make an open chip look shut.
property int atPress: Model.NO_CHIP
onPressedChanged: if (pressed) atPress = strip.shownChip
onTapped: strip.shownChip = Model.chipAfterTap(atPress, arrow.index)
}
PanelToolTip {
visible: strip.shownChip === arrow.index
delay: 0
closePolicy: Popup.NoAutoClose
text: arrow.modelData.name + " " + Model.formatMinuteOfDay(arrow.modelData.minutes, strip.hour24)
fontFamily: strip.fontFamily
}
}
}
}
+615
View File
@@ -0,0 +1,615 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "GlobeModel.js" as Solar
import "Model.js" as Model
// A spinnable orthographic globe: coastlines, the day/night terminator and the
// major city of every zone. Owns its data files and clock probe; Panel mounts it.
Item {
id: root
property color foreground: Color.foreground
property color dim: Qt.darker(foreground, 1.55)
property color fainter: Qt.darker(foreground, 2.1)
property color daylightMarker
// Passed in so the footer's moon agrees with the rows' strips.
property real moonPhase: 0.5
// Night dots are dark so they read against the bright continents.
readonly property color nightMarker: Util.alpha(Color.background, 0.92)
property string fontFamily: Style.font.family
property bool hour24: false
// The panel's offset setting, and the offset "home" is measured from.
property string offsetMode: "home"
property int homeOffsetMinutes: 0
// Cities the list tracks, lower-cased: accent color and first label slots.
property var trackedNames: []
// Faded in near the end of the zoom, where footer and jump bar are legible.
property real chromeOpacity: 1
// Opaque tones mixed against the background, so the rows behind never show through.
readonly property color surfaceBase: Color.popups.background
// Light names over sea, dark over land, split at the coastline.
readonly property color seaInk: foreground
readonly property color landInk: surfaceBase
// Merged into the built-ins so a tracked city always appears. [name, zone, lat, lon, rank]
property var trackedCities: []
// Reached with the jump box; the panel never saves them.
property var sessionCities: []
property var jumpOptions: [] // the whole zone catalogue, unfiltered
// Always shown, always labelled, drawn in the accent like the hero globe's marker.
property var homeRow: []
readonly property string homeName:
homeRow.length > 0 ? String(homeRow[0]).toLowerCase() : ""
property string pendingJump: "" // waiting on coordinates to arrive
signal jumpRequested(string label, string zone)
// The jump box closed; the host should take the keyboard back.
signal jumpDismissed()
signal exitRequested()
// The footer offset was clicked; the panel owns the setting.
signal offsetModeToggleRequested()
// Only real selections: -1 means "no city" here but "home" in the list.
signal citySelected(string label, string zone)
// ---- data -------------------------------------------------------------
property var land: [] // coastline rings, flat [lon,lat,...]
property var landCoarse: [] // half the vertices, for the small end of the zoom
property var cities: [] // [name, zone, lat, lon, rank]
property var offsets: ({}) // zone -> minutes east of UTC
property real spin: 20 // degrees; the meridian facing the viewer
// Latitude under the viewer. Not "tilt": the hero globe uses that for the axial lean.
property real viewLat: 20
property real velocity: 0 // degrees per tick, for the throw
readonly property real dragSpinRate: 0.45 // degrees per pixel
readonly property real dragTiltRate: 0.35
readonly property real dragLatLimit: 80
readonly property real flyLatLimit: 70
readonly property real throwDecay: 0.96 // per 16 ms tick
// A "label|zone" key, not an index: allCities is rebuilt as its inputs land
// and an index would silently move to another city.
property string selectedKey: ""
readonly property int selected: {
if (selectedKey === "") return -1
for (var i = 0; i < allCities.length; i++)
if (keyAt(i) === selectedKey) return i
return -1
}
function keyAt(i) {
var c = allCities[i]
return (c === undefined || c === null) ? "" : Model.factsKey({ label: c[0], id: c[1] })
}
function selectAt(i) { selectedKey = i >= 0 ? keyAt(i) : "" }
// A click at disc-centered coordinates: select the hit and turn it to face
// you. A miss only clears. On root so tests share the pointer's code path.
function pickAt(cx, cy) {
var hit = hitAt(cx, cy)
selectAt(hit)
if (hit >= 0) flyTo(allCities[hit][2], allCities[hit][3])
return hit
}
property double nowMs: Date.now()
property bool dragging: false
// Both set from Panel, which owns the zoom: 0 in the header, 1 full size.
property bool transitioning: false
property real zoomLevel: 1
property bool smoothMotion: true
// A full paint is ~14 ms; names and the graticule are the expensive parts,
// so they drop while the globe moves and nobody can read them anyway.
readonly property bool reduced: smoothMotion
&& (dragging || flight.running || transitioning || Math.abs(velocity) > 0.01)
onReducedChanged: canvas.requestPaint()
readonly property var allCities:
Model.mergeCities(homeRow, cities, trackedCities, sessionCities)
readonly property var jumpMatches: jumpSearch.matches
onJumpMatchesChanged: probeZones()
// Centering a city is setting spin and viewLat to its own coordinates.
function flyTo(lat, lon) {
flight.stop()
velocity = 0
flightSpin.from = spin
flightSpin.to = spin + Solar.shortestTurn(spin, lon)
flightViewLat.from = viewLat
flightViewLat.to = Util.clamp(lat, -flyLatLimit, flyLatLimit)
flight.restart()
}
// Home's coordinates can land after the globe on a cold cache; hold the request.
property bool homePending: false
// Opening lands on home and selects it, so the footer names it.
function showHome() {
if (homeRow.length < 4 || homeRow[2] === undefined || homeRow[2] === null) {
homePending = true
return
}
homePending = false
selectAt(indexOfCity(homeRow[0], homeRow[1]))
flyTo(homeRow[2], homeRow[3])
}
onHomeRowChanged: if (homePending) showHome()
function indexOfCity(label, zone) {
for (var i = 0; i < allCities.length; i++)
if (allCities[i][0] === label && allCities[i][1] === zone) return i
return -1
}
function goTo(label, zone) {
var i = indexOfCity(label, zone)
if (i >= 0) {
selectedKey = keyAt(i)
flyTo(allCities[i][2], allCities[i][3])
pendingJump = ""
return
}
// Not on the globe yet: ask for it, and fly once its coordinates land.
pendingJump = label + "|" + zone
jumpRequested(label, zone)
}
onAllCitiesChanged: {
probeZones()
canvas.requestPaint()
if (pendingJump === "") return
var parts = pendingJump.split("|")
if (indexOfCity(parts[0], parts[1]) >= 0) goTo(parts[0], parts[1])
}
function startJump() {
jumpSearch.start()
}
// Sub-pixel scale (spaceReal, not space) so thin strokes keep their weights.
readonly property real uiScale: Style.spaceReal(1)
function scaled(px) { return Solar.scalePx(px, uiScale, 1) }
readonly property real footerHeight: Style.space(34)
readonly property real jumpHeight: Style.space(4) + jumpSearch.implicitHeight
readonly property real radius: Math.max(40,
Math.min(width, height - footerHeight - jumpHeight) / 2 - Style.space(6))
readonly property var sub: Solar.subsolarPoint(nowMs)
// FileView, not XMLHttpRequest: XHR on file:// comes back empty in the shell.
readonly property string pluginDir: Quickshell.env("OMARCHY_PATH") + "/shell/plugins/panels/elsewhen"
// "UTC+2" or "+9h", per the panel's setting, exactly as the rows print it.
function offsetLabelFor(zone) {
var off = offsets[zone]
if (off === undefined) return ""
return offsetMode === "utc" ? Model.utcOffsetLabel(off)
: Model.relativeOffsetLabel(off, homeOffsetMinutes)
}
function isTracked(i) {
var c = allCities[i]
return c !== undefined
&& trackedNames.indexOf(String(c[0]).toLowerCase()) >= 0
}
// Guarded: read from paints while the city list is still being assembled.
function isHome(i) {
var c = allCities[i]
return c !== undefined && homeName !== ""
&& String(c[0]).toLowerCase() === homeName
}
function cityDaylight(i) {
var c = allCities[i]
if (c === undefined) return false
return Solar.isDaylight(c[2], c[3], sub)
}
// Labels count as part of their city and win over a neighbouring dot.
function hitAt(px, py) {
var pad = Style.space(3)
for (var i = 0; i < labels.length; i++) {
var b = labels[i].box
if (px >= b.x - pad && px <= b.x + b.w + pad
&& py >= b.y - pad && py <= b.y + b.h + pad) return labels[i].index
}
var best = -1, bestD = Style.space(13)
for (var j = 0; j < plotted.length; j++) {
var d = Math.hypot(px - plotted[j].x, py - plotted[j].y)
if (d < bestD) { bestD = d; best = plotted[j].index }
}
return best
}
// ---- what actually gets drawn -----------------------------------------
// Near-side cities in priority order, thinned; `keep` always survives.
readonly property var plotted: {
if (allCities.length === 0 || radius <= 0) return []
var cand = []
for (var i = 0; i < allCities.length; i++) {
var p = Solar.project(allCities[i][2], allCities[i][3], spin, viewLat, radius)
if (!p.visible) continue
var must = isHome(i) || isTracked(i) || i === selected
cand.push({ index: i, x: p.x, y: p.y, cosc: p.cosc,
rank: allCities[i][4], keep: must })
}
cand.sort(function(a, b) {
if (a.keep !== b.keep) return a.keep ? -1 : 1
if (a.rank !== b.rank) return a.rank - b.rank
return b.cosc - a.cosc
})
return Solar.declutter(cand, Style.space(19))
}
// ---- labels -----------------------------------------------------------
readonly property var labels: {
if (reduced) return []
var cand = []
for (var i = 0; i < plotted.length; i++) {
var p = plotted[i]
if (p.cosc < 0.12) continue // the rim; labels run off
cand.push({ index: p.index, name: allCities[p.index][0], x: p.x, y: p.y,
rank: p.keep ? 0 : p.rank, cosc: p.cosc })
}
var charW = Style.font.caption * 0.62
return Solar.layoutLabels(cand, charW, Style.font.caption + Style.space(3), 14,
width / 2 - Style.space(4), scaled(6))
}
FileView {
path: root.pluginDir + "/world.json"
printErrors: true
onLoaded: {
try {
root.land = JSON.parse(text())
var coarse = []
for (var i = 0; i < root.land.length; i++)
coarse.push(Solar.decimateRing(root.land[i], 2, 8))
root.landCoarse = coarse
canvas.requestPaint()
} catch (e) { }
}
}
FileView {
path: root.pluginDir + "/cities.json"
printErrors: true
onLoaded: {
try { root.cities = JSON.parse(text()); root.probeZones() } catch (e) { }
}
}
property bool probeQueued: false
// Queued rather than dropped while running, or late zones never get an offset.
// Includes the jump matches, whose offsets tell two results apart.
function probeZones() {
if (allCities.length === 0) return
if (zoneProc.running) { probeQueued = true; return }
var zones = [], seen = {}
function add(id) { if (!seen[id]) { seen[id] = true; zones.push(id) } }
for (var i = 0; i < allCities.length; i++) add(allCities[i][1])
for (var j = 0; j < jumpMatches.length; j++) add(jumpMatches[j].value)
zoneProc.command = Model.probeCommand(zones, false)
zoneProc.running = true
}
Process {
id: zoneProc
stdout: StdioCollector {
onStreamFinished: {
var probe = Model.parseProbe(text)
var map = {}
for (var id in probe) map[id] = probe[id].offsetMinutes
root.offsets = map
canvas.requestPaint()
Qt.callLater(function() {
if (!root.probeQueued) return
root.probeQueued = false
root.probeZones()
})
}
}
}
Timer { interval: 20000; running: true; repeat: true
onTriggered: { root.nowMs = Date.now(); canvas.requestPaint() } }
Timer { interval: 300000; running: true; repeat: true; onTriggered: root.probeZones() }
ParallelAnimation {
id: flight
NumberAnimation { id: flightSpin; target: root; property: "spin"
duration: 800; easing.type: Easing.OutCubic }
NumberAnimation { id: flightViewLat; target: root; property: "viewLat"
duration: 800; easing.type: Easing.OutCubic }
}
// The throw: spin keeps going after the drag and eases to a stop.
Timer {
interval: 16
running: !root.dragging && Math.abs(root.velocity) > 0.01
repeat: true
onTriggered: {
root.spin += root.velocity
root.velocity *= root.throwDecay
canvas.requestPaint()
}
}
onPlottedChanged: canvas.requestPaint()
onTrackedNamesChanged: canvas.requestPaint()
onSpinChanged: canvas.requestPaint()
onViewLatChanged: canvas.requestPaint()
onSelectedChanged: {
canvas.requestPaint()
var c = selected >= 0 ? allCities[selected] : null
if (c !== null && c !== undefined) citySelected(String(c[0]), String(c[1]))
}
// ---- the globe --------------------------------------------------------
Canvas {
id: canvas
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: parent.height - root.footerHeight - root.jumpHeight
renderStrategy: Canvas.Cooperative
// The clipped continents from the last paint, reused by the label pass.
property var landPolys: []
function paintLabels(ctx, ink) {
ctx.fillStyle = ink
for (var i = 0; i < root.labels.length; i++) {
var L = root.labels[i]
var idx = L.index
var city = root.allCities[idx]
if (city === undefined) continue
var strong = root.isHome(idx) || root.isTracked(idx)
ctx.font = (strong ? "bold " : "") + Style.font.caption
+ "px \"" + root.fontFamily + "\""
ctx.fillText(city[0], L.box.x, L.box.y + L.box.h / 2)
}
}
// Points arrive as [lat, lon]; runs end exactly on the horizon.
function strokePath(ctx, pts) {
var segs = Solar.visibleSegments(pts, root.spin, root.viewLat, root.radius)
for (var i = 0; i < segs.length; i++) {
ctx.moveTo(segs[i][0].x, segs[i][0].y)
for (var j = 1; j < segs[i].length; j++) ctx.lineTo(segs[i][j].x, segs[i][j].y)
}
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.translate(width / 2, height / 2)
var r = root.radius
var fg = root.foreground
// Ocean disc.
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = Model.mix(root.surfaceBase, fg, 0.05)
ctx.fill()
ctx.lineWidth = root.scaled(1)
ctx.strokeStyle = Util.alpha(fg, 0.22)
ctx.stroke()
// Graticule every 30 degrees, or 60 while moving.
var gStep = root.reduced ? 60 : 30
ctx.beginPath()
var lat, lon, pts, i
for (lon = -180; lon < 180; lon += gStep) {
pts = []
for (lat = -90; lat <= 90; lat += 3) pts.push([lat, lon])
strokePath(ctx, pts)
}
for (lat = -60; lat <= 60; lat += gStep) {
pts = []
for (lon = -180; lon <= 180; lon += 3) pts.push([lat, lon])
strokePath(ctx, pts)
}
ctx.lineWidth = root.scaled(1)
ctx.strokeStyle = Util.alpha(fg, 0.10)
ctx.stroke()
// Land, filled bright with no coastline stroke (the stroke pass cost ~4 ms).
// Coarse rings only below half size, where the dropped islands are invisible.
var coarseOk = root.reduced && root.zoomLevel < 0.5 && root.landCoarse.length > 0
var rings = coarseOk ? root.landCoarse : root.land
var landPolys = []
ctx.beginPath()
for (i = 0; i < rings.length; i++) {
var poly = Solar.clipRingToDisc(rings[i], root.spin, root.viewLat, root.radius)
if (poly.length < 3) continue
landPolys.push(poly)
ctx.moveTo(poly[0].x, poly[0].y)
for (var q = 1; q < poly.length; q++) ctx.lineTo(poly[q].x, poly[q].y)
ctx.closePath()
}
ctx.fillStyle = Model.mix(root.surfaceBase, fg, 0.92)
ctx.fill()
canvas.landPolys = landPolys
// Day/night line.
ctx.beginPath()
strokePath(ctx, Solar.terminator(root.sub, 180))
ctx.lineWidth = root.scaled(1)
ctx.strokeStyle = Util.alpha(root.daylightMarker, 0.55)
ctx.stroke()
// Cities: gold in daylight, dark at night, each edged in the opposite tone.
for (var pi = 0; pi < root.plotted.length; pi++) {
var p = root.plotted[pi]
i = p.index
var day = root.cityDaylight(i)
var isSel = (i === root.selected)
ctx.beginPath()
ctx.arc(p.x, p.y, root.scaled(isSel ? 3.6 : 2.2), 0, Math.PI * 2)
ctx.fillStyle = day ? root.daylightMarker : root.nightMarker
ctx.fill()
ctx.lineWidth = root.scaled(1.2)
ctx.strokeStyle = day ? Util.alpha(Color.background, 0.7) : Util.alpha(fg, 0.85)
ctx.stroke()
if (root.isHome(i)) {
ctx.beginPath()
ctx.arc(p.x, p.y, root.scaled(3.4), 0, Math.PI * 2)
ctx.fillStyle = Color.accent
ctx.fill()
ctx.lineWidth = root.scaled(1.2)
ctx.strokeStyle = Util.alpha(Color.background, 0.5)
ctx.stroke()
ctx.beginPath()
ctx.arc(p.x, p.y, root.scaled(6.4), 0, Math.PI * 2)
ctx.lineWidth = root.scaled(1.4)
ctx.strokeStyle = Util.alpha(Color.accent, 0.65)
ctx.stroke()
} else if (root.isTracked(i)) {
ctx.beginPath()
ctx.arc(p.x, p.y, root.scaled(4.6), 0, Math.PI * 2)
ctx.lineWidth = root.scaled(1.3)
ctx.strokeStyle = Color.accent
ctx.stroke()
}
if (isSel) {
ctx.beginPath()
ctx.arc(p.x, p.y, root.scaled(7.5), 0, Math.PI * 2)
ctx.lineWidth = root.scaled(1.4)
ctx.strokeStyle = day ? root.daylightMarker : fg
ctx.stroke()
}
}
// ---- city names: light everywhere, then dark clipped to the land ----
ctx.textBaseline = "middle"
paintLabels(ctx, root.seaInk)
ctx.save()
ctx.beginPath()
for (var lp = 0; lp < landPolys.length; lp++) {
var poly2 = landPolys[lp]
ctx.moveTo(poly2[0].x, poly2[0].y)
for (var r2 = 1; r2 < poly2.length; r2++) ctx.lineTo(poly2[r2].x, poly2[r2].y)
ctx.closePath()
}
ctx.clip()
paintLabels(ctx, root.landInk)
ctx.restore()
}
MouseArea {
anchors.fill: parent
property real lastX: 0
property real lastY: 0
property bool moved: false
onPressed: function(mouse) {
lastX = mouse.x; lastY = mouse.y
moved = false
root.dragging = true
root.velocity = 0
}
onPositionChanged: function(mouse) {
var dx = mouse.x - lastX, dy = mouse.y - lastY
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true
root.spin -= dx * root.dragSpinRate
root.viewLat = Util.clamp(root.viewLat + dy * root.dragTiltRate,
-root.dragLatLimit, root.dragLatLimit)
root.velocity = -dx * root.dragSpinRate
lastX = mouse.x; lastY = mouse.y
}
onReleased: function(mouse) {
root.dragging = false
if (moved) return
root.velocity = 0
root.pickAt(mouse.x - canvas.width / 2, mouse.y - canvas.height / 2)
}
}
}
GlobeFooter {
id: footer
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: jumpSearch.top
anchors.bottomMargin: Style.space(4)
height: root.footerHeight
opacity: root.chromeOpacity
city: root.selected >= 0 ? root.allCities[root.selected] : null
// The globe's solar daylight, so the mark agrees with the city's dot.
daylight: footer.has && root.cityDaylight(root.selected)
clock: footer.has ? Model.formatClock(root.nowMs, root.offsets[footer.city[1]], root.hour24) : ""
offsetLabel: footer.has ? root.offsetLabelFor(footer.city[1]) : ""
badge: root.isHome(root.selected) ? "home" : (root.isTracked(root.selected) ? "tracked" : "")
moonPhase: root.moonPhase
foreground: root.foreground
dim: root.dim
fainter: root.fainter
daylightMarker: root.daylightMarker
fontFamily: root.fontFamily
onOffsetClicked: root.offsetModeToggleRequested()
}
// ---- jump to a city -----------------------------------------------------
// Searches the whole catalogue; a city not on the globe joins for this session.
CitySearch {
id: jumpSearch
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
opacity: root.chromeOpacity
options: root.jumpOptions
limit: 5
inlineResults: false
loading: root.jumpOptions.length === 0
buttonText: "Jump to a city"
loadingText: "Loading cities\u2026"
offsetLabel: function(zoneId) { return Model.utcOffsetLabel(root.offsets[zoneId]) }
foreground: root.foreground
dim: root.dim
fainter: root.fainter
fontFamily: root.fontFamily
onPicked: function(label, id) { root.goTo(label, id) }
onDismissed: root.jumpDismissed()
}
// Results overlay the globe so it never resizes under the pointer mid-search.
Rectangle {
visible: jumpSearch.active
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: jumpSearch.top
anchors.bottomMargin: Style.space(4)
height: Math.min(results.implicitHeight + Style.space(8),
parent.height - root.jumpHeight - Style.space(20))
radius: Style.cornerRadius
color: root.surfaceBase
CityMatches {
id: results
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.space(4)
citySearch: jumpSearch
}
}
}
@@ -0,0 +1,128 @@
import QtQuick
import qs.Commons
// The selected city under the globe: name, local time and badge, then its
// zone and offset. Blank without a selection; the parent reserves the height.
Item {
id: root
property var city: null // [name, zone, lat, lon, rank]
property bool daylight: false
property string clock: ""
property string offsetLabel: ""
property string badge: "" // "home", "tracked" or ""
property real moonPhase: 0.5
property color foreground: Color.foreground
property color dim: Qt.darker(foreground, 1.55)
property color fainter: Qt.darker(foreground, 2.1)
property color daylightMarker
property string fontFamily: Style.font.family
signal offsetClicked()
readonly property bool has: city !== null && city !== undefined
// The mark matches the ink height of the name's capital; a pixel less, since
// a circle's antialiased edge reads a pixel wider than a glyph's.
TextMetrics {
id: capHeight
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.weight: Font.DemiBold
text: "M"
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(1)
visible: root.has
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(7)
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(4)
// A lit dot by day, tonight's moon by night, as on the rows' strips.
// Placed on the name's baseline by y: anchors.baseline inside a Row loops.
Rectangle {
y: cityName.baselineOffset - height
width: Math.max(6, Math.round(capHeight.tightBoundingRect.height) - 1)
height: width
radius: width / 2
visible: root.has
color: root.daylight ? root.daylightMarker : "transparent"
MoonDot {
anchors.fill: parent
visible: !root.daylight
phase: root.moonPhase
color: root.foreground
}
}
Text {
id: cityName
textFormat: Text.PlainText
text: root.has ? root.city[0] : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.weight: Font.DemiBold
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: root.clock
visible: text !== ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: root.badge
visible: root.has && text !== ""
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// Zone and offset together are the toggle, so it survives a blank offset.
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.has ? root.city[1] : ""
color: offsetHover.hovered ? root.dim : root.fainter
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
text: root.offsetLabel
visible: text !== ""
color: offsetHover.hovered ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
HoverHandler {
id: offsetHover
cursorShape: Qt.PointingHandCursor
}
TapHandler { onTapped: root.offsetClicked() }
}
}
}
+300
View File
@@ -0,0 +1,300 @@
.pragma library
// Orthographic globe math: projection, the day/night terminator, the moon,
// and label placement.
var DEG = Math.PI / 180
// Earth's obliquity, also the hero icon's lean.
var AXIAL_TILT = 23.44
// Every `keepEvery`th vertex of a flat [lon, lat, ...] ring, for drawing while
// the globe is scaled down. Rings at or under `minPoints` are returned whole.
function decimateRing(ring, keepEvery, minPoints) {
var step = keepEvery === undefined ? 2 : keepEvery
var floor = minPoints === undefined ? 8 : minPoints
var n = ring.length / 2
if (step < 2 || n <= floor) return ring
var out = []
for (var i = 0; i < n; i += step) out.push(ring[i * 2], ring[i * 2 + 1])
// End on the original last vertex, not a chord back to the start.
var lastI = (n - 1) * 2
if (out[out.length - 2] !== ring[lastI] || out[out.length - 1] !== ring[lastI + 1])
out.push(ring[lastI], ring[lastI + 1])
return out
}
// A drawn pixel size that follows the shell's UI scale, floored so hairlines
// do not drop out of the raster.
function scalePx(px, scale, minPx) {
var s = (typeof scale === "number" && isFinite(scale) && scale > 0) ? scale : 1
var n = px * s
var floor = (minPx === undefined) ? 1 : minPx
return n < floor ? floor : n
}
// Orthographic projection onto a disc of radius r seen from over (viewLat, spin).
function project(lat, lon, spin, viewLat, r) {
var phi = lat * DEG
var lam = (lon - spin) * DEG
var p0 = viewLat * DEG
var cosc = Math.sin(p0) * Math.sin(phi) + Math.cos(p0) * Math.cos(phi) * Math.cos(lam)
return {
x: r * Math.cos(phi) * Math.sin(lam),
y: -r * (Math.cos(p0) * Math.sin(phi) - Math.sin(p0) * Math.cos(phi) * Math.cos(lam)),
visible: cosc > 0,
cosc: cosc
}
}
// The point with the sun directly overhead, good to a fraction of a degree.
function subsolarPoint(ms) {
var d = new Date(ms)
var jd = ms / 86400000 + 2440587.5
var n = jd - 2451545.0
var L = (280.460 + 0.9856474 * n) % 360 // mean longitude
var g = ((357.528 + 0.9856003 * n) % 360) * DEG // mean anomaly
var lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG // ecliptic longitude
var eps = (AXIAL_TILT - 0.0000004 * n) * DEG // obliquity, slowly drifting
var decl = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG
// Equation of time, in minutes, then the subsolar meridian.
var alpha = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG
var eot = (L - alpha + 540) % 360 - 180 // degrees, wrapped to +-180
var utcHours = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600
var lon = -15 * (utcHours - 12) - eot
lon = ((lon + 540) % 360) - 180
return { lat: decl, lon: lon }
}
// Degrees above the horizon, negative below it.
function solarElevation(lat, lon, sub) {
var cosz = Math.sin(lat * DEG) * Math.sin(sub.lat * DEG)
+ Math.cos(lat * DEG) * Math.cos(sub.lat * DEG) * Math.cos((lon - sub.lon) * DEG)
return Math.asin(Math.max(-1, Math.min(1, cosz))) / DEG
}
// -0.833 allows for refraction and the sun's disc, as sunrise tables do.
function isDaylight(lat, lon, sub) {
return solarElevation(lat, lon, sub) > -0.833
}
// The great circle 90 degrees from the subsolar point: the day/night line.
function terminator(sub, steps) {
var n = steps || 180
var out = []
var slat = sub.lat * DEG, slon = sub.lon * DEG
// Build an orthonormal frame around the subsolar axis and sweep a circle.
var s = [Math.cos(slat) * Math.cos(slon), Math.cos(slat) * Math.sin(slon), Math.sin(slat)]
var up = Math.abs(s[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]
var a = norm(cross(up, s))
var b = norm(cross(s, a))
for (var i = 0; i <= n; i++) {
var t = i / n * 2 * Math.PI
var v = [a[0] * Math.cos(t) + b[0] * Math.sin(t),
a[1] * Math.cos(t) + b[1] * Math.sin(t),
a[2] * Math.cos(t) + b[2] * Math.sin(t)]
out.push([Math.asin(v[2]) / DEG, Math.atan2(v[1], v[0]) / DEG])
}
return out
}
function cross(u, v) {
return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
}
function norm(v) {
var m = Math.hypot(v[0], v[1], v[2]) || 1
return [v[0] / m, v[1] / m, v[2] / m]
}
// Keep points, in priority order, that clear every kept point by minDist.
// Points marked `keep` always survive.
function declutter(points, minDist) {
var kept = []
for (var i = 0; i < points.length; i++) {
var p = points[i]
if (p.keep) { kept.push(p); continue }
var clash = false
for (var j = 0; j < kept.length; j++) {
if (Math.hypot(p.x - kept[j].x, p.y - kept[j].y) < minDist) { clash = true; break }
}
if (!clash) kept.push(p)
}
return kept
}
// ---- the moon
// The mean synodic month against a known new moon: good to a few hours.
var SYNODIC_MONTH = 29.530588853 // days
var KNOWN_NEW_MOON_JD = 2451550.1 // 2000-01-06 18:14 UTC
// Position through the lunation: 0 new, 0.25 first quarter, 0.5 full,
// 0.75 last quarter.
function moonPhase(ms) {
var jd = Number(ms) / 86400000 + 2440587.5
var p = ((jd - KNOWN_NEW_MOON_JD) / SYNODIC_MONTH) % 1
return p < 0 ? p + 1 : p
}
// The lit part of the moon on a disc of radius r at the origin: the limb, then
// the terminator back, a semicircle squashed by the signed cos(2*pi*phase).
function moonLitOutline(phase, r, steps) {
var n = steps || 24
var theta = 2 * Math.PI * Number(phase)
var squash = Math.cos(theta)
var side = Number(phase) > 0.5 ? -1 : 1 // waning lights the other limb
var out = []
var i, t
for (i = 0; i <= n; i++) {
t = Math.PI * i / n
out.push({ x: side * r * Math.sin(t), y: -r * Math.cos(t) })
}
for (i = n; i >= 0; i--) {
t = Math.PI * i / n
out.push({ x: side * r * squash * Math.sin(t), y: -r * Math.cos(t) })
}
return out
}
// ---- clipping to the disc
// Where a [lat, lon] segment crosses the horizon, by bisection.
function limbCrossing(a, b, spin, viewLat, r) {
// Segments spanning the antimeridian cannot be interpolated in lat/lon.
if (Math.abs(b[1] - a[1]) > 180) return null
var lo = 0, hi = 1
for (var i = 0; i < 8; i++) {
var m = (lo + hi) / 2
var p = project(a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, spin, viewLat, r)
if (p.visible) lo = m; else hi = m
}
return project(a[0] + (b[0] - a[0]) * lo, a[1] + (b[1] - a[1]) * lo, spin, viewLat, r)
}
// The near-side runs of a polyline, each ending exactly on the horizon.
function visibleSegments(pts, spin, viewLat, r) {
var out = [], run = [], prev = null, prevVis = false
function flush() { if (run.length > 1) out.push(run); run = [] }
for (var i = 0; i < pts.length; i++) {
var p = project(pts[i][0], pts[i][1], spin, viewLat, r)
if (p.visible) {
if (run.length === 0 && prev !== null && !prevVis) {
var enter = limbCrossing(pts[i], prev, spin, viewLat, r)
if (enter) run.push(enter)
}
run.push(p)
} else {
if (run.length > 0 && prev !== null) {
var exit = limbCrossing(prev, pts[i], spin, viewLat, r)
if (exit) run.push(exit)
}
flush()
}
prev = pts[i]; prevVis = p.visible
}
flush()
return out
}
// A flat [lon, lat, ...] ring clipped to the visible hemisphere as one polygon
// that follows the limb, so its area changes smoothly as the globe turns.
function clipRingToDisc(ring, spin, viewLat, r) {
var pts = []
for (var k = 0; k < ring.length; k += 2) pts.push([ring[k + 1], ring[k]])
var out = []
for (var i = 0; i < pts.length; i++) {
var A = pts[i], B = pts[(i + 1) % pts.length]
var pa = project(A[0], A[1], spin, viewLat, r)
var pb = project(B[0], B[1], spin, viewLat, r)
if (pa.visible && pb.visible) out.push({ p: pb, limb: false })
else if (pa.visible) {
var ex = limbCrossing(A, B, spin, viewLat, r)
if (ex) out.push({ p: ex, limb: true })
} else if (pb.visible) {
var en = limbCrossing(B, A, spin, viewLat, r)
if (en) out.push({ p: en, limb: true })
out.push({ p: pb, limb: false })
}
}
if (out.length < 3) return []
var res = []
for (var j = 0; j < out.length; j++) {
res.push(out[j].p)
var nx = out[(j + 1) % out.length]
if (!out[j].limb || !nx.limb) continue
var a0 = Math.atan2(out[j].p.y, out[j].p.x)
var a1 = Math.atan2(nx.p.y, nx.p.x)
var d = a1 - a0
while (d > Math.PI) d -= 2 * Math.PI
while (d < -Math.PI) d += 2 * Math.PI
var steps = Math.max(1, Math.round(Math.abs(d) / 0.15))
for (var t = 1; t < steps; t++) {
var a = a0 + d * t / steps
res.push({ x: r * Math.cos(a), y: r * Math.sin(a) })
}
}
return res
}
// Greedy label placement by rank, then nearest the disc center; a label that
// would pass `maxX` flips to the left of its dot. `gap` is dot to name.
function layoutLabels(candidates, charWidth, lineHeight, limit, maxX, gap) {
var g = (typeof gap === "number" && isFinite(gap) && gap > 0) ? gap : 6
var placed = []
var sorted = candidates.slice().sort(function (p, q) {
if (p.rank !== q.rank) return p.rank - q.rank
return q.cosc - p.cosc
})
for (var i = 0; i < sorted.length; i++) {
var c = sorted[i]
var w = c.name.length * charWidth
var x = c.x + g
if (maxX !== undefined && x + w > maxX) x = c.x - g - w
var box = { x: x, y: c.y - lineHeight / 2, w: w, h: lineHeight }
var clash = false
for (var j = 0; j < placed.length; j++) {
var o = placed[j].box
if (box.x < o.x + o.w && box.x + box.w > o.x && box.y < o.y + o.h && box.y + box.h > o.y) {
clash = true
break
}
}
if (clash) continue
placed.push({ index: c.index, box: box })
if (limit && placed.length >= limit) break
}
return placed
}
// Signed degrees in (-180, 180] to turn from one longitude to another.
function shortestTurn(from, to) {
var d = ((to - from) % 360 + 360) % 360
return d > 180 ? d - 360 : d
}
if (typeof module !== "undefined") {
module.exports = {
AXIAL_TILT: AXIAL_TILT,
SYNODIC_MONTH: SYNODIC_MONTH,
decimateRing: decimateRing,
scalePx: scalePx,
project: project,
subsolarPoint: subsolarPoint,
solarElevation: solarElevation,
isDaylight: isDaylight,
terminator: terminator,
declutter: declutter,
moonPhase: moonPhase,
moonLitOutline: moonLitOutline,
limbCrossing: limbCrossing,
visibleSegments: visibleSegments,
clipRingToDisc: clipRingToDisc,
layoutLabels: layoutLabels,
shortestTurn: shortestTurn
}
}
+978
View File
@@ -0,0 +1,978 @@
// What people say to each other at this hour where a clock points: zone ->
// country -> the language you would hear spoken there, with a few city
// overrides. Bands are local hours ascending from 0; `roman` is a
// pronunciation, given only where the script is not Latin.
var LANGUAGES = {
en: { name: "English", bands: [
{ from: 0, text: "Good night", roman: "" },
{ from: 5, text: "Good morning", roman: "" },
{ from: 12, text: "Good afternoon", roman: "" },
{ from: 18, text: "Good evening", roman: "" }
] },
enAU: { name: "Australian English", bands: [
{ from: 0, text: "Night", roman: "" },
{ from: 5, text: "Morning", roman: "" },
{ from: 12, text: "G'day", roman: "" },
{ from: 18, text: "Evening", roman: "" }
] },
ga: { name: "Irish", bands: [
{ from: 0, text: "O\u00edche mhaith", roman: "" },
{ from: 6, text: "Maidin mhaith", roman: "" },
{ from: 12, text: "Tr\u00e1thn\u00f3na maith", roman: "" },
{ from: 19, text: "O\u00edche mhaith", roman: "" }
] },
es: { name: "Spanish", bands: [
{ from: 0, text: "Buenas noches", roman: "" },
{ from: 6, text: "Buenos d\u00edas", roman: "" },
{ from: 12, text: "Buenas tardes", roman: "" },
{ from: 19, text: "Buenas noches", roman: "" }
] },
esES: { name: "Spanish (Spain)", bands: [
{ from: 0, text: "Buenas noches", roman: "" },
{ from: 6, text: "Buenos d\u00edas", roman: "" },
{ from: 14, text: "Buenas tardes", roman: "" },
{ from: 21, text: "Buenas noches", roman: "" }
] },
ca: { name: "Catalan", bands: [
{ from: 0, text: "Bona nit", roman: "" },
{ from: 6, text: "Bon dia", roman: "" },
{ from: 13, text: "Bona tarda", roman: "" },
{ from: 20, text: "Bona nit", roman: "" }
] },
pt: { name: "Portuguese", bands: [
{ from: 0, text: "Boa noite", roman: "" },
{ from: 6, text: "Bom dia", roman: "" },
{ from: 12, text: "Boa tarde", roman: "" },
{ from: 20, text: "Boa noite", roman: "" }
] },
fr: { name: "French", bands: [
{ from: 0, text: "Bonne nuit", roman: "" },
{ from: 6, text: "Bonjour", roman: "" },
{ from: 18, text: "Bonsoir", roman: "" },
{ from: 22, text: "Bonne nuit", roman: "" }
] },
it: { name: "Italian", bands: [
{ from: 0, text: "Buonanotte", roman: "" },
{ from: 5, text: "Buongiorno", roman: "" },
{ from: 14, text: "Buon pomeriggio", roman: "" },
{ from: 18, text: "Buonasera", roman: "" },
{ from: 23, text: "Buonanotte", roman: "" }
] },
ro: { name: "Romanian", bands: [
{ from: 0, text: "Noapte bun\u0103", roman: "" },
{ from: 5, text: "Bun\u0103 diminea\u021ba", roman: "" },
{ from: 12, text: "Bun\u0103 ziua", roman: "" },
{ from: 18, text: "Bun\u0103 seara", roman: "" }
] },
de: { name: "German", bands: [
{ from: 0, text: "Gute Nacht", roman: "" },
{ from: 5, text: "Guten Morgen", roman: "" },
{ from: 11, text: "Guten Tag", roman: "" },
{ from: 18, text: "Guten Abend", roman: "" },
{ from: 22, text: "Gute Nacht", roman: "" }
] },
deAT: { name: "Austrian German", bands: [
{ from: 0, text: "Gute Nacht", roman: "" },
{ from: 5, text: "Guten Morgen", roman: "" },
{ from: 11, text: "Gr\u00fc\u00df Gott", roman: "" },
{ from: 18, text: "Guten Abend", roman: "" },
{ from: 22, text: "Gute Nacht", roman: "" }
] },
deCH: { name: "Swiss German", bands: [
{ from: 0, text: "Gute Nacht", roman: "" },
{ from: 5, text: "Guete Morge", roman: "" },
{ from: 11, text: "Gr\u00fcezi", roman: "" },
{ from: 18, text: "Guete Abig", roman: "" },
{ from: 22, text: "Gute Nacht", roman: "" }
] },
lb: { name: "Luxembourgish", bands: [
{ from: 0, text: "Gutt Nuecht", roman: "" },
{ from: 5, text: "Moien", roman: "" },
{ from: 12, text: "Gudde M\u00ebtteg", roman: "" },
{ from: 18, text: "Gudden Owend", roman: "" }
] },
nl: { name: "Dutch", bands: [
{ from: 0, text: "Goedenacht", roman: "" },
{ from: 6, text: "Goedemorgen", roman: "" },
{ from: 12, text: "Goedemiddag", roman: "" },
{ from: 18, text: "Goedenavond", roman: "" }
] },
da: { name: "Danish", bands: [
{ from: 0, text: "Godnat", roman: "" },
{ from: 5, text: "Godmorgen", roman: "" },
{ from: 10, text: "Goddag", roman: "" },
{ from: 18, text: "Godaften", roman: "" }
] },
no: { name: "Norwegian", bands: [
{ from: 0, text: "God natt", roman: "" },
{ from: 5, text: "God morgen", roman: "" },
{ from: 10, text: "God dag", roman: "" },
{ from: 18, text: "God kveld", roman: "" }
] },
sv: { name: "Swedish", bands: [
{ from: 0, text: "God natt", roman: "" },
{ from: 5, text: "God morgon", roman: "" },
{ from: 10, text: "God dag", roman: "" },
{ from: 18, text: "God kv\u00e4ll", roman: "" }
] },
fi: { name: "Finnish", bands: [
{ from: 0, text: "Hyv\u00e4\u00e4 y\u00f6t\u00e4", roman: "" },
{ from: 5, text: "Huomenta", roman: "" },
{ from: 11, text: "P\u00e4iv\u00e4\u00e4", roman: "" },
{ from: 18, text: "Iltaa", roman: "" },
{ from: 23, text: "Hyv\u00e4\u00e4 y\u00f6t\u00e4", roman: "" }
] },
is: { name: "Icelandic", bands: [
{ from: 0, text: "G\u00f3\u00f0a n\u00f3tt", roman: "" },
{ from: 6, text: "G\u00f3\u00f0an daginn", roman: "" },
{ from: 18, text: "Gott kv\u00f6ld", roman: "" }
] },
fo: { name: "Faroese", bands: [
{ from: 0, text: "G\u00f3\u00f0a n\u00e1tt", roman: "" },
{ from: 5, text: "G\u00f3\u00f0an morgun", roman: "" },
{ from: 10, text: "G\u00f3\u00f0an dag", roman: "" },
{ from: 18, text: "Gott kv\u00f8ld", roman: "" }
] },
kl: { name: "Greenlandic", bands: [
{ from: 0, text: "Inuugujoq", roman: "" }
] },
et: { name: "Estonian", bands: [
{ from: 0, text: "Head \u00f6\u00f6d", roman: "" },
{ from: 5, text: "Tere hommikust", roman: "" },
{ from: 12, text: "Tere p\u00e4evast", roman: "" },
{ from: 18, text: "Tere \u00f5htust", roman: "" }
] },
lv: { name: "Latvian", bands: [
{ from: 0, text: "Ar labu nakti", roman: "" },
{ from: 5, text: "Labr\u012bt", roman: "" },
{ from: 12, text: "Labdien", roman: "" },
{ from: 18, text: "Labvakar", roman: "" }
] },
lt: { name: "Lithuanian", bands: [
{ from: 0, text: "Labanakt", roman: "" },
{ from: 5, text: "Labas rytas", roman: "" },
{ from: 12, text: "Laba diena", roman: "" },
{ from: 18, text: "Labas vakaras", roman: "" }
] },
pl: { name: "Polish", bands: [
{ from: 0, text: "Dobranoc", roman: "" },
{ from: 5, text: "Dzie\u0144 dobry", roman: "" },
{ from: 18, text: "Dobry wiecz\u00f3r", roman: "" }
] },
cs: { name: "Czech", bands: [
{ from: 0, text: "Dobrou noc", roman: "" },
{ from: 5, text: "Dobr\u00e9 r\u00e1no", roman: "" },
{ from: 10, text: "Dobr\u00fd den", roman: "" },
{ from: 18, text: "Dobr\u00fd ve\u010der", roman: "" }
] },
sk: { name: "Slovak", bands: [
{ from: 0, text: "Dobr\u00fa noc", roman: "" },
{ from: 5, text: "Dobr\u00e9 r\u00e1no", roman: "" },
{ from: 10, text: "Dobr\u00fd de\u0148", roman: "" },
{ from: 18, text: "Dobr\u00fd ve\u010der", roman: "" }
] },
sl: { name: "Slovenian", bands: [
{ from: 0, text: "Lahko no\u010d", roman: "" },
{ from: 5, text: "Dobro jutro", roman: "" },
{ from: 10, text: "Dober dan", roman: "" },
{ from: 18, text: "Dober ve\u010der", roman: "" }
] },
hu: { name: "Hungarian", bands: [
{ from: 0, text: "J\u00f3 \u00e9jszak\u00e1t", roman: "" },
{ from: 5, text: "J\u00f3 reggelt", roman: "" },
{ from: 10, text: "J\u00f3 napot", roman: "" },
{ from: 18, text: "J\u00f3 est\u00e9t", roman: "" }
] },
hr: { name: "Croatian", bands: [
{ from: 0, text: "Laku no\u0107", roman: "" },
{ from: 5, text: "Dobro jutro", roman: "" },
{ from: 12, text: "Dobar dan", roman: "" },
{ from: 18, text: "Dobra ve\u010der", roman: "" }
] },
bs: { name: "Bosnian", bands: [
{ from: 0, text: "Laku no\u0107", roman: "" },
{ from: 5, text: "Dobro jutro", roman: "" },
{ from: 12, text: "Dobar dan", roman: "" },
{ from: 18, text: "Dobro ve\u010de", roman: "" }
] },
sq: { name: "Albanian", bands: [
{ from: 0, text: "Nat\u00ebn e mir\u00eb", roman: "" },
{ from: 5, text: "Mir\u00ebm\u00ebngjes", roman: "" },
{ from: 12, text: "Mir\u00ebdita", roman: "" },
{ from: 18, text: "Mir\u00ebmbr\u00ebma", roman: "" }
] },
mt: { name: "Maltese", bands: [
{ from: 0, text: "Il-lejl it-tajjeb", roman: "" },
{ from: 5, text: "Bon\u0121u", roman: "" },
{ from: 13, text: "Bonsw\u00e0", roman: "" }
] },
tr: { name: "Turkish", bands: [
{ from: 0, text: "\u0130yi geceler", roman: "" },
{ from: 5, text: "G\u00fcnayd\u0131n", roman: "" },
{ from: 12, text: "\u0130yi g\u00fcnler", roman: "" },
{ from: 18, text: "\u0130yi ak\u015famlar", roman: "" }
] },
af: { name: "Afrikaans", bands: [
{ from: 0, text: "Goeienag", roman: "" },
{ from: 5, text: "Goeiem\u00f4re", roman: "" },
{ from: 12, text: "Goeiemiddag", roman: "" },
{ from: 18, text: "Goeienaand", roman: "" }
] },
yo: { name: "Yoruba", bands: [
{ from: 0, text: "O d\u00e0\u00e1r\u1ecd\u0300", roman: "" },
{ from: 5, text: "\u1eb8 k\u00e1\u00e0\u00e1r\u1ecd\u0300", roman: "" },
{ from: 12, text: "\u1eb8 k\u00e1\u00e0s\u00e1n", roman: "" },
{ from: 16, text: "\u1eb8 k\u00fa\u00f9r\u1ecd\u0300l\u1eb9\u0301", roman: "" },
{ from: 20, text: "\u1eb8 k\u00fa\u00f9\u00e1l\u1eb9\u0301", roman: "" }
] },
tw: { name: "Twi", bands: [
{ from: 0, text: "Maadwo", roman: "" },
{ from: 5, text: "Maakye", roman: "" },
{ from: 12, text: "Maaha", roman: "" },
{ from: 18, text: "Maadwo", roman: "" }
] },
ha: { name: "Hausa", bands: [
{ from: 0, text: "Barka da dare", roman: "" },
{ from: 5, text: "Barka da safiya", roman: "" },
{ from: 12, text: "Barka da rana", roman: "" },
{ from: 16, text: "Barka da yamma", roman: "" },
{ from: 20, text: "Barka da dare", roman: "" }
] },
wo: { name: "Wolof", bands: [
{ from: 0, text: "Fanaanal j\u00e0mm", roman: "" },
{ from: 5, text: "J\u00e0mm nga fanaan", roman: "" },
{ from: 12, text: "Naka nga def", roman: "" },
{ from: 18, text: "J\u00e0mm nga yendoo", roman: "" }
] },
sw: { name: "Swahili", bands: [
{ from: 0, text: "Usiku mwema", roman: "" },
{ from: 5, text: "Habari za asubuhi", roman: "" },
{ from: 12, text: "Habari za mchana", roman: "" },
{ from: 16, text: "Habari za jioni", roman: "" },
{ from: 20, text: "Usiku mwema", roman: "" }
] },
rw: { name: "Kinyarwanda", bands: [
{ from: 0, text: "Ijoro ryiza", roman: "" },
{ from: 5, text: "Mwaramutse", roman: "" },
{ from: 12, text: "Mwiriwe", roman: "" },
{ from: 18, text: "Ijoro ryiza", roman: "" }
] },
lg: { name: "Luganda", bands: [
{ from: 0, text: "Sula bulungi", roman: "" },
{ from: 5, text: "Wasuze otya", roman: "" },
{ from: 12, text: "Osiibye otya", roman: "" },
{ from: 18, text: "Sula bulungi", roman: "" }
] },
ny: { name: "Chichewa", bands: [
{ from: 0, text: "Gonani bwino", roman: "" },
{ from: 5, text: "Mwadzuka bwanji", roman: "" },
{ from: 12, text: "Mwaswera bwanji", roman: "" },
{ from: 18, text: "Gonani bwino", roman: "" }
] },
sn: { name: "Shona", bands: [
{ from: 0, text: "Manheru", roman: "" },
{ from: 5, text: "Mangwanani", roman: "" },
{ from: 12, text: "Masikati", roman: "" },
{ from: 18, text: "Manheru", roman: "" }
] },
st: { name: "Sesotho", bands: [
{ from: 0, text: "Lumela", roman: "" }
] },
ss: { name: "Siswati", bands: [
{ from: 0, text: "Sawubona", roman: "" }
] },
mg: { name: "Malagasy", bands: [
{ from: 0, text: "Manao ahoana", roman: "" }
] },
so: { name: "Somali", bands: [
{ from: 0, text: "Habeen wanaagsan", roman: "" },
{ from: 5, text: "Subax wanaagsan", roman: "" },
{ from: 12, text: "Galab wanaagsan", roman: "" },
{ from: 18, text: "Habeen wanaagsan", roman: "" }
] },
id: { name: "Indonesian", bands: [
{ from: 0, text: "Selamat malam", roman: "" },
{ from: 4, text: "Selamat pagi", roman: "" },
{ from: 11, text: "Selamat siang", roman: "" },
{ from: 15, text: "Selamat sore", roman: "" },
{ from: 18, text: "Selamat malam", roman: "" }
] },
ms: { name: "Malay", bands: [
{ from: 0, text: "Selamat malam", roman: "" },
{ from: 5, text: "Selamat pagi", roman: "" },
{ from: 12, text: "Selamat tengah hari", roman: "" },
{ from: 15, text: "Selamat petang", roman: "" },
{ from: 19, text: "Selamat malam", roman: "" }
] },
tl: { name: "Filipino", bands: [
{ from: 0, text: "Magandang gabi", roman: "" },
{ from: 5, text: "Magandang umaga", roman: "" },
{ from: 12, text: "Magandang tanghali", roman: "" },
{ from: 13, text: "Magandang hapon", roman: "" },
{ from: 18, text: "Magandang gabi", roman: "" }
] },
vi: { name: "Vietnamese", bands: [
{ from: 0, text: "Ch\u00fac ng\u1ee7 ngon", roman: "" },
{ from: 5, text: "Ch\u00e0o bu\u1ed5i s\u00e1ng", roman: "" },
{ from: 12, text: "Ch\u00e0o bu\u1ed5i chi\u1ec1u", roman: "" },
{ from: 18, text: "Ch\u00e0o bu\u1ed5i t\u1ed1i", roman: "" }
] },
sm: { name: "Samoan", bands: [
{ from: 0, text: "Manuia le po", roman: "" },
{ from: 5, text: "Manuia le taeao", roman: "" },
{ from: 12, text: "Manuia le aoauli", roman: "" },
{ from: 17, text: "Manuia le afiafi", roman: "" },
{ from: 21, text: "Manuia le po", roman: "" }
] },
mi: { name: "Maori", bands: [
{ from: 0, text: "P\u014d m\u0101rie", roman: "" },
{ from: 5, text: "M\u014drena", roman: "" },
{ from: 11, text: "Kia ora", roman: "" },
{ from: 17, text: "Ahiahi m\u0101rie", roman: "" },
{ from: 21, text: "P\u014d m\u0101rie", roman: "" }
] },
rar: { name: "Cook Islands Maori", bands: [
{ from: 0, text: "Kia orana", roman: "" }
] },
niu: { name: "Niuean", bands: [
{ from: 0, text: "Fakalofa lahi atu", roman: "" }
] },
tkl: { name: "Tokelauan", bands: [
{ from: 0, text: "M\u0101l\u014d ni", roman: "" }
] },
tvl: { name: "Tuvaluan", bands: [
{ from: 0, text: "T\u0101lofa", roman: "" }
] },
to: { name: "Tongan", bands: [
{ from: 0, text: "M\u0101l\u014d e lelei", roman: "" }
] },
ty: { name: "Tahitian", bands: [
{ from: 0, text: "Ia ora na", roman: "" }
] },
fj: { name: "Fijian", bands: [
{ from: 0, text: "Ni sa moce", roman: "" },
{ from: 5, text: "Ni sa yadra", roman: "" },
{ from: 11, text: "Bula", roman: "" },
{ from: 19, text: "Ni sa moce", roman: "" }
] },
haw: { name: "Hawaiian", bands: [
{ from: 0, text: "Aloha p\u014d", roman: "" },
{ from: 5, text: "Aloha kakahiaka", roman: "" },
{ from: 11, text: "Aloha awakea", roman: "" },
{ from: 14, text: "Aloha \u02bbauinal\u0101", roman: "" },
{ from: 18, text: "Aloha ahiahi", roman: "" },
{ from: 23, text: "Aloha p\u014d", roman: "" }
] },
gil: { name: "Gilbertese", bands: [
{ from: 0, text: "Mauri", roman: "" }
] },
ch: { name: "Chamorro", bands: [
{ from: 0, text: "H\u00e5fa adai", roman: "" }
] },
mh: { name: "Marshallese", bands: [
{ from: 0, text: "Iakwe", roman: "" }
] },
pau: { name: "Palauan", bands: [
{ from: 0, text: "Alii", roman: "" }
] },
tpi: { name: "Tok Pisin", bands: [
{ from: 0, text: "Gutnait", roman: "" },
{ from: 5, text: "Moning", roman: "" },
{ from: 12, text: "Apinun", roman: "" },
{ from: 18, text: "Gutnait", roman: "" }
] },
bi: { name: "Bislama", bands: [
{ from: 0, text: "Gudnaet", roman: "" },
{ from: 5, text: "Gude", roman: "" },
{ from: 18, text: "Gudnaet", roman: "" }
] },
ht: { name: "Haitian Creole", bands: [
{ from: 0, text: "B\u00f2nwit", roman: "" },
{ from: 5, text: "Bonjou", roman: "" },
{ from: 12, text: "Bon apre midi", roman: "" },
{ from: 18, text: "Bonswa", roman: "" }
] },
pap: { name: "Papiamento", bands: [
{ from: 0, text: "Bon nochi", roman: "" },
{ from: 5, text: "Bon dia", roman: "" },
{ from: 12, text: "Bon tardi", roman: "" },
{ from: 18, text: "Bon nochi", roman: "" }
] },
gn: { name: "Guarani", bands: [
{ from: 0, text: "Mba'\u00e9ichapa", roman: "" }
] },
el: { name: "Greek", bands: [
{ from: 0, text: "\u039a\u03b1\u03bb\u03b7\u03bd\u03cd\u03c7\u03c4\u03b1", roman: "kalinihta" },
{ from: 5, text: "\u039a\u03b1\u03bb\u03b7\u03bc\u03ad\u03c1\u03b1", roman: "kalimera" },
{ from: 12, text: "\u039a\u03b1\u03bb\u03b7\u03c3\u03c0\u03ad\u03c1\u03b1", roman: "kalispera" },
{ from: 21, text: "\u039a\u03b1\u03bb\u03b7\u03bd\u03cd\u03c7\u03c4\u03b1", roman: "kalinihta" }
] },
ru: { name: "Russian", bands: [
{ from: 0, text: "\u0421\u043f\u043e\u043a\u043e\u0439\u043d\u043e\u0439 \u043d\u043e\u0447\u0438", roman: "spokoynoy nochi" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e\u0435 \u0443\u0442\u0440\u043e", roman: "dobroye utro" },
{ from: 12, text: "\u0414\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c", roman: "dobryy den" },
{ from: 18, text: "\u0414\u043e\u0431\u0440\u044b\u0439 \u0432\u0435\u0447\u0435\u0440", roman: "dobryy vecher" }
] },
uk: { name: "Ukrainian", bands: [
{ from: 0, text: "\u041d\u0430\u0434\u043e\u0431\u0440\u0430\u043d\u0456\u0447", roman: "nadobranich" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0440\u0430\u043d\u043a\u0443", roman: "dobroho ranku" },
{ from: 12, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0434\u043d\u044f", roman: "dobroho dnya" },
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0432\u0435\u0447\u043e\u0440\u0430", roman: "dobroho vechora" }
] },
be: { name: "Belarusian", bands: [
{ from: 0, text: "\u0414\u0430\u0431\u0440\u0430\u043d\u0430\u0447", roman: "dabranach" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u0430\u0439 \u0440\u0430\u043d\u0456\u0446\u044b", roman: "dobray ranitsy" },
{ from: 12, text: "\u0414\u043e\u0431\u0440\u044b \u0434\u0437\u0435\u043d\u044c", roman: "dobry dzen" },
{ from: 18, text: "\u0414\u043e\u0431\u0440\u044b \u0432\u0435\u0447\u0430\u0440", roman: "dobry vechar" }
] },
bg: { name: "Bulgarian", bands: [
{ from: 0, text: "\u041b\u0435\u043a\u0430 \u043d\u043e\u0449", roman: "leka nosht" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0443\u0442\u0440\u043e", roman: "dobro utro" },
{ from: 12, text: "\u0414\u043e\u0431\u044a\u0440 \u0434\u0435\u043d", roman: "dobar den" },
{ from: 18, text: "\u0414\u043e\u0431\u044a\u0440 \u0432\u0435\u0447\u0435\u0440", roman: "dobar vecher" }
] },
sr: { name: "Serbian", bands: [
{ from: 0, text: "\u041b\u0430\u043a\u0443 \u043d\u043e\u045b", roman: "laku noc" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0458\u0443\u0442\u0440\u043e", roman: "dobro jutro" },
{ from: 12, text: "\u0414\u043e\u0431\u0430\u0440 \u0434\u0430\u043d", roman: "dobar dan" },
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e \u0432\u0435\u0447\u0435", roman: "dobro vece" }
] },
mk: { name: "Macedonian", bands: [
{ from: 0, text: "\u0414\u043e\u0431\u0440\u0430 \u043d\u043e\u045c", roman: "dobra nok" },
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0443\u0442\u0440\u043e", roman: "dobro utro" },
{ from: 12, text: "\u0414\u043e\u0431\u0430\u0440 \u0434\u0435\u043d", roman: "dobar den" },
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e \u0432\u0435\u0447\u0435\u0440", roman: "dobro vecher" }
] },
kk: { name: "Kazakh", bands: [
{ from: 0, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u0442\u04af\u043d", roman: "qaiyrly tun" },
{ from: 5, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u0442\u0430\u04a3", roman: "qaiyrly tang" },
{ from: 12, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u043a\u04af\u043d", roman: "qaiyrly kun" },
{ from: 18, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u043a\u0435\u0448", roman: "qaiyrly kesh" }
] },
ky: { name: "Kyrgyz", bands: [
{ from: 0, text: "\u0416\u0430\u043a\u0448\u044b \u0436\u0430\u0442\u044b\u043f \u0442\u0443\u0440\u0443\u04a3\u0443\u0437", roman: "jakshy jatyp turunguz" },
{ from: 5, text: "\u041a\u0430\u0439\u044b\u0440\u043b\u0443\u0443 \u0442\u0430\u04a3", roman: "kaiyrluu tang" },
{ from: 12, text: "\u041a\u0443\u0442\u043c\u0430\u043d\u0434\u0443\u0443 \u043a\u04af\u043d", roman: "kutmanduu kun" },
{ from: 18, text: "\u041a\u0443\u0442\u043c\u0430\u043d\u0434\u0443\u0443 \u043a\u0435\u0447", roman: "kutmanduu kech" }
] },
tg: { name: "Tajik", bands: [
{ from: 0, text: "\u0428\u0430\u0431 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "shab ba khayr" },
{ from: 5, text: "\u0421\u0443\u0431\u04b3 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "subh ba khayr" },
{ from: 12, text: "\u0420\u04ef\u0437 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "ruz ba khayr" },
{ from: 18, text: "\u0428\u043e\u043c \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "shom ba khayr" }
] },
mn: { name: "Mongolian", bands: [
{ from: 0, text: "\u0421\u0430\u0439\u0445\u0430\u043d \u0430\u043c\u0440\u0430\u0430\u0440\u0430\u0439", roman: "saikhan amraarai" },
{ from: 5, text: "\u04e8\u0433\u043b\u04e9\u04e9\u043d\u0438\u0439 \u043c\u044d\u043d\u0434", roman: "ogloonii mend" },
{ from: 12, text: "\u04e8\u0434\u0440\u0438\u0439\u043d \u043c\u044d\u043d\u0434", roman: "odriin mend" },
{ from: 18, text: "\u041e\u0440\u043e\u0439\u043d \u043c\u044d\u043d\u0434", roman: "oroin mend" }
] },
ka: { name: "Georgian", bands: [
{ from: 0, text: "\u10e6\u10d0\u10db\u10d4 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "ghame mshvidobisa" },
{ from: 5, text: "\u10d3\u10d8\u10da\u10d0 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "dila mshvidobisa" },
{ from: 12, text: "\u10d3\u10e6\u10d4 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "dghe mshvidobisa" },
{ from: 18, text: "\u10e1\u10d0\u10e6\u10d0\u10db\u10dd \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "saghamo mshvidobisa" }
] },
hy: { name: "Armenian", bands: [
{ from: 0, text: "\u0562\u0561\u0580\u056b \u0563\u056b\u0577\u0565\u0580", roman: "bari gisher" },
{ from: 5, text: "\u0562\u0561\u0580\u056b \u056c\u0578\u0582\u0575\u057d", roman: "bari luys" },
{ from: 12, text: "\u0562\u0561\u0580\u056b \u0585\u0580", roman: "bari or" },
{ from: 18, text: "\u0562\u0561\u0580\u056b \u0565\u0580\u0565\u056f\u0578", roman: "bari ereko" }
] },
he: { name: "Hebrew", bands: [
{ from: 0, text: "\u05dc\u05d9\u05dc\u05d4 \u05d8\u05d5\u05d1", roman: "laila tov" },
{ from: 5, text: "\u05d1\u05d5\u05e7\u05e8 \u05d8\u05d5\u05d1", roman: "boker tov" },
{ from: 12, text: "\u05e6\u05d4\u05e8\u05d9\u05d9\u05dd \u05d8\u05d5\u05d1\u05d9\u05dd", roman: "tzohorayim tovim" },
{ from: 17, text: "\u05e2\u05e8\u05d1 \u05d8\u05d5\u05d1", roman: "erev tov" }
] },
ar: { name: "Arabic", bands: [
{ from: 0, text: "\u062a\u0635\u0628\u062d \u0639\u0644\u0649 \u062e\u064a\u0631", roman: "tusbih ala khayr" },
{ from: 5, text: "\u0635\u0628\u0627\u062d \u0627\u0644\u062e\u064a\u0631", roman: "sabah al-khayr" },
{ from: 12, text: "\u0645\u0633\u0627\u0621 \u0627\u0644\u062e\u064a\u0631", roman: "masaa al-khayr" }
] },
fa: { name: "Persian", bands: [
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bekheir" },
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "sobh bekheir" },
{ from: 12, text: "\u0639\u0635\u0631 \u0628\u062e\u06cc\u0631", roman: "asr bekheir" },
{ from: 20, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bekheir" }
] },
ur: { name: "Urdu", bands: [
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bakhair" },
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "subah bakhair" },
{ from: 12, text: "\u0627\u0644\u0633\u0644\u0627\u0645 \u0639\u0644\u06cc\u06a9\u0645", roman: "assalam-o-alaikum" },
{ from: 18, text: "\u0634\u0627\u0645 \u0628\u062e\u06cc\u0631", roman: "shaam bakhair" }
] },
prs: { name: "Dari", bands: [
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bakhair" },
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "sobh bakhair" },
{ from: 12, text: "\u0631\u0648\u0632 \u0628\u062e\u06cc\u0631", roman: "roz bakhair" },
{ from: 18, text: "\u0634\u0627\u0645 \u0628\u062e\u06cc\u0631", roman: "sham bakhair" }
] },
dv: { name: "Dhivehi", bands: [
{ from: 0, text: "\u0787\u07a6\u0787\u07b0\u0790\u07a6\u078d\u07a7\u0789\u07aa \u07a2\u07a6\u078d\u07a6\u0787\u07a8\u0786\u07aa\u0789\u07b0", roman: "assalaamu alaikum" }
] },
hi: { name: "Hindi", bands: [
{ from: 0, text: "\u0936\u0941\u092d \u0930\u093e\u0924\u094d\u0930\u093f", roman: "shubh ratri" },
{ from: 5, text: "\u0938\u0941\u092a\u094d\u0930\u092d\u093e\u0924", roman: "suprabhat" },
{ from: 12, text: "\u0928\u092e\u0938\u094d\u0924\u0947", roman: "namaste" },
{ from: 17, text: "\u0936\u0941\u092d \u0938\u0902\u0927\u094d\u092f\u093e", roman: "shubh sandhya" }
] },
bn: { name: "Bengali", bands: [
{ from: 0, text: "\u09b6\u09c1\u09ad \u09b0\u09be\u09a4\u09cd\u09b0\u09bf", roman: "shubho ratri" },
{ from: 5, text: "\u09b8\u09c1\u09aa\u09cd\u09b0\u09ad\u09be\u09a4", roman: "suprobhat" },
{ from: 12, text: "\u09a8\u09ae\u09b8\u09cd\u0995\u09be\u09b0", roman: "nomoshkar" },
{ from: 17, text: "\u09b6\u09c1\u09ad \u09b8\u09a8\u09cd\u09a7\u09cd\u09af\u09be", roman: "shubho shondha" }
] },
ne: { name: "Nepali", bands: [
{ from: 0, text: "\u0936\u0941\u092d \u0930\u093e\u0924\u094d\u0930\u0940", roman: "shubha ratri" },
{ from: 5, text: "\u0936\u0941\u092d \u092a\u094d\u0930\u092d\u093e\u0924", roman: "shubha prabhat" },
{ from: 12, text: "\u0928\u092e\u0938\u094d\u0924\u0947", roman: "namaste" },
{ from: 17, text: "\u0936\u0941\u092d \u0938\u0928\u094d\u0927\u094d\u092f\u093e", roman: "shubha sandhya" }
] },
si: { name: "Sinhala", bands: [
{ from: 0, text: "\u0dc3\u0dd4\u0db6 \u0dbb\u0dcf\u0dad\u0dca\u200d\u0dbb\u0dd2\u0dba\u0d9a\u0dca", roman: "suba rathriyak" },
{ from: 5, text: "\u0dc3\u0dd4\u0db6 \u0d8b\u0daf\u0dd1\u0dc3\u0db1\u0d9a\u0dca", roman: "suba udaesanak" },
{ from: 12, text: "\u0dc3\u0dd4\u0db6 \u0daf\u0dc0\u0dc3\u0d9a\u0dca", roman: "suba dawasak" },
{ from: 18, text: "\u0dc3\u0dd4\u0db6 \u0dc3\u0db1\u0dca\u0db0\u0dca\u200d\u0dba\u0dcf\u0dc0\u0d9a\u0dca", roman: "suba sandhyawak" }
] },
ta: { name: "Tamil", bands: [
{ from: 0, text: "\u0b87\u0bb0\u0bb5\u0bc1 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "iravu vanakkam" },
{ from: 5, text: "\u0b95\u0bbe\u0bb2\u0bc8 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "kaalai vanakkam" },
{ from: 12, text: "\u0bae\u0ba4\u0bbf\u0baf \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "madhiya vanakkam" },
{ from: 17, text: "\u0bae\u0bbe\u0bb2\u0bc8 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "maalai vanakkam" }
] },
dz: { name: "Dzongkha", bands: [
{ from: 0, text: "\u0f40\u0f74\u0f0b\u0f5f\u0f74\u0f0b\u0f5f\u0f44\u0f0b\u0f54\u0f7c\u0f0b\u0f63\u0f42\u0f66", roman: "kuzuzangpo la" }
] },
my: { name: "Burmese", bands: [
{ from: 0, text: "\u1019\u1004\u103a\u1039\u1002\u101c\u102c\u1015\u102b", roman: "mingalaba" }
] },
th: { name: "Thai", bands: [
{ from: 0, text: "\u0e23\u0e32\u0e15\u0e23\u0e35\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e34\u0e4c", roman: "ratri sawat" },
{ from: 5, text: "\u0e2d\u0e23\u0e38\u0e13\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e34\u0e4c", roman: "arun sawat" },
{ from: 12, text: "\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e35", roman: "sawatdee" }
] },
lo: { name: "Lao", bands: [
{ from: 0, text: "\u0eaa\u0eb0\u0e9a\u0eb2\u0e8d\u0e94\u0eb5", roman: "sabaidee" }
] },
km: { name: "Khmer", bands: [
{ from: 0, text: "\u179a\u17b6\u178f\u17d2\u179a\u17b8\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "reatrei suostei" },
{ from: 5, text: "\u17a2\u179a\u17bb\u178e\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "arun suostei" },
{ from: 12, text: "\u1791\u17b7\u179c\u17b6\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "tivea suostei" },
{ from: 18, text: "\u179f\u17b6\u1799\u17d0\u178e\u17d2\u17a0\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "sayoanh suostei" }
] },
zh: { name: "Mandarin", bands: [
{ from: 0, text: "\u665a\u5b89", roman: "wan an" },
{ from: 5, text: "\u65e9\u4e0a\u597d", roman: "zaoshang hao" },
{ from: 11, text: "\u4e2d\u5348\u597d", roman: "zhongwu hao" },
{ from: 14, text: "\u4e0b\u5348\u597d", roman: "xiawu hao" },
{ from: 18, text: "\u665a\u4e0a\u597d", roman: "wanshang hao" }
] },
yue: { name: "Cantonese", bands: [
{ from: 0, text: "\u65e9\u551e", roman: "jou tau" },
{ from: 5, text: "\u65e9\u6668", roman: "jou san" },
{ from: 12, text: "\u5348\u5b89", roman: "ng on" },
{ from: 18, text: "\u665a\u5b89", roman: "maan on" }
] },
ko: { name: "Korean", bands: [
{ from: 0, text: "\uc548\ub155\ud788 \uc8fc\ubb34\uc138\uc694", roman: "annyeonghi jumuseyo" },
{ from: 5, text: "\uc88b\uc740 \uc544\uce68", roman: "joeun achim" },
{ from: 11, text: "\uc548\ub155\ud558\uc138\uc694", roman: "annyeonghaseyo" },
{ from: 18, text: "\uc88b\uc740 \uc800\ub141", roman: "joeun jeonyeok" }
] },
ja: { name: "Japanese", bands: [
{ from: 0, text: "\u304a\u3084\u3059\u307f", roman: "oyasumi" },
{ from: 5, text: "\u304a\u306f\u3088\u3046", roman: "ohayo" },
{ from: 11, text: "\u3053\u3093\u306b\u3061\u306f", roman: "konnichiwa" },
{ from: 18, text: "\u3053\u3093\u3070\u3093\u306f", roman: "konbanwa" }
] },
am: { name: "Amharic", bands: [
{ from: 0, text: "\u12f0\u1205\u1293 \u12a5\u12f0\u1229", roman: "dehna ederu" },
{ from: 5, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12a0\u12f0\u1229", roman: "endemin aderu" },
{ from: 12, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12cb\u1209", roman: "endemin walu" },
{ from: 18, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12a0\u1218\u1239", roman: "endemin ameshu" }
] },
ti: { name: "Tigrinya", bands: [
{ from: 0, text: "\u1230\u120b\u121d", roman: "selam" }
] },
az: { name: "Azerbaijani", bands: [
{ from: 0, text: "Gec\u0259niz xeyr\u0259 qals\u0131n", roman: "" },
{ from: 5, text: "Sabah\u0131n\u0131z xeyir", roman: "" },
{ from: 12, text: "G\u00fcnortan\u0131z xeyir", roman: "" },
{ from: 18, text: "Ax\u015fam\u0131n\u0131z xeyir", roman: "" }
] },
uz: { name: "Uzbek", bands: [
{ from: 0, text: "Xayrli tun", roman: "" },
{ from: 5, text: "Xayrli tong", roman: "" },
{ from: 12, text: "Xayrli kun", roman: "" },
{ from: 18, text: "Xayrli kech", roman: "" }
] },
tk: { name: "Turkmen", bands: [
{ from: 0, text: "Gij\u00e4\u0148iz rahat", roman: "" },
{ from: 5, text: "Ertiri\u0148iz ha\u00fdyrly", roman: "" },
{ from: 12, text: "Salam", roman: "" },
{ from: 18, text: "Ag\u015famy\u0148yz ha\u00fdyrly", roman: "" }
] }
}
var COUNTRIES = {
"AD": "ca", "AE": "ar", "AF": "prs", "AG": "en", "AI": "en", "AL": "sq",
"AM": "hy", "AO": "pt", "AQ": "en", "AR": "es", "AS": "sm", "AT": "deAT",
"AU": "enAU", "AW": "pap", "AX": "sv", "AZ": "az", "BA": "bs", "BB": "en",
"BD": "bn", "BE": "fr", "BF": "fr", "BG": "bg", "BH": "ar", "BI": "fr",
"BJ": "fr", "BL": "fr", "BM": "en", "BN": "ms", "BO": "es", "BQ": "pap",
"BR": "pt", "BS": "en", "BT": "dz", "BW": "en", "BY": "be", "BZ": "en",
"CA": "en", "CC": "en", "CD": "fr", "CF": "fr", "CG": "fr", "CH": "deCH",
"CI": "fr", "CK": "rar", "CL": "es", "CM": "fr", "CN": "zh", "CO": "es",
"CR": "es", "CU": "es", "CV": "pt", "CW": "pap", "CX": "en", "CY": "el",
"CZ": "cs", "DE": "de", "DJ": "fr", "DK": "da", "DM": "en", "DO": "es",
"DZ": "ar", "EC": "es", "EE": "et", "EG": "ar", "EH": "ar", "ER": "ti",
"ES": "esES", "ET": "am", "FI": "fi", "FJ": "fj", "FK": "en", "FM": "en",
"FO": "fo", "FR": "fr", "GA": "fr", "GB": "en", "GD": "en", "GE": "ka",
"GF": "fr", "GG": "en", "GH": "tw", "GI": "en", "GL": "kl", "GM": "en",
"GN": "fr", "GP": "fr", "GQ": "es", "GR": "el", "GS": "en", "GT": "es",
"GU": "ch", "GW": "pt", "GY": "en", "HK": "yue", "HN": "es", "HR": "hr",
"HT": "ht", "HU": "hu", "ID": "id", "IE": "ga", "IL": "he", "IM": "en",
"IN": "hi", "IO": "en", "IQ": "ar", "IR": "fa", "IS": "is", "IT": "it",
"JE": "en", "JM": "en", "JO": "ar", "JP": "ja", "KE": "sw", "KG": "ky",
"KH": "km", "KI": "gil", "KM": "fr", "KN": "en", "KP": "ko", "KR": "ko",
"KW": "ar", "KY": "en", "KZ": "kk", "LA": "lo", "LB": "ar", "LC": "en",
"LI": "deCH", "LK": "si", "LR": "en", "LS": "st", "LT": "lt", "LU": "lb",
"LV": "lv", "LY": "ar", "MA": "ar", "MC": "fr", "MD": "ro", "ME": "hr",
"MF": "fr", "MG": "mg", "MH": "mh", "MK": "mk", "ML": "fr", "MM": "my",
"MN": "mn", "MO": "yue", "MP": "ch", "MQ": "fr", "MR": "ar", "MS": "en",
"MT": "mt", "MU": "fr", "MV": "dv", "MW": "ny", "MX": "es", "MY": "ms",
"MZ": "pt", "NA": "af", "NC": "fr", "NE": "fr", "NF": "en", "NG": "yo",
"NI": "es", "NL": "nl", "NO": "no", "NP": "ne", "NR": "en", "NU": "niu",
"NZ": "mi", "OM": "ar", "PA": "es", "PE": "es", "PF": "ty", "PG": "tpi",
"PH": "tl", "PK": "ur", "PL": "pl", "PM": "fr", "PN": "en", "PR": "es",
"PS": "ar", "PT": "pt", "PW": "pau", "PY": "gn", "QA": "ar", "RE": "fr",
"RO": "ro", "RS": "sr", "RU": "ru", "RW": "rw", "SA": "ar", "SB": "bi",
"SC": "fr", "SD": "ar", "SE": "sv", "SG": "ms", "SH": "en", "SI": "sl",
"SJ": "no", "SK": "sk", "SL": "en", "SM": "it", "SN": "wo", "SO": "so",
"SR": "nl", "SS": "en", "ST": "pt", "SV": "es", "SX": "nl", "SY": "ar",
"SZ": "ss", "TC": "en", "TD": "fr", "TF": "fr", "TG": "fr", "TH": "th",
"TJ": "tg", "TK": "tkl", "TL": "pt", "TM": "tk", "TN": "ar", "TO": "to",
"TR": "tr", "TT": "en", "TV": "tvl", "TW": "zh", "TZ": "sw", "UA": "uk",
"UG": "lg", "UM": "en", "US": "en", "UY": "es", "UZ": "uz", "VA": "it",
"VC": "en", "VE": "es", "VG": "en", "VI": "en", "VN": "vi", "VU": "bi",
"WF": "fr", "WS": "sm", "YE": "ar", "YT": "fr", "ZA": "af", "ZM": "en",
"ZW": "sn"
}
// Cities whose own language is not their country's.
var OVERRIDES = {
"America/Montreal": "fr",
"Asia/Kolkata": "hi",
"Pacific/Honolulu": "haw"
}
// Legacy aliases that matching compiled zoneinfo put in the wrong country.
// Europe/Simferopol stays UA in ZONE_COUNTRY by choice.
var ALIAS_COUNTRY = {
"Africa/Asmera": "ER", // Asmara, Eritrea
"Africa/Timbuktu": "ML", // Timbuktu, Mali
"America/Montreal": "CA",
"America/Nipigon": "CA",
"America/Thunder_Bay": "CA",
"America/Virgin": "VI", // the US Virgin Islands
"Asia/Rangoon": "MM", // Yangon, Myanmar
"Canada/Eastern": "CA",
"EST": "US", // a North American offset, not a place
"Iceland": "IS",
"MST": "US",
"NZ": "NZ",
"Pacific/Truk": "FM", // Chuuk, Micronesia
"Pacific/Yap": "FM", // Yap, Micronesia
"Singapore": "SG",
"US/Arizona": "US"
}
// Every zone the picker can offer, baked from the system's zone.tab.
var ZONE_COUNTRY = {
"Africa/Abidjan": "CI", "Africa/Accra": "GH", "Africa/Addis_Ababa": "ET",
"Africa/Algiers": "DZ", "Africa/Asmara": "ER", "Africa/Asmera": "DJ",
"Africa/Bamako": "ML", "Africa/Bangui": "CF", "Africa/Banjul": "GM",
"Africa/Bissau": "GW", "Africa/Blantyre": "MW",
"Africa/Brazzaville": "CG", "Africa/Bujumbura": "BI",
"Africa/Cairo": "EG", "Africa/Casablanca": "MA", "Africa/Ceuta": "ES",
"Africa/Conakry": "GN", "Africa/Dakar": "SN",
"Africa/Dar_es_Salaam": "TZ", "Africa/Djibouti": "DJ",
"Africa/Douala": "CM", "Africa/El_Aaiun": "EH", "Africa/Freetown": "SL",
"Africa/Gaborone": "BW", "Africa/Harare": "ZW",
"Africa/Johannesburg": "ZA", "Africa/Juba": "SS", "Africa/Kampala": "UG",
"Africa/Khartoum": "SD", "Africa/Kigali": "RW", "Africa/Kinshasa": "CD",
"Africa/Lagos": "NG", "Africa/Libreville": "GA", "Africa/Lome": "TG",
"Africa/Luanda": "AO", "Africa/Lubumbashi": "CD", "Africa/Lusaka": "ZM",
"Africa/Malabo": "GQ", "Africa/Maputo": "MZ", "Africa/Maseru": "LS",
"Africa/Mbabane": "SZ", "Africa/Mogadishu": "SO", "Africa/Monrovia": "LR",
"Africa/Nairobi": "KE", "Africa/Ndjamena": "TD", "Africa/Niamey": "NE",
"Africa/Nouakchott": "MR", "Africa/Ouagadougou": "BF",
"Africa/Porto-Novo": "BJ", "Africa/Sao_Tome": "ST",
"Africa/Timbuktu": "BF", "Africa/Tripoli": "LY", "Africa/Tunis": "TN",
"Africa/Windhoek": "NA", "America/Adak": "US", "America/Anchorage": "US",
"America/Anguilla": "AI", "America/Antigua": "AG",
"America/Araguaina": "BR", "America/Argentina/Buenos_Aires": "AR",
"America/Argentina/Catamarca": "AR",
"America/Argentina/ComodRivadavia": "AR",
"America/Argentina/Cordoba": "AR", "America/Argentina/Jujuy": "AR",
"America/Argentina/La_Rioja": "AR", "America/Argentina/Mendoza": "AR",
"America/Argentina/Rio_Gallegos": "AR", "America/Argentina/Salta": "AR",
"America/Argentina/San_Juan": "AR", "America/Argentina/San_Luis": "AR",
"America/Argentina/Tucuman": "AR", "America/Argentina/Ushuaia": "AR",
"America/Aruba": "AW", "America/Asuncion": "PY", "America/Atikokan": "CA",
"America/Atka": "US", "America/Bahia": "BR",
"America/Bahia_Banderas": "MX", "America/Barbados": "BB",
"America/Belem": "BR", "America/Belize": "BZ",
"America/Blanc-Sablon": "CA", "America/Boa_Vista": "BR",
"America/Bogota": "CO", "America/Boise": "US",
"America/Buenos_Aires": "AR", "America/Cambridge_Bay": "CA",
"America/Campo_Grande": "BR", "America/Cancun": "MX",
"America/Caracas": "VE", "America/Catamarca": "AR",
"America/Cayenne": "GF", "America/Cayman": "KY", "America/Chicago": "US",
"America/Chihuahua": "MX", "America/Ciudad_Juarez": "MX",
"America/Coral_Harbour": "CA", "America/Cordoba": "AR",
"America/Costa_Rica": "CR", "America/Coyhaique": "CL",
"America/Creston": "CA", "America/Cuiaba": "BR", "America/Curacao": "CW",
"America/Danmarkshavn": "GL", "America/Dawson": "CA",
"America/Dawson_Creek": "CA", "America/Denver": "US",
"America/Detroit": "US", "America/Dominica": "DM",
"America/Edmonton": "CA", "America/Eirunepe": "BR",
"America/El_Salvador": "SV", "America/Ensenada": "MX",
"America/Fort_Nelson": "CA", "America/Fort_Wayne": "US",
"America/Fortaleza": "BR", "America/Glace_Bay": "CA",
"America/Godthab": "GL", "America/Goose_Bay": "CA",
"America/Grand_Turk": "TC", "America/Grenada": "GD",
"America/Guadeloupe": "GP", "America/Guatemala": "GT",
"America/Guayaquil": "EC", "America/Guyana": "GY",
"America/Halifax": "CA", "America/Havana": "CU",
"America/Hermosillo": "MX", "America/Indiana/Indianapolis": "US",
"America/Indiana/Knox": "US", "America/Indiana/Marengo": "US",
"America/Indiana/Petersburg": "US", "America/Indiana/Tell_City": "US",
"America/Indiana/Vevay": "US", "America/Indiana/Vincennes": "US",
"America/Indiana/Winamac": "US", "America/Indianapolis": "US",
"America/Inuvik": "CA", "America/Iqaluit": "CA", "America/Jamaica": "JM",
"America/Jujuy": "AR", "America/Juneau": "US",
"America/Kentucky/Louisville": "US", "America/Kentucky/Monticello": "US",
"America/Knox_IN": "US", "America/Kralendijk": "BQ",
"America/La_Paz": "BO", "America/Lima": "PE", "America/Los_Angeles": "US",
"America/Louisville": "US", "America/Lower_Princes": "SX",
"America/Maceio": "BR", "America/Managua": "NI", "America/Manaus": "BR",
"America/Marigot": "MF", "America/Martinique": "MQ",
"America/Matamoros": "MX", "America/Mazatlan": "MX",
"America/Mendoza": "AR", "America/Menominee": "US",
"America/Merida": "MX", "America/Metlakatla": "US",
"America/Mexico_City": "MX", "America/Miquelon": "PM",
"America/Moncton": "CA", "America/Monterrey": "MX",
"America/Montevideo": "UY", "America/Montreal": "BS",
"America/Montserrat": "MS", "America/Nassau": "BS",
"America/New_York": "US", "America/Nipigon": "BS", "America/Nome": "US",
"America/Noronha": "BR", "America/North_Dakota/Beulah": "US",
"America/North_Dakota/Center": "US",
"America/North_Dakota/New_Salem": "US", "America/Nuuk": "GL",
"America/Ojinaga": "MX", "America/Panama": "PA",
"America/Pangnirtung": "CA", "America/Paramaribo": "SR",
"America/Phoenix": "US", "America/Port-au-Prince": "HT",
"America/Port_of_Spain": "TT", "America/Porto_Acre": "BR",
"America/Porto_Velho": "BR", "America/Puerto_Rico": "PR",
"America/Punta_Arenas": "CL", "America/Rainy_River": "CA",
"America/Rankin_Inlet": "CA", "America/Recife": "BR",
"America/Regina": "CA", "America/Resolute": "CA",
"America/Rio_Branco": "BR", "America/Rosario": "AR",
"America/Santa_Isabel": "MX", "America/Santarem": "BR",
"America/Santiago": "CL", "America/Santo_Domingo": "DO",
"America/Sao_Paulo": "BR", "America/Scoresbysund": "GL",
"America/Shiprock": "US", "America/Sitka": "US",
"America/St_Barthelemy": "BL", "America/St_Johns": "CA",
"America/St_Kitts": "KN", "America/St_Lucia": "LC",
"America/St_Thomas": "VI", "America/St_Vincent": "VC",
"America/Swift_Current": "CA", "America/Tegucigalpa": "HN",
"America/Thule": "GL", "America/Thunder_Bay": "BS",
"America/Tijuana": "MX", "America/Toronto": "CA", "America/Tortola": "VG",
"America/Vancouver": "CA", "America/Virgin": "AG",
"America/Whitehorse": "CA", "America/Winnipeg": "CA",
"America/Yakutat": "US", "America/Yellowknife": "CA",
"Antarctica/Casey": "AQ", "Antarctica/Davis": "AQ",
"Antarctica/DumontDUrville": "AQ", "Antarctica/Macquarie": "AU",
"Antarctica/Mawson": "AQ", "Antarctica/McMurdo": "AQ",
"Antarctica/Palmer": "AQ", "Antarctica/Rothera": "AQ",
"Antarctica/South_Pole": "AQ", "Antarctica/Syowa": "AQ",
"Antarctica/Troll": "AQ", "Antarctica/Vostok": "AQ",
"Arctic/Longyearbyen": "SJ", "Asia/Aden": "YE", "Asia/Almaty": "KZ",
"Asia/Amman": "JO", "Asia/Anadyr": "RU", "Asia/Aqtau": "KZ",
"Asia/Aqtobe": "KZ", "Asia/Ashgabat": "TM", "Asia/Ashkhabad": "TM",
"Asia/Atyrau": "KZ", "Asia/Baghdad": "IQ", "Asia/Bahrain": "BH",
"Asia/Baku": "AZ", "Asia/Bangkok": "TH", "Asia/Barnaul": "RU",
"Asia/Beirut": "LB", "Asia/Bishkek": "KG", "Asia/Brunei": "BN",
"Asia/Calcutta": "IN", "Asia/Chita": "RU", "Asia/Choibalsan": "MN",
"Asia/Chongqing": "CN", "Asia/Chungking": "CN", "Asia/Colombo": "LK",
"Asia/Dacca": "BD", "Asia/Damascus": "SY", "Asia/Dhaka": "BD",
"Asia/Dili": "TL", "Asia/Dubai": "AE", "Asia/Dushanbe": "TJ",
"Asia/Famagusta": "CY", "Asia/Gaza": "PS", "Asia/Harbin": "CN",
"Asia/Hebron": "PS", "Asia/Ho_Chi_Minh": "VN", "Asia/Hong_Kong": "HK",
"Asia/Hovd": "MN", "Asia/Irkutsk": "RU", "Asia/Istanbul": "TR",
"Asia/Jakarta": "ID", "Asia/Jayapura": "ID", "Asia/Jerusalem": "IL",
"Asia/Kabul": "AF", "Asia/Kamchatka": "RU", "Asia/Karachi": "PK",
"Asia/Kashgar": "CN", "Asia/Kathmandu": "NP", "Asia/Katmandu": "NP",
"Asia/Khandyga": "RU", "Asia/Kolkata": "IN", "Asia/Krasnoyarsk": "RU",
"Asia/Kuala_Lumpur": "MY", "Asia/Kuching": "MY", "Asia/Kuwait": "KW",
"Asia/Macao": "MO", "Asia/Macau": "MO", "Asia/Magadan": "RU",
"Asia/Makassar": "ID", "Asia/Manila": "PH", "Asia/Muscat": "OM",
"Asia/Nicosia": "CY", "Asia/Novokuznetsk": "RU", "Asia/Novosibirsk": "RU",
"Asia/Omsk": "RU", "Asia/Oral": "KZ", "Asia/Phnom_Penh": "KH",
"Asia/Pontianak": "ID", "Asia/Pyongyang": "KP", "Asia/Qatar": "QA",
"Asia/Qostanay": "KZ", "Asia/Qyzylorda": "KZ", "Asia/Rangoon": "CC",
"Asia/Riyadh": "SA", "Asia/Saigon": "VN", "Asia/Sakhalin": "RU",
"Asia/Samarkand": "UZ", "Asia/Seoul": "KR", "Asia/Shanghai": "CN",
"Asia/Singapore": "SG", "Asia/Srednekolymsk": "RU", "Asia/Taipei": "TW",
"Asia/Tashkent": "UZ", "Asia/Tbilisi": "GE", "Asia/Tehran": "IR",
"Asia/Tel_Aviv": "IL", "Asia/Thimbu": "BT", "Asia/Thimphu": "BT",
"Asia/Tokyo": "JP", "Asia/Tomsk": "RU", "Asia/Ujung_Pandang": "ID",
"Asia/Ulaanbaatar": "MN", "Asia/Ulan_Bator": "MN", "Asia/Urumqi": "CN",
"Asia/Ust-Nera": "RU", "Asia/Vientiane": "LA", "Asia/Vladivostok": "RU",
"Asia/Yakutsk": "RU", "Asia/Yangon": "MM", "Asia/Yekaterinburg": "RU",
"Asia/Yerevan": "AM", "Atlantic/Azores": "PT", "Atlantic/Bermuda": "BM",
"Atlantic/Canary": "ES", "Atlantic/Cape_Verde": "CV",
"Atlantic/Faeroe": "FO", "Atlantic/Faroe": "FO",
"Atlantic/Jan_Mayen": "DE", "Atlantic/Madeira": "PT",
"Atlantic/Reykjavik": "IS", "Atlantic/South_Georgia": "GS",
"Atlantic/St_Helena": "SH", "Atlantic/Stanley": "FK",
"Australia/ACT": "AU", "Australia/Adelaide": "AU",
"Australia/Brisbane": "AU", "Australia/Broken_Hill": "AU",
"Australia/Canberra": "AU", "Australia/Currie": "AU",
"Australia/Darwin": "AU", "Australia/Eucla": "AU",
"Australia/Hobart": "AU", "Australia/LHI": "AU",
"Australia/Lindeman": "AU", "Australia/Lord_Howe": "AU",
"Australia/Melbourne": "AU", "Australia/NSW": "AU",
"Australia/North": "AU", "Australia/Perth": "AU",
"Australia/Queensland": "AU", "Australia/South": "AU",
"Australia/Sydney": "AU", "Australia/Tasmania": "AU",
"Australia/Victoria": "AU", "Australia/West": "AU",
"Australia/Yancowinna": "AU", "Brazil/Acre": "BR",
"Brazil/DeNoronha": "BR", "Brazil/East": "BR", "Brazil/West": "BR",
"CET": "BE", "CST6CDT": "US", "Canada/Atlantic": "CA",
"Canada/Central": "CA", "Canada/Eastern": "BS", "Canada/Mountain": "CA",
"Canada/Newfoundland": "CA", "Canada/Pacific": "CA",
"Canada/Saskatchewan": "CA", "Canada/Yukon": "CA",
"Chile/Continental": "CL", "Chile/EasterIsland": "CL", "Cuba": "CU",
"EET": "GR", "EST": "CA", "EST5EDT": "US", "Egypt": "EG", "Eire": "IE",
"Europe/Amsterdam": "NL", "Europe/Andorra": "AD",
"Europe/Astrakhan": "RU", "Europe/Athens": "GR", "Europe/Belfast": "GB",
"Europe/Belgrade": "RS", "Europe/Berlin": "DE", "Europe/Bratislava": "SK",
"Europe/Brussels": "BE", "Europe/Bucharest": "RO",
"Europe/Budapest": "HU", "Europe/Busingen": "DE", "Europe/Chisinau": "MD",
"Europe/Copenhagen": "DK", "Europe/Dublin": "IE",
"Europe/Gibraltar": "GI", "Europe/Guernsey": "GG",
"Europe/Helsinki": "FI", "Europe/Isle_of_Man": "IM",
"Europe/Istanbul": "TR", "Europe/Jersey": "JE",
"Europe/Kaliningrad": "RU", "Europe/Kiev": "UA", "Europe/Kirov": "RU",
"Europe/Kyiv": "UA", "Europe/Lisbon": "PT", "Europe/Ljubljana": "SI",
"Europe/London": "GB", "Europe/Luxembourg": "LU", "Europe/Madrid": "ES",
"Europe/Malta": "MT", "Europe/Mariehamn": "AX", "Europe/Minsk": "BY",
"Europe/Monaco": "MC", "Europe/Moscow": "RU", "Europe/Nicosia": "CY",
"Europe/Oslo": "NO", "Europe/Paris": "FR", "Europe/Podgorica": "ME",
"Europe/Prague": "CZ", "Europe/Riga": "LV", "Europe/Rome": "IT",
"Europe/Samara": "RU", "Europe/San_Marino": "SM", "Europe/Sarajevo": "BA",
"Europe/Saratov": "RU", "Europe/Simferopol": "UA", "Europe/Skopje": "MK",
"Europe/Sofia": "BG", "Europe/Stockholm": "SE", "Europe/Tallinn": "EE",
"Europe/Tirane": "AL", "Europe/Tiraspol": "MD", "Europe/Ulyanovsk": "RU",
"Europe/Uzhgorod": "UA", "Europe/Vaduz": "LI", "Europe/Vatican": "VA",
"Europe/Vienna": "AT", "Europe/Vilnius": "LT", "Europe/Volgograd": "RU",
"Europe/Warsaw": "PL", "Europe/Zagreb": "HR", "Europe/Zaporozhye": "UA",
"Europe/Zurich": "CH", "GB": "GB", "GB-Eire": "GB", "HST": "US",
"Hongkong": "HK", "Iceland": "BF", "Indian/Antananarivo": "MG",
"Indian/Chagos": "IO", "Indian/Christmas": "CX", "Indian/Cocos": "CC",
"Indian/Comoro": "KM", "Indian/Kerguelen": "TF", "Indian/Mahe": "SC",
"Indian/Maldives": "MV", "Indian/Mauritius": "MU", "Indian/Mayotte": "YT",
"Indian/Reunion": "RE", "Iran": "IR", "Israel": "IL", "Jamaica": "JM",
"Japan": "JP", "Kwajalein": "MH", "Libya": "LY", "MET": "BE", "MST": "CA",
"MST7MDT": "US", "Mexico/BajaNorte": "MX", "Mexico/BajaSur": "MX",
"Mexico/General": "MX", "NZ": "AQ", "NZ-CHAT": "NZ", "Navajo": "US",
"PRC": "CN", "PST8PDT": "US", "Pacific/Apia": "WS",
"Pacific/Auckland": "NZ", "Pacific/Bougainville": "PG",
"Pacific/Chatham": "NZ", "Pacific/Chuuk": "FM", "Pacific/Easter": "CL",
"Pacific/Efate": "VU", "Pacific/Enderbury": "KI", "Pacific/Fakaofo": "TK",
"Pacific/Fiji": "FJ", "Pacific/Funafuti": "TV", "Pacific/Galapagos": "EC",
"Pacific/Gambier": "PF", "Pacific/Guadalcanal": "SB",
"Pacific/Guam": "GU", "Pacific/Honolulu": "US", "Pacific/Johnston": "US",
"Pacific/Kanton": "KI", "Pacific/Kiritimati": "KI",
"Pacific/Kosrae": "FM", "Pacific/Kwajalein": "MH", "Pacific/Majuro": "MH",
"Pacific/Marquesas": "PF", "Pacific/Midway": "UM", "Pacific/Nauru": "NR",
"Pacific/Niue": "NU", "Pacific/Norfolk": "NF", "Pacific/Noumea": "NC",
"Pacific/Pago_Pago": "AS", "Pacific/Palau": "PW",
"Pacific/Pitcairn": "PN", "Pacific/Pohnpei": "FM", "Pacific/Ponape": "FM",
"Pacific/Port_Moresby": "PG", "Pacific/Rarotonga": "CK",
"Pacific/Saipan": "MP", "Pacific/Samoa": "AS", "Pacific/Tahiti": "PF",
"Pacific/Tarawa": "KI", "Pacific/Tongatapu": "TO", "Pacific/Truk": "AQ",
"Pacific/Wake": "UM", "Pacific/Wallis": "WF", "Pacific/Yap": "AQ",
"Poland": "PL", "Portugal": "PT", "ROC": "TW", "ROK": "KR",
"Singapore": "MY", "Turkey": "TR", "US/Alaska": "US", "US/Aleutian": "US",
"US/Arizona": "CA", "US/Central": "US", "US/East-Indiana": "US",
"US/Eastern": "US", "US/Hawaii": "US", "US/Indiana-Starke": "US",
"US/Michigan": "US", "US/Mountain": "US", "US/Pacific": "US",
"US/Samoa": "AS", "W-SU": "RU", "WET": "PT"
}
var FALLBACK = "en"
// Offset-only zones such as Etc/GMT+5 have no country and get the fallback.
function languageFor(zoneId) {
var id = String(zoneId)
if (OVERRIDES[id] && LANGUAGES[OVERRIDES[id]]) return OVERRIDES[id]
// countryFor, not ZONE_COUNTRY, so the alias corrections apply.
var key = COUNTRIES[countryFor(id)]
return (key && LANGUAGES[key]) ? key : FALLBACK
}
// { text, roman, language, key } for a zone at a local hour.
function greeting(zoneId, hour) {
var key = languageFor(zoneId)
var bands = LANGUAGES[key].bands
var h = Math.max(0, Math.min(23, Math.floor(Number(hour))))
if (!isFinite(h)) h = 0
var band = bands[0]
for (var i = 1; i < bands.length; i++) {
if (bands[i].from > h) break
band = bands[i]
}
return { text: band.text, roman: band.roman, language: LANGUAGES[key].name, key: key }
}
function languageKeys() {
var out = []
for (var key in LANGUAGES) out.push(key)
return out
}
function zoneIds() {
var out = []
for (var id in ZONE_COUNTRY) out.push(id)
return out
}
// "" for the offset-only zones.
function countryFor(zoneId) {
var id = String(zoneId)
return ALIAS_COUNTRY[id] || ZONE_COUNTRY[id] || ""
}
function countryCodes() {
var out = []
for (var code in COUNTRIES) out.push(code)
return out
}
function bandsOf(key) { return LANGUAGES[key].bands }
if (typeof module !== "undefined") {
module.exports = {
greeting: greeting,
languageFor: languageFor,
countryFor: countryFor,
languageKeys: languageKeys,
zoneIds: zoneIds,
countryCodes: countryCodes,
bandsOf: bandsOf
}
}
+164
View File
@@ -0,0 +1,164 @@
import QtQuick
import qs.Commons
import "GlobeModel.js" as Solar
// "World [globe] Clock" under an arched caption. The little globe is the door
// into globe mode and, once the big globe has left it, the cross back out.
Column {
id: hero
property string caption: ""
property color captionColor: dim
property bool captionClickable: false
property color foreground: Color.foreground
property color dim: Qt.darker(foreground, 1.55)
property string fontFamily: Style.font.family
// 0 is the list, 1 is the globe filling the panel.
property real zoom: 0
property bool globeEnabled: true
property real bigGlobeOpacity: 0
// Where the little globe rests, and the city it marks.
property real restLon: 0
property bool markerShown: false
property real markerLat: 0
property real markerLon: 0
readonly property Item icon: heroIcon
readonly property bool iconHovered: heroMouse.containsMouse
readonly property real spin: heroIcon.spin
signal captionClicked()
signal globeClicked(bool slow)
// Three turns that land on `lon`; null replays the last endpoints.
function spinOpening(lon) {
globeSpin.stop()
if (lon !== null) {
globeSpin.from = lon - 1080
globeSpin.to = lon
}
globeSpin.restart()
}
// `from` is passed in: once the focus changes, the rest binding has already moved spin.
function turn(from, lon) {
globeSpin.stop()
focusSpin.from = from
focusSpin.to = from + Solar.shortestTurn(from, lon)
focusSpin.restart()
}
spacing: Style.space(2)
ArcText {
width: parent.width
text: hero.caption
rise: Style.space(6)
color: hero.captionColor
fontFamily: hero.fontFamily
pixelSize: Style.font.caption
MouseArea {
anchors.fill: parent
enabled: hero.captionClickable
cursorShape: Qt.PointingHandCursor
onClicked: hero.captionClicked()
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(9)
TitleWord { text: "World" }
Item {
id: heroIcon
anchors.verticalCenter: parent.verticalCenter
implicitWidth: Math.round(Style.font.display * 1.3)
implicitHeight: Math.round(Style.font.display * 1.3)
// Degrees of longitude facing the viewer; the globe draws the spin itself.
property real spin: 0
Binding {
target: heroIcon
property: "spin"
value: hero.restLon
when: !globeSpin.running && !focusSpin.running
restoreMode: Binding.RestoreNone
}
rotation: Solar.AXIAL_TILT
Text {
anchors.centerIn: parent
textFormat: Text.PlainText
text: "\u00d7"
color: heroMouse.containsMouse ? hero.foreground : hero.dim
font.family: hero.fontFamily
font.pixelSize: Math.round(heroIcon.width * 0.8)
// The globe leans with the earth; the cross stays upright.
rotation: -Solar.AXIAL_TILT
opacity: Util.clamp((hero.zoom - 0.2) / 0.35, 0, 1)
visible: opacity > 0
}
MiniGlobe {
anchors.fill: parent
opacity: 1 - hero.bigGlobeOpacity
visible: opacity > 0
spin: heroIcon.spin
// Full strength: at this size a dimmed globe reads as washed out.
color: hero.foreground
bold: true
showMarker: hero.markerShown
markerLat: hero.markerLat
markerLon: hero.markerLon
}
// A MouseArea rather than a TapHandler: only mouse events carry the
// Shift modifier that asks for slow motion.
MouseArea {
id: heroMouse
anchors.fill: parent
enabled: hero.globeEnabled
hoverEnabled: true
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) {
hero.globeClicked((mouse.modifiers & Qt.ShiftModifier) !== 0)
}
}
NumberAnimation {
id: globeSpin
target: heroIcon
property: "spin"
duration: 1250
easing.type: Easing.OutQuart
}
NumberAnimation {
id: focusSpin
target: heroIcon
property: "spin"
duration: 700
easing.type: Easing.OutCubic
}
}
TitleWord { text: "Clock" }
}
component TitleWord: Text {
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
color: hero.foreground
font.family: hero.fontFamily
font.pixelSize: Style.font.title
font.weight: Font.DemiBold
}
}
+141
View File
@@ -0,0 +1,141 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import "GlobeModel.js" as Solar
import "Model.js" as Model
// A small drawn globe for where an icon would go. Drawn, not glyphed, so it
// can spin: `spin` is the longitude facing the viewer; rotate the item to lean it.
Item {
id: root
property real spin: 0
property color color: Color.foreground
// Below this size land turns to noise; draw the graticule alone.
readonly property bool showLand: width >= 22
property var land: []
// Heavier strokes and fuller fills, for a centerpiece rather than an inline icon.
property bool bold: false
// "You are here".
property bool showMarker: false
property real markerLat: 0
property real markerLon: 0
property color markerColor: Color.accent
readonly property real radius: Math.min(width, height) / 2 - 1
readonly property string pluginDir: Quickshell.env("OMARCHY_PATH") + "/shell/plugins/panels/elsewhen"
onSpinChanged: canvas.requestPaint()
onColorChanged: canvas.requestPaint()
onShowMarkerChanged: canvas.requestPaint()
onMarkerLatChanged: canvas.requestPaint()
onMarkerLonChanged: canvas.requestPaint()
FileView {
path: root.pluginDir + "/world.json"
printErrors: false
onLoaded: {
try { root.land = JSON.parse(text()); canvas.requestPaint() } catch (e) { }
}
}
Canvas {
id: canvas
anchors.fill: parent
renderStrategy: Canvas.Cooperative
function strokePath(ctx, pts) {
var segs = Solar.visibleSegments(pts, root.spin, 0, root.radius)
for (var i = 0; i < segs.length; i++) {
ctx.moveTo(segs[i][0].x, segs[i][0].y)
for (var j = 1; j < segs[i].length; j++) ctx.lineTo(segs[i][j].x, segs[i][j].y)
}
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.translate(width / 2, height / 2)
var r = root.radius
if (r <= 0) return
var c = root.color
var lat, lon, pts, i
// The ocean, opaque like the large globe.
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = Model.mix(Color.popups.background, c, root.bold ? 0.16 : 0.13)
ctx.fill()
ctx.save()
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.clip()
// Graticule first, so the land sits on top of it.
ctx.beginPath()
for (lon = -90; lon < 90; lon += 90) {
pts = []
for (lat = -90; lat <= 90; lat += 6) pts.push([lat, lon])
strokePath(ctx, pts)
}
pts = []
for (lon = -180; lon <= 180; lon += 6) pts.push([0, lon])
strokePath(ctx, pts)
ctx.lineWidth = Math.max(1, r * (root.bold ? 0.055 : 0.045))
ctx.strokeStyle = Util.alpha(c, root.showLand ? (root.bold ? 0.42 : 0.30) : 0.85)
ctx.stroke()
if (root.showLand && root.land.length > 0) {
ctx.beginPath()
for (i = 0; i < root.land.length; i++) {
var ring = root.land[i]
// Major landmasses only; islands read as dirt on the lens.
if (ring.length < 40) continue
var poly = Solar.clipRingToDisc(ring, root.spin, 0, root.radius)
if (poly.length < 3) continue
ctx.moveTo(poly[0].x, poly[0].y)
for (var q = 1; q < poly.length; q++) ctx.lineTo(poly[q].x, poly[q].y)
ctx.closePath()
}
ctx.fillStyle = Util.alpha(c, root.bold ? 1.0 : 0.85)
ctx.fill()
}
// "You are here", only while on the near side.
if (root.showMarker) {
var mp = Solar.project(root.markerLat, root.markerLon, root.spin, 0, r)
if (mp.visible) {
var mr = Math.max(1.6, r * 0.13)
ctx.beginPath()
ctx.arc(mp.x, mp.y, mr, 0, Math.PI * 2)
ctx.fillStyle = root.markerColor
ctx.fill()
// Edged so it does not dissolve into a continent of similar lightness.
ctx.lineWidth = Math.max(1, r * 0.04)
ctx.strokeStyle = Util.alpha(Color.background, 0.5)
ctx.stroke()
ctx.beginPath()
ctx.arc(mp.x, mp.y, mr * 1.9, 0, Math.PI * 2)
ctx.lineWidth = Math.max(1, r * 0.045)
ctx.strokeStyle = Util.alpha(root.markerColor, 0.65)
ctx.stroke()
}
}
ctx.restore()
// The rim last, so nothing spills over it.
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.lineWidth = Math.max(1, r * (root.bold ? 0.095 : 0.08))
ctx.strokeStyle = Util.alpha(c, root.bold ? 1.0 : 0.95)
ctx.stroke()
}
}
}
+925
View File
@@ -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
}
}
+47
View File
@@ -0,0 +1,47 @@
import QtQuick
import qs.Commons
import "GlobeModel.js" as Solar
// The night marker drawn as the moon's current phase. The faint full disc
// keeps it findable at new moon; the lit part is filled solid on top.
Item {
id: root
property real phase: 0 // 0 new, 0.25 first quarter, 0.5 full
property color color: Color.foreground
onPhaseChanged: canvas.requestPaint()
onColorChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
renderStrategy: Canvas.Cooperative
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var r = Math.min(width, height) / 2
if (r <= 0) return
ctx.translate(width / 2, height / 2)
var c = root.color
// The whole disc, faint.
ctx.beginPath()
ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.fillStyle = Util.alpha(c, 0.22)
ctx.fill()
// The lit part, solid.
var lit = Solar.moonLitOutline(root.phase, r, 28)
if (lit.length > 2) {
ctx.beginPath()
ctx.moveTo(lit[0].x, lit[0].y)
for (var i = 1; i < lit.length; i++) ctx.lineTo(lit[i].x, lit[i].y)
ctx.closePath()
ctx.fillStyle = c
ctx.fill()
}
}
}
}
+888
View File
@@ -0,0 +1,888 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
import "GlobeModel.js" as Solar
// A bar button that opens a panel of clocks, one row per city, and zooms into
// a globe. QML has no Intl, so a `date` probe supplies each zone's offset and
// the rows tick locally against it; the probe re-runs on open and every five
// minutes to catch DST.
Panel {
id: root
moduleName: "omarchy.elsewhen"
ipcTarget: "omarchy.elsewhen"
manageIpc: false
// ---- settings
readonly property var zones: Model.parseZones(setting("zones", ""))
readonly property var zoneIds: zones.map(function(z) { return z.id })
// Never written: the first probe seeds the list around the local zone.
readonly property bool needsSeed: String(setting("zones", "")).trim() === ""
readonly property var probeIds: needsSeed ? zoneIds.concat(Model.seedCandidateZones()) : zoneIds
readonly property bool autoHour24: Model.usesTwentyFourHour(Qt.locale().timeFormat(Locale.ShortFormat))
readonly property bool hour24: Model.resolveHour24(setting("hour24", ""), autoHour24)
// "home" shows the distance from here ("+2h"), "utc" the zone itself ("UTC-5").
readonly property string offsetMode: setting("offsetMode", "home") === "utc" ? "utc" : "home"
// Only the US measurement system means Fahrenheit.
readonly property string autoUnits: Qt.locale().measurementSystem === Locale.ImperialUSSystem ? "F" : "C"
readonly property string units: Model.resolveUnits(setting("units", ""), autoUnits)
readonly property bool globeEnabled: setting("globeEnabled", true) !== false
// Draw the globe with less in it while it moves.
readonly property bool smoothMotion: setting("smoothMotion", true) !== false
function toggleHour24() { persistSettings({ hour24: !hour24 }) }
function toggleOffsetMode() { persistSettings({ offsetMode: offsetMode === "utc" ? "home" : "utc" }) }
function toggleUnits() { persistSettings({ units: units === "C" ? "F" : "C" }) }
function offsetTextFor(rowData) {
if (!rowData || !rowData.ready) return ""
return offsetMode === "utc" ? Model.utcOffsetLabel(rowData.offsetMinutes) : rowData.relative
}
// ---- state
property var probe: ({})
property var searchProbe: ({})
property string zoneCatalogText: ""
property var facts: ({})
// Geocoder answers are final for the session; fallbacks are retried.
property var geo: ({})
property var fallbackGeo: ({})
property var weather: ({})
// Cities jumped to on the globe; never persisted.
property var sessionCities: []
property int localOffsetMinutes: -(new Date().getTimezoneOffset())
property string localZone: ""
property double nowMs: Date.now()
property bool probeQueued: false
property bool searchProbeQueued: false
property bool factsQueued: false
readonly property bool adding: citySearch.active
onAddingChanged: if (!adding) scroller.scrollToTop()
readonly property var zoneOptions: Model.zoneOptions(zoneCatalogText, zones)
// The globe's jump box may go to a city that is already tracked.
readonly property var allZoneOptions: Model.zoneOptions(zoneCatalogText, [])
readonly property var searchZoneIds: {
var out = []
for (var i = 0; i < citySearch.matches.length; i++) {
var id = citySearch.matches[i].value
if (out.indexOf(id) < 0) out.push(id)
}
return out
}
onSearchZoneIdsChanged: probeSearchZones()
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property color fainter: Qt.darker(foreground, 2.1)
// A literal gold: several themes' "yellow" is not yellow.
readonly property color daylightMarker: "#E5C736"
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
// ---- the zoom
// One number drives the list-to-globe transition: 0 is the list, 1 the globe.
property bool globeMode: false
property real zoom: globeMode ? 1 : 0
readonly property bool zoomIdle: zoom === 0 || zoom === 1
readonly property real bigGlobeOpacity: Util.clamp(zoom / 0.12, 0, 1)
readonly property real globeStageHeight: Style.space(378)
// Far enough for the top row to fall past the whole list.
readonly property real knockFall: listWrap.implicitHeight + Style.space(220)
// Shift-click the globe to run the transition at a third of the speed.
readonly property int slowMotionFactor: 3
// Set before globeMode flips, so the animation never reads a stale duration.
property int zoomDuration: 800
property int zoomEasing: Easing.OutQuart
function setGlobeMode(on, slow) {
zoomDuration = (on ? 800 : 500) * (slow === true ? slowMotionFactor : 1)
zoomEasing = on ? Easing.OutQuart : Easing.InOutCubic
globeMode = on
if (on && globeLoader.item) showFocusOnGlobe()
}
// The globe opens on the list's focus, when its coordinates are known.
function showFocusOnGlobe() {
var g = globeLoader.item
if (!g) return
if (focusIndex >= 0 && focusKnown) g.goTo(focusZone.label, focusZone.id)
else g.showHome()
}
function focusFromGlobe(label, zone) {
var i = Model.indexOfZone(zones, label, zone)
if (i >= 0) focusOn(i)
}
// The little globe's center in stage coordinates, where the big one grows
// from. The unused sum makes it recompute when the layout above moves.
readonly property var heroCenter: {
var _ = hero.icon.width + hero.icon.height + stage.y + stage.width
var pt = hero.icon.mapToItem(stage, hero.icon.width / 2, hero.icon.height / 2)
return (pt && pt.x !== undefined) ? pt : null
}
readonly property real heroCenterX: heroCenter ? heroCenter.x : stage.width / 2
readonly property real heroCenterY: heroCenter ? heroCenter.y : 0
readonly property real heroDiscRadius: Math.max(1, hero.icon.width / 2 - 1)
// ---- drag to reorder
// Rows move by transform while dragging; the order is committed on drop.
property int dragIndex: -1
property real dragOffset: 0
property real rowPitch: 0
property int dragTarget: -1
function beginRowDrag(index, pitch) {
dropAnimation.stop()
dragIndex = index
dragTarget = index
rowPitch = pitch
}
function moveRowDrag(offset) {
dragOffset = offset
dragTarget = Model.dragTargetFor(dragIndex, dragOffset, rowPitch, dragTarget, zones.length)
}
// Glide into the slot, then commit.
function releaseRowDrag() {
if (dragIndex < 0) { cancelRowDrag(); return }
dropAnimation.to = (dragTarget - dragIndex) * rowPitch
dropAnimation.restart()
}
function commitRowDrag() {
if (dragIndex >= 0 && dragTarget >= 0 && dragTarget !== dragIndex)
persistSettings({ zones: Model.serializeZones(Model.moveZone(zones, dragIndex, dragTarget)) })
cancelRowDrag()
}
function cancelRowDrag() {
dropAnimation.stop()
dragIndex = -1
dragTarget = -1
dragOffset = 0
}
// ---- scrubbing
// Set absolutely from the pointer, held briefly after release, then back to now.
property real scrubMinutes: 0
readonly property double effectiveMs: nowMs + scrubMinutes * 60000
readonly property string scrubLabel: Model.formatScrubDelta(scrubMinutes)
// Measured against the unscrubbed present, so a drag cannot drift.
function scrubTo(rowData, fraction) {
if (!rowData || !rowData.ready) return
var parts = Model.zoneParts(nowMs, rowData.offsetMinutes)
scrubMinutes = Model.scrubDeltaMinutes(fraction, parts.hour * 60 + parts.minute)
}
function beginScrub(rowData, fraction) {
scrubHold.stop()
scrubTo(rowData, fraction)
}
function endScrub() { scrubHold.restart() }
// ---- the moon
// Shift-click a moon marker to walk the phase through a lunation.
property bool moonShowing: false
property real moonDemo: -1
readonly property var moonShowStops: [0, 0.12, 0.25, 0.38, 0.5, 0.62, 0.75, 0.88, 1]
property int moonShowStep: 0
readonly property real moonPhase: moonShowing && moonDemo >= 0 ? moonDemo : Solar.moonPhase(effectiveMs)
function startMoonShow() {
moonShowStep = 0
moonShowing = true
moonDemo = moonShowStops[0]
moonShowTimer.restart()
}
function stopMoonShow() {
moonShowTimer.stop()
// Before the value, so the tween does not run the phase back to -1.
moonShowing = false
moonDemo = -1
}
// ---- places
readonly property var clockRows: Model.rows(zones, probe, effectiveMs, localOffsetMinutes, hour24)
// The system zone names a representative city; `homeCity` overrides it.
readonly property string homeCity: {
var override = String(setting("homeCity", "")).trim()
if (override !== "") return override
return localZone === "" ? "" : Model.labelForZoneId(localZone)
}
readonly property var homeZone: ({ label: homeCity, id: localZone })
function placeOf(zone) {
var f = facts[Model.factsKey(zone)]
return (f && f.lat !== undefined && f.lon !== undefined) ? f : null
}
readonly property var homePlace: homeCity === "" || localZone === "" ? null : placeOf(homeZone)
// Matched by name: Miami should not light up New York.
readonly property var trackedNames: zones.map(function(z) { return String(z.label).toLowerCase() })
function globeRows(list, rank) {
var out = []
for (var i = 0; i < list.length; i++) {
var f = placeOf(list[i])
if (f) out.push([list[i].label, list[i].id, f.lat, f.lon, rank])
}
return out
}
readonly property var trackedCities: globeRows(zones, 0)
readonly property var sessionPlaces: globeRows(sessionCities, 1)
// Held as the city's key, not a row index, so a reorder carries the focus
// with the city and a removal drops it back to home.
property string focusKey: ""
readonly property int focusIndex: Model.indexOfZoneKey(zones, focusKey)
readonly property var focusZone: focusIndex >= 0 ? zones[focusIndex] : homeZone
readonly property var focusPlace: placeOf(focusZone)
readonly property bool focusKnown: focusPlace !== null
readonly property real focusLat: focusKnown ? focusPlace.lat : 0
readonly property real focusLon: focusKnown ? focusPlace.lon : 0
function focusOn(index) {
var from = hero.spin
focusKey = index >= 0 && index < zones.length ? Model.factsKey(zones[index]) : ""
if (focusKnown) hero.turn(from, focusLon)
}
// Surrogate pairs rather than literal glyphs, which re-encoding can break.
function weatherGlyph(zone) {
var f = facts[Model.factsKey(zone)]
if (!f || f.w === undefined) return ""
var kind = Model.weatherKind(f.w)
if (kind === "sunny") return "\udb81\udda8" // white-balance-sunny: a solid disc
if (kind === "partly") return "\udb81\udd95"
if (kind === "cloudy") return "\udb81\udd90"
if (kind === "rain") return "\udb81\udd97"
if (kind === "snow") return "\udb81\udd98"
return ""
}
readonly property string localTime: {
var here = Model.localParts(effectiveMs)
var text = Model.formatTime(here, hour24)
return hour24 ? text : text + " " + Model.meridiem(here)
}
// No full stop: a period hanging off the arc reads as a speck.
readonly property string hereLine: "It's " + localTime + " here"
+ (homeCity !== "" ? " in " + homeCity : "")
+ (scrubLabel !== "" ? " " + scrubLabel : "")
// A capital's line box has empty space above it; the row's top pad drops by it.
FontMetrics {
id: nameFontMetrics
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
TextMetrics {
id: nameCapMetrics
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
text: "M"
}
readonly property real capGap: Math.max(0,
nameFontMetrics.height - nameFontMetrics.descent + nameCapMetrics.tightBoundingRect.y)
function tick() {
nowMs = Date.now()
localOffsetMinutes = -(new Date().getTimezoneOffset())
}
// Written straight back to this widget's shell.json entry, like the clock's.
function persistSettings(values) {
var entry = { id: root.moduleName }
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
for (var key in values) entry[key] = values[key]
root.settings = entry
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
root.bar.shell.updateEntryInline(root.moduleName, entry)
}
function setZones(next) {
if (next === zones) return
persistSettings({ zones: Model.serializeZones(next) })
refresh()
refreshFacts()
}
// The label matters as much as the zone: many cities share one.
function addCity(id, label) { setZones(Model.addZone(zones, id, label || "")) }
function removeCity(id) { setZones(Model.removeZone(zones, id)) }
function removeCityAt(index) { setZones(Model.removeZoneAt(zones, index)) }
function startAdding() { citySearch.start() }
function addSessionCity(label, id) {
for (var i = 0; i < sessionCities.length; i++)
if (sessionCities[i].label === label && sessionCities[i].id === id) return
sessionCities = sessionCities.concat([{ label: label, id: id }])
refreshFacts()
}
// The local city plus four places spread round the clock from it.
function seedFirstRun() {
var offs = ({})
for (var id in probe) offs[id] = probe[id].offsetMinutes
var seeded = localZone === "" ? []
: Model.seedZones({ label: Model.labelForZoneId(localZone), id: localZone }, offs, 4)
if (seeded.length === 0) seeded = Model.parseZones(Model.DEFAULT_ZONES)
if (seeded.length === 0) return
persistSettings({ zones: Model.serializeZones(seeded) })
refresh()
refreshFacts()
}
// ---- processes
// Each refresh queues behind one in flight rather than being dropped.
function refresh() {
if (probeIds.length === 0) return
if (probeProc.running) { probeQueued = true; return }
probeProc.running = true
}
function probeSearchZones() {
if (searchZoneIds.length === 0) return
if (searchProc.running) { searchProbeQueued = true; return }
searchProc.running = true
}
function refreshFacts() {
if (zones.length === 0) return
if (geoProc.running || weatherProc.running) { factsQueued = true; return }
var missing = factsRequest.filter(function(row) { return !geo[Model.factsKey(row)] })
if (missing.length === 0) { fetchWeather(); return }
geoProc.rows = missing
geoProc.command = ["bash", "-c", 'for url; do curl -fsS --max-time 8 "$url"; printf "\\n\\036\\n"; done', "bash"]
.concat(missing.map(function(row) { return Model.geocodeUrl(row.label) }))
geoProc.running = true
}
function coordsFor(key) {
return geo[key] || fallbackGeo[key] || null
}
function fetchWeather() {
var now = Date.now()
var stale = factsRequest.map(Model.factsKey).filter(function(key) {
return coordsFor(key) && Model.weatherStale(weather[key], now)
})
if (stale.length === 0) { publishFacts(); return }
weatherProc.keys = stale
weatherProc.command = ["curl", "-fsS", "--max-time", "8", Model.forecastUrl(stale.map(coordsFor))]
weatherProc.running = true
}
function publishFacts() {
var coords = {}
var keys = factsRequest.map(Model.factsKey)
keys.forEach(function(key) { coords[key] = coordsFor(key) })
facts = Model.mergeFacts(keys, coords, weather)
if (!factsQueued) return
factsQueued = false
Qt.callLater(refreshFacts)
}
function loadCatalog() {
if (zoneCatalogText !== "" || catalogProc.running) return
catalogProc.running = true
}
function utcLabelFor(zoneId) {
var known = searchProbe[zoneId]
return known === undefined ? "" : Model.utcOffsetLabel(known.offsetMinutes)
}
// Tracked cities, the city you are in (for the globe's home) and session cities.
readonly property var factsRequest: {
var out = zones.map(function(z) { return { label: z.label, id: z.id } })
if (localZone !== "" && homeCity !== "") out.push({ label: homeCity, id: localZone })
return out.concat(sessionCities)
}
// ---- opening spin
// It lands on the home meridian, so it waits for home's coordinates, read
// straight from the facts: focusKnown can flip a beat before focusLon lands.
property bool spinPending: false
function homeMeridian() {
var f = facts[Model.factsKey(focusZone)]
return (f && f.lon !== undefined && f.lon !== null) ? f.lon : null
}
function startOpeningSpin() {
var lon = homeMeridian()
if (lon === null) { spinPending = true; spinFallback.restart(); return }
spinPending = false
spinFallback.stop()
hero.spinOpening(lon)
}
onFactsChanged: if (spinPending) startOpeningSpin()
// No coordinates on a cold cache without network: spin anyway.
Timer {
id: spinFallback
interval: 1500
onTriggered: if (root.spinPending) { root.spinPending = false; hero.spinOpening(null) }
}
onOpenedChanged: {
if (opened) {
focusKey = ""
tick(); refresh(); refreshFacts(); loadCatalog()
startOpeningSpin()
} else {
citySearch.stop(); setGlobeMode(false, false); scrubMinutes = 0
spinPending = false; spinFallback.stop()
}
}
Component.onCompleted: refresh()
Process {
id: probeProc
command: Model.probeCommand(root.probeIds, true)
stdout: StdioCollector {
onStreamFinished: {
root.probe = Model.parseProbe(text)
var lz = Model.localZoneFromProbe(text)
if (lz !== "") root.localZone = lz
if (root.needsSeed) root.seedFirstRun()
root.tick()
Qt.callLater(function() {
if (!root.probeQueued) return
root.probeQueued = false
root.refresh()
})
}
}
}
// Merged, so a known offset stays on screen while the next probe runs.
Process {
id: searchProc
command: Model.probeCommand(root.searchZoneIds, false)
stdout: StdioCollector {
onStreamFinished: {
var merged = {}
for (var known in root.searchProbe) merged[known] = root.searchProbe[known]
var fresh = Model.parseProbe(text)
for (var id in fresh) merged[id] = fresh[id]
root.searchProbe = merged
Qt.callLater(function() {
if (!root.searchProbeQueued) return
root.searchProbeQueued = false
root.probeSearchZones()
})
}
}
}
Process {
id: catalogProc
command: ["timedatectl", "list-timezones"]
stdout: StdioCollector {
onStreamFinished: root.zoneCatalogText = text
}
}
FileView {
id: zoneTab
path: "/usr/share/zoneinfo/zone1970.tab"
printErrors: false
}
Process {
id: geoProc
property var rows: []
stdout: StdioCollector {
onStreamFinished: {
var answers = text.split("\n\u001e\n")
var nextGeo = Object.assign({}, root.geo)
var nextFallback = {}
geoProc.rows.forEach(function(row, i) {
var key = Model.factsKey(row)
var found = Model.pickGeocode(answers[i] || "", row.id)
var place = found.place || Model.zoneTabCoords(zoneTab.text(), row.id)
if (!place) return
if (found.answered) nextGeo[key] = place
else nextFallback[key] = place
})
root.geo = nextGeo
root.fallbackGeo = nextFallback
root.fetchWeather()
}
}
}
Process {
id: weatherProc
property var keys: []
stdout: StdioCollector {
onStreamFinished: {
root.weather = Object.assign({}, root.weather, Model.parseForecast(text, weatherProc.keys, Date.now()))
root.publishFacts()
}
}
}
Timer {
interval: 900000
running: root.opened
repeat: true
onTriggered: root.refreshFacts()
}
// Heavy on the way out, brisk on the way back.
Behavior on zoom {
NumberAnimation {
duration: root.zoomDuration
easing.type: root.zoomEasing
}
}
Timer {
id: moonShowTimer
interval: 620
repeat: true
onTriggered: {
root.moonShowStep++
if (root.moonShowStep >= root.moonShowStops.length) root.stopMoonShow()
else root.moonDemo = root.moonShowStops[root.moonShowStep]
}
}
Behavior on moonDemo {
enabled: root.moonShowing
NumberAnimation { duration: 460; easing.type: Easing.InOutSine }
}
NumberAnimation {
id: dropAnimation
target: root
property: "dragOffset"
duration: 150
easing.type: Easing.OutCubic
onFinished: root.commitRowDrag()
}
Timer {
id: scrubHold
interval: 2500
onTriggered: root.scrubMinutes = 0
}
Timer {
interval: 1000
running: root.opened
repeat: true
onTriggered: root.tick()
}
Timer {
interval: 300000
running: root.opened
repeat: true
onTriggered: root.refresh()
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { root.refresh(); return "ok" }
function globe(): string {
if (!root.globeEnabled) return "disabled"
root.setGlobeMode(!root.globeMode, false)
return root.globeMode ? "globe" : "list"
}
// Ticks first: the clock only runs while the panel is open.
function times(): string {
root.tick()
return JSON.stringify(root.clockRows)
}
function add(zone: string, label: string): string {
root.setZones(Model.addZone(root.zones, zone, label))
return Model.serializeZones(root.zones)
}
function remove(zone: string): string { root.removeCity(zone); return Model.serializeZones(root.zones) }
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: "󰇧"
tooltipText: "World clock"
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton || buttonCode === Qt.MiddleButton) root.refresh()
else root.toggle()
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(340))
contentHeight: panel.fittedContentHeight(content.implicitHeight, Style.space(680))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: root.adding
// Escape unwinds one layer: search (its own field), globe, then panel.
onCloseRequested: {
if (root.globeMode) root.setGlobeMode(false, false)
else root.close()
}
// Space toggles the globe. Return also arrives as an activate, which it skips.
property bool returnHandled: false
onReturnRequested: returnHandled = true
onActivateRequested: {
if (returnHandled) { returnHandled = false; return }
if (root.globeEnabled) root.setGlobeMode(!root.globeMode, false)
}
onTabRequested: function(direction) { root.switchPanel(direction) }
// "+" searches in either view; "j" jumps on the globe, "a" adds on the list.
onTextKey: function(text) {
var key = text.toLowerCase()
if (key === "r") root.refresh()
else if (root.globeMode && (key === "+" || key === "j")) {
if (globeLoader.item) globeLoader.item.startJump()
} else if (!root.globeMode && (key === "+" || key === "a")) {
root.startAdding()
}
}
// Clipped to the card and scrolled by the wheel only: the rows own drags.
Flickable {
id: scroller
anchors.fill: parent
clip: true
interactive: false
contentWidth: width
contentHeight: content.implicitHeight
boundsBehavior: Flickable.StopAtBounds
readonly property real maxScroll: Math.max(0, contentHeight - height)
function clamp() { contentY = Util.clamp(contentY, 0, maxScroll) }
onHeightChanged: clamp()
onContentHeightChanged: clamp()
function scrollTo(y) {
scrollAnim.stop()
scrollAnim.from = contentY
scrollAnim.to = y
scrollAnim.start()
}
function scrollToTop() { scrollTo(0) }
// Follows maxScroll: the results arrive over several frames after the
// search opens, so a one-shot scroll lands short.
onMaxScrollChanged: if (root.adding) scrollTo(maxScroll)
// Not a Behavior: the wheel writes contentY too and must track directly.
NumberAnimation {
id: scrollAnim
target: scroller
property: "contentY"
duration: 160
easing.type: Easing.OutCubic
}
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(event) {
var d = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 120 * Style.space(40)
scroller.contentY -= d
scroller.clamp()
}
}
Column {
id: content
width: scroller.width
spacing: Style.spacing.panelGap
HeroTitle {
id: hero
width: parent.width
caption: root.hereLine
// Accent while scrubbed, so a shifted time is never taken for now.
captionColor: root.scrubMinutes !== 0 ? Color.accent : root.dim
captionClickable: root.focusIndex >= 0
foreground: root.foreground
dim: root.dim
fontFamily: root.fontFamily
zoom: root.zoom
globeEnabled: root.globeEnabled
bigGlobeOpacity: root.bigGlobeOpacity
restLon: root.focusLon
markerShown: root.focusKnown
markerLat: root.focusLat
markerLon: root.focusLon
onCaptionClicked: root.focusOn(-1)
onGlobeClicked: function(slow) { root.setGlobeMode(!root.globeMode, slow) }
}
// ---- stage
// The list and the globe share it; it grows with the zoom. Not
// clipped, so the globe can start from the header above it.
Item {
id: stage
width: parent.width
height: Math.max(1, listWrap.implicitHeight
+ (root.globeStageHeight - listWrap.implicitHeight) * root.zoom)
// Clipped, so knocked rows fall out of the panel.
Item {
anchors.fill: parent
clip: true
Column {
id: listWrap
width: parent.width
spacing: Style.spacing.panelGap
Column {
visible: root.zoom < 1
width: parent.width
spacing: Style.spacing.md
// The zone list, not clockRows: that ticks, and would
// rebuild every delegate mid-drag.
Repeater {
model: root.zones
CityRow { panel: root }
}
}
CitySearch {
id: citySearch
visible: root.zoom < 1
width: parent.width
options: root.zoneOptions
loading: root.zoneCatalogText === ""
offsetLabel: function(zoneId) { return root.utcLabelFor(zoneId) }
foreground: root.foreground
dim: root.dim
fainter: root.fainter
fontFamily: root.fontFamily
onActiveChanged: if (active) root.loadCatalog()
onPicked: function(label, id) { root.addCity(id, label) }
onDismissed: Qt.callLater(function() { keyCatcher.forceActiveFocus() })
// Knocked aside last, after every row.
transform: Translate {
x: Model.knockX(root.zones.length, root.zoom, Style.space(95))
y: Model.knockY(root.zones.length, root.zoom, root.knockFall)
}
}
}
}
// ---- globe
// Loaded early on hover and kept for the whole flight, so reading
// its data never lands mid-animation.
Loader {
id: globeLoader
active: root.globeEnabled && (root.globeMode || root.zoom > 0 || hero.iconHovered)
visible: root.zoom > 0
opacity: root.bigGlobeOpacity
width: parent.width
height: root.globeStageHeight
source: "Globe.qml"
// The disc's center, above the globe's footer and jump bar.
readonly property real discCX: width / 2
readonly property real discCY: item ? (height - item.footerHeight - item.jumpHeight) / 2 : height / 2
readonly property real discR: item && item.radius > 0 ? item.radius : 1
readonly property real startScale: root.heroDiscRadius / discR
readonly property real zoomScale: startScale + (1 - startScale) * root.zoom
// Scaled about the disc, then carried from the little globe.
transform: [
Scale {
origin.x: globeLoader.discCX
origin.y: globeLoader.discCY
xScale: globeLoader.zoomScale
yScale: globeLoader.zoomScale
},
Translate {
x: (root.heroCenterX - globeLoader.discCX) * (1 - root.zoom)
y: (root.heroCenterY - globeLoader.discCY) * (1 - root.zoom)
}
]
onLoaded: {
item.foreground = Qt.binding(function() { return root.foreground })
item.dim = Qt.binding(function() { return root.dim })
item.fainter = Qt.binding(function() { return root.fainter })
item.daylightMarker = Qt.binding(function() { return root.daylightMarker })
item.moonPhase = Qt.binding(function() { return root.moonPhase })
item.fontFamily = Qt.binding(function() { return root.fontFamily })
item.hour24 = Qt.binding(function() { return root.hour24 })
item.trackedNames = Qt.binding(function() { return root.trackedNames })
item.trackedCities = Qt.binding(function() { return root.trackedCities })
item.sessionCities = Qt.binding(function() { return root.sessionPlaces })
item.homeRow = Qt.binding(function() {
var home = root.homePlace
return home ? [root.homeCity, root.localZone, home.lat, home.lon, 0] : []
})
item.offsetMode = Qt.binding(function() { return root.offsetMode })
item.homeOffsetMinutes = Qt.binding(function() { return root.localOffsetMinutes })
item.smoothMotion = Qt.binding(function() { return root.smoothMotion })
item.transitioning = Qt.binding(function() { return !root.zoomIdle })
item.zoomLevel = Qt.binding(function() { return root.zoom })
// The footer and jump bar arrive once the globe has landed.
item.chromeOpacity = Qt.binding(function() { return Util.clamp((root.zoom - 0.74) / 0.26, 0, 1) })
item.jumpOptions = Qt.binding(function() { return root.allZoneOptions })
item.offsetModeToggleRequested.connect(function() { root.toggleOffsetMode() })
item.jumpRequested.connect(function(label, zone) { root.addSessionCity(label, zone) })
item.exitRequested.connect(function() { root.setGlobeMode(false, false) })
item.jumpDismissed.connect(function() { Qt.callLater(function() { keyCatcher.forceActiveFocus() }) })
item.citySelected.connect(function(label, zone) { root.focusFromGlobe(label, zone) })
// A cold start can finish loading after the mode switched on.
if (root.globeMode) root.showFocusOnGlobe()
}
}
}
}
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
.pragma library
.import "GlobeModel.js" as Solar
// Sunrise and sunset for a place on its own day, from the globe's own
// subsolarPoint so the strip and the terminator cannot disagree.
var DEG = Math.PI / 180
// Refraction plus the sun's radius, the figure published sunrise tables use.
var HORIZON = -0.833
function wrap180(deg) {
return ((deg + 540) % 360) - 180
}
// Local solar noon. The subsolar meridian moves 15 degrees an hour, so three
// corrections take the residual below a second.
function solarNoonMs(lon, nearMs) {
var t = nearMs
for (var i = 0; i < 3; i++) {
var sub = Solar.subsolarPoint(t)
t += wrap180(sub.lon - lon) / 15 * 3600000
}
return t
}
// The zone's own local midnight, as a UTC instant.
function localMidnightMs(ms, offsetMinutes) {
var local = ms + offsetMinutes * 60000
return Math.floor(local / 86400000) * 86400000 - offsetMinutes * 60000
}
// Sunrise and sunset for the local day containing `ms`, as instants and as
// minutes from local midnight. The minutes may fall outside 0..1440 for a zone
// far from its own sun. `kind` is "normal", "midnightSun" or "polarNight".
function sunTimes(lat, lon, ms, offsetMinutes) {
var midnight = localMidnightMs(ms, offsetMinutes)
var noon = solarNoonMs(lon, midnight + 43200000)
var dec = Solar.subsolarPoint(noon).lat
var cosH = (Math.sin(HORIZON * DEG) - Math.sin(lat * DEG) * Math.sin(dec * DEG))
/ (Math.cos(lat * DEG) * Math.cos(dec * DEG))
if (cosH <= -1)
return { kind: "midnightSun", noonMs: noon, riseMs: null, setMs: null,
riseMinutes: null, setMinutes: null, dayMinutes: 1440 }
if (cosH >= 1)
return { kind: "polarNight", noonMs: noon, riseMs: null, setMs: null,
riseMinutes: null, setMinutes: null, dayMinutes: 0 }
var halfDayMs = Math.acos(cosH) / DEG / 15 * 3600000
var riseMs = noon - halfDayMs
var setMs = noon + halfDayMs
return {
kind: "normal",
noonMs: noon,
riseMs: riseMs,
setMs: setMs,
riseMinutes: (riseMs - midnight) / 60000,
setMinutes: (setMs - midnight) / 60000,
dayMinutes: (setMs - riseMs) / 60000
}
}
// The lit parts of a 24-hour strip as 0..1 spans. The day is also drawn a day
// either side, so a sunset past midnight lights the start of the bar too.
function litSpans(times) {
if (!times) return []
if (times.kind === "polarNight") return []
if (times.kind === "midnightSun") return [{ x0: 0, x1: 1 }]
var out = []
for (var shift = -1440; shift <= 1440; shift += 1440) {
var a = Math.max(0, Math.min(1440, times.riseMinutes + shift))
var b = Math.max(0, Math.min(1440, times.setMinutes + shift))
if (b > a) out.push({ x0: a / 1440, x1: b / 1440 })
}
return out
}
// Where an event's tick goes, or null when it falls off this bar.
function eventMark(minutes) {
if (minutes === null || minutes === undefined) return null
if (minutes < 0 || minutes > 1440) return null
return minutes / 1440
}
// Daylight at `minutes`, read from the same three days litSpans draws.
function litAt(times, minutes) {
if (!times) return false
if (times.kind === "midnightSun") return true
if (times.kind === "polarNight") return false
for (var shift = -1440; shift <= 1440; shift += 1440)
if (minutes >= times.riseMinutes + shift && minutes < times.setMinutes + shift)
return true
return false
}
if (typeof module !== "undefined") {
module.exports = {
solarNoonMs: solarNoonMs,
localMidnightMs: localMidnightMs,
sunTimes: sunTimes,
litSpans: litSpans,
eventMark: eventMark,
litAt: litAt
}
}
@@ -0,0 +1 @@
[["Pago Pago","Pacific/Pago_Pago",-14.28,-170.7,3],["Honolulu","Pacific/Honolulu",21.31,-157.86,2],["Anchorage","America/Anchorage",61.22,-149.9,2],["Los Angeles","America/Los_Angeles",34.05,-118.24,1],["Vancouver","America/Vancouver",49.25,-123.12,2],["Denver","America/Denver",39.74,-104.98,2],["Phoenix","America/Phoenix",33.45,-112.07,3],["Mexico City","America/Mexico_City",19.43,-99.13,1],["Chicago","America/Chicago",41.85,-87.65,1],["New York","America/New_York",40.71,-74.01,1],["Toronto","America/Toronto",43.71,-79.4,2],["Bogota","America/Bogota",4.61,-74.08,2],["Lima","America/Lima",-12.04,-77.03,2],["Halifax","America/Halifax",44.64,-63.58,3],["Caracas","America/Caracas",10.49,-66.88,3],["Santiago","America/Santiago",-33.46,-70.65,2],["Sao Paulo","America/Sao_Paulo",-23.55,-46.64,1],["Buenos Aires","America/Argentina/Buenos_Aires",-34.61,-58.38,1],["Praia","Atlantic/Cape_Verde",14.93,-23.51,3],["Reykjavik","Atlantic/Reykjavik",64.14,-21.9,3],["London","Europe/London",51.51,-0.13,1],["Accra","Africa/Accra",5.56,-0.2,3],["Lisbon","Europe/Lisbon",38.73,-9.15,2],["Paris","Europe/Paris",48.85,2.35,1],["Berlin","Europe/Berlin",52.52,13.41,1],["Madrid","Europe/Madrid",40.42,-3.7,2],["Lagos","Africa/Lagos",6.45,3.39,2],["Rome","Europe/Rome",41.89,12.51,2],["Cairo","Africa/Cairo",30.06,31.25,1],["Athens","Europe/Athens",37.98,23.73,2],["Johannesburg","Africa/Johannesburg",-26.2,28.04,1],["Kyiv","Europe/Kyiv",50.45,30.52,2],["Moscow","Europe/Moscow",55.75,37.62,1],["Istanbul","Europe/Istanbul",41.01,28.95,1],["Nairobi","Africa/Nairobi",-1.28,36.82,2],["Riyadh","Asia/Riyadh",24.69,46.72,2],["Tehran","Asia/Tehran",35.69,51.42,2],["Dubai","Asia/Dubai",25.08,55.31,1],["Kabul","Asia/Kabul",34.53,69.17,3],["Karachi","Asia/Karachi",24.86,67.01,2],["Delhi","Asia/Kolkata",28.65,77.23,1],["Kathmandu","Asia/Kathmandu",27.7,85.32,3],["Dhaka","Asia/Dhaka",23.71,90.41,2],["Almaty","Asia/Almaty",43.25,76.91,3],["Yangon","Asia/Yangon",16.81,96.16,3],["Bangkok","Asia/Bangkok",13.75,100.5,1],["Jakarta","Asia/Jakarta",-6.21,106.85,2],["Singapore","Asia/Singapore",1.29,103.85,1],["Shanghai","Asia/Shanghai",31.22,121.46,1],["Hong Kong","Asia/Hong_Kong",22.28,114.17,2],["Perth","Australia/Perth",-31.95,115.86,2],["Seoul","Asia/Seoul",37.57,126.98,1],["Tokyo","Asia/Tokyo",35.69,139.69,1],["Darwin","Australia/Darwin",-12.46,130.84,3],["Adelaide","Australia/Adelaide",-34.93,138.6,3],["Sydney","Australia/Sydney",-33.87,151.21,1],["Brisbane","Australia/Brisbane",-27.47,153.03,3],["Noumea","Pacific/Noumea",-22.27,166.45,3],["Auckland","Pacific/Auckland",-36.85,174.76,1],["Suva","Pacific/Fiji",-18.14,178.43,3],["Apia","Pacific/Apia",-13.83,-171.77,3],["Kiritimati","Pacific/Kiritimati",1.87,-157.43,3],["St John's","America/St_Johns",47.56,-52.71,3],["Copenhagen","Europe/Copenhagen",55.68,12.57,2],["Stockholm","Europe/Stockholm",59.33,18.07,2],["Oslo","Europe/Oslo",59.91,10.75,2],["Amsterdam","Europe/Amsterdam",52.37,4.89,2],["Brussels","Europe/Brussels",50.85,4.35,3],["Zurich","Europe/Zurich",47.37,8.55,2],["Vienna","Europe/Vienna",48.21,16.37,2],["Warsaw","Europe/Warsaw",52.23,21.01,2],["Prague","Europe/Prague",50.09,14.42,2],["Dublin","Europe/Dublin",53.33,-6.25,2],["Helsinki","Europe/Helsinki",60.17,24.94,3]]
@@ -0,0 +1,78 @@
{
"schemaVersion": 1,
"id": "omarchy.elsewhen",
"name": "Elsewhen",
"version": "1.0.0",
"author": "Jason Fried",
"license": "MIT",
"description": "A world clock for the Omarchy shell: the time in several cities at once, with a spinnable globe.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Elsewhen",
"description": "A world clock: one row per city, and a globe.",
"category": "Time",
"aliases": [
"worldclock",
"world clock",
"timezones"
],
"allowMultiple": false,
"defaultSection": "right",
"defaults": {
"zones": "",
"offsetMode": "home",
"units": "",
"globeEnabled": true,
"homeCity": "",
"smoothMotion": true
},
"schema": [
{
"key": "zones",
"type": "string",
"label": "Zones (Label|IANA name, comma separated; blank seeds on first run)",
"defaultValue": ""
},
{
"key": "hour24",
"type": "boolean",
"label": "24-hour time (unset follows the system)"
},
{
"key": "offsetMode",
"type": "string",
"label": "Row offsets: home (distance from you) or utc",
"defaultValue": "home"
},
{
"key": "units",
"type": "string",
"label": "Temperature units (F or C; blank follows the system)",
"defaultValue": ""
},
{
"key": "globeEnabled",
"type": "boolean",
"label": "Globe mode (tap the hero globe)",
"defaultValue": true
},
{
"key": "homeCity",
"type": "string",
"label": "Your city (blank = from the system time zone)",
"defaultValue": ""
},
{
"key": "smoothMotion",
"type": "boolean",
"label": "Drop labels and detail while the globe moves",
"defaultValue": true
}
]
}
}
+1
View File
@@ -0,0 +1 @@
[[-59.6,-80.0,-60.2,-81.0,-66.3,-80.3,-59.6,-80.0],[-159.2,-79.5,-163.7,-78.6,-161.2,-78.4,-159.2,-79.5],[-45.2,-78.0,-43.9,-78.5,-43.3,-80.0,-50.5,-81.0,-54.2,-80.6,-48.7,-78.0,-45.2,-78.0],[-121.2,-73.5,-118.7,-73.5,-122.6,-73.7,-121.2,-73.5],[-125.6,-73.5,-124.0,-73.9,-127.3,-73.5,-125.6,-73.5],[-99.0,-71.9,-96.2,-72.5,-102.3,-71.9,-99.0,-71.9],[-68.5,-71.0,-68.8,-72.2,-71.1,-72.5,-75.0,-71.7,-72.1,-71.2,-71.7,-69.5,-70.3,-68.9,-68.5,-71.0],[-58.6,-64.2,-62.0,-64.8,-62.6,-65.5,-62.1,-66.2,-65.7,-68.0,-61.8,-70.7,-60.8,-73.7,-70.6,-76.6,-77.2,-76.7,-73.7,-77.9,-77.9,-78.4,-78.0,-79.2,-58.2,-83.2,-49.8,-81.7,-42.8,-82.1,-28.5,-80.3,-29.7,-79.3,-35.6,-79.5,-35.8,-78.3,-17.5,-75.1,-15.7,-74.5,-16.5,-73.9,-15.4,-73.1,-10.3,-71.3,-7.4,-71.7,-6.9,-70.9,-0.2,-71.6,7.7,-69.9,10.8,-70.8,13.4,-70.0,27.1,-70.5,32.0,-69.7,33.9,-68.5,38.6,-69.8,54.5,-65.8,61.4,-68.0,68.9,-67.9,69.7,-69.2,67.8,-70.3,69.1,-70.7,67.9,-71.9,69.9,-72.3,73.9,-69.9,77.6,-69.5,82.8,-67.2,86.8,-67.2,88.0,-66.2,89.7,-67.2,95.8,-67.4,99.7,-67.2,102.8,-65.6,106.2,-66.9,113.6,-65.9,119.8,-67.3,134.8,-66.2,135.1,-65.3,137.5,-67.0,145.5,-66.9,148.8,-68.4,154.3,-68.6,161.6,-70.6,171.2,-71.7,169.3,-73.7,166.1,-74.4,163.6,-76.2,164.7,-78.2,167.0,-78.8,161.8,-79.2,159.8,-80.9,169.4,-83.8,180,-84.7,180,-90,-180,-90,-180,-84.7,-179.1,-84.1,-170.0,-83.9,-158.1,-85.4,-143.1,-85.0,-153.6,-83.7,-152.9,-82.0,-156.8,-81.1,-150.6,-81.3,-146.4,-80.3,-155.3,-79.1,-158.1,-78.0,-158.4,-76.9,-151.3,-77.4,-146.1,-76.5,-146.2,-75.4,-135.2,-74.3,-121.1,-74.5,-113.9,-73.7,-112.3,-74.7,-107.6,-75.2,-100.1,-74.9,-102.5,-74.1,-103.7,-72.6,-96.3,-73.6,-90.1,-73.3,-89.2,-72.6,-81.5,-73.9,-80.3,-73.1,-74.9,-73.9,-67.4,-72.5,-68.5,-69.7,-67.4,-68.1,-67.7,-67.3,-63.0,-64.6,-57.2,-63.5,-58.6,-64.2],[-67.8,-53.9,-65.0,-54.7,-69.2,-55.5,-74.7,-52.8,-71.1,-54.1,-69.3,-52.5,-67.8,-53.9],[-58.5,-51.1,-58.0,-51.9,-61.2,-51.9,-58.5,-51.1],[145.4,-40.8,148.3,-40.9,147.9,-43.2,146.0,-43.5,144.7,-41.2,145.4,-40.8],[173.0,-40.9,174.2,-41.3,173.1,-43.9,171.5,-44.2,169.3,-46.6,166.7,-46.2,167.0,-45.1,173.0,-40.9],[174.6,-36.2,176.8,-37.9,178.5,-37.7,175.2,-41.7,174.9,-39.9,173.8,-39.5,174.7,-37.4,172.6,-34.5,174.6,-36.2],[50.1,-13.6,50.4,-15.7,49.7,-15.7,47.1,-24.9,45.4,-25.6,44.0,-25.0,43.3,-22.1,44.4,-20.1,44.4,-16.2,47.7,-14.6,49.2,-12.0,50.1,-13.6],[143.6,-13.8,145.4,-15.0,146.4,-19.0,148.8,-20.4,153.1,-26.1,152.9,-31.6,150.0,-37.4,146.3,-39.0,145.0,-37.9,143.6,-38.8,140.6,-38.0,139.6,-36.1,138.1,-35.6,138.2,-34.4,136.8,-35.3,137.8,-32.9,136.0,-34.9,134.3,-32.6,131.3,-31.5,126.1,-32.2,123.7,-33.9,119.9,-34.0,118.0,-35.1,115.0,-34.2,115.7,-31.6,113.3,-26.1,114.2,-26.3,113.4,-24.4,114.1,-21.8,114.2,-22.5,116.7,-20.7,120.9,-19.7,123.0,-16.4,123.9,-17.1,123.5,-16.6,125.7,-14.2,127.1,-13.8,129.6,-15.0,130.6,-12.5,132.6,-12.1,131.8,-11.3,132.4,-11.1,135.3,-12.2,136.5,-11.9,137.0,-12.4,135.5,-15.0,140.2,-17.7,141.3,-16.4,142.5,-10.7,143.6,-13.8],[124.4,-10.1,123.5,-10.2,125.1,-8.7,127.3,-8.4,124.4,-10.1],[108.6,-6.8,110.8,-6.5,115.7,-8.4,105.4,-6.9,106.1,-5.9,108.6,-6.8],[152.0,-5.5,150.2,-6.3,148.3,-5.7,150.8,-5.5,151.5,-4.2,152.3,-4.3,152.0,-5.5],[134.1,-1.2,134.4,-2.8,135.5,-3.4,138.3,-1.7,144.6,-3.9,147.6,-6.1,147.2,-7.4,150.7,-10.6,147.9,-10.1,144.7,-7.6,142.6,-9.3,139.1,-8.1,137.6,-8.4,138.7,-7.3,137.9,-5.4,133.7,-3.5,133.0,-4.1,132.0,-2.8,133.7,-2.2,130.5,-0.9,132.4,-0.4,134.1,-1.2],[125.2,1.4,123.7,0.2,120.2,0.2,120.9,-1.4,123.3,-0.6,121.5,-1.9,123.2,-5.3,122.2,-5.3,122.7,-4.5,121.5,-4.6,121.0,-2.6,120.3,-2.9,120.4,-5.5,119.4,-5.4,119.5,-3.5,118.8,-2.8,120.0,0.6,120.9,1.3,125.2,1.4],[128.7,1.1,128.1,-0.9,127.4,1.0,127.9,2.2,128.7,1.1],[105.8,-5.9,102.6,-4.2,95.3,5.5,97.5,5.2,103.8,0.1,103.4,-0.7,106.1,-3.1,105.8,-5.9],[117.9,1.8,119.0,0.9,117.8,0.8,116.1,-4.0,110.2,-2.9,109.1,-0.5,109.7,2.0,111.2,1.9,111.4,2.7,113.0,3.1,116.7,6.9,119.2,5.4,117.3,3.2,117.9,1.8],[126.4,8.4,126.5,7.2,126.2,6.3,125.8,7.3,125.4,6.8,125.4,5.6,124.2,6.2,123.6,7.8,121.9,7.2,123.5,8.7,125.5,9.0,125.4,9.8,126.4,8.4],[81.2,6.2,79.9,6.8,80.1,9.8,81.8,7.5,81.2,6.2],[118.5,9.3,117.2,8.4,119.5,11.4,118.5,9.3],[121.3,18.5,122.2,18.5,122.5,17.1,121.7,14.3,124.0,13.8,124.1,12.5,120.6,13.9,121.0,14.5,120.1,15.0,119.9,16.4,121.3,18.5],[-72.6,19.9,-68.3,18.6,-70.7,18.4,-71.4,17.6,-74.5,18.3,-72.3,18.7,-73.4,19.6,-72.6,19.9],[-79.7,22.8,-74.2,20.3,-77.8,19.9,-77.1,20.4,-78.7,21.6,-81.8,22.6,-85.0,21.9,-82.3,23.2,-79.7,22.8],[121.2,22.8,120.7,22.0,120.1,23.6,121.5,25.3,121.2,22.8],[15.5,38.2,15.1,36.6,12.4,37.6,15.5,38.2],[141.0,37.1,140.3,35.1,137.2,34.6,135.8,33.5,135.1,34.6,131.0,33.9,132.0,33.1,131.3,31.5,130.2,31.4,130.4,32.3,129.4,33.3,132.6,35.4,135.7,35.5,136.7,37.3,137.4,36.8,139.4,38.2,139.9,40.6,141.4,41.4,141.9,39.2,141.0,37.1],[143.9,44.2,145.3,44.4,145.5,43.3,143.2,42.0,141.6,42.7,141.1,41.6,140.0,41.6,139.8,42.6,141.4,43.4,142.0,45.6,143.9,44.2],[-123.5,48.5,-125.7,48.8,-128.4,50.8,-125.8,50.3,-123.5,48.5],[-56.1,50.7,-56.8,49.8,-53.5,49.2,-53.8,48.5,-53.1,48.7,-52.6,47.5,-53.1,46.7,-54.2,46.8,-54.2,47.8,-55.4,Line truncated