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