diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..138c50d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + manifest: + name: Manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The same checks `omarchy plugin validate` runs: schema version, required + # fields, a well-formed id outside the reserved namespace, entry points + # that are safe relative paths and exist, and no symlinks in the tree. + - name: Validate manifest.json + run: | + set -euo pipefail + jq -e '.schemaVersion == 1' manifest.json + for field in id name version kinds entryPoints; do + jq -e --arg f "$field" 'has($f)' manifest.json >/dev/null + done + jq -e '.id | test("^[A-Za-z0-9][A-Za-z0-9._-]*$")' manifest.json + jq -e '.id | (startswith("omarchy.") | not) and (contains("..") | not)' manifest.json + jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' manifest.json + jq -e '(.entryPoints | type) == "object"' manifest.json + jq -e '.barWidget.defaultSection as $s | $s == null or (["left","center","right"] | index($s)) != null' manifest.json + jq -r '.entryPoints[]' manifest.json | while IFS= read -r ep; do + case "$ep" in + /*|*..*) echo "unsafe entry point: $ep"; exit 1 ;; + esac + test -f "$ep" || { echo "missing entry point: $ep"; exit 1; } + done + for kind in bar bar-widget menu overlay panel service; do + jq -e --arg k "$kind" '(.kinds | index($k)) == null' manifest.json >/dev/null && continue + key="$kind"; [[ $kind == bar-widget ]] && key=barWidget + jq -e --arg k "$key" '.entryPoints | has($k)' manifest.json >/dev/null \ + || { echo "kind '$kind' has no entryPoints.$key"; exit 1; } + done + link=$(find . -name .git -prune -o -type l -print -quit) + [[ -z $link ]] || { echo "symlink in plugin tree: $link"; exit 1; } + - name: Data files parse + run: | + jq -e 'type == "array" and length > 0' cities.json >/dev/null + jq -e 'type == "array" and length > 0' world.json >/dev/null + - name: Python compiles + run: python3 -m py_compile worldclock-data.py tests/currency_check.py + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install Qt test runner and iso-codes + run: | + sudo apt-get update + sudo apt-get install --yes \ + iso-codes \ + qt6-declarative-dev-tools \ + qml6-module-qtqml-workerscript \ + qml6-module-qtquick \ + qml6-module-qtquick-window \ + qml6-module-qttest + # --offline keeps the run deterministic: the live-rate and Open-Meteo + # cross-checks are worth running by hand but should not fail a PR when + # a third-party feed hiccups. + - name: Run tests + run: ./tests/run --offline diff --git a/Arc.js b/Arc.js new file mode 100644 index 0000000..5406ca7 --- /dev/null +++ b/Arc.js @@ -0,0 +1,84 @@ +// Laying a line of text along a shallow circular arc. +// +// Kept out of the QML so the geometry can be tested without a window. The +// caller measures each character (advance widths from FontMetrics) and gets +// back where to put it and how far to turn it; nothing here knows about +// fonts, items or colours. +// +// Parameterised by `rise` - how far the ends of the line sit above (or below) +// its middle - rather than by a radius. A radius means nothing at a glance +// and its effect changes with the length of the string: the same 1600px +// circle bends a short line barely at all and a long one visibly. A rise in +// pixels is the thing being judged by eye, and it holds steady as the text +// changes underneath it, which this line does every second. + +.pragma library + +// widths: advance width of each character, in order. +// rise: pixels the ends are displaced from the middle. 0 is a flat line. +// smile: true bends the ends up (a shallow U), false bends them down. +// +// Returns { width, height, chars: [{ x, y, rotation }] }, where x/y are the +// top-left of each character's box in the returned bounding size, and +// rotation is degrees about the character's own centre. +function layout(widths, rise, smile) { + var chars = [] + var total = 0 + var i + for (i = 0; i < widths.length; i++) total += widths[i] + + // A flat line is not a special case worth a separate code path in the + // caller, so it is one here: radius would divide by zero. + 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 } + } + + // Past a quarter of the width the arc stops reading as a bent line and + // starts reading as a circle with a word on it. Nothing sane comes near + // this; it only keeps the trig in range. + var sag = Math.min(rise, total / 4) + + // Sagitta of a circular segment: s = R(1 - cos(w/2R)), and for shallow + // arcs R = w^2/8s. The approximation is what sets the radius; the angles + // below are then exact for that radius, so the ends land within a fraction + // of a pixel of the rise that was asked for. + 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] + // Arc length from the middle of the line to the middle of this character. + 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 + // y grows downward, so a smile puts the middle of the line at the bottom + // of the box and the ends at the top. + 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 + } + + // The end characters straddle the ends of the chord, so their boxes hang + // outside it. Reported width is what actually has to be reserved, and x + // starts at zero, so a caller can centre the result on its own width + // without the first character falling off the left of the panel. (Turning + // each box about its centre widens it a little further, by an amount that + // depends on the glyph height this file does not know; the caller pads.) + for (i = 0; i < chars.length; i++) chars[i].x -= left + + return { width: right - left, height: maxDrop, chars: chars } +} diff --git a/ArcText.qml b/ArcText.qml new file mode 100644 index 0000000..3df4713 --- /dev/null +++ b/ArcText.qml @@ -0,0 +1,122 @@ +import QtQuick +import qs.Commons +import "Arc.js" as Arc + +// One line of text bent onto a shallow arc, each character turned to follow +// it. +// +// Not a Canvas: the panel's text is real Text items everywhere else, and a +// canvas would render this one line through a different path - its own font +// string, its own hinting, its own idea of the pixel grid - so it would sit +// visibly apart from the title above it at the same size. A character per +// Text item costs a handful of items on a line of thirty and keeps the +// rendering identical to its neighbours. +// +// Styling arrives as `runs` - {text, color, underline} - rather than as +// markup, because there is no per-character position inside a StyledText to +// place a glyph from. The flat version of the same line builds its markup +// from the same runs, so the two cannot drift apart. +// +// There is no eliding. The sentence is short, the panel's width is fixed, and +// an elide on an arc would have to decide what a truncated curve looks like; +// if the text ever outgrows the panel, the flat line is the one that handles +// it. +Item { + id: root + + // [{ text: string, color: string ("" inherits), underline: bool }] + property var runs: [] + + // How far the ends sit off the middle. Small: this is meant to be noticed + // as a shape, not read as a curve. + property real rise: 6 + // Ends up (a shallow smile) or ends down (an arch over what is below it). + property bool smile: true + + property string fontFamily: Style.font.family + property int pixelSize: Style.font.caption + property int weight: Font.Normal + property color color: Color.foreground + + FontMetrics { + id: metrics + font.family: root.fontFamily + font.pixelSize: root.pixelSize + font.weight: root.weight + } + + // Runs flattened to characters, each carrying the run's styling with it. + readonly property var glyphs: { + var out = [] + for (var i = 0; i < runs.length; i++) { + var run = runs[i] + var text = String(run && run.text !== undefined ? run.text : "") + for (var j = 0; j < text.length; j++) + out.push({ ch: text.charAt(j), + color: run.color !== undefined ? String(run.color) : "", + underline: run.underline === true }) + } + return out + } + + readonly property var placed: { + // FontMetrics does not announce itself as a dependency of advanceWidth, + // so name the font here or a theme or size change leaves the old layout + // in place under new glyphs. + var _ = metrics.font.family + metrics.font.pixelSize + metrics.font.weight + var widths = [] + for (var i = 0; i < glyphs.length; i++) widths.push(metrics.advanceWidth(glyphs[i].ch)) + return Arc.layout(widths, rise, smile) + } + + // Turning a box about its centre pushes its corners out by up to half its + // diagonal, and the end characters are the most turned; half a line height + // either side covers it at any rise this is used at. + readonly property real slack: Math.round(metrics.height / 2) + + implicitWidth: Math.ceil(placed.width) + 2 * slack + implicitHeight: Math.ceil(placed.height + metrics.height) + + // The arc is centred on whatever width it is given, so it stays centred in + // a panel that is wider than the sentence. + readonly property real originX: (width - placed.width) / 2 + + Repeater { + model: root.glyphs.length + + Text { + required property int index + // The model is a count, and the count changes a beat before the arrays + // behind it do - every second, as the clock ticks the sentence's length + // around. Without a fallback that beat is a torrent of TypeErrors from + // delegates reaching past the end of the old layout. + readonly property var glyph: + root.glyphs[index] || { ch: "", color: "", underline: false } + readonly property var spot: + root.placed.chars[index] || { x: 0, y: 0, rotation: 0 } + + // The box is exactly one advance wide, so the arc's arithmetic and the + // glyph agree about where the character's middle is; left-aligned text + // in a wider box would drift off the curve. + width: metrics.advanceWidth(glyph.ch) + height: metrics.height + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + x: root.originX + spot.x + y: spot.y + rotation: spot.rotation + transformOrigin: Item.Center + + text: glyph.ch + color: glyph.color !== "" ? glyph.color : root.color + font.family: root.fontFamily + font.pixelSize: root.pixelSize + font.weight: root.weight + // Per character, so the rule follows the curve in short segments that + // meet at the character boundaries rather than cutting the chord. + font.underline: glyph.underline + renderType: Text.QtRendering // native rendering ignores the rotation + } + } +} diff --git a/Chip.qml b/Chip.qml new file mode 100644 index 0000000..822edac --- /dev/null +++ b/Chip.qml @@ -0,0 +1,46 @@ +import QtQuick +import qs.Commons + +// A small dark label that floats over the row: sunrise, sunset, the moon's +// phase. Always an answer to something just clicked, never part of the resting +// state of the panel. +// +// Its two colours are literal rather than theme roles. This is a tooltip, and +// tooltips are inverted everywhere - a dark chip with light text is the same +// shape in a light theme as in a dark one, while the theme's own foreground +// would put dark text on a dark chip half the time. +// +// It is opaque on purpose. It sits over the date line and the offset, and text +// over text is unreadable whatever the two colours are. +Rectangle { + id: chip + + property string label: "" + property string fontFamily: Style.font.family + property bool shown: false + + // Where the chip wants to be centred, in the parent's coordinates. Held + // inside the parent by the binding below: a sunrise a few minutes after + // midnight would otherwise hang its chip off the left edge of the row. + property real centreX: 0 + + width: chipText.implicitWidth + Style.space(10) + height: chipText.implicitHeight + Style.space(5) + radius: Style.space(3) + color: "#0B0D11" + + x: Math.round(Math.max(0, Math.min(parent.width - width, centreX - width / 2))) + + opacity: shown ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + + Text { + id: chipText + anchors.centerIn: parent + text: chip.label + color: "#EDE7DA" + font.family: chip.fontFamily + font.pixelSize: Style.font.caption + } +} diff --git a/DeepTime.js b/DeepTime.js new file mode 100644 index 0000000..2bef85b --- /dev/null +++ b/DeepTime.js @@ -0,0 +1,145 @@ +.pragma library + +// The Earth as one more city, whose day is 4.54 billion years long. +// +// The panel already draws a row as a name, a clock and a 24-hour strip. Give +// that row the whole planet and the clock reads a minute before midnight: not +// as a gimmick, but because at this scale that is the honest reading, and it +// is the only way to get the whole span into a line of a panel without +// cheating the arithmetic somewhere. +// +// The numbers that make it worth looking at, all of them consequences of the +// one division: +// +// one hour = 189 million years +// one minute = 3.15 million years - the entire genus Homo +// one second = 52,500 years - longer than every city ever built +// +// So all of recorded history is the last tenth of a second of the day, and +// the row's minute hand has not moved since before there were people to read +// it. Nothing here ticks. That is the point of it. +// +// Ages are the ICS chart (v2023/07), in millions of years before present, and +// they are the published boundaries rather than round numbers - 538.8 for the +// base of the Cambrian, not 540. `tests/deeptime_check.js` checks that every +// division is contiguous with its neighbours and nested inside its parent, +// which is the property a hand-typed table actually loses. + +// The age of the Earth. Everything below is a fraction of this. +var AGE_MA = 4540 + +// Eons. The four bands the strip is drawn in - and, conveniently, four bands +// wide enough to see at the width of a panel: the shortest, the Phanerozoic, +// is still 12% of the day. +var EONS = [ + { name: "Hadean", from: 4540, to: 4031 }, + { name: "Archean", from: 4031, to: 2500 }, + { name: "Proterozoic", from: 2500, to: 538.8 }, + { name: "Phanerozoic", from: 538.8, to: 0 } +] + +var ERAS = [ + { name: "Eoarchean", from: 4031, to: 3600 }, + { name: "Paleoarchean", from: 3600, to: 3200 }, + { name: "Mesoarchean", from: 3200, to: 2800 }, + { name: "Neoarchean", from: 2800, to: 2500 }, + { name: "Paleoproterozoic", from: 2500, to: 1600 }, + { name: "Mesoproterozoic", from: 1600, to: 1000 }, + { name: "Neoproterozoic", from: 1000, to: 538.8 }, + { name: "Paleozoic", from: 538.8, to: 251.902 }, + { name: "Mesozoic", from: 251.902, to: 66 }, + { name: "Cenozoic", from: 66, to: 0 } +] + +var PERIODS = [ + { name: "Cambrian", from: 538.8, to: 486.85 }, + { name: "Ordovician", from: 486.85, to: 443.1 }, + { name: "Silurian", from: 443.1, to: 419.62 }, + { name: "Devonian", from: 419.62, to: 358.86 }, + { name: "Carboniferous", from: 358.86, to: 298.9 }, + { name: "Permian", from: 298.9, to: 251.902 }, + { name: "Triassic", from: 251.902, to: 201.4 }, + { name: "Jurassic", from: 201.4, to: 143.1 }, + { name: "Cretaceous", from: 143.1, to: 66 }, + { name: "Paleogene", from: 66, to: 23.03 }, + { name: "Neogene", from: 23.03, to: 2.58 }, + { name: "Quaternary", from: 2.58, to: 0 } +] + +var EPOCHS = [ + { name: "Pleistocene", from: 2.58, to: 0.0117 }, + { name: "Holocene", from: 0.0117, to: 0 } +] + +// The Holocene's three ages. The one we are in was ratified in 2018 and +// starts at a drought that ended several civilisations at once, which is a +// better answer to "what is now called" than most people expect there to be. +var AGES = [ + { name: "Greenlandian", from: 0.0117, to: 0.008326 }, + { name: "Northgrippian", from: 0.008326, to: 0.0042 }, + { name: "Meghalayan", from: 0.0042, to: 0 } +] + +var LEVELS = [EONS, ERAS, PERIODS, EPOCHS, AGES] + +// Which division of a given level a moment falls in. Boundaries belong to the +// younger division, the way the chart reads them: 66 Ma is the first instant +// of the Cenozoic, not the last of the Mesozoic. +function divisionAt(level, ma) { + for (var i = 0; i < level.length; i++) + if (ma <= level[i].from && ma > level[i].to) return level[i] + // The present sits exactly on the `to` of every innermost division. + for (var j = 0; j < level.length; j++) + if (level[j].to === 0 && ma <= level[j].from) return level[j] + return null +} + +// Eon down to age, skipping the levels that do not cover this moment - the +// Hadean has no named eras, and only the Holocene is divided into ages. +function breadcrumb(ma) { + var out = [] + for (var i = 0; i < LEVELS.length; i++) { + var found = divisionAt(LEVELS[i], ma) + if (found) out.push(found.name) + } + return out +} + +// How far through the day a moment is, 0 at formation and 1 now. +function dayFraction(ma) { + return Math.max(0, Math.min(1, (AGE_MA - ma) / AGE_MA)) +} + +// The clock face for a moment. The present is midnight of the following day - +// 24:00:00, which no clock shows - so it is held one second short, which is +// also the truth to the nearest 52,000 years. +function clockAt(ma) { + var seconds = Math.min(86399, Math.floor(dayFraction(ma) * 86400)) + return { + hour: Math.floor(seconds / 3600), + minute: Math.floor(seconds / 60) % 60, + second: seconds % 60 + } +} + +// Years of real time per unit of this clock. +function yearsPer(unit) { + var seconds = { day: 86400, hour: 3600, minute: 60, second: 1 }[unit] + return seconds === undefined ? 0 : AGE_MA * 1e6 * seconds / 86400 +} + +// A real span, measured in this clock's units: 300,000 years of humans is +// 5.7 seconds of the day. +function asClockSpan(years) { + return years / yearsPer("second") +} + +// The strip: eons as fractions of the width, oldest first. +function bands() { + var out = [] + for (var i = 0; i < EONS.length; i++) + out.push({ name: EONS[i].name, + x0: dayFraction(EONS[i].from), + x1: dayFraction(EONS[i].to) }) + return out +} diff --git a/EarthRow.qml b/EarthRow.qml new file mode 100644 index 0000000..1cc9dc7 --- /dev/null +++ b/EarthRow.qml @@ -0,0 +1,234 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "DeepTime.js" as Deep + +// One more row, whose city is the planet. +// +// The list is a column of places each answering "what time is it there". This +// row answers it for the Earth: its day is the whole 4.54 billion years, so +// its clock stands a minute short of midnight and its strip is banded by eon +// rather than by daylight. Everything else about it is deliberately the same +// as a city - the same padding, the same type, the same strip, the same +// marker - because the joke only works if it arrives looking like a row and +// only turns out to be the planet on the second read. +// +// It is built as its own file rather than as another entry in the list model: +// the model is cities, with a `date` probe, a drag handle, a remove button, +// weather and a currency behind each one, and none of that means anything +// here. Sharing the delegate would have meant a special case in every one of +// those, which is more code than this file and worse code than this file. +// +// Nothing on it moves. The minute hand last changed 3.15 million years ago +// and will not change again for another 3.15 million; a row that cannot tick +// is a strange thing to put in a clock, which is the entire reason it is +// worth putting in a clock. +Rectangle { + id: earth + + property string fontFamily: Style.font.family + property color foreground: Color.foreground + property color dim: Color.foreground + property color fainter: Color.foreground + // The resting and hovered surfaces, passed in so this row picks up the same + // mix against the panel's background as its neighbours rather than + // recomputing it from a different base. + property color fill: "transparent" + property color fillHover: "transparent" + property bool hour24: false + // The top of the name's cap, so the text sits on the same line as the rows + // above it rather than a pixel or two lower. + property real capGap: 0 + property real moonPhase: 0.5 + + readonly property var face: Deep.clockAt(0) + readonly property var here: Deep.breadcrumb(0) + + readonly property string timeText: { + var h = hour24 ? face.hour : ((face.hour % 12 === 0) ? 12 : face.hour % 12) + var m = face.minute + return h + ":" + (m < 10 ? "0" : "") + m + } + readonly property string meridiem: hour24 ? "" : (face.hour >= 12 ? "PM" : "AM") + + // Where we are in the Earth's own calendar, which is what the date line on + // a city row is. The full chain runs Phanerozoic > Cenozoic > Quaternary > + // Holocene > Meghalayan and does not come close to fitting, so it is cut to + // the two divisions that are actually about us: the epoch since the ice, + // and the age since the drought that ended several civilisations at once. + readonly property string epochText: here.slice(-2).join(" ยท ") + + // The key to reading the row at all, and the reason it is worth a hover: + // one minute of this clock is the entire genus Homo. + readonly property string scaleText: "one minute = 3.15 Myr" + + readonly property int pad: Style.space(15) + readonly property int stripGap: Style.space(9) + + + implicitHeight: (pad - capGap) + labels.implicitHeight + stripGap + strip.height + pad + radius: Style.cornerRadius + color: hover.hovered ? fillHover : fill + border.width: 0 + + HoverHandler { id: hover } + + Column { + id: labels + anchors.left: parent.left + anchors.leftMargin: Style.space(12) + anchors.right: timeBlock.left + anchors.rightMargin: Style.space(10) + anchors.top: parent.top + anchors.topMargin: earth.pad - earth.capGap + spacing: Style.space(2) + + Row { + spacing: Style.space(7) + + Text { + id: earthName + text: "Earth" + color: earth.foreground + font.family: earth.fontFamily + font.pixelSize: Style.font.subtitle + font.weight: Font.DemiBold + } + + // Where a city carries its temperature: the one number about this place + // that matters, in the same slot as the one number about theirs. + Text { + anchors.baseline: earthName.baseline + text: "4.54 Ga" + color: earth.dim + font.family: earth.fontFamily + font.pixelSize: Style.font.caption + } + } + + // The epoch line, and the scale it is drawn at, stacked in one slot the + // way the city rows stack their date and their greeting. + Item { + width: parent.width + implicitHeight: epochLine.implicitHeight + + Text { + id: epochLine + text: earth.epochText + opacity: hover.hovered ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 110 } } + color: earth.dim + font.family: earth.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: earth.scaleText + opacity: hover.hovered ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 110 } } + color: earth.foreground + font.family: earth.fontFamily + font.pixelSize: Style.font.caption + } + } + } + + Column { + id: timeBlock + anchors.right: parent.right + anchors.rightMargin: Style.space(12) + anchors.verticalCenter: labels.verticalCenter + spacing: Style.space(1) + + Row { + anchors.right: parent.right + spacing: Style.space(3) + + Text { + id: bigTime + text: earth.timeText + color: earth.foreground + font.family: earth.fontFamily + font.pixelSize: Style.font.heading + font.weight: Font.DemiBold + } + + Text { + anchors.baseline: bigTime.baseline + text: earth.meridiem + visible: text !== "" + color: earth.dim + font.family: earth.fontFamily + font.pixelSize: Style.font.caption + } + } + + // Where a city puts its zone abbreviation and offset, this puts the scale + // that makes the time above it mean anything. It is the row's legend, and + // it belongs in the slot that is already the legend on every other row. + Text { + anchors.right: parent.right + text: "24h = 4.54 Ga" + color: earth.fainter + font.family: earth.fontFamily + font.pixelSize: Style.font.caption + } + } + + // ---- The strip: the same 24 hours as every other row, banded by eon + // instead of by daylight. It brightens toward the present, so the deep past + // falls away into the dark rather than being colour-coded at the reader - + // and the four eons are, by luck, all wide enough to see: the shortest of + // them, the Phanerozoic, is still an eighth of the day. + Rectangle { + id: strip + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + anchors.bottomMargin: earth.pad + height: Math.max(2, Style.space(3)) + radius: height / 2 + color: Qt.rgba(earth.foreground.r, earth.foreground.g, earth.foreground.b, 0.10) + + Repeater { + model: Deep.bands() + + Rectangle { + required property var modelData + required property int index + + x: Math.round(strip.width * modelData.x0) + // A pixel of overlap, so the seams between bands do not show as + // hairlines of the darker strip underneath them. + width: Math.round(strip.width * (modelData.x1 - modelData.x0)) + (index < 3 ? 1 : 0) + height: strip.height + radius: strip.radius + color: Qt.rgba(earth.foreground.r, earth.foreground.g, earth.foreground.b, + [0.05, 0.11, 0.19, 0.32][index]) + } + } + + // Now, in the same marker the city rows use - and by their own rule it is + // the moon, because the Earth's clock says a minute to midnight. It hangs + // half off the end of the strip, which is exactly where we are. + Rectangle { + id: nowMarker + width: Math.max(8, Style.space(10)) + height: width + radius: width / 2 + x: Math.round(strip.width - width / 2) + y: (strip.height - height) / 2 + color: "transparent" + + MoonDot { + anchors.fill: parent + phase: earth.moonPhase + color: earth.foreground + } + } + } +} diff --git a/Globe.qml b/Globe.qml new file mode 100644 index 0000000..f8c9c89 --- /dev/null +++ b/Globe.qml @@ -0,0 +1,1141 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui +import "GlobeModel.js" as Globe +import "Model.js" as Model +import "Sky.js" as Sky + +// A spinnable orthographic globe: coastlines, a day/night terminator, and the +// major cities of every time zone. Drag to spin; tap a city for its local +// time. The globe button in the hero returns to the list. +// +// Self-contained on purpose - it owns its own data loading and its own clock +// probe - so removing the feature is deleting this file plus the Loader that +// mounts it. See README, "Globe mode". +Item { + id: root + + property QtObject bar: null + property color foreground: Color.foreground + property color dim: Qt.darker(foreground, 1.55) + property color fainter: Qt.darker(foreground, 2.1) + property color daylightMarker: "#E5C736" + // Tonight's moon, for the footer's marker. Passed in rather than computed + // here: the panel already works it out for every row's strip, and two + // calculations of the same sky could disagree by a day. + property real moonPhase: 0.5 + // Night dots are drawn dark, so they read against the bright continents. + // Derived from the panel background rather than fixed, so it holds up in a + // light theme as well as a dark one. + readonly property color nightMarker: Qt.rgba(Color.background.r, Color.background.g, + Color.background.b, 0.92) + property string fontFamily: Style.font.family + property bool hour24: false + // Which offset the footer prints, and where "home" is measured from. Both + // come from the panel: the setting is one setting for the whole widget, so + // flipping it here and flipping it in the list are the same act, and the + // globe reads back whatever the rows are already showing. + property string offsetMode: "home" + property int homeOffsetMinutes: 0 + // Cities the list is already tracking, lower-cased. They get the accent + // colour and first claim on a label slot. + property var trackedNames: [] + property bool skyTint: false + // Faded in near the end of the zoom: at a fraction of full size the footer + // and the jump bar are illegible specks. + property real chromeOpacity: 1 + + // The globe has to be genuinely opaque, not a tinted pane. Painting the + // ocean at a low alpha over the panel background gives the right tone but + // leaves the rows visible straight through it, which ruins the illusion + // that the globe is a solid object arriving in front of them. These mix the + // same tones against the background and hand back a colour with no alpha at + // all. + readonly property color surfaceBase: Color.popups.background + // Names are light over the sea and dark over the land, so each half of a + // name that straddles a coastline contrasts with what is under it. + readonly property color seaInk: foreground + readonly property color landInk: surfaceBase + function solid(tone, t) { + return Qt.rgba(surfaceBase.r + (tone.r - surfaceBase.r) * t, + surfaceBase.g + (tone.g - surfaceBase.g) * t, + surfaceBase.b + (tone.b - surfaceBase.b) * t, 1) + } + // Tracked cities as [name, zone, lat, lon, rank]. Merged into the globe's + // own set so a city in the list always appears, even when it is nowhere in + // cities.json - Copenhagen was exactly that case. + property var trackedCities: [] + + // Cities reached with the jump box. Session-only: the panel never writes + // them to settings, so they vanish when the panel does. + property var sessionCities: [] + property var jumpOptions: [] // the whole zone catalogue, unfiltered + + // The city you are in: always on the globe, always labelled, and drawn in + // its own sky the way the panel globe draws it - so "you" looks the same + // on both. + property var homeRow: [] + // The home dot and its label; falls back to the accent when tinting is off. + readonly property color homeSky: { + for (var i = 0; i < allCities.length; i++) { + if (!isHome(i)) continue + var hex = skyOf(i) + return hex === "" ? Color.accent : hex + } + return Color.accent + } + readonly property string homeName: + homeRow.length > 0 ? String(homeRow[0]).toLowerCase() : "" + + property bool jumping: false + property string jumpQuery: "" + property string pendingJump: "" // waiting on coordinates to arrive + + signal jumpRequested(string label, string zone) + // The jump box has closed and is no longer entitled to the keyboard. + signal jumpDismissed() + + signal exitRequested() + + // The footer's offset was clicked. The globe does not own the setting - it + // asks the panel to flip it, and the change comes back down through + // offsetMode like any other. + signal offsetModeToggleRequested() + + // The globe's selection, on its way back to the list so a city picked here + // is still the focused one when the globe closes. Carries the label and the + // zone rather than an index: `selected` indexes the globe's own catalogue, + // which the list has no way to read. + // + // Only a real selection is announced. Clicking empty ocean clears the + // selection, which on the globe means "no city"; -1 in the list means home, + // so forwarding it would send the list somewhere nobody asked to go. + signal citySelected(string label, string zone) + + // ---- data ------------------------------------------------------------- + property var land: [] // coastline rings, flat [lon,lat,...] + // The same coastlines at half the vertices, built once when the file lands + // and used only while the globe is riding the zoom - see decimateRing. + property var landCoarse: [] + 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 + // Degrees; the latitude the viewer is over. Named viewLat and not tilt: + // the hero globe in the panel uses "tilt" for Earth's axial lean, an + // unrelated angle, and the two meanings sharing one word cost an hour. + property real viewLat: 20 + property real velocity: 0 // degrees per tick, for the throw + // The selection is held as the city's own "label|zone" and not as an index. + // + // allCities is a binding over home, the built-ins, the tracked rows and the + // session cities, so it is rebuilt whenever any of those lands - and an + // index into it then silently means a different city. Opening the globe on + // a focused Auckland and finding the footer naming Honolulu was exactly + // this: the flight was right, the index had moved under it. A key survives + // the rebuild; an index only looks like it does. + 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) ? "" + : String(c[0]) + "|" + String(c[1]) + } + + function selectAt(i) { selectedKey = i >= 0 ? keyAt(i) : "" } + + // A pick on the globe itself, in canvas coordinates measured from the disc's + // centre. Selects what was hit and brings it round to face you, the same as + // opening the globe or jumping to a city already does. A pick left where it + // was landed off centre and often near the limb, where the projection is at + // its most foreshortened and the city just chosen is the least legible thing + // on the globe; the click was the one way of choosing that did not centre. + // + // Only on a hit. Tapping open ocean clears the selection, and turning the + // globe because someone missed would be answering a gesture nobody made. + // + // A function on the root rather than a body inside the MouseArea, so a test + // can exercise the same code the pointer does instead of a copy of it. + 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 + + // True while the panel is between the list and the globe. Set from Panel, + // which owns the zoom. + property bool transitioning: false + // How big the globe is being drawn, 0 in the header to 1 filling the panel. + // Set from Panel, which owns the zoom. + property real zoomLevel: 1 + // The kill switch for everything below. Off means always draw at full + // detail, however that costs. + property bool smoothMotion: true + + // Whether the globe is moving right now - turning, being dragged, coasting + // after a throw, or riding the zoom in or out of the panel. + // + // A moving globe is drawn with less in it. Measured on this machine, a full + // paint costs about 14ms, which is already over a 120Hz frame at 8.3ms, and + // the globe was managing roughly 20 paints a second whenever it moved. The + // two expensive parts are the city names - the layout, and two passes of + // text, one of them through a clip rebuilt from every coastline - and the + // graticule, which is 17 stroked polylines. Dropping both while the globe + // is in motion roughly doubled the frame rate in measurement. + // + // Nothing is lost that could be read: names on a turning globe are a smear, + // and during the zoom the whole thing is a few dozen pixels across. Detail + // comes back the moment it stops, which is the only time anyone reads it. + readonly property bool reduced: smoothMotion + && (dragging || flight.running || transitioning || Math.abs(velocity) > 0.01) + + // The globe stopped moving: draw it properly. + onReducedChanged: canvas.requestPaint() + + readonly property var allCities: { + var out = [] + var seen = {} + // Home first, so a tracked row or a built-in of the same name does not + // shadow it and it keeps its own marker. + if (homeRow.length > 0 && homeRow[2] !== undefined) { + out.push(homeRow) + seen[String(homeRow[0]).toLowerCase()] = true + } + for (var b = 0; b < cities.length; b++) { + var bk = String(cities[b][0]).toLowerCase() + if (seen[bk]) continue + seen[bk] = true + out.push(cities[b]) + } + for (var j = 0; j < trackedCities.length; j++) { + var t = trackedCities[j] + if (t[2] === null || t[2] === undefined) continue + var key = String(t[0]).toLowerCase() + if (seen[key]) continue + seen[key] = true + out.push(t) + } + for (var m = 0; m < sessionCities.length; m++) { + var c = sessionCities[m] + if (c[2] === null || c[2] === undefined) continue + var ck = String(c[0]).toLowerCase() + if (seen[ck]) continue + seen[ck] = true + out.push(c) + } + return out + } + + readonly property var jumpMatches: + jumping ? Model.searchZones(jumpOptions, jumpQuery, 5) : [] + + // Which match the keyboard is on, by the same rules as the panel's city + // search: an index rather than the match itself, because the list is rebuilt + // on every keystroke; back to the top whenever the query changes, because + // the list under the selection has been replaced; and wrapping, because five + // results are entirely on screen and there is no edge to guard. + property int jumpIndex: 0 + onJumpQueryChanged: jumpIndex = 0 + onJumpMatchesChanged: { + if (jumpIndex >= jumpMatches.length) jumpIndex = 0 + probeZones() + } + + function moveJumpSelection(delta) { + var count = jumpMatches.length + if (count === 0) { jumpIndex = 0; return } + jumpIndex = ((jumpIndex + delta) % count + count) % count + } + + function commitJump() { + if (jumpMatches.length === 0) return + var hit = jumpMatches[Math.max(0, Math.min(jumpIndex, jumpMatches.length - 1))] + goTo(hit.label, hit.value) + stopJump() + } + + // Turn and lean the globe until a city is centred. Spin is the longitude + // facing the viewer and viewLat is the latitude under it, so centring is just + // setting them to the city's own coordinates - taking the short way round, + // as the panel globe does. + function flyTo(lat, lon) { + var d = lon - spin + while (d > 180) d -= 360 + while (d <= -180) d += 360 + flight.stop() + velocity = 0 + flightSpin.from = spin + flightSpin.to = spin + d + flightViewLat.from = viewLat + flightViewLat.to = Math.max(-70, Math.min(70, lat)) + flight.restart() + } + + // Turn to the city you are in when the globe opens. Held rather than + // dropped if home is not known yet: the coordinates arrive from the + // fetcher's geocode, which on a cold cache lands after the globe does. + property bool homePending: false + + // Opening the globe lands on the city you are in - and selects it, so the + // footer names it like any other pick. Flying without selecting left the + // globe pointed at home with nothing written underneath, which read as a + // bug in the footer rather than as "nothing is selected yet": the marker + // was clearly on a city. + 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() { + jumpQuery = "" + jumpIndex = 0 + jumping = true + Qt.callLater(function() { jumpField.text = ""; jumpField.forceActiveFocus() }) + } + + function stopJump() { + jumping = false + jumpQuery = "" + // The field that was taking the keys is now hidden, and a hidden item + // keeps its focus - so every key after this went into it and vanished, + // Escape included. Whoever is hosting the globe has to take the keyboard + // back; this globe does not know who that is. + jumpDismissed() + } + + // How much bigger everything drawn is than its pixel literal, so stroke + // widths and marker radii track the shell's base font size the way the + // globe's own radius and the city labels already do. + // + // Style.spaceReal and not Style.space: space() rounds to whole pixels, + // which would flatten every sub-pixel stroke here to a flat 1 and lose the + // weight difference between a city's edge and its selection ring. This is + // the same scale radius and padding below are built from, so the drawing + // moves as one piece rather than in two halves. + readonly property real uiScale: Style.spaceReal(1) + + // Every drawn constant in the canvas goes through here, so the rule lives + // in one tested place instead of being re-derived at each call site. + function scaled(px) { return Globe.scalePx(px, uiScale, 1) } + + readonly property real footerHeight: Style.space(34) + readonly property real jumpHeight: Style.space(34) + readonly property real radius: Math.max(40, + Math.min(width, height - footerHeight - jumpHeight) / 2 - Style.space(6)) + readonly property var sub: Globe.subsolarPoint(nowMs) + + // Data files sit next to this one. FileView rather than XMLHttpRequest: + // XHR against a file:// URL comes back empty inside the shell. + readonly property string here: { + var u = Qt.resolvedUrl(".").toString() + return u.replace(/^file:\/\//, "").replace(/\/$/, "") + } + + function zoneTime(zone) { + var off = offsets[zone] + if (off === undefined) return "" + var d = new Date(nowMs + off * 60000) + var h = d.getUTCHours(), m = d.getUTCMinutes() + var mm = (m < 10 ? "0" : "") + m + if (hour24) return (h < 10 ? "0" : "") + h + ":" + mm + var h12 = h % 12; if (h12 === 0) h12 = 12 + return h12 + ":" + mm + (h < 12 ? " AM" : " PM") + } + + // The same two readings the rows offer, chosen by the same setting: where + // the zone actually sits ("UTC+2"), or how far it is from you ("+9h"). A + // zone on your own offset has no relative label - saying "same time" to + // someone who can read both clocks is noise - so that case falls back to + // nothing, exactly as it does in the list. + 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 + } + + // The sky over any city on the globe. Computed here rather than handed in: + // every city already carries its coordinates, so the panel does not need to + // ship a colour table alongside them. + function skyOf(i) { + if (!skyTint) return "" + var c = allCities[i] + if (c === undefined) return "" + var hex = Sky.tint(Globe.solarElevation(c[2], c[3], sub)) + return hex === null ? "" : hex + } + + // Guarded, along with the two below. These are read both from bindings and + // from inside a paint, and the city list is assembled in stages as the file + // load, the tracked rows and the session cities each arrive - so an index + // can briefly outlive the array it came from. + 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 Globe.isDaylight(c[2], c[3], sub) + } + + // Nearest city to a point, for click-to-select. + // What a click at this point selects. Label text counts as part of its city + // - a name is a far easier target than a two-pixel dot - and is checked + // first, since a label sits beside its own dot and would otherwise lose to + // a neighbouring one. + 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 ----------------------------------------- + // Cities on the near side, in priority order, thinned so a dense region + // shows a few legible cities rather than a smear. Tracked cities and the + // current selection carry `keep` and always survive the thinning. + readonly property var plotted: { + if (allCities.length === 0 || radius <= 0) return [] + var cand = [] + for (var i = 0; i < allCities.length; i++) { + var p = Globe.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 Globe.declutter(cand, Style.space(19)) + } + + // ---- labels ----------------------------------------------------------- + // Recomputed whenever the view moves; the greedy pass in GlobeModel keeps + // names from stacking on top of each other. + readonly property var labels: { + // Not merely unpainted - not laid out either. layoutLabels is the + // expensive half, and it ran on every frame of every turn. + 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 + // Bounded, so a name near the right limb is placed to the left of its dot + // rather than running off the edge of the panel. + return Globe.layoutLabels(cand, charW, Style.font.caption + Style.space(3), 14, + width / 2 - Style.space(4), scaled(6)) + } + + FileView { + path: root.here + "/world.json" + printErrors: true + onLoaded: { + try { + root.land = JSON.parse(text()) + var coarse = [] + for (var i = 0; i < root.land.length; i++) + coarse.push(Globe.decimateRing(root.land[i], 2, 8)) + root.landCoarse = coarse + canvas.requestPaint() + } catch (e) { } + } + } + + FileView { + path: root.here + "/cities.json" + printErrors: true + onLoaded: { + try { root.cities = JSON.parse(text()); root.probeZones() } catch (e) { } + } + } + + property bool probeQueued: false + + // The city set grows in two steps - the built-ins land when the file loads, + // the tracked ones when the panel binds them - so a probe is often already + // running when the set changes. Queue it rather than dropping it, or the + // zones that arrived late never get an offset. + function probeZones() { + if (allCities.length === 0) return + if (zoneProc.running) { probeQueued = true; return } + var zones = [], seen = {} + for (var i = 0; i < allCities.length; i++) { + zones.push(allCities[i][1]) + seen[allCities[i][1]] = true + } + // The cities the search is offering, which are not on the globe yet and + // so are not in allCities. Their offsets are wanted before they are + // picked, not after: the offset is half of what tells two results apart. + for (var j = 0; j < jumpMatches.length; j++) { + var id = jumpMatches[j].value + if (!seen[id]) { seen[id] = true; zones.push(id) } + } + zoneProc.command = ["bash", "-c", + "for z in \"$@\"; do TZ=\"$z\" date \"+$z|%z\"; done", "bash"].concat(zones) + zoneProc.running = true + } + + Process { + id: zoneProc + stdout: StdioCollector { + onStreamFinished: { + var map = {} + var lines = String(text).split("\n") + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].split("|") + if (parts.length < 2) continue + var m = /^([+-])(\d{2})(\d{2})$/.exec(parts[1].trim()) + if (!m) continue + var mins = parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + map[parts[0].trim()] = m[1] === "-" ? -mins : mins + } + 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 *= 0.96 + 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 + + // The clipped continents from the last paint, reused by the label pass. + property var landPolys: [] + + // City names are drawn twice: once light over everything, then again dark + // through a clip of the continents. A name that straddles a coastline + // comes out dark on the land half and light on the sea half, so every + // part of it sits against something it contrasts with. An outline cannot + // do that - it only fattens the letters and dulls both halves. + 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) + } + } + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: parent.height - root.footerHeight - root.jumpHeight + renderStrategy: Canvas.Cooperative + + // Points arrive as [lat, lon]. Runs begin and end exactly on the horizon + // rather than at the last vertex before it, so lines do not snap by up to + // a segment as the globe turns. Shared with the panel globe. + function strokePath(ctx, pts) { + var segs = Globe.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 = root.solid(fg, 0.05) + ctx.fill() + ctx.lineWidth = root.scaled(1) + ctx.strokeStyle = Qt.rgba(fg.r, fg.g, fg.b, 0.22) + ctx.stroke() + + // Graticule every 30 degrees, or every 60 while the globe is moving: + // 17 stroked polylines against 8, and at 10% alpha on a globe in motion + // the difference is not something the eye can catch. + 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) + } + // Set explicitly rather than inheriting whatever the ocean disc left + // on the context - that was an invisible dependency between two passes + // that only held while both wanted the same width. + ctx.lineWidth = root.scaled(1) + ctx.strokeStyle = Qt.rgba(fg.r, fg.g, fg.b, 0.10) + ctx.stroke() + + // Land: filled bright, as on the panel globe, so the two read as the + // same planet. Anything dimmer and the labels have to compete with a + // mid-tone continent, which is exactly what makes them hard to read. + // + // No separate coastline stroke: the fill's own edge is the coastline, + // and that second pass over every ring was the expensive part - filling + // and stroking measured 13.4ms a paint against 9.1ms for filling alone + // and 8.1ms for the outlines this replaced. + // Kept, because the label pass below reuses exactly this shape as a + // clip so that text lands dark on the continents and light on the sea. + // Half the vertices while the globe is riding the zoom: it is scaled + // down to a fraction of its size then, so the dropped points are not on + // screen to be missed. A drag or a throw keeps every one of them - the + // globe is full size for those, and the coast would visibly simplify. + // Half the vertices, but only while the globe is drawn at less than + // half size. Tying this to "is it transitioning" rather than "how big is + // it" would have kept the coarse coastline through the slow tail of the + // zoom, where the globe is nearly full size and the missing islands are + // there to be seen - then snapped them back in. The easing spends its + // early, fast frames down here, which is where the dropped frames were. + 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 = Globe.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 = root.solid(fg, 0.92) + ctx.fill() + canvas.landPolys = landPolys + + + + // Day/night line. + ctx.beginPath() + strokePath(ctx, Globe.terminator(root.sub, 180)) + ctx.lineWidth = root.scaled(1) + ctx.strokeStyle = Qt.rgba(root.daylightMarker.r, root.daylightMarker.g, + root.daylightMarker.b, 0.55) + ctx.stroke() + + // Cities: gold in daylight, pale at night - the same rule the list uses. + 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) + // Gold in daylight, dark at night - the same rule the daylight strips + // in the list use. The night colour is a dark ink rather than a pale + // one: the continents are filled bright, and a pale dot sitting on + // one reads as an empty ring. (With skyTint on, each dot takes its + // own sky instead.) + var sky = root.skyOf(i) + ctx.fillStyle = sky !== "" ? sky + : (day ? root.daylightMarker : root.nightMarker) + ctx.fill() + // Every dot is edged so it survives whichever background it lands on: + // a dark ring around a light dot, a light ring around a dark one. + var lightDot = sky !== "" || day + ctx.lineWidth = root.scaled(1.2) + ctx.strokeStyle = lightDot ? Qt.rgba(0, 0, 0, 0.7) + : Qt.rgba(fg.r, fg.g, fg.b, 0.85) + ctx.stroke() + if (root.isHome(i)) { + // Same treatment as the panel globe: the dot in its own sky, a dark + // edge so a daylight sky does not vanish into the land, and a halo. + ctx.beginPath() + ctx.arc(p.x, p.y, root.scaled(3.4), 0, Math.PI * 2) + ctx.fillStyle = root.homeSky + ctx.fill() + ctx.lineWidth = root.scaled(1.2) + ctx.strokeStyle = Qt.rgba(0, 0, 0, 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 = Qt.rgba(root.homeSky.r, root.homeSky.g, + root.homeSky.b, 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, in two tones ------------------------------------- + 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 * 0.45 + root.viewLat = Math.max(-80, Math.min(80, root.viewLat + dy * 0.35)) + root.velocity = -dx * 0.45 + 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) + } + } + } + + // ---- footer ----------------------------------------------------------- + // Blank until a city is picked; the height stays reserved either way so the + // globe does not shift. The parts are separate items in a centred Row, so + // the gaps between them are pixel values rather than runs of monospace + // spaces, and the whole line sits under the middle of the globe. + Item { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: jumpBar.top + height: root.footerHeight + opacity: root.chromeOpacity + + readonly property bool has: root.selected >= 0 + readonly property var city: has ? root.allCities[root.selected] : null + + // Two lines. The zone and its offset will not fit beside the name at this + // width - "Johannesburg Africa/Johannesburg UTC+2 7:50 PM daylight" runs + // off the end of the panel - and the footer's reserved height already + // holds two caption lines, so nothing above it has to move. + Column { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(1) + visible: parent.has + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(7) + + // The same mark the rows carry on their strips: a lit dot by day, and + // tonight's moon by night. One vocabulary for what the sky is doing + // there, wherever the city happens to be named. + // + // Judged by this globe's own daylight, which is real solar geometry + // rather than the rows' fixed civil hours - so it agrees with the dot + // already drawn on the city an inch above it. Those two definitions + // disagree near sunrise, and of the two disagreements the visible one + // is worse. + // The mark and the name are their own Row inside the line, so the gap + // between them can be tighter than the gaps between everything else - + // the mark belongs to the name, not to the row of facts after it. + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(4) + + // Measured, not guessed: the mark is exactly the cap height of the + // name beside it. tightBoundingRect is the ink of the glyph rather + // than its line box, which is the number the eye compares against. + // + // One pixel under that measurement, which is what actually rasterises + // to the same height as the M: a circle's antialiased edge reads a + // pixel wider than a glyph's, so taking the number at face value drew + // a dot one pixel taller than the capital beside it. Counted, not + // guessed - the two now come out at 14 device pixels each. + // + // Do not shrink it further. Two pixels under the cap turned the moon + // into a bullet point; a crescent needs room to be a crescent. + TextMetrics { + id: capHeight + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.weight: Font.DemiBold + text: "M" + } + + Rectangle { + // Standing on the name's own baseline, so it occupies exactly the + // band the capital does and cannot ride above the cap or hang below + // the letters. + // + // Positioned rather than anchored. `anchors.baseline` looks like the + // right tool and is not: inside a Row, anchoring to a sibling whose + // own position depends on the Row's height is a loop, and it settled + // by dropping the dot onto the line underneath, on top of the zone + // name. A Row leaves y alone, so both items start at the top of it + // and the dot's underside can be put on the baseline directly - + // Text publishes that as baselineOffset, the ascent of its first + // line, which is the same number the glyph is drawn from. + y: cityName.baselineOffset - height + width: Math.max(6, Math.round(capHeight.tightBoundingRect.height) - 1) + height: width + radius: width / 2 + visible: footer.has + readonly property bool day: footer.has && root.cityDaylight(root.selected) + color: day ? root.daylightMarker : "transparent" + + MoonDot { + anchors.fill: parent + visible: !parent.day + phase: root.moonPhase + color: root.foreground + } + } + + Text { + id: cityName + text: footer.has ? footer.city[0] : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.weight: Font.DemiBold + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: footer.has ? root.zoneTime(footer.city[1]) : "" + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.isHome(root.selected) ? "home" : "tracked" + visible: footer.has + && (root.isHome(root.selected) || root.isTracked(root.selected)) + color: root.isHome(root.selected) ? root.homeSky : Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // Which zone that city keeps, and where the zone is. The offset reads + // whichever way the list is reading - click it to swap both at once, the + // same gesture and the same setting as the offset on a row. The zone name + // is part of the target rather than only the number: on your own home + // city the relative offset is blank, and a control that vanishes on one + // city out of the list is not a control. + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(6) + + Text { + text: footer.has ? footer.city[1] : "" + color: offsetHover.hovered ? root.dim : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: footer.has ? root.offsetLabelFor(footer.city[1]) : "" + 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.offsetModeToggleRequested() } + } + } + } + + // ---- jump to a city ----------------------------------------------------- + // Search the whole zone catalogue and turn the globe to whatever is picked. + // A city that is not on the globe is added for this session only - nothing + // is saved, so there is nothing to tidy up later. Want it again, type it + // again. + Item { + id: jumpBar + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: root.jumpHeight + opacity: root.chromeOpacity + + Rectangle { + anchors.fill: parent + anchors.topMargin: Style.space(4) + visible: !root.jumping + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, + jumpHover.hovered ? 0.10 : 0.05) + + Text { + anchors.centerIn: parent + text: "Jump to a city" + color: jumpHover.hovered ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + HoverHandler { id: jumpHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.startJump() } + } + + TextField { + id: jumpField + visible: root.jumping + anchors.fill: parent + anchors.topMargin: Style.space(4) + placeholderText: "Search cities\u2026" + foreground: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + onTextChanged: root.jumpQuery = text + Keys.onEscapePressed: root.stopJump() + Keys.onReturnPressed: root.commitJump() + Keys.onEnterPressed: root.commitJump() + // The results grow upward out of this field, but the first match is at + // the top of them, so Down still moves down the screen as well as down + // the list. Nothing to invert. + Keys.onUpPressed: root.moveJumpSelection(-1) + Keys.onDownPressed: root.moveJumpSelection(1) + } + } + + // Results sit over the globe rather than growing the panel, so the globe + // never resizes underneath the pointer while a search is being typed. + Rectangle { + visible: root.jumping + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: jumpBar.top + height: Math.min(results.implicitHeight + Style.space(8), + parent.height - root.jumpHeight - Style.space(20)) + radius: Style.cornerRadius + color: root.surfaceBase + + Column { + id: results + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(4) + spacing: Style.space(1) + + Repeater { + model: root.jumpMatches + + Rectangle { + id: hit + required property var modelData + required property int index + + // Two marks, not one: a pointer resting over the results must not + // pull the selection away from the arrow keys mid-search. + readonly property bool selected: root.jumpIndex === hit.index + + width: parent.width + implicitHeight: Style.spacing.popupRowHeight + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, + hit.selected ? 0.20 : (hitHover.hovered ? 0.10 : 0.0)) + + Text { + anchors.left: parent.left + anchors.leftMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + text: hit.modelData.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Row { + anchors.right: parent.right + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(6) + + Text { + text: hit.modelData.value + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: Model.utcOffsetLabel(root.offsets[hit.modelData.value]) + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + HoverHandler { id: hitHover; cursorShape: Qt.PointingHandCursor } + TapHandler { + onTapped: { + root.goTo(hit.modelData.label, hit.modelData.value) + root.stopJump() + } + } + } + } + + Text { + visible: root.jumpMatches.length === 0 + width: parent.width + horizontalAlignment: Text.AlignHCenter + topPadding: Style.space(4) + text: root.jumpOptions.length === 0 ? "Loading cities\u2026" : "No matches" + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } +} diff --git a/GlobeModel.js b/GlobeModel.js new file mode 100644 index 0000000..c84e7ba --- /dev/null +++ b/GlobeModel.js @@ -0,0 +1,373 @@ +.pragma library + +// Orthographic globe maths: projection, the day/night terminator, and label +// placement. Kept free of QML types so tests/globe_check.js can exercise it. + +var DEG = Math.PI / 180 + +// Earth's obliquity: the tilt of its rotation axis against the plane of its +// orbit. The subsolar calculation below uses it to place the sun; the hero +// icon uses it to sit at the angle the real thing does. +var AXIAL_TILT = 23.44 + +// Every other vertex of a coastline ring, for drawing while the globe is +// scaled down mid-transition. +// +// The canvas always paints at full size and the item is scaled by a +// transform, so during the zoom the panel is drawing 1337 coastline points +// and then shrinking the result to a few dozen pixels across. Half of them +// land on the same pixel. Rings shorter than the floor are returned whole - +// below it a shape stops being an island and becomes a triangle. +// +// `ring` is flat [lon, lat, lon, lat, ...], and so is the result. +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]) + // Keep the ring closed on the vertex the original ended on, so the coast + // does not develop a straight 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 constant that follows the shell's UI scale. +// +// The large globe's radius, padding and labels all scale with the shell's +// base font size, but its stroke widths and marker radii were fixed pixel +// literals. Raising the base size therefore grew the globe and its names +// while the lines and dots stayed put, so they read as proportionally +// thinner - the small globe already avoided this by deriving its widths +// from its own radius, which the large globe cannot do because its radius +// is hundreds of pixels. +// +// The floor is what the small globe uses: below one pixel a stroke stops +// being a thin line and starts dropping out of the raster altogether. +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 of a lat/lon onto a disc of radius r, as seen from +// a viewpoint over (viewLat, spin). `visible` is false for the far hemisphere. +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 on Earth with the sun directly overhead. Low-precision solar +// position: good to a fraction of a degree, which is far finer than a globe +// a few hundred pixels across can show. +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 } +} + +// The sun's angle above the horizon, in degrees. Negative below it: about +// -6 at the end of civil twilight, -18 at full night. +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 +} + +// True where the sun is above the horizon. The threshold is -0.833 degrees +// rather than 0 to allow for refraction and the sun's disc - the same +// convention sunrise tables use. +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] +} + +// Thin out points that crowd each other on screen. Offered in priority order, +// a point is kept only if it clears everything already kept by minDist pixels +// - so a dense region like western Europe keeps a few cities instead of a +// smear of overlapping dots, and the survivors change as the globe turns or +// resizes. Priority is caller-supplied, which is how tracked cities and +// the current selection 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 +// +// Enough to draw a phase, not enough to predict an eclipse: the mean synodic +// month against a known new moon. Good to a few hours, which is far finer +// than a dot a few pixels across can show. + +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 +} + +// Fraction of the disc lit, 0 at new and 1 at full. +function moonIllumination(phase) { + return (1 - Math.cos(2 * Math.PI * Number(phase))) / 2 +} + +// What people call the shape in the sky. +// +// The four principal phases are instants, not eighths of a cycle: the moon is +// exactly full for a moment and then it is waning. But nobody says "waning +// gibbous" about a disc that is 99.9% lit, so each principal phase is given a +// day either side of its instant and the crescents and gibbous phases fill the +// gaps between. A day is what the eye cannot tell apart at this size, and it is +// also roughly how long people go on saying "full moon" for. +// +// Every cut here is a convention rather than a fact, which is why the width is +// stated once as a named constant instead of being spread through the tests. +var PRINCIPAL_DAYS = 1.0 + +function moonPhaseName(phase) { + var p = Number(phase) + if (!isFinite(p)) return "" + p = p % 1 + if (p < 0) p += 1 + + var w = PRINCIPAL_DAYS / SYNODIC_MONTH + var near = function(target) { + var d = Math.abs(p - target) + if (d > 0.5) d = 1 - d + return d <= w + } + + if (near(0)) return "New moon" + if (near(0.25)) return "First quarter" + if (near(0.5)) return "Full moon" + if (near(0.75)) return "Last quarter" + if (p < 0.25) return "Waxing crescent" + if (p < 0.5) return "Waxing gibbous" + if (p < 0.75) return "Waning gibbous" + return "Waning crescent" +} + +// The outline of the lit part of the moon, as points on a disc of radius r +// centred on the origin. +// +// Two arcs: the limb on the lit side, and the terminator returning. The +// terminator is the same semicircle squashed horizontally by cos(2*pi*phase), +// which is signed - positive gives a crescent bulging away from the limb, +// negative a gibbous bulging past the centre - so one construction covers +// every phase without special cases. +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 +// +// Shared by both globes. The maths lived in MiniGlobe first; the large globe +// was dropping points at the limb with no interpolation, so its coastlines +// and graticule snapped by up to a segment as it turned. + +// The exact point where a segment crosses the horizon, by bisection on the +// projection's own visibility test. Points are [lat, lon]. +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) +} + +// A polyline split into the runs that are on the near side, each beginning +// and ending exactly on the horizon rather than at the last vertex before it. +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 +} + +// One closed polygon for a ring clipped to the visible hemisphere. +// +// Sutherland-Hodgman, keeping the ring whole: splitting it into visible runs +// and closing each separately makes self-intersecting shapes whose area jumps +// as runs split, which reads as continents morphing at the limb. Where the +// shape leaves and re-enters the horizon the limb is followed round rather +// than cut across. `ring` is flat [lon, lat, lon, lat, ...]. +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. Cities are offered in rank order, nearest the disc +// centre first, and a label is kept only if its box clears every label +// already placed - so spinning the globe reveals and hides names instead of +// piling them on top of each other. +// `maxX` is the half-width of the drawing area, in the same centred +// coordinates as the candidates. A label that would run off the right edge is +// placed to the left of its dot instead of being allowed to overflow the +// panel - names near the right limb read inward. +// `gap` is the distance from a city's dot to its name. It defaults to the +// 6px this used before it was a parameter, so any caller that does not scale +// its drawing keeps exactly the layout it had. +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 +} diff --git a/Greetings.js b/Greetings.js new file mode 100644 index 0000000..42a774d --- /dev/null +++ b/Greetings.js @@ -0,0 +1,1045 @@ +.pragma library + +// What people actually say to each other at this hour, where this clock is +// pointing. +// +// The point of the panel is that 8pm is a different social object in Tokyo +// than it is at home, and a number cannot say that. "18:40" is the same +// symbol everywhere; "konbanwa" is the evening itself. +// +// Baked, never fetched. Greetings do not change and a network round trip for +// a hover would be absurd - and the panel's rule is that nothing here needs +// the network to draw. +// +// GENERATED IN PART. The language tables and the country map below are hand +// written; the zone-to-country map is derived from the system's own +// `zone.tab`, with the aliases (`Asia/Calcutta`, `US/Pacific`, and 178 more) +// resolved by matching their compiled zoneinfo against a canonical zone's. +// The picker offers every zone `timedatectl list-timezones` returns, which is +// where the first version of this file went wrong: it covered the 74 cities in +// `cities.json` and quietly greeted the other 500-odd zones in English. Tel +// Aviv said "Good morning". +// +// Three levels, because a flat zone-to-language map of 600 entries explains +// nothing: a zone belongs to a country, a country is greeted in a language, +// and a handful of cities override the country because being right about the +// country would be wrong about the city. +// +// Two things this deliberately does not do: +// +// - It does not pick a language from a country's official list. It picks the +// one you would actually hear said out loud there, which is why Brussels is +// French, Dublin is Irish, Hong Kong is Cantonese, Singapore is Malay and +// Paraguay is Guarani. Where a colonial language really is the everyday one, +// it says so rather than reaching for something more picturesque. +// - It does not invent an hourly split where the language has none. Burmese +// greets you with mingalaba at any hour, Tongan with malo e lelei; those +// tables are one band long on purpose, and a short table is information +// about the language rather than a gap in the data. +// +// Bands run in local hours, ascending, and the first always starts at 0. The +// boundaries are where the language moves, not where a clock does: Spanish in +// Madrid holds "buenas tardes" until 21:00 while Spanish in Lima gives it up +// at 19:00, and that difference is the most interesting thing in this file. +// +// `roman` is a pronunciation, given only where the script is not Latin. It is +// not a translation and it is not a fallback for a missing font - it is there +// so the greeting can be said aloud, which is the only thing a greeting is +// for. + + +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: "" } + ] } +} + +// The language a country is greeted in. Judgement calls, every one of them; +// see the note at the top about which way they lean. +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. Kept short on purpose. +var OVERRIDES = { + "America/Montreal": "fr", + "Asia/Kolkata": "hi", + "Pacific/Honolulu": "haw" +} + +// Aliases the generator put in the wrong country. +// +// ZONE_COUNTRY below was built by matching each zone's *compiled* zoneinfo file +// against the ones in zone.tab. That works for real zones and fails for the +// legacy aliases, because an alias shares its rules with whatever zone the tz +// database linked it to - which is chosen for having identical rules, not for +// being anywhere near it. `Iceland` keeps the same time as Abidjan all year, so +// it matched Burkina Faso and greeted people in French. `NZ` matched Antarctica, +// `Asia/Rangoon` the Cocos Islands, `Africa/Asmera` Djibouti. +// +// Following the tz link table instead does not fix it: that hands back the +// country of the *canonical* zone, which for `Iceland` is Cรดte d'Ivoire and for +// `Pacific/Truk` is Papua New Guinea. There is no rule here, only places. These +// fifteen are named by hand, each one the country the place is actually in. +// +// Two that look wrong and are not: `Antarctica/South_Pole` really is AQ, and +// `Pacific/Ponape` really is FM, though the link table says NZ and SB. And +// `Europe/Simferopol` stays UA, which is a choice rather than a lookup - the +// tz database has moved it to RU, and this greets Crimea in Ukrainian. +// +// tests/greetings_check.js pins every one of these. +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, from the system's own zone.tab, aliases +// included. Baked rather than read at runtime: the panel draws without +// touching the disk, and a zone list that changed under a running shell +// would be a stranger bug than a stale one. +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" + +// The language key a zone is greeted in: the city's own if it has one, its +// country's otherwise. Zones with no country at all - Etc/GMT+5, UTC and the +// rest - are not places and have nobody to greet you; they get the fallback. +function languageFor(zoneId) { + var id = String(zoneId) + if (OVERRIDES[id] && LANGUAGES[OVERRIDES[id]]) return OVERRIDES[id] + // Through countryFor, not the raw table: the alias corrections above are what + // make Iceland Icelandic, and reading ZONE_COUNTRY directly walked straight + // past them. + var key = COUNTRIES[countryFor(id)] + return (key && LANGUAGES[key]) ? key : FALLBACK +} + +// The greeting for a zone at a local hour: { text, roman, language, key }. +// `roman` is "" where the script is already Latin, and callers show it only +// when it is there rather than testing the language. +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 + // Bands ascend from 0, so the last one that has started is the one in force. + 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 } +} + +// For the tests, which check every table rather than a sample of them. +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 +} + +// The country a zone belongs to, "" for the offset-only zones that are not +// places at all. Exposed so the tests can tell a deliberate English from an +// accidental one: English is only ever right when a country asked for it. +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 } diff --git a/LICENSE b/LICENSE index 04d378d..1b29fdf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Omacom +Copyright (c) 2026 Jason Fried Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MiniGlobe.qml b/MiniGlobe.qml new file mode 100644 index 0000000..26559ce --- /dev/null +++ b/MiniGlobe.qml @@ -0,0 +1,165 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import "GlobeModel.js" as Solar + +// A small drawn globe, for use where an icon would otherwise go. +// +// It is drawn rather than glyphed because a glyph cannot spin: rotating a +// flat image about the vertical axis squashes it to a line and flips it, +// which reads as a coin. A sphere keeps its circular outline and moves only +// its surface across it, which is what this does - the disc is constant and +// the graticule and coastlines are re-projected as `spin` advances. +// +// `spin` is the longitude facing the viewer, so animating it 0 -> 360 is one +// full rotation of the earth. Tilt the whole item to lean the axis. +Item { + id: root + + property real spin: 0 + property color color: Color.foreground + // Landmasses are the point of a globe, but below about this size they turn + // to noise, so small instances draw the graticule alone. + readonly property bool showLand: width >= 22 + + property var land: [] + + // Heavier strokes and fuller fills, for when the globe is a centrepiece + // rather than an icon sitting inside a line of text. + 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 here: { + var u = Qt.resolvedUrl(".").toString() + return u.replace(/^file:\/\//, "").replace(/\/$/, "") + } + + onSpinChanged: canvas.requestPaint() + onColorChanged: canvas.requestPaint() + onShowMarkerChanged: canvas.requestPaint() + onMarkerLatChanged: canvas.requestPaint() + onMarkerLonChanged: canvas.requestPaint() + + FileView { + path: root.here + "/world.json" + printErrors: false + onLoaded: { + try { root.land = JSON.parse(text()); canvas.requestPaint() } catch (e) { } + } + } + + Canvas { + id: canvas + anchors.fill: parent + renderStrategy: Canvas.Cooperative + + // Both helpers live in GlobeModel now, shared with the large globe. + 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. Its outline never changes shape, which is the whole + // difference between a turning globe and a flipping coin. + ctx.beginPath() + ctx.arc(0, 0, r, 0, Math.PI * 2) + // Opaque, like the large globe: a globe that lets the panel show + // through is a tinted disc, not an object. + var base = Color.popups.background + ctx.fillStyle = Qt.rgba(base.r + (c.r - base.r) * (root.bold ? 0.16 : 0.13), + base.g + (c.g - base.g) * (root.bold ? 0.16 : 0.13), + base.b + (c.b - base.b) * (root.bold ? 0.16 : 0.13), 1) + 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 = Qt.rgba(c.r, c.g, c.b, + 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] + // Only the major landmasses. Islands are single pixels here and + // 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 = Qt.rgba(c.r, c.g, c.b, root.bold ? 1.0 : 0.85) + ctx.fill() + } + + // "You are here", drawn only while it is on the near side - so it + // sweeps around with the spin and is facing you when it stops. + 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() + // A dark edge, because a daylight sky is nearly the same lightness + // as the filled continents and the dot would otherwise dissolve + // into whichever landmass it happens to be sitting on. + ctx.lineWidth = Math.max(1, r * 0.04) + ctx.strokeStyle = Qt.rgba(0, 0, 0, 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 = Qt.rgba(root.markerColor.r, root.markerColor.g, + root.markerColor.b, 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 = Qt.rgba(c.r, c.g, c.b, root.bold ? 1.0 : 0.95) + ctx.stroke() + } + } +} diff --git a/Model.js b/Model.js new file mode 100644 index 0000000..fb84743 --- /dev/null +++ b/Model.js @@ -0,0 +1,998 @@ +.pragma library + +// Pure helpers for the world clock. Kept free of QML types so they can be +// exercised from plain JS in tests/. +// +// Qt's QML engine has no Intl, so there is no way to ask JavaScript for the +// time in an arbitrary IANA zone. What we can do is ask `date` once for each +// zone's current UTC offset, then tick locally against that offset. Offsets +// only move at a DST boundary, and the offsets are refetched whenever the +// panel opens and every few minutes while it is open, so the displayed time +// stays honest without spawning a process per second. + +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"] + +// "Los Angeles|America/Los_Angeles, Tokyo|Asia/Tokyo" -> [{label, id}, ...] +// A bare "Asia/Tokyo" is accepted too and labelled from its last path segment. +// What a zone id may contain. Ids are handed to `date` as arguments, never +// interpolated into a script, so this is not an injection guard - it keeps +// the list free of entries that could never name a zone. +var ZONE_ID = /^[A-Za-z0-9_+\-\/]+$/ + +// Labels are stored in a "Label|Zone, Label|Zone" string, so a label may not +// carry either delimiter: "Tokyo, Japan" would otherwise come back from +// shell.json as two rows called "Tokyo" and "Japan". +function cleanLabel(label) { + return String(label || "").replace(/[,|]/g, " ").replace(/\s+/g, " ").trim() +} + +function parseZones(spec) { + // An empty setting is a fresh install, not a request for the old hardcoded + // trio: it returns nothing so the panel knows to seed itself. DEFAULT_ZONES + // survives only as the last resort if even the local zone cannot be read. + 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 + // "Label|Zone" or "Label|Zone|w", where the third field marks a city as + // one of the working group the overlap band is computed from. Zone ids + // never contain a pipe, so splitting on it is safe. + var fields = entry.split("|") + var label = "" + var id = "" + var work = false + if (fields.length >= 2) { + label = fields[0].trim() + id = fields[1].trim() + work = String(fields[2] || "").trim() === "w" + } 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, work: work }) + } + return out +} + +// "-0700" -> -420. Returns null for anything unparseable. +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 +} + +// One "America/Los_Angeles|PDT|-0700" line from the probe. +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 } +} + +// The system's own IANA zone. The probe emits it as a "LOCAL|" line; +// parseProbe ignores that line because it carries no offset, so the two +// parsers can share one process without stepping on each other. +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 "" +} + +// Whole probe stdout -> { "America/Los_Angeles": {abbr, offsetMinutes}, ... } +// "UTC+2", "UTC-3:30", and plain "UTC" at Greenwich. +// +// Whole hours drop the minutes: most zones are whole hours, and ":00" on +// every one of them is noise. The odd ones keep them, because a zone that is +// three quarters of an hour off is exactly the case somebody is reading this +// line to find out about. +// +// Not the same thing as the offset on a row. That one is relative to where +// you are - "+9h" means nine hours from here - and this one is absolute, +// because a city you have not added yet has no relationship to you yet. +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) +} + +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 +} + +// Shift the instant by the zone offset, then read it back with the UTC +// getters โ€” that yields the wall-clock fields as that zone would show them. +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() + } +} + +// Whole days between two date triples, as seen from `from`. Uses UTC +// arithmetic on the calendar fields alone 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" +} + +function formatDate(parts) { + return WEEKDAYS[parts.weekday].slice(0, 3) + " " + MONTHS[parts.month] + " " + parts.day +} + +// A rough day/night read, used only to pick the row's icon. +function isDaytime(parts) { + return parts.hour >= 6 && parts.hour < 18 +} + +// Which part of the day a city is in. This is clock-based, not astronomical: +// without a latitude and a network round trip there is no real sunrise to +// consult, so the panel commits to fixed civil hours and says so rather than +// implying a precision it does not have. +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" +} + +// Where the city sits in its own 24 hours, 0..1. Drives the marker on the +// row's daylight strip. +function dayProgress(parts) { + return (parts.hour * 60 + parts.minute) / 1440 +} + +// The lit span of the strip, as two 0..1 fractions. +function daylightStart() { return DAY_START / 24 } +function daylightEnd() { return DAY_END / 24 } + +// Whether the marker sits inside the lit band. Deliberately geometric rather +// than derived from phaseFor(): the band is 06-18, but "dusk" runs to 20:00, +// so a phase test would colour the dot as daylight while it sat visibly out +// in the dark end of the strip. +function inDaylight(progress) { + return progress >= daylightStart() && progress < daylightEnd() +} + +// Offset relative to the viewer's own clock, e.g. "+9h" or "-3.5h". A zone on +// the viewer's own offset gets no label at all: "same time" is the one case +// where the reader already knows the answer, and printing it only widens the +// line and pushes the zone abbreviation away from the edge. +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, or null when the zone has not been probed yet. +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), + // The local hour on its own, for anything that has to say something about + // the time of day rather than print it - the greeting, so far. + 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 +// +// Cities are added and removed from the panel, so the zone list has to make +// the round trip back into the `zones` setting that parseZones() reads. + +// Which row of the list, if any, is the city the globe just selected. +// +// The two views index different things: a row is an index into `zones`, while +// the globe's `selected` indexes its own catalogue of every city it draws. +// The pair that survives the crossing is (label, zone id) - the globe's +// tracked entries are built from exactly those two fields, so matching on +// them needs no shared index space. +// +// Returns -1 for a city the globe can draw but the list does not track, which +// is the common case: the globe knows every zone's main city and the list +// knows only the handful you chose. +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 +} + +// The same crossing as indexOfZone, from the key a caller has been holding on +// to rather than from a label and an id. Used for the list's focus, which has +// to survive `zones` being replaced under it by a reorder or a removal: an +// index into a binding is not an identity, and this project has now been bitten +// by that twice - once on the globe's selection, once here. +// +// An unknown key is -1, which is the same answer as "nothing is focused", and +// is what a removed city should give. +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, " ") +} + +// [{label, id}] -> "Los Angeles|America/Los_Angeles, Paris|Europe/Paris" +function serializeZones(zones) { + var parts = [] + for (var i = 0; i < zones.length; i++) { + var z = zones[i] + if (!z || !z.id) continue + var base = z.label && z.label !== "" ? z.label + "|" + z.id : z.id + parts.push(z.work ? base + "|w" : base) + } + return parts.join(", ") +} + +// Two cities may share a zone, so a listed entry is identified by its label +// and its 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 +} + +// Appends unless the zone is already listed; returns the same array when +// there is nothing to do so callers can skip a needless write. The picker +// only ever offers catalogue zones, but the IPC `add` takes whatever it is +// given, so the id and label are held to what parseZones will read back. +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, work: false }) + return out +} + +// ------------------------------------------------------- the working group +// +// The overlap band is computed only from cities with the briefcase toggled - +// tracking a city and having a colleague in it are different things. + +function toggleWorkAt(zones, index) { + if (index < 0 || index >= zones.length) return zones + var out = [] + for (var i = 0; i < zones.length; i++) { + var z = zones[i] + out.push(i === index ? { label: z.label, id: z.id, work: !z.work } + : { label: z.label, id: z.id, work: z.work }) + } + return out +} + +function workZones(zones) { + var out = [] + for (var i = 0; i < zones.length; i++) if (zones[i].work) out.push(zones[i]) + return out +} + +// Removal is by position: with two rows on the same zone, an id is no longer +// enough to say which one the user 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 +} + +// Move a city to a new position. Used by drag-to-reorder, which commits once +// on release rather than shuffling the list as the pointer moves - changing +// the model mid-drag would rebuild the delegates and drop the gesture. +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 โ€” an empty panel has nothing to show and no +// obvious way back. +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` output -> dropdown options. The city is the +// label and the full IANA name the description, so a search for either +// "tokyo" or "asia" finds it. +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 +} + +// Filter the zone catalog for the inline picker. Matches the city name and +// the full IANA id, so "tokyo", "asia", and "asia/tok" all land on Tokyo. +// Prefix matches on the city sort first โ€” typing "par" should reach Paris +// before Valparaiso. +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) +} + +// ---------------------------------------------------------------- aliases +// +// The tz database ships one representative city per zone, so most places a +// person actually thinks of are missing from `timedatectl list-timezones` โ€” +// there is no Miami, only America/New_York. These are extra search entries +// pointing at the zone that already governs them. Adding a city here is a +// one-line change; the only rule is that the zone must be the one that place +// actually observes, DST rules included. +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" } +] + +// ------------------------------------------------- temperature and currency +// +// worldclock-data.py returns a map keyed by "label|zone" holding a Celsius +// temperature (`c`), an ISO 4217 code (`ccy`), and what one unit of that +// currency is worth in US dollars (`usd`). US cities carry no `ccy` at all โ€” +// quoting dollars in dollars says nothing. + +function factsKey(zone) { + return String(zone.label) + "|" + String(zone.id) +} + +// Which unit to print in. An explicit setting wins; anything else - unset, +// empty, or a value nobody recognises - falls through to what the system +// measures in, so a fresh install reads in the units of the place it is +// running rather than in the author's. +// +// Kept here rather than inline in the panel because it is the part with rules: +// the panel's job is only to ask Qt what the measurement system is. +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" +} + +// Twelve or twenty-four, read off the system's own short time format. Qt's +// pattern is something like "h:mm AP" or "HH:mm"; the AM/PM designator is the +// only 'a' or 'A' the grammar has, once quoted literal text is stripped - some +// locales write their hour separator as "H'h'mm". +// +// The designator rather than the case of the hour letter: 'h' means 1-12 and +// 'H' means 0-23, which is the same answer, but a locale is free to spell a +// 24-hour clock with either while a designator only ever belongs to a +// 12-hour one. +function usesTwentyFourHour(timeFormat) { + var pattern = String(timeFormat === undefined || timeFormat === null ? "" : timeFormat) + .replace(/'[^']*'/g, "") + return !/[Aa]/.test(pattern) +} + +// As with the units: an explicit setting wins, anything else follows the +// system. A stored `false` is an explicit twelve-hour clock and must not fall +// through to the automatic answer, so the boolean is checked before the +// emptiness. +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" +} + +// Currencies span four orders of magnitude against the dollar, so a fixed +// number of decimals either wastes room on the euro or rounds the yen to +// nothing. Show two decimals where that carries real information and four +// where it does not. +function formatMoney(usd) { + if (usd === undefined || usd === null || !isFinite(usd) || usd <= 0) return "" + if (usd >= 0.01) return "$" + usd.toFixed(2) + return "$" + usd.toFixed(4) +} + +function currencyLabel(facts) { + if (!facts || !facts.ccy) return "" + var money = formatMoney(facts.usd) + return money === "" ? facts.ccy : facts.ccy + " " + money +} + +function tempLabel(facts, units) { + return facts ? formatTemp(facts.c, units) : "" +} + +// ----------------------------------------------- overlap band and scrubbing +// +// Two features share this arithmetic. The overlap band answers "when can we +// all talk", and the scrubber answers "if I move the clock, what happens to +// everyone". Both live or die on getting circular time right, so both are +// computed in minutes-of-day with explicit wrap handling rather than by +// juggling Date objects. + +var DAY_MINUTES = 1440 + +// Minutes east of UTC -> that zone's local minute-of-day for a given UTC +// minute-of-day. +function localMinuteOfDay(utcMinute, offsetMinutes) { + return ((utcMinute + offsetMinutes) % DAY_MINUTES + DAY_MINUTES) % DAY_MINUTES +} + +// Is a local minute inside [start, end)? Windows may run past midnight +// (start > end), which is what makes a naive comparison wrong. +function withinWindow(minute, start, end) { + if (start === end) return false + if (start < end) return minute >= start && minute < end + return minute >= start || minute < end // wraps midnight +} + +// UTC minute ranges where every zone is inside its working window at once. +// +// Sampled a minute at a time rather than solved analytically: intersecting N +// circular intervals has enough edge cases (wrapping windows, empty results, +// two separate arcs) that 1440 cheap checks are worth more than clever code. +// Runs that touch both ends of the day are merged, so a window spanning +// midnight comes back as one range with end > 1440 rather than two. +function overlapRuns(offsets, winStart, winEnd) { + if (!offsets || offsets.length === 0) return [] + var inside = [] + var any = false + for (var m = 0; m < DAY_MINUTES; m++) { + var all = true + for (var i = 0; i < offsets.length; i++) { + if (!withinWindow(localMinuteOfDay(m, offsets[i]), winStart, winEnd)) { all = false; break } + } + inside.push(all) + if (all) any = true + } + if (!any) return [] + + var runs = [] + var start = -1 + for (var k = 0; k < DAY_MINUTES; k++) { + if (inside[k] && start < 0) start = k + if (!inside[k] && start >= 0) { runs.push({ start: start, end: k }); start = -1 } + } + if (start >= 0) runs.push({ start: start, end: DAY_MINUTES }) + + // A run ending at midnight and one starting at midnight are one run. + if (runs.length > 1 && runs[0].start === 0 && runs[runs.length - 1].end === DAY_MINUTES) { + var last = runs.pop() + runs[0] = { start: last.start, end: DAY_MINUTES + runs[0].end } + } + return runs +} + +// A UTC run drawn on one city's strip, as 0..1 fractions of its local day. +// A run crossing that city's local midnight becomes two segments. +function localSegments(runs, offsetMinutes) { + var out = [] + for (var i = 0; i < runs.length; i++) { + var a = localMinuteOfDay(runs[i].start, offsetMinutes) + var span = runs[i].end - runs[i].start + if (span >= DAY_MINUTES) { out.push({ x0: 0, x1: 1 }); continue } + var b = a + span + if (b <= DAY_MINUTES) { + out.push({ x0: a / DAY_MINUTES, x1: b / DAY_MINUTES }) + } else { + out.push({ x0: a / DAY_MINUTES, x1: 1 }) + out.push({ x0: 0, x1: (b - DAY_MINUTES) / DAY_MINUTES }) + } + } + return out +} + +// Total minutes covered by the overlap, for "no overlap" vs "18 minutes". +function overlapMinutes(runs) { + var total = 0 + for (var i = 0; i < runs.length; i++) total += runs[i].end - runs[i].start + return total +} + +// How far to move the clock when the pointer lands at `fraction` across a +// city's strip. Picks the nearest occurrence of that local time - dragging +// slightly left of now should mean an hour ago, never twenty-three hours on. +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 to a whole minute *before* splitting it, and wrap after. +// +// Flooring the hour and rounding the minute independently has no carry between +// them, so a value 12 seconds short of the hour printed "6:60 AM" - an hour +// that reads as the one before and a minute that does not exist. The inputs +// were whole minutes when this was written and are not any more: sunrise and +// sunset land wherever they land, and 23:59:42 has to roll the day as well as +// the hour, which is why the wrap comes after the rounding rather than before. +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")) +} + +// ----------------------------------------------- the strip's sunrise arrows +// +// Where an arrow's hit box sits along the bar, and whether the now-marker is +// standing on it. One implementation used twice: to place the arrow, and to +// hide it while the marker is over it. +// +// Here rather than inline in the delegate so the placement is testable: the +// glyph has to land outside the band it points at, and that is a claim about +// arithmetic, not about how it looks. + +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))) +} + +// The marker covers the arrow when their centres are within a marker's radius +// of each other, plus a little air. +function arrowCovered(boxX, boxWidth, markerCentre, markerWidth, slack) { + return Math.abs(markerCentre - (boxX + boxWidth / 2)) < markerWidth / 2 + slack +} + +// ------------------------------------------------- the row's popup chips +// +// A row can show one chip at a time - sunrise or sunset - held as the slot that +// is showing, or NO_CHIP for none. Two rules, because two different things can +// decide it during a single click. +// +// The arrows sit over the strip's scrub area and over the row's reorder grab. +// A press on an arrow does not reach either of them - measured with synthetic +// mouse events in tests/qml/tst_arrows.qml, after a long stretch of assuming it +// must - but a press on the row body does reach the grab, which dismisses, so +// the two decisions can still meet on one click. +// +// Neither rule reads the live value, then. Both are answered from what was +// showing when the press began, which is the same number whichever of them runs +// first; the delivery order is Qt's business and not worth depending on. +// tests/selection_check.js plays both orders through every case. +// +// Written for any number of slots, though only two are drawn. The moon's phase +// was a third for a day - see the note on the scrub area in Panel.qml. +var NO_CHIP = -1 + +// A tap on the arrow in `slot`: it closes that chip if it was the +// one already open, and opens it otherwise. +function chipAfterTap(shownAtPress, slot) { + return shownAtPress === slot ? NO_CHIP : slot +} + +// A release on the row body or the bar. It clears the chip, unless something +// else has already changed it during this same press - in which case that +// decision stands and this one keeps out of the way. +function chipAfterRelease(shownNow, shownAtPress) { + return shownNow === shownAtPress ? NO_CHIP : shownNow +} + +// "+3h", "-45m", "" for now. Shown while scrubbing so the offset from the +// real present is never ambiguous. +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" : "") +} + +// --------------------------------------------------------------- weather +// +// Open-Meteo reports WMO present-weather codes: nearly a hundred of them, +// separating drizzle from freezing drizzle from rain showers. A row has space +// for one glyph, so they collapse to the five states worth telling apart at a +// glance. Fog joins cloud rather than getting its own icon - at this size the +// distinction is not worth a symbol nobody can read. +function weatherKind(code) { + // Number(null) is 0, which is a valid code meaning "clear" - so a missing + // reading would quietly render a sun. Rejected explicitly first. + if (code === null || code === undefined || code === "") return "" + var c = Number(code) + if (!isFinite(c)) return "" + if (c <= 1) return "sunny" // clear, mainly clear + if (c === 2) return "partly" // partly cloudy + if (c === 3 || c === 45 || c === 48) return "cloudy" // overcast, fog + if (c >= 71 && c <= 77) return "snow" // snow fall, snow grains + if (c === 85 || c === 86) return "snow" // snow showers + if (c >= 51 && c <= 67) return "rain" // drizzle, rain, freezing + if (c >= 80 && c <= 82) return "rain" // rain showers + if (c >= 95 && c <= 99) return "rain" // thunderstorms + return "" +} + +// ------------------------------------------------------------ first run +// +// A fresh install should look like a world clock straight away, without +// asking anyone to configure anything. It gets the city you are in plus four +// more, and those four are chosen relative to *you* rather than being a fixed +// list - a constant list would hand someone in Paris two Parises, and would +// give a reader in Tokyo a spread that is really a spread around California. + +var SEED_SEPARATION_MIN = 120 // keep seeds at least two hours off home + +// Well-known destinations, wide enough apart to cover the dial. Rank 1 is +// used only to break ties when two cities sit equally near a target. +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 +} + +// Hours east of home, wrapped to a single turn of the clock: a city 20 hours +// ahead and one 4 hours behind are the same place on a 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) +} + +// Recognisable cities first, kept far enough apart to be worth having. +// +// The alternative - spacing four cities evenly round the dial and taking +// whoever is nearest each mark - gives a tidier spread but a stranger list: +// from Los Angeles it produces Sao Paulo, Cairo, Bangkok and Auckland, which +// is even but reads like a lottery. Going by fame and enforcing a gap gives +// New York, London, Dubai and Tokyo, which is both recognisable and spread, +// because the gap does the spreading. +function pickSeedAt(home, offsets, count, gap) { + var homeOff = offsets[home.id] + var homeLabel = String(home.label || "").toLowerCase() + var picked = [] + for (var i = 0; i < SEED_CANDIDATES.length && picked.length < count; i++) { + // Left un-sorted and swept once per rank, so ties fall out in the order + // the table is written - which is west to east, and stable. + 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 }) + } + } + break + } + return picked +} + +// `offsets` maps zone id to minutes east of UTC, and must include home's own. +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 [] + + // Three hours apart is the goal. Somewhere like Sydney has half the world + // sitting within a couple of hours of it, so the gap relaxes rather than + // handing back a short list. + 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 + } + + // Sorted eastward from home, so the starting list reads as a journey round + // the world rather than in the order the picker happened to find them. + 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, work: false }) + return out +} + +// The whole starting list: the city you are in, then the spread. +function seedZones(home, offsets, count) { + if (!home || !home.id) return [] + var out = [{ label: home.label, id: home.id, work: false }] + var rest = pickSeedZones(home, offsets, count) + for (var i = 0; i < rest.length; i++) out.push(rest[i]) + return out +} diff --git a/MoonDot.qml b/MoonDot.qml new file mode 100644 index 0000000..a51b275 --- /dev/null +++ b/MoonDot.qml @@ -0,0 +1,57 @@ +import QtQuick +import qs.Commons +import "GlobeModel.js" as Solar + +// The night marker on a row's daylight strip, drawn as the moon's current +// phase rather than a plain dot. +// +// The whole disc is always drawn, faintly, so the marker never disappears at +// new moon and never stops being findable on the strip; the lit part is then +// filled solid on top. So the phase reads as a bite taken out of the dot, +// which is the point - it is the same marker carrying one more fact, not an +// extra thing on the row. +Item { + id: root + + property real phase: 0 // 0 new, 0.25 first quarter, 0.5 full + property color color: Color.foreground + // The unlit part is painted in the card's own colour, so the bite reads as + // absence rather than as a second grey shape. + property color shadowColor: Color.popups.background + + onPhaseChanged: canvas.requestPaint() + onColorChanged: canvas.requestPaint() + onShadowColorChanged: 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: the moon is still there when it is new. + ctx.beginPath() + ctx.arc(0, 0, r, 0, Math.PI * 2) + ctx.fillStyle = Qt.rgba(c.r, c.g, c.b, 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() + } + } + } +} diff --git a/Panel.qml b/Panel.qml new file mode 100644 index 0000000..96475bb --- /dev/null +++ b/Panel.qml @@ -0,0 +1,2799 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Model.js" as Model +import "GlobeModel.js" as Solar +import "Sky.js" as Sky +import "Sun.js" as Sun +import "Greetings.js" as Greet + +// A sidebar button that opens a panel of clocks โ€” one row per city. +// +// The times come from a `date` probe rather than from JavaScript: Qt's QML +// engine ships no Intl, so asking JS for the time in Asia/Tokyo is not an +// option. The probe returns each zone's current UTC offset, and the rows tick +// locally against those offsets. Offsets only move at a DST boundary, so the +// probe re-runs when the panel opens and periodically while it stays open, +// and the seconds in between cost nothing. +Panel { + id: root + moduleName: "omacom.elsewhen" + ipcTarget: "omacom.elsewhen" + manageIpc: false + + readonly property var zones: Model.parseZones(setting("zones", "")) + readonly property var zoneIds: { + var out = [] + for (var i = 0; i < zones.length; i++) out.push(zones[i].id) + return out + } + // A fresh install: the setting has never been written. The panel seeds + // itself rather than asking anyone to configure anything. + readonly property bool needsSeed: String(setting("zones", "")).trim() === "" + + // On a fresh install the probe also prices the candidate cities, so the + // whole first-run choice costs one process rather than a second round trip. + readonly property var probeIds: + needsSeed ? zoneIds.concat(Model.seedCandidateZones()) : zoneIds + + // Twelve or twenty-four, from the system's own short time format until + // someone says otherwise by clicking a time. Same shape as the units below: + // the panel asks Qt what the locale does, Model decides what that means and + // what an explicit setting overrides. + readonly property bool autoHour24: + Model.usesTwentyFourHour(Qt.locale().timeFormat(Locale.ShortFormat)) + readonly property bool hour24: Model.resolveHour24(setting("hour24", ""), autoHour24) + + function toggleHour24() { persistSettings({ hour24: !root.hour24 }) } + + // Which offset the rows show: "home" is the distance from where you are + // ("+2h"), "utc" is where the zone actually sits ("UTC-5"). One setting for + // the whole list rather than one per row - the list is read down a column, + // and a column where each row had chosen its own units would be unreadable. + // Clicking any row's offset flips all of them. + readonly property string offsetMode: setting("offsetMode", "home") === "utc" ? "utc" : "home" + + function toggleOffsetMode() { + persistSettings({ offsetMode: root.offsetMode === "utc" ? "home" : "utc" }) + } + + function offsetTextFor(rowData) { + if (!rowData || !rowData.ready) return "" + return offsetMode === "utc" ? Model.utcOffsetLabel(rowData.offsetMinutes) + : rowData.relative + } + + // The Earth's own row at the foot of the list: a 4.54-billion-year day + // reading a minute to midnight. Built, lived with, and switched off on + // 2026-08-29 - it did not land. Off by default, code and tests intact; the + // README says what was wrong with it. + readonly property bool showEarth: setting("showEarth", false) === true + // Degrees in whichever unit the machine measures in, until someone says + // otherwise by clicking a temperature. Qt reads the measurement system from + // the system locale, and only the US system means Fahrenheit - the UK + // reports its own imperial system but has taken its weather in Celsius for + // fifty years. Shipping "F" as the default was reasonable while this ran on + // one desktop and wrong for everywhere else. + // + // The stored setting still wins whenever it says C or F; the automatic + // answer is only what an unset setting falls through to. + readonly property string autoUnits: + Qt.locale().measurementSystem === Locale.ImperialUSSystem ? "F" : "C" + readonly property string units: Model.resolveUnits(setting("units", ""), autoUnits) + + function toggleUnits() { persistSettings({ units: root.units === "C" ? "F" : "C" }) } + // Off by default. The plumbing stays in place - flip this to true and the + // currency comes back with no code change. + readonly property bool showCurrency: setting("showCurrency", false) === true + // The overlap band and its briefcase toggles, shelved for now. All the + // machinery stays - flip this to true and it comes back whole. The time + // scrubber is independent and unaffected. + readonly property bool showOverlap: setting("showOverlap", false) === true + // Sky tint: colour each city name, and each dot on the globe, by the sky + // where it is. Shelved - the colours were pleasant but the rule was never + // visible from the interface, so they read as decoration. Set skyTint true + // to bring it back; Sky.js and its tests are untouched. + readonly property bool skyTint: setting("skyTint", false) === true + + property var probe: ({}) + property string zoneCatalogText: "" + property var facts: ({}) + // Cities jumped to from the globe. They live for this session only - they + // are never written to shell.json, so there is nothing to clean up and no + // delete affordance to design. Want one again? Type it again. + property var sessionCities: [] + property bool factsQueued: false + + // The helper script lives next to this file; resolve it rather than + // hard-coding a path, so a renamed or relocated plugin still works. + readonly property string pluginDir: { + var here = Qt.resolvedUrl(".").toString() + return here.replace(/^file:\/\//, "").replace(/\/$/, "") + } + // Globe mode. Everything it needs lives in Globe.qml and its two data + // files; see README, "Globe mode", for how to remove the feature. + // One switch turns the whole feature off without deleting anything: the + // hero stops being a button and the Loader never activates. + readonly property bool globeEnabled: setting("globeEnabled", true) !== false + // Draw the globe with less in it while it is moving, so the transition and + // the spin hold their frame rate. Off draws everything, always. + readonly property bool smoothMotion: setting("smoothMotion", true) !== false + property bool globeMode: false + + // ---- the zoom ----------------------------------------------------------- + // One number drives the whole transition: 0 is the list, 1 is the globe + // filling the panel. Everything else - the stage height, the knocked-aside + // rows, the globe's scale and position, the cross in the header - is a + // function of it, so nothing can fall out of step with anything else. + // Not readonly: the Behavior below animates it, which a readonly property + // cannot be. The binding still drives it; the Behavior only smooths the + // journey between the two ends. + property real zoom: globeMode ? 1 : 0 + readonly property bool zoomIdle: zoom === 0 || zoom === 1 + + // Hold Shift while clicking the globe to run the whole transition at a + // third speed. Shift is the gesture macOS has used for slow-motion window + // animations for years, so it is the one people already try, and nothing in + // Hyprland claims a bare modifier on a click. Read at the moment of the + // click, so each transition runs at whatever was held when it began. + property bool slowMotion: false + readonly property int slowMotionFactor: 3 + + // The animation cannot derive its own duration from globeMode: globeMode is + // the thing whose change starts it, so whether the binding has updated by + // the time the animation reads it is a race - and losing that race means an + // opening transition runs with the closing duration. These are set first, + // then globeMode is flipped, so the animation always starts correctly + // configured. Every path into globe mode goes through setGlobeMode. + property int zoomDuration: 800 + property int zoomEasing: Easing.OutQuart + + function setGlobeMode(on, slow) { + slowMotion = slow === true + zoomDuration = (on ? 800 : 500) * (slowMotion ? slowMotionFactor : 1) + zoomEasing = on ? Easing.OutQuart : Easing.InOutCubic + globeMode = on + // Turn the globe as it opens, so the two motions - the zoom out of the + // header and the spin round - land together. The Loader is synchronous, + // so the item exists by the time this runs. + if (on && globeLoader.item) showFocusOnGlobe() + } + + // Where the globe should be pointing when it opens: the city the list has + // focused, or home when the list is on home. Without this the globe always + // reset to home, so clicking a row and then opening the globe threw the + // choice away and the two views disagreed about what was selected. + // + // Only when the coordinates are already known. goTo on a city the globe has + // never heard of asks for it as a session city, and a tracked row whose + // geocode has not landed yet does not need adding - it will be there on its + // own once the facts arrive. Home is the honest thing to show until then. + function showFocusOnGlobe() { + var g = globeLoader.item + if (!g) return + if (focusIndex >= 0 && focusKnown) g.goTo(focusZone.label, focusZone.id) + else g.showHome() + } + + // The other direction: a city picked on the globe becomes the list's focus, + // so closing the globe leaves the header and the small globe on the city + // you were just looking at. + // + // A globe city the list does not track has no row to focus, so the list is + // left as it was rather than being forced to home. + function focusFromGlobe(label, zone) { + var i = Model.indexOfZone(root.zones, label, zone) + if (i >= 0) focusOn(i) + } + + readonly property real globeStageHeight: Style.space(378) + + // The centre of the little circle in the header, in the stage's own + // coordinates - the point the big globe grows out of and shrinks back into. + // Guarded, because before layout the mapping is undefined. The unused sum + // makes the binding depend on the layout, so it recomputes if anything + // above the stage changes size. + readonly property var heroCentre: { + var _ = heroIcon.width + heroIcon.height + stage.y + stage.width + var pt = heroIcon.mapToItem(stage, heroIcon.width / 2, heroIcon.height / 2) + return (pt && pt.x !== undefined) ? pt : null + } + readonly property real heroCentreX: heroCentre ? heroCentre.x : stage.width / 2 + readonly property real heroCentreY: heroCentre ? heroCentre.y : 0 + readonly property real heroDiscRadius: Math.max(1, heroIcon.width / 2 - 1) + + // The two globes are the same size and in the same place at the moment they + // trade, so this crossfade is invisible. It exists only so that there are + // never two globes drawn at once. + // Cards are solid too: while they are being shoved aside they pass over one + // another and over the globe's edge, and a translucent card lets whatever is + // behind it show through mid-flight. + readonly property color surfaceBase: Color.popups.background + function solid(t) { + return Qt.rgba(surfaceBase.r + (foreground.r - surfaceBase.r) * t, + surfaceBase.g + (foreground.g - surfaceBase.g) * t, + surfaceBase.b + (foreground.b - surfaceBase.b) * t, 1) + } + + readonly property real bigGlobeOpacity: Math.max(0, Math.min(1, zoom / 0.12)) + + // Rows are shoved aside in sequence rather than together, so it reads as + // something arriving from the top rather than the list simply leaving. Each + // row waits its turn, then covers the rest of the distance on its own. + function knockAt(i) { + var lead = Math.min(0.5, i * 0.09) + return Math.max(0, Math.min(1, (zoom - lead) / (1 - lead))) + } + // Far enough that even the topmost row clears the bottom of the panel: it + // has the whole list below it to fall past. Measured from the list at rest, + // so it does not shrink as the stage does. + readonly property real knockFall: listWrap.implicitHeight + Style.space(220) + + // Squared, so the fall accelerates instead of easing out with the zoom. + // Rows are dropped; they should not decelerate on the way down. + function knockY(i) { + var p = knockAt(i) + return p * p * knockFall + } + // Thrown sideways in alternating directions on the way down, so it reads as + // scattering rather than as the list politely sliding away. + function knockX(i) { return knockAt(i) * (i % 2 === 0 ? -Style.space(95) : Style.space(95)) } + function knockTilt(i) { return knockAt(i) * (i % 2 === 0 ? -14 : 14) } + function knockShrink(i) { return 1 - 0.18 * knockAt(i) } + // No fade at all. Rows leave by falling out of the bottom of the panel, and + // anything that dims on the way reads as dissolving in place rather than + // dropping into the dark. + function knockFade(i) { return 1 } + property bool adding: false + onAddingChanged: if (!adding) scroller.scrollToTop() + property string addQuery: "" + readonly property var addMatches: adding ? Model.searchZones(zoneOptions, addQuery, 6) : [] + readonly property var zoneOptions: Model.zoneOptions(zoneCatalogText, zones) + // The same catalogue with nothing filtered out - the globe's jump box can + // go to a city that is already on the globe as happily as a new one. + readonly property var allZoneOptions: Model.zoneOptions(zoneCatalogText, []) + property double nowMs: Date.now() + + // Scrubbing. `scrubMinutes` shifts every row off the real present; the + // drag sets it absolutely from the pointer position rather than + // accumulating, so a drag maps to a place on the strip, not to a gesture + // history. It holds briefly after release so the answer can be read, then + // returns to now. + // Drag-to-reorder. The list is not touched while the pointer moves - rows + // are shifted visually with a transform, and the new order is committed + // once on release. Reordering mid-drag would replace the model array, + // rebuild every delegate, and drop the gesture. + property int dragIndex: -1 + property real dragOffset: 0 + property real rowPitch: 0 + + // The slot the row would land in. Held rather than recomputed freely: a + // pointer sitting near a boundary would otherwise flip between two slots + // on sub-pixel movement, which is what made the drag feel unsteady. The + // target only changes once the pointer is clearly past the midpoint. + property int dragTarget: -1 + + function updateDragTarget() { + if (dragIndex < 0 || rowPitch <= 0) { dragTarget = -1; return } + var raw = dragOffset / rowPitch + var held = dragTarget < 0 ? 0 : dragTarget - dragIndex + var next = Math.abs(raw - held) >= 0.6 ? Math.round(raw) : held + dragTarget = Math.max(0, Math.min(zones.length - 1, dragIndex + next)) + } + + // How far a row is displaced right now: the dragged one follows the + // pointer, the ones it has passed step aside by exactly one row. + function rowShift(index) { + if (dragIndex < 0) return 0 + if (index === dragIndex) return dragOffset + var t = dragTarget + if (dragIndex < t && index > dragIndex && index <= t) return -rowPitch + if (dragIndex > t && index >= t && index < dragIndex) return rowPitch + return 0 + } + + // On release the row does not vanish from under the pointer: it glides the + // remaining distance into its slot, and the reorder is committed when it + // arrives. That is what makes it read as snapping into place. + function releaseRowDrag() { + if (dragIndex < 0) { cancelRowDrag(); return } + dropAnimation.to = (dragTarget - dragIndex) * rowPitch + dropAnimation.restart() + } + + function commitRowDrag() { + var t = dragTarget + if (dragIndex >= 0 && t >= 0 && t !== dragIndex) + persistSettings({ zones: Model.serializeZones(Model.moveZone(zones, dragIndex, t)) }) + cancelRowDrag() + } + + function cancelRowDrag() { + dropAnimation.stop() + dragIndex = -1 + dragTarget = -1 + dragOffset = 0 + } + + property real scrubMinutes: 0 + property bool scrubbing: false + readonly property double effectiveMs: nowMs + scrubMinutes * 60000 + readonly property string scrubLabel: Model.formatScrubDelta(scrubMinutes) + + readonly property int workStart: Math.round(Number(setting("workStartHour", 9)) * 60) + readonly property int workEnd: Math.round(Number(setting("workEndHour", 17)) * 60) + property int localOffsetMinutes: -(new Date().getTimezoneOffset()) + property string localZone: "" + property bool probed: false + property bool probeQueued: false + + // The bar sizes a widget slot from its root's implicit size; a bare Item + // reports zero and the icon never gets any room to draw in. + 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 rather than a theme role: several themes map the palette + // name "yellow" to something that isn't yellow at all (this one uses it for + // a green), and the dot is meant to read as daylight. + readonly property color daylightMarker: "#E5C736" + readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family + + // UTC minute ranges where every tracked city is inside its working window. + // Empty until every zone has been probed - a partial answer here would be + // wrong rather than merely incomplete. + // Only the cities with the briefcase toggled on. Tracking a city and + // having someone to work with there are different things. + readonly property var workZones: Model.workZones(zones) + + readonly property var overlap: { + if (workZones.length < 2) return [] + var offs = [] + for (var i = 0; i < workZones.length; i++) { + var o = probe[workZones[i].id] + if (!o) return [] + offs.push(o.offsetMinutes) + } + return Model.overlapRuns(offs, workStart, workEnd) + } + + readonly property string overlapText: { + if (!showOverlap) return "" + if (workZones.length < 2) return "" + if (!probed) return "" + if (overlap.length === 0) return workZones.length + " cities, no shared hours" + var parts = [] + for (var i = 0; i < overlap.length; i++) { + parts.push(Model.formatMinuteOfDay(overlap[i].start + localOffsetMinutes, hour24) + + " \u2013 " + Model.formatMinuteOfDay(overlap[i].end + localOffsetMinutes, hour24)) + } + return workZones.length + " cities overlap " + parts.join(", ") + } + + // Lower-cased labels of the cities in the list, for the globe to highlight. + // Matching is by name, not zone: tracking Miami should not light up New + // York just because they share America/New_York. + readonly property var trackedNames: { + var out = [] + for (var i = 0; i < zones.length; i++) out.push(String(zones[i].label).toLowerCase()) + return out + } + + // Tracked cities in the globe's own shape, so it can plot one that is not + // among its built-ins. Coordinates come from the fetcher's geocode cache. + readonly property var trackedCities: { + var out = [] + for (var i = 0; i < zones.length; i++) { + var f = facts[Model.factsKey(zones[i])] + if (!f || f.lat === undefined || f.lon === undefined) continue + out.push([zones[i].label, zones[i].id, f.lat, f.lon, 0]) + } + return out + } + + // Computed once per tick rather than per row. Follows the scrubber, so + // dragging time sweeps the names through dawn and dusk. + readonly property var subsolar: Solar.subsolarPoint(effectiveMs) + + // Tonight's moon, shared by every row - the phase is the same everywhere on + // Earth. Follows the scrubber, so dragging time walks the moon through its + // month. + // Shift-click a moon marker to walk the phase through a full lunation. The + // moon is nearly always somewhere unremarkable, so there is no other way to + // see that the marker really is drawing a phase and not a dot. + 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() + // Cleared before the value, so the tween below does not run the phase + // backwards through the whole month on the way to -1. + moonShowing = false + moonDemo = -1 + } + + // Written as surrogate pairs rather than literal characters: these live + // outside the basic plane, and a stray re-encoding anywhere between here and + // the font turns them into replacement boxes. + function weatherGlyph(zone) { + var f = facts[Model.factsKey(zone)] + if (!f || f.w === undefined) return "" + var kind = Model.weatherKind(f.w) + // white-balance-sunny, not weather-sunny: the latter is a hollow ring + // inside a six-point burst, which at this size is a snowflake sitting + // next to an actual snowflake. This one is a solid disc with short rays. + if (kind === "sunny") return "\udb81\udda8" + 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 "" + } + + // The sky over a city right now, or the plain foreground when the tint is + // off or the city has not been geocoded yet. + function skyColorFor(zone) { + if (!skyTint) return foreground + var f = facts[Model.factsKey(zone)] + if (!f || f.lat === undefined || f.lon === undefined) return foreground + var c = Sky.tint(Solar.solarElevation(f.lat, f.lon, subsolar)) + return c === null ? foreground : c + } + + readonly property var clockRows: Model.rows(zones, probe, effectiveMs, localOffsetMinutes, hour24) + // Where "here" is. Derived from the system zone, which names a + // representative city - so a user in Boca Raton would read "New York". + // `homeCity` overrides it for exactly that case. + readonly property string homeCity: { + var override = String(setting("homeCity", "")).trim() + if (override !== "") return override + return localZone === "" ? "" : Model.labelForZoneId(localZone) + } + + // A text line box has empty space above the capitals, so padding a row + // equally top and bottom *looks* top-heavy: the eye measures to the letter, + // not to the line box. This is that gap, so the top margin can be reduced + // by exactly it. Derived from the font rather than tuned, so it holds at + // any base size. + 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" + } + + // height - descent is the line box top measured down from the baseline; + // tightBoundingRect.y is the cap top, also from the baseline (negative). + // Using ascent alone misses the leading QML puts above it. + readonly property real capGap: Math.max(0, + nameFontMetrics.height - nameFontMetrics.descent + nameCapMetrics.tightBoundingRect.y) + + // Coordinates of the city you are in, once the fetcher has geocoded it. + readonly property var homePlace: { + if (homeCity === "" || localZone === "") return null + var f = facts[Model.factsKey({ label: homeCity, id: localZone })] + if (!f || f.lat === undefined || f.lon === undefined) return null + return f + } + readonly property bool homeKnown: homePlace !== null + readonly property real homeLat: homeKnown ? homePlace.lat : 0 + // The projection centres whatever longitude `spin` holds, so resting at the + // home longitude is what leaves your city facing you. + readonly property real homeLon: homeKnown ? homePlace.lon : 0 + + // The sky over any city, as a hex string, or "" when it cannot be known. + function skyHexFor(zone) { + if (!skyTint || !zone || !zone.id) return "" + var f = facts[Model.factsKey(zone)] + if (!f || f.lat === undefined || f.lon === undefined) return "" + var c = Sky.tint(Solar.solarElevation(f.lat, f.lon, subsolar)) + return c === null ? "" : c + } + + readonly property var homeZone: ({ label: homeCity, id: localZone }) + readonly property string homeSkyHex: skyHexFor(homeZone) + + // Which city the globe is showing. Home is the empty key, which is where it + // starts and where the header line sends it back to. + // + // Held as the city's own "label|id" and not as a row number. `zones` is a + // binding, replaced wholesale on a reorder or a removal, so an index into it + // silently comes to mean a different city: focus Tokyo, drag a row above it, + // and the header, the small globe and the next globe opening would all have + // followed the index to whoever now sat in that slot. The globe learned this + // the hard way with its own selection - see selectedKey in Globe.qml - and + // this is the same fix. + // + // The index is derived rather than stored, so a reorder carries the focus + // with the city and a removal drops it back to home, both without anyone + // having to remember to remap it. + property string focusKey: "" + readonly property int focusIndex: Model.indexOfZoneKey(zones, focusKey) + readonly property var focusZone: + focusIndex >= 0 ? zones[focusIndex] : homeZone + readonly property var focusPlace: { + var f = facts[Model.factsKey(focusZone)] + return (f && f.lat !== undefined && f.lon !== undefined) ? f : null + } + readonly property bool focusKnown: focusPlace !== null + readonly property real focusLat: focusKnown ? focusPlace.lat : 0 + readonly property real focusLon: focusKnown ? focusPlace.lon : 0 + readonly property string focusSkyHex: skyHexFor(focusZone) + + // Turn the globe to a city by the shortest way round, rather than always + // forward - a neighbouring time zone should be a nudge, not a lap. + function focusOn(index) { + var from = heroIcon.spin + focusKey = index >= 0 && index < zones.length ? Model.factsKey(zones[index]) : "" + if (!focusKnown) return + var d = focusLon - from + while (d > 180) d -= 360 + while (d <= -180) d += 360 + globeSpin.stop() + focusSpin.from = from + focusSpin.to = from + d + focusSpin.restart() + } + + // Accent while the clock is off the present, so a scrubbed time can never + // be mistaken for the real one. Shared by all three renderings of the line. + readonly property color hereColor: scrubMinutes !== 0 ? Color.accent : dim + + // Where the header sentence goes and whether it is bent: "flat" is a + // straight line under the title, "under" bends it into a shallow smile + // there, "over" moves it above the title. Under trial, 2026-08-29 - the + // shape is a matter of taste, so it is a switch until the author has seen + // all three, and the two that lose come out with this comment. + property string hereStyle: "over" + // Ends up (a smile) or ends down (an arch). At this rise it reads as a + // tilt of the sentence more than as a curve, which is the point. Arching + // over the title is the one the author picked, 2026-08-29. + property bool hereSmile: false + // Chosen against the width of the sentence, not in the abstract: about a + // fifth of a line's height over ~40 characters is the most it can take + // before the ends start to look like they are falling off. + readonly property real hereRise: Style.space(6) + + // The header sentence, as styled runs. The arc draws it a character at a + // time and has nowhere to put markup; the flat line below builds its markup + // from these same runs, so the two spellings of the sentence cannot drift. + readonly property var hereRuns: { + var runs = [{ text: "It's " + localTime + " here" }] + if (homeCity !== "") { + runs.push({ text: " in " }) + // The name is the way back to your own city, and it is underlined to + // say so - but only when the sky tint is colouring it, since without + // the colour an underline on its own reads as a defect in the line. + runs.push({ text: homeCity, color: homeSkyHex, + underline: homeSkyHex !== "" && focusIndex >= 0 }) + } + // No full stop. The line is a caption on a curve rather than a sentence + // in a paragraph, and a period hanging off the end of the arc reads as a + // speck of dirt on the panel. + return scrubLabel === "" ? runs : runs.concat([{ text: " " + scrubLabel }]) + } + + // Styled rather than split into separate Texts, so the sentence keeps its + // spacing and stays one centred, elidable line. + readonly property string hereText: { + var out = "" + for (var i = 0; i < hereRuns.length; i++) { + var run = hereRuns[i] + var text = run.text + if (run.underline) text = "" + text + "" + if (run.color !== undefined && run.color !== "") + text = "" + text + "" + out += text + } + return out + } + + readonly property string localTime: { + var here = Model.localParts(effectiveMs) + var text = Model.formatTime(here, hour24) + return hour24 ? text : text + " " + Model.meridiem(here) + } + + function tick() { + nowMs = Date.now() + localOffsetMinutes = -(new Date().getTimezoneOffset()) + } + + // Cities are edited from the panel, so every change has to survive a + // restart: write the new list straight back to this widget's shell.json + // entry, the same path the built-in clock uses for its own settings. + 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 === root.zones) return + persistSettings({ zones: Model.serializeZones(next) }) + refresh() + refreshFacts() + } + + // Offsets do not change, so this skips the re-probe that setZones does. + function toggleWork(index) { + persistSettings({ zones: Model.serializeZones(Model.toggleWorkAt(zones, index)) }) + } + + // The label matters as much as the zone. Half a dozen cities share + // America/Los_Angeles, and the picker offers them by name - so committing + // the zone alone put "Los Angeles" on the list when Oakland was chosen. + // Blank still means "name it after the zone", which is what the IPC and a + // bare zone id get. + function addCity(id, label) { setZones(Model.addZone(root.zones, id, label || "")) } + + function startAdding() { + loadCatalog() + addQuery = "" + addIndex = 0 + adding = true + Qt.callLater(function() { searchField.text = ""; searchField.forceActiveFocus() }) + } + + function stopAdding() { + adding = false + addQuery = "" + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + function commitMatch(id, label) { + addCity(id, label) + stopAdding() + } + + // What each search result's zone is doing right now. The probe that feeds + // the rows only knows the cities you already track; a search result is a + // place you have not added yet, and its offset is half of what tells two + // entries in the same zone apart. + // + // Merged rather than replaced on each answer, so a zone that has already + // been asked about keeps its offset while the next probe is in flight. + // Replacing blanked the column on every keystroke, which read as flicker. + property var searchProbe: ({}) + property bool searchProbeQueued: false + + readonly property var searchZoneIds: { + var out = [], seen = {} + for (var i = 0; i < addMatches.length; i++) { + var id = addMatches[i].value + if (!seen[id]) { seen[id] = true; out.push(id) } + } + return out + } + + onSearchZoneIdsChanged: probeSearchZones() + + function probeSearchZones() { + if (searchZoneIds.length === 0) return + if (searchProc.running) { searchProbeQueued = true; return } + searchProc.running = true + } + + function utcLabelFor(zoneId) { + var known = searchProbe[zoneId] + return known === undefined ? "" : Model.utcOffsetLabel(known.offsetMinutes) + } + + // Which match the keyboard is on. An index into addMatches rather than the + // match itself: the list is rebuilt on every keystroke, and an index + // survives that where an object identity does not. + property int addIndex: 0 + + // A changed query is a different list, so the selection goes back to the + // top. Leaving it on row four of a list that has just been replaced means + // Return adds a city nobody looked at. + onAddQueryChanged: addIndex = 0 + onAddMatchesChanged: if (addIndex >= addMatches.length) addIndex = 0 + + // Wraps at both ends. The list is six long at most and entirely on screen, + // so there is no edge to protect anyone from, and wrapping means holding + // Down can never strand the selection against the bottom. + function moveAddSelection(delta) { + var count = addMatches.length + if (count === 0) { addIndex = 0; return } + addIndex = ((addIndex + delta) % count + count) % count + } + + function commitSelectedMatch() { + if (addMatches.length === 0) return + var match = addMatches[Math.max(0, Math.min(addIndex, addMatches.length - 1))] + commitMatch(match.value, match.label) + } + function removeCity(id) { setZones(Model.removeZone(root.zones, id)) } + function removeCityAt(index) { setZones(Model.removeZoneAt(root.zones, index)) } + + // Temperature and currency. The script caches on disk with its own TTLs, so + // calling this on every open is cheap - a warm run does no network at all. + // What the fetcher is asked about: the tracked cities, plus the city you + // are in - it is not a row, but the header needs its coordinates to tint + // the name, and a geocode is cached forever anyway. + readonly property var factsRequest: { + var out = [] + for (var i = 0; i < zones.length; i++) + out.push({ label: zones[i].label, id: zones[i].id }) + if (localZone !== "" && homeCity !== "") + out.push({ label: homeCity, id: localZone }) + for (var j = 0; j < sessionCities.length; j++) + out.push({ label: sessionCities[j].label, id: sessionCities[j].id }) + return out + } + + // Session cities in the globe's shape, once their coordinates land. + readonly property var sessionPlaces: { + var out = [] + for (var i = 0; i < sessionCities.length; i++) { + var z = sessionCities[i] + var f = facts[Model.factsKey(z)] + if (!f || f.lat === undefined || f.lon === undefined) continue + out.push([z.label, z.id, f.lat, f.lon, 1]) + } + return out + } + + function addSessionCity(label, id) { + for (var i = 0; i < sessionCities.length; i++) + if (sessionCities[i].label === label && sessionCities[i].id === id) return + var next = sessionCities.slice() + next.push({ label: label, id: id }) + sessionCities = next + refreshFacts() + } + + // Write the starting list on a fresh install: the city you are in, plus + // four well-known places spread round the clock from it. Chosen relative to + // home rather than fixed, or someone in Paris would be handed a second + // Paris and a spread that is really a spread around California. + 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) + + // Only if the local zone could not be read at all - a machine with no + // timedatectl and no /etc/localtime. Better a working clock than none. + if (seeded.length === 0) seeded = Model.parseZones(Model.DEFAULT_ZONES) + if (seeded.length === 0) return + + persistSettings({ zones: Model.serializeZones(seeded) }) + refresh() + refreshFacts() + } + + function refreshFacts() { + if (zones.length === 0) return + if (factsProc.running) { factsQueued = true; return } + factsProc.running = true + } + + // The pointer lands at `fraction` across a city's strip; move the clock to + // that city's local time. Measured against the unscrubbed present so the + // mapping is absolute and 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() + scrubbing = true + scrubTo(rowData, fraction) + } + + function endScrub() { + scrubbing = false + scrubHold.restart() + } + + function loadCatalog() { + if (zoneCatalogText !== "" || catalogProc.running) return + catalogProc.running = true + } + + // A city added while a probe is in flight would otherwise sit unprobed โ€” + // and unprobed means a row with no time on it โ€” until the next open or the + // five-minute tick. Queue the re-probe instead of dropping it. + function refresh() { + // probeIds, not zoneIds: on a fresh install there are no cities yet, and + // guarding on those would skip the very probe whose answer decides what + // the first cities should be. + if (probeIds.length === 0) return + if (probeProc.running) { probeQueued = true; return } + probeProc.running = true + } + + // The opening spin lands on the home meridian, so it cannot start until it + // knows where home is. + // + // globeSpin's from and to are bindings on focusLon, and a NumberAnimation + // reads them once, when it starts. Home's coordinates arrive from the + // geocode a couple of hundred milliseconds after the panel opens, so + // starting immediately captured to: 0, spun three turns to Greenwich, and + // left the Binding below to snap the globe to the home meridian afterwards. + // That snap is the jump. Same family as the duration trap in CLAUDE.md: an + // animation must not read a property that is still settling. + property bool spinPending: false + + // The meridian the opening spin has to land on, read straight out of the + // facts rather than through focusLon. + // + // focusKnown and focusLon are two bindings over the same focusPlace, and + // when the geocode lands focusKnown flips to true a beat before focusLon + // has the coordinate: measured, known=true with lon=0. Gating on focusKnown + // and then reading focusLon started the spin with to: 0, so it turned three + // times, stopped on Greenwich, and left the Binding to snap the globe to + // home - which is the jump. + 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() + // Set, then start - never leave the endpoints as bindings on a value that + // is still settling. Same rule as setGlobeMode's duration. + globeSpin.stop() + globeSpin.from = lon - 1080 + globeSpin.to = lon + globeSpin.restart() + } + + // Driven by the facts arriving, not by focusKnown: this reads the + // coordinate itself, so there is no ordering to lose. + onFactsChanged: if (spinPending) startOpeningSpin() + + // If the coordinates never turn up - no network on a cold cache - spin + // anyway rather than sitting still. It lands wherever it can. + Timer { + id: spinFallback + interval: 1500 + onTriggered: if (root.spinPending) { root.spinPending = false; globeSpin.restart() } + } + + onOpenedChanged: { + if (opened) { + focusKey = "" + tick(); refresh(); refreshFacts(); loadCatalog() + startOpeningSpin() + } + else { + stopAdding(); setGlobeMode(false, false); scrubbing = false; scrubMinutes = 0 + spinPending = false; spinFallback.stop() + } + } + Component.onCompleted: refresh() + + // One process, one line per zone: "Asia/Tokyo|JST|+0900". The zone names + // are passed as arguments rather than interpolated into the script, so a + // configured zone can never become shell syntax. + // One `date` for however many zones the search is showing - at most six, and + // coalesced while one is in flight, so a fast typist spends one process per + // answer rather than one per keystroke. + Process { + id: searchProc + command: ["bash", "-c", + "for z in \"$@\"; do TZ=\"$z\" date \"+$z|%Z|%z\"; done", "bash"] + .concat(root.searchZoneIds) + 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: probeProc + // One extra line, "LOCAL|", so the panel can name the city you are + // in without spending a row on it. timedatectl is authoritative; the + // /etc/localtime symlink is the fallback where it is absent. + command: ["bash", "-c", + "tz=$(timedatectl show -p Timezone --value 2>/dev/null" + + " || readlink -f /etc/localtime | sed 's|.*/zoneinfo/||'); " + // The local zone is emitted twice: once as the LOCAL marker, and once as + // an ordinary row, so its offset is available like any other city's - + // which the first-run seed needs and cannot ask for in advance. + + "printf 'LOCAL|%s\\n' \"$tz\"; TZ=\"$tz\" date \"+$tz|%Z|%z\"; " + + "for z in \"$@\"; do TZ=\"$z\" date \"+$z|%Z|%z\"; done", "bash"].concat(root.probeIds) + stdout: StdioCollector { + onStreamFinished: { + root.probe = Model.parseProbe(text) + var lz = Model.localZoneFromProbe(text) + if (lz !== "") root.localZone = lz + root.probed = true + if (root.needsSeed) root.seedFirstRun() + root.tick() + Qt.callLater(function() { + if (!root.probeQueued) return + root.probeQueued = false + root.refresh() + }) + } + } + } + + // The pickable zone list. systemd knows it; the zoneinfo tree is the + // fallback for a system without timedatectl. + Process { + id: catalogProc + command: ["bash", "-c", + "timedatectl list-timezones 2>/dev/null || find /usr/share/zoneinfo -type f -printf '%P\\n' 2>/dev/null | grep / | sort"] + stdout: StdioCollector { + onStreamFinished: root.zoneCatalogText = text + } + } + + Process { + id: factsProc + command: root.showCurrency + ? ["python3", root.pluginDir + "/worldclock-data.py", JSON.stringify(root.factsRequest)] + : ["python3", root.pluginDir + "/worldclock-data.py", JSON.stringify(root.factsRequest), "--no-fx"] + stdout: StdioCollector { + onStreamFinished: { + try { + var parsed = JSON.parse(text) + if (parsed && parsed.cities) root.facts = parsed.cities + } catch (e) { + // A broken payload leaves the previous values on screen, which is + // better than blanking every row over one bad fetch. + } + Qt.callLater(function() { + if (!root.factsQueued) return + root.factsQueued = false + root.refreshFacts() + }) + } + } + } + + // Weather moves; the script's own TTL decides whether this costs a request. + Timer { + interval: 900000 + running: root.opened + repeat: true + onTriggered: root.refreshFacts() + } + + // Heavy on the way out, brisk on the way back. OutQuint spends most of its + // travel early and then settles for a long time, which is what gives the + // globe its weight; coming back wants to feel like tidying up, not like + // being pushed. + 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] + } + } + + // Only while the show is running, so the reset to -1 is instant. + 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() + } + + // Hold the scrubbed time briefly after release, so the answer can be read + // before the clock returns to the present. + Timer { + id: scrubHold + interval: 2500 + onTriggered: root.scrubMinutes = 0 + } + + // Only ticks while the panel is on screen; a closed panel has nothing to + // repaint. + Timer { + interval: 1000 + running: root.opened + repeat: true + onTriggered: root.tick() + } + + // Catches a DST rollover during a long-open panel. + 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" } + + // TEMPORARY - remove with the header-arc trial. + function hereline(style: string, smile: string): string { + if (style !== "") root.hereStyle = style + if (smile !== "") root.hereSmile = smile === "smile" + return root.hereStyle + " " + (root.hereSmile ? "smile" : "arch") + } + // The tick timer only runs while the panel is open, so a caller asking + // over IPC with the panel closed would otherwise get whatever minute it + // was when the panel last closed. + function globeStatus(): string { + return JSON.stringify({ + mode: root.globeMode, + active: globeLoader.active, + status: globeLoader.status, + source: String(globeLoader.source), + item: globeLoader.item !== null, + land: globeLoader.item ? globeLoader.item.land.length : -1, + cities: globeLoader.item ? globeLoader.item.allCities.length : -1, + offsets: globeLoader.item ? Object.keys(globeLoader.item.offsets).length : -1, + labels: globeLoader.item ? globeLoader.item.labels.length : -1, + plotted: globeLoader.item ? globeLoader.item.plotted.length : -1, + h: globeLoader.height, + w: globeLoader.width + }) + } + + function globe(): string { + if (!root.globeEnabled) return "disabled" + root.setGlobeMode(!root.globeMode, false) + return root.globeMode ? "globe" : "list" + } + + 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: "๏‚ฌ" + // Leaned over like the real thing, same obliquity as the panel globe. + textRotation: Solar.AXIAL_TILT + 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 at a time rather than closing outright: out + // of the globe first, then out of the panel. The search fields handle + // their own Escape before this ever sees it, so the full ladder is + // search, globe, panel - each press undoing the last thing that opened. + onCloseRequested: { + if (root.globeMode) root.setGlobeMode(false, false) + else root.close() + } + // Space opens the globe and closes it again. + // + // It was a door rather than a switch for a while, on the argument that + // Escape already unwinds one layer at a time and one key per direction is + // easier to hold in the head. That argument is about the keyboard as a + // system; this is about the hand, which is already on the space bar and + // has nowhere to go for the round trip. Escape still works, and still + // unwinds the whole ladder, so nothing is lost by letting Space come back + // the way it went. + // + // The catcher reports Space as "activate", and reports Return as a + // return *and then* an activate. Only Space is meant to work the globe, + // so Return marks itself on the way past and the activate behind it + // steps aside. + 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) } + onTextKey: function(text) { + if (text === "r" || text === "R") root.refresh() + // One key for "add something", whichever list is in front of you: the + // globe's jump box and the panel's city search are the same gesture + // pointed at two different places. + if (text === "+") { + if (root.globeMode && globeLoader.item) globeLoader.item.startJump() + else if (!root.globeMode) root.startAdding() + } + // Then a letter per box, each one named after the box it opens: "j" + // for the globe's "Jump to a city", "a" for the list's "Add a city". + // "+" stays as the one key that means the same thing in both views. + // Neither letter crosses over - "a" on the globe would be adding + // nothing, since a jump is only ever for the session - and a mnemonic + // that stops matching its own word is worse than no mnemonic. + if ((text === "j" || text === "J") && root.globeMode && globeLoader.item) + globeLoader.item.startJump() + if ((text === "a" || text === "A") && !root.globeMode) + root.startAdding() + } + + // The card stops growing at its cap - the screen's height, or space(680), + // whichever is smaller - but the column inside it does not, so whatever + // did not fit used to paint straight past the border. The search results + // were the easy way to see it; a long enough list of cities does it on + // its own. Clipped here so nothing can leave the card, and scrollable so + // what overflows is still reachable. + // + // Not interactive: the rows own the pointer. Drag-to-reorder and the + // scrub strip are MouseAreas with preventStealing, and a Flickable that + // grabbed drags would be fighting them for every gesture. The wheel is + // free, so that is what scrolls. + 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 = Math.max(0, Math.min(contentY, maxScroll)) + } + onHeightChanged: clamp() + onContentHeightChanged: clamp() + + // While the search is open the results are the point of the panel, so + // it scrolls to them rather than leaving the last one clipped against + // the bottom. + // + // Driven by maxScroll rather than fired once when the search opens. + // The card grows into its cap and the Repeater builds the result rows + // over the frames after `adding` flips, so a one-shot call - even a + // Qt.callLater - reads a list that has not finished arriving and + // scrolls to where it used to end. It measured maxScroll 0 doing + // exactly that. Following the property instead lands on each new + // bottom as the list settles. + function scrollToResults() { + scrollAnim.stop() + scrollAnim.from = contentY + scrollAnim.to = maxScroll + scrollAnim.start() + } + + onMaxScrollChanged: if (root.adding) scrollToResults() + + function scrollToTop() { + scrollAnim.stop() + scrollAnim.from = contentY + scrollAnim.to = 0 + scrollAnim.start() + } + + // Set explicitly rather than through a Behavior: the wheel writes + // contentY too, and a Behavior would animate every notch of it into a + // slow chase instead of tracking the fingers. + 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.space(14) + + // ---- Hero. The title reads "World [globe] Clock" with the globe + // itself as the button into globe mode, centred over the panel so it + // sits square above either the list or the globe. + Column { + width: parent.width + spacing: Style.space(2) + + // The same line, above the title. See the note on its twin below. + ArcText { + width: parent.width + visible: root.hereStyle === "over" + runs: root.hereRuns + rise: root.hereRise + smile: root.hereSmile + color: root.hereColor + fontFamily: root.fontFamily + pixelSize: Style.font.caption + MouseArea { + anchors.fill: parent + enabled: root.focusIndex >= 0 + cursorShape: Qt.PointingHandCursor + onClicked: root.focusOn(-1) + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Style.space(9) + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "World" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.weight: Font.DemiBold + } + + // OpticalGlyph is a zero-sized Item that centers its glyph on the + // box, so without an explicit one the icon paints half its width + // to the left of where it sits. + Item { + id: heroIcon + anchors.verticalCenter: parent.verticalCenter + // The centrepiece, so it is drawn larger than a text icon. + implicitWidth: Math.round(Style.font.display * 1.3) + implicitHeight: Math.round(Style.font.display * 1.3) + + // Spun about the vertical axis, the way the earth turns - + // east to west across the face - rather than tumbling in the + // plane of the screen. Edge-on twice per turn, which is what + // sells it as a sphere. + property real spin: 0 + + // At rest the globe sits with your city facing you. The + // animation writes this property directly while it runs, so the + // binding stands down for the duration. + Binding { + target: heroIcon + property: "spin" + value: root.focusLon + when: !globeSpin.running && !focusSpin.running + restoreMode: Binding.RestoreNone + } + + // Two transforms, applied in order: spin about the polar + // axis, then lean the whole globe over by Earth's obliquity. + // Tilting after the spin is what makes the axis itself tilted, + // rather than the globe wobbling upright inside a tilted frame. + // The spin is drawn into the globe now, not applied as a + // transform; all that is left here is leaning the axis over by + // Earth's obliquity. + rotation: Solar.AXIAL_TILT + + // The little globe hands over to a cross once the big one has + // left the circle, so the circle keeps its job: it is the way + // in, and then it is the way out. + Text { + anchors.centerIn: parent + text: "\u00d7" + color: heroHover.hovered ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Math.round(heroIcon.width * 0.8) + // The circle leans with the earth; the cross should not. + rotation: -Solar.AXIAL_TILT + opacity: Math.max(0, Math.min(1, (root.zoom - 0.2) / 0.35)) + visible: opacity > 0 + } + + MiniGlobe { + anchors.fill: parent + opacity: 1 - root.bigGlobeOpacity + visible: opacity > 0 + spin: heroIcon.spin + // Full strength always: at this size a dimmed globe reads as + // washed out rather than as "inactive". + color: root.foreground + bold: true + showMarker: root.focusKnown + markerLat: root.focusLat + markerLon: root.focusLon + // The same sky the header paints the city name with, so the + // dot and the name agree about what time of day it is there. + // Falls back to the accent when the tint is switched off. + markerColor: root.focusSkyHex !== "" ? root.focusSkyHex : Color.accent + } + + // One MouseArea rather than a HoverHandler plus a TapHandler: + // only the classic mouse events carry the keyboard modifiers, + // and slow motion has to know whether Shift was down at the + // moment of the click. Doing hover here too keeps a single + // input item over the circle instead of two that could disagree + // about which one is receiving the pointer. + MouseArea { + id: heroHover + anchors.fill: parent + enabled: root.globeEnabled + hoverEnabled: true + acceptedButtons: Qt.LeftButton + cursorShape: Qt.PointingHandCursor + readonly property bool hovered: containsMouse + onClicked: function(mouse) { + root.setGlobeMode(!root.globeMode, + (mouse.modifiers & Qt.ShiftModifier) !== 0) + } + } + + // Three turns on opening the panel, thrown rather than driven: + // OutQuart puts most of the rotation in the first third and + // lets the rest coast out, which is what a globe flicked by + // hand does. Under a second and a half start to stop. + NumberAnimation { + id: globeSpin + target: heroIcon + property: "spin" + // Three whole turns that land on the home meridian, so it + // stops with your own city in view rather than wherever the + // arithmetic happens to leave it. from and to are set by + // startOpeningSpin immediately before it runs, not bound - + // see the note there. + duration: 1250 + easing.type: Easing.OutQuart + } + + // Turning to a city that was clicked: shorter, and decaying the + // same way so both motions feel like the same globe. + NumberAnimation { + id: focusSpin + target: heroIcon + property: "spin" + duration: 700 + easing.type: Easing.OutCubic + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Clock" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.weight: Font.DemiBold + } + } + + // The curved sentence, below the title. Declared twice rather + // than moved, because a Column orders its children by declaration + // and skips the invisible ones entirely - so which of these two is + // visible is also which side of the title the line lands on. + ArcText { + width: parent.width + visible: root.hereStyle === "under" + runs: root.hereRuns + rise: root.hereRise + smile: root.hereSmile + color: root.hereColor + fontFamily: root.fontFamily + pixelSize: Style.font.caption + MouseArea { + anchors.fill: parent + enabled: root.focusIndex >= 0 + cursorShape: Qt.PointingHandCursor + onClicked: root.focusOn(-1) + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + visible: root.hereStyle === "flat" + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + textFormat: Text.StyledText + text: root.hereText + + // Only live while the globe is showing somewhere else, so it is + // not a dead click target the rest of the time. The whole line is + // the target rather than just the name - it is a small piece of + // text to have to hit exactly. + MouseArea { + anchors.fill: parent + enabled: root.focusIndex >= 0 + cursorShape: Qt.PointingHandCursor + onClicked: root.focusOn(-1) + } + color: root.hereColor + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + // The answer to "when can we all talk", in your own clock. + Text { + width: parent.width + visible: root.zoom < 1 && text !== "" + opacity: 1 - root.zoom + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap // two windows is a long line + text: root.overlapText + color: root.overlap.length === 0 ? root.fainter : Color.accent + font.family: root.fontFamily + font.pixelSize: Style.font.caption + topPadding: Style.space(3) + } + } + + // ---- The stage. The list and the globe share it, and one `zoom` + // value moves between them: the globe grows out of the little circle + // in the header while the rows are shoved aside, and the stage itself + // grows with them so the whole panel opens out rather than swapping + // one view for another. + Item { + id: stage + width: parent.width + // Deliberately not clipped: at the start of the flight the globe is + // still up in the header, above this item, and clipping here would + // cut it in half exactly when it should look like the little circle. + height: Math.max(1, listWrap.implicitHeight + + (root.globeStageHeight - listWrap.implicitHeight) * root.zoom) + + // The rows do get clipped, so that as they are shoved aside they + // slide out of the panel rather than piling up past its edge. + Item { + anchors.fill: parent + clip: true + + Column { + id: listWrap + width: parent.width + spacing: Style.space(14) + + // ---- One row per city. + Column { + id: rowsColumn + visible: root.zoom < 1 + width: parent.width + spacing: Style.space(6) + + Repeater { + // Modelled on the zone list, not on clockRows. clockRows is a + // binding on the (scrubbable, ticking) clock, so using it as the + // model rebuilt every delegate on every tick - and destroyed the + // MouseArea mid-drag the instant scrubbing began, which turned + // every drag into a click. The zone list only changes when a city + // is added or removed. + model: root.zones + + Rectangle { + id: row + required property var modelData + required property int index + + readonly property var rowData: root.clockRows[index] + || ({ ready: false, label: modelData.label, id: modelData.id }) + + // What is said out loud there at this hour. Cheap enough to + // recompute every tick - it is a table lookup - and it has to + // be, because the hour it depends on is what ticks. + readonly property var greeting: + Greet.greeting(rowData.id, rowData.ready ? rowData.hour : 0) + + // Purely visual: a transform does not disturb the Column's + // layout, so the model stays still while the pointer moves. + // Two transforms: the drag offset the row already had, and + // the shove that clears it out of the globe's way. + transform: [ + Translate { + x: root.knockX(row.index) + y: root.knockY(row.index) + }, + Rotation { + origin.x: row.width / 2 + origin.y: row.height / 2 + angle: root.knockTilt(row.index) + }, + Scale { + origin.x: row.width / 2 + origin.y: row.height / 2 + xScale: root.knockShrink(row.index) + yScale: root.knockShrink(row.index) + }, + Translate { + y: root.rowShift(row.index) + // The rows making room ease into place. The dragged row is + // excluded: it must follow the pointer one-to-one or it + // feels like it is lagging behind the cursor. + Behavior on y { + enabled: root.dragIndex !== row.index + NumberAnimation { duration: 130; easing.type: Easing.OutCubic } + } + } + ] + z: root.dragIndex === row.index ? 2 : 0 + opacity: (root.dragIndex === row.index ? 0.9 : 1) + * root.knockFade(row.index) + + Behavior on opacity { NumberAnimation { duration: 120 } } + + // The last city stays put; removing it would leave an empty + // panel with no way back. + readonly property bool removable: root.clockRows.length > 1 + + // Anything you click on this row that is not an arrow or the + // moon puts the chip away. A tooltip that can only be + // dismissed by hitting the same few pixels that opened it is a + // trap: clicking elsewhere is what everyone tries first, and + // until now it did nothing. + // + // Called from each of the row's own handlers rather than from + // a transparent sheet over the row. A sheet would have to sit + // above the drag grab and the scrub strip to see the press, + // and would then be in the way of both. + function dismissChips() { sunArrows.shown = Model.NO_CHIP } + + // The two big surfaces - the reorder grab and the scrub strip + // - lie under the arrows, so a click on an arrow reaches them + // as well. Both therefore dismiss only if nothing opened or + // closed a chip during the same press. + // + // Written so that either delivery order is correct, because + // the order is Qt's business and not worth depending on. Tap + // first: the chip has changed by the time release runs, so + // release leaves it alone. Release first: it dismisses, and + // the tap that follows sets the chip from what it read at + // *press* time rather than from what the dismissal just did - + // which is why every one of these decisions is made against a + // value captured on press. + function dismissUnlessChanged(atPress) { + sunArrows.shown = Model.chipAfterRelease(sunArrows.shown, atPress) + } + + // This city's own sunrise and sunset, for the strip below. + // + // Null until the fetcher's geocode lands, and null forever + // for a city it cannot place. The strip falls back to the + // fixed civil band in that case, which is what it always + // drew - a row with no coordinates should look like the old + // honest convention rather than like a broken new feature. + // + // Keyed to the local day, not to the clock. + // + // The panel's timer reassigns nowMs every second, and this + // used to read effectiveMs directly - so `sun` returned a new + // object every second, `sunMarks` a new array, and the three + // Repeaters below destroyed and rebuilt every delegate once a + // second. CLAUDE.md already lists that trap from the drag + // work; this walked back into it. The cost was not only waste: + // a press on an arrow could outlive the arrow. + // + // Sunrise does not change during a day, so the whole thing + // hangs off local midnight, which changes once a day and when + // the scrubber crosses into another one - which is exactly + // when the band should be redrawn, so the scrubber still + // sweeps it. + // + // The three properties below are the barrier. clockRows is + // rebuilt every tick, so anything reading `rowData` directly + // re-evaluates every tick however little has changed; reading + // it once into a bool, an int and a double stops that there, + // because a property whose value has not changed emits no + // change signal. factsKey is taken from modelData - the zone + // itself - for the same reason. + readonly property bool sunReady: row.rowData.ready + readonly property int sunOffset: + row.rowData.ready ? row.rowData.offsetMinutes : 0 + readonly property double sunDayMs: + row.sunReady ? Sun.localMidnightMs(root.effectiveMs, row.sunOffset) : 0 + + readonly property var sun: { + if (!row.sunReady) return null + var f = root.facts[Model.factsKey(row.modelData)] + if (!f || f.lat === undefined || f.lon === undefined) return null + // Noon of that local day: any instant inside it gives the + // same answer, and noon is the furthest from either edge. + return Sun.sunTimes(f.lat, f.lon, row.sunDayMs + 43200000, + row.sunOffset) + } + + // Where the sun crosses the horizon on this bar, as + // {x, minutes} in bar fractions. Empty above the Arctic + // circle in the weeks when it does not cross at all. + readonly property var sunMarks: { + if (!row.sun || row.sun.kind !== "normal") return [] + var out = [] + var up = Sun.eventMark(row.sun.riseMinutes) + var down = Sun.eventMark(row.sun.setMinutes) + if (up !== null) out.push({ x: up, minutes: row.sun.riseMinutes, + rising: true, name: "Sunrise" }) + if (down !== null) out.push({ x: down, minutes: row.sun.setMinutes, + rising: false, name: "Sunset" }) + return out + } + + // Read from the band the strip actually draws, so the sun can + // never be painted sitting in the dark half of its own bar. + readonly property bool litNow: + row.sun ? Sun.litAt(row.sun, row.rowData.ready ? row.rowData.progress * 1440 : 0) + : (row.rowData.ready && row.rowData.lit) + + // Rows read lightest at midday and darkest at night, so the + // list dims as your eye travels into the small hours. + readonly property real phaseFill: { + var p = row.rowData.ready ? row.rowData.phase : "day" + if (p === "day") return 0.10 + if (p === "night") return 0.035 + return 0.07 + } + + width: parent.width + // Padding is stated once and used on both ends, so the space + // above the name always matches the space below the strip. + readonly property int pad: Style.space(15) + readonly property int stripGap: Style.space(9) + + implicitHeight: (pad - root.capGap) + rowLabels.implicitHeight + + stripGap + strip.height + pad + radius: Style.cornerRadius + color: root.solid(rowHover.hovered ? phaseFill + 0.05 : phaseFill) + border.width: 0 + + // ---- Daylight strip: this city's own 24 hours, midnight to + // midnight, with the civil-daylight window lit and a marker at + // now. Read down the column and you can see who is awake โ€” + // markers sitting in the lit band are in daylight, markers out + // in the dark ends are not. + // + // The band is this city's real day, from its own sunrise to + // its own sunset, computed from the coordinates the fetcher + // already geocoded for the weather. Reykjavik in December and + // Auckland in January are different shapes, and that + // difference is most of what a daylight bar is worth looking + // at. A city with no coordinates yet keeps the old fixed + // civil band rather than showing nothing. + Rectangle { + id: strip + visible: row.rowData.ready + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Style.space(12) + anchors.rightMargin: Style.space(12) + // The same pad the top uses, so the two ends stay in step + // whatever it is set to. + anchors.bottomMargin: row.pad + height: Math.max(2, Style.space(3)) + radius: height / 2 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + + // A Repeater over spans rather than one Rectangle, because + // the count is not always one: polar night draws no band at + // all, and drawing it as a zero-width one would leave a seam + // where the day is supposed to be missing. + Repeater { + model: row.sun ? Sun.litSpans(row.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: Qt.rgba(root.foreground.r, root.foreground.g, + root.foreground.b, 0.28) + } + } + + // The shared working window, drawn in this city's own local + // hours. The same real interval lands at a different place on + // every row - which is the point: one instant, many clocks. + Repeater { + model: root.showOverlap + ? Model.localSegments(root.overlap, row.rowData.offsetMinutes) + : [] + + 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: Color.accent + opacity: 0.75 + } + } + + Rectangle { + id: nowMarker + // Sun and moon are the same size. They are the same + // marker in the same place meaning the same thing - + // "now" - so only their content should differ. + width: Math.max(8, Style.space(10)) + height: width + radius: width / 2 + x: Math.round(parent.width * (row.rowData.ready ? row.rowData.progress : 0) - width / 2) + y: (parent.height - height) / 2 + color: row.litNow ? root.daylightMarker : "transparent" + + // By night the same marker becomes the moon, showing + // tonight's phase. One element, two facts. + MoonDot { + anchors.fill: parent + visible: !row.litNow + phase: root.moonPhase + color: root.foreground + } + + Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.OutCubic } } + } + } + + HoverHandler { id: rowHover } + + // Grab anywhere on the body of the row to reorder. Stops above + // the strip so it never competes with the time scrubber, and is + // declared first so the briefcase and remove buttons - later + // siblings - keep their taps. + MouseArea { + id: grab + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: strip.top + preventStealing: true + cursorShape: root.dragIndex === row.index ? Qt.ClosedHandCursor + : Qt.OpenHandCursor + + property real pressY: 0 + property bool armed: false + + // The pointer measured in the Column, which never moves. + // Reading mouse.y directly is a feedback loop: this MouseArea + // sits inside the row, the row is translated by dragOffset, so + // the local frame slides out from under a stationary pointer + // and the offset chases itself. That was the stutter. + function pointerY(mouse) { + return grab.mapToItem(rowsColumn, 0, mouse.y).y + } + + property int chipAtPress: -1 + + onPressed: function(mouse) { + pressY = pointerY(mouse) + armed = false + chipAtPress = sunArrows.shown + } + + 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 + dropAnimation.stop() + root.dragIndex = row.index + root.dragTarget = row.index + root.rowPitch = row.height + rowsColumn.spacing + } + root.dragOffset = dy + root.updateDragTarget() + } + + onReleased: { + // A drag reorders; a click without one turns the globe. + if (armed) root.releaseRowDrag() + else root.focusOn(row.index) + armed = false + row.dismissUnlessChanged(chipAtPress) + } + onCanceled: { + root.cancelRowDrag() + armed = false + row.dismissUnlessChanged(chipAtPress) + } + } + + Column { + id: rowLabels + anchors.left: parent.left + anchors.leftMargin: Style.space(12) + anchors.right: timeBlock.left + anchors.rightMargin: Style.space(10) + anchors.top: parent.top + anchors.topMargin: row.pad - root.capGap + spacing: Style.space(2) + + // City name with its temperature alongside it, and the value + // of its currency pushed over to sit against the time. A + // RowLayout rather than a Row so the name is the part that + // gives way when the line is tight - the facts are short and + // fixed, the name is not. + RowLayout { + width: parent.width + spacing: Style.space(7) + + // Sizing the name is fiddlier than it looks. QtQuick + // Layouts never resize an item with fillWidth false, so + // without it a long name overruns the row and collides with + // the time instead of eliding. But fillWidth alone would let + // the name grow and shove the temperature to the right, so it + // needs a cap at its natural width. + // + // The cap cannot come from the Text's own implicitWidth - + // that is circular once elide is on, since eliding shrinks + // implicitWidth, which tightens the cap, which elides more. + // TextMetrics measures the unelided text outside the layout. + // Ceil plus a pixel of slack: matching the advance width + // exactly left some names a sub-pixel short and elided them + // with the row half empty. + TextMetrics { + id: nameMetrics + font.family: root.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 + text: row.rowData.label + // A label is whatever was typed or sent over + // IPC; drawn as text, never parsed as markup. + textFormat: Text.PlainText + color: root.skyColorFor(row.modelData) + font.family: root.fontFamily + font.pixelSize: Style.font.subtitle + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + // Click to swap every row between C and F. The same + // gesture as the offset below it: one setting for the + // whole list, changed where you are already looking + // rather than in a settings pane. The reading is the + // control - there is nowhere else a unit could + // sensibly live. + Text { + Layout.alignment: Qt.AlignBaseline + text: Model.tempLabel(root.facts[Model.factsKey(row.rowData)], root.units) + visible: text !== "" + color: tempHover.hovered ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + + HoverHandler { + id: tempHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { onTapped: { root.toggleUnits(); row.dismissChips() } } + } + + // What it is doing there, in one glyph. Sits with the + // temperature rather than anywhere else on the row, + // because the two are the same fact about outside. + Text { + Layout.alignment: Qt.AlignBaseline + text: root.weatherGlyph(row.rowData) + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + Item { + Layout.fillWidth: true + Layout.minimumWidth: 0 + } + + Text { + Layout.alignment: Qt.AlignBaseline + text: Model.currencyLabel(root.facts[Model.factsKey(row.rowData)]) + visible: root.showCurrency && text !== "" + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // The date line, and the greeting that stands in its place + // while the pointer is on the row. + // + // Stacked in one slot rather than added beside it: the row + // cannot change size as the pointer crosses it, or the list + // shifts under the mouse and the row you were reaching for + // moves. Both children keep their space whichever is showing. + Item { + width: parent.width + implicitHeight: dateLine.implicitHeight + + // Nothing to greet before the probe has landed: the hour + // would be a guess, and a wrong greeting is worse than none. + readonly property bool greeting: row.rowData.ready && rowHover.hovered + + // Date and day-offset as separate items rather than one + // joined string: the separator was padded with monospace + // spaces, which set the gap to two character widths on each + // side. As a Row it is a pixel value that scales with the font. + Row { + id: dateLine + spacing: Style.space(4) + opacity: parent.greeting ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 110 } } + + Text { + text: row.rowData.ready ? row.rowData.date : "โ€ฆ" + color: row.rowData.ready && row.rowData.dayLabel !== "" ? root.dim : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: "ยท" + visible: row.rowData.ready && row.rowData.dayLabel !== "" + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: row.rowData.ready ? row.rowData.dayLabel : "" + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + // What you would hear said there, right now. Brighter than + // the date it replaces, because it is the answer to the + // question the row was pointed at rather than a label. + Row { + spacing: Style.space(5) + opacity: parent.greeting ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 110 } } + + Text { + // The panel's own family carries none of these scripts; + // fontconfig substitutes per character, so the line is + // set in whatever the system has for Japanese or Thai + // and only the Latin greetings stay monospaced. + text: row.greeting.text + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + // A pronunciation, not a translation - the point is to be + // able to say it. Absent for the Latin-script languages, + // where the greeting is already its own pronunciation. + Text { + text: row.greeting.roman + visible: text !== "" + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + } + } + + // Drag the strip to move every clock at once. Declared before + // removeSlot so the remove button keeps the corner they share. + MouseArea { + anchors.left: strip.left + anchors.right: strip.right + anchors.verticalCenter: strip.verticalCenter + height: Style.space(18) + cursorShape: Qt.SizeHorCursor + preventStealing: true + enabled: row.rowData.ready + + // Shift-clicking the moon itself runs the phase demo + // rather than scrubbing. Handled here rather than with + // a MouseArea on the marker, because this one sits over + // the strip and would swallow it anyway. + property bool showing: false + + function onMoon(mouse) { + if (row.litNow) return false // it is the sun + var dx = Math.abs(mouse.x - (nowMarker.x + nowMarker.width / 2)) + return dx <= nowMarker.width + } + + // A plain click on the marker used to name the moon's + // phase. It came out again the day after it went in: the + // marker is also the scrub handle, so aiming at a ten-pixel + // dot to read a label meant nudging the whole list two + // minutes off the hour on every miss. The label was worth + // less than the accident cost, and the scrub cursor over the + // marker is a promise about what a press there does. + // + // Shift-click still runs the phase animation, which is a + // deliberate gesture rather than one you land on by aiming + // badly. Solar.moonPhaseName stays too, tested and unused - + // the naming was never the part that was wrong. + property real pressX: 0 + property bool moved: false + property int chipAtPress: -1 + + // No guard here against the arrows drawn on top of this + // area, though it looked as if there had to be one. A + // TapHandler takes only a passive grab, which reads like the + // MouseArea underneath must see the press too and start + // scrubbing the clock to wherever the arrow points. + // + // It does not. Measured with synthetic mouse events - see + // tests/qml/tst_arrows.qml - a click on the item in front + // reaches its TapHandler and this MouseArea records no press + // at all. A guard would have been dead code defending + // against a bug that is not there. + onPressed: function(mouse) { + showing = (mouse.modifiers & Qt.ShiftModifier) !== 0 + && onMoon(mouse) + pressX = mouse.x + moved = false + chipAtPress = sunArrows.shown + if (showing) root.startMoonShow() + else root.beginScrub(row.rowData, mouse.x / width) + } + onPositionChanged: function(mouse) { + if (Math.abs(mouse.x - pressX) > Style.space(2)) moved = true + if (!showing) root.scrubTo(row.rowData, mouse.x / width) + } + onReleased: { + if (!showing) root.endScrub() + row.dismissUnlessChanged(chipAtPress) + showing = false + } + onCanceled: { + if (!showing) root.endScrub() + row.dismissUnlessChanged(chipAtPress) + showing = false + } + } + + // ---- Sunrise and sunset, as a pair of arrows flanking the + // lit band: one pointing up just before the day starts, one + // pointing down just after it ends. The band already says how + // long the day is; the arrows say which end is which, which is + // the one thing a bare band cannot. + // + // Declared after the scrub MouseArea on purpose. That one + // covers the whole strip to drag time, and an earlier sibling + // would never see a press - the same later-sibling rule the + // remove button and the briefcase rely on. + // + // Outside the band rather than on its edge. A mark sitting on + // the boundary reads as part of the band and gets lost in it, + // and the arrow's job is to point *at* that boundary. + Item { + id: sunArrows + anchors.left: strip.left + anchors.right: strip.right + anchors.verticalCenter: strip.verticalCenter + height: Style.space(16) + + // Above everything else on the row. Declaration order puts + // this before the time block, so the zone line was painting + // straight through the chip - it looked like a transparency + // bug and was a stacking one, which is the same picture from + // the reader's side. Later siblings still win the *tap*; + // only the paint order moves. + z: 1 + + // The arrows' geometry, named here because three things + // need to agree on it: the arrow places itself with it, the + // marker hides it with it, and the scrub area underneath + // uses it to tell a press on an arrow from a press on the + // bar. See Model.arrowBox and Model.arrowCovered. + readonly property real boxWidth: Style.space(14) + readonly property real tuck: Style.space(3) + readonly property real coverSlack: Style.space(4) + + // Which chip is showing, if any: 0 for sunrise, 1 for + // sunset, -1 for none. One at a time - two chips on a row is + // the table this was meant to avoid - and they close when the + // pointer leaves the row, so a click never leaves anything + // behind to tidy up. + property int shown: Model.NO_CHIP + Connections { + target: rowHover + function onHoveredChanged() { + if (!rowHover.hovered) sunArrows.shown = Model.NO_CHIP + } + } + + Repeater { + model: row.sunMarks + + Item { + id: arrow + required property var modelData + required property int index + readonly property bool rising: modelData.rising + + // A target you can hit, around a glyph you can barely + // see. The arrow is caption-sized because it sits beside + // a three-pixel bar; the box around it is finger-sized + // because the arrow is a button. + width: sunArrows.boxWidth + height: parent.height + // Tucked in towards the band. The box is finger-sized + // and the glyph sits in the middle of it, so placing the + // box flush against the crossing left the arrow itself + // half a box away - pointing at the boundary from across + // a gap. The nudge closes most of that without letting + // the glyph touch the band, which is the thing it must + // not do: a mark on the edge reads as part of the band. + x: Model.arrowBox(modelData.x, parent.width, width, + sunArrows.tuck, arrow.rising) + + // The marker wins the space it stands on. An arrow + // showing through the sun read as a printing fault + // rather than as two things at the same place, and the + // arrow is the one that can be spared: it marks a + // boundary that is not going anywhere, while the marker + // is the only thing on the bar that says when now is. + // + // nowMarker.x rather than the row's progress, so this + // follows the marker's own eased motion and the arrow + // comes back exactly as it clears - reading progress + // would uncover the arrow while the sun was still + // sliding over it. Passed in as an argument so the + // binding registers the dependency on a value that + // animates. + readonly property bool covered: { + // Read first, so the binding registers a dependency on + // a value that animates. + var markerX = nowMarker.x + if (!row.sunReady) return false + return Model.arrowCovered(arrow.x, arrow.width, + markerX + nowMarker.width / 2, + nowMarker.width, + sunArrows.coverSlack) + } + + // Only while the pointer is on the row. Five rows each + // carrying two arrows all the time is a lot of furniture + // for something looked up rarely; at rest the bar should + // be the shape of the day and nothing else. + // + // Gone rather than faded: invisible is also untappable, + // so a click on the sun scrubs time the way it does + // everywhere else on the bar instead of popping a time + // from an arrow nobody can see. + opacity: rowHover.hovered && !arrow.covered ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + + // Sunrise sits a little high, sunset a little low. + // + // The two glyphs are the same height and the same shape + // reversed, which is the pair the eye is worst at telling + // apart at this size - it has to stop and read the + // arrowhead. Two pixels of offset gives a second, coarser + // cue that needs no reading: the one above the line is + // the one going up. The asymmetry is the information. + transform: Translate { + y: arrow.rising ? -Style.space(2) : Style.space(2) + } + + // If the marker slides over an arrow whose time is open, + // the time goes with it. A chip pointing at nothing is + // worse than no chip. + onCoveredChanged: { + if (arrow.covered && sunArrows.shown === arrow.index) + sunArrows.shown = Model.NO_CHIP + } + + Text { + anchors.centerIn: parent + text: arrow.rising ? "\u2191" : "\u2193" + color: arrowHover.hovered || sunArrows.shown === arrow.index + ? root.foreground : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + HoverHandler { + id: arrowHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { + // What was showing when this press began. Read here + // and not in onTapped, so a dismissal that has already + // run underneath cannot make an open chip look shut. + property int atPress: -1 + onPressedChanged: if (pressed) atPress = sunArrows.shown + onTapped: sunArrows.shown = + Model.chipAfterTap(atPress, arrow.index) + } + } + } + + // The time itself, popped above the arrow that asked for it. + // + // Above and not below, because below is where the pointer + // is: the hand cursor that has just clicked the arrow sits + // squarely on top of the answer. The one place on a row + // guaranteed to be clear is the side the pointer came from. + // + // In its own dark box rather than as bare text. It floats + // over the date line, and text on text is unreadable + // whatever the two colours are; the box gives it a ground of + // its own, and reads as an overlay rather than as one more + // field on an already busy row. + // + // The two colours are literal, not theme roles. This is a + // tooltip and tooltips are inverted everywhere - a dark chip + // with light text is the same shape in a light theme as in a + // dark one, while the theme's own foreground would be dark + // text on a dark chip half the time. + Repeater { + model: row.sunMarks + + Chip { + required property var modelData + required property int index + // Named, not just numbered. A bare "6:16 AM" over a row + // that already has a clock on it has to be worked out + // from which arrow was clicked; the word costs one + // chip's width and removes the question. + label: modelData.name + " " + + Model.formatMinuteOfDay(modelData.minutes, root.hour24) + fontFamily: root.fontFamily + shown: sunArrows.shown === index + centreX: parent.width * modelData.x + // Clear of the bar, in the space the strip already keeps + // above it, overlapping the line above when it needs the + // room. + y: parent.height / 2 - Style.space(4) - height + } + } + } + + // Reserved whether or not the button is showing, so hovering a + // row never nudges the time. + // + // It has been three places. Centred across both label lines it + // floated between them, level with nothing. Level with the name + // line it read as part of the name. In the corner it reads as + // belonging to the row as a whole, which is what it removes. + Item { + id: removeSlot + // The corner itself, not a box floating near it. The + // slot is anchored flush into the row's top-right and + // the glyph is inset within it, so the target covers + // the corner - the easiest place on a row to hit - + // while the mark sits tucked in close to it. + anchors.right: parent.right + anchors.top: parent.top + width: Style.space(24) + height: Style.space(24) + + Text { + id: removeGlyph + // Placed from the corner rather than centred in the + // slot, so how close it reads does not depend on how + // big the target happens to be. + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: Style.space(5) + anchors.topMargin: Style.space(3) + text: "\u00d7" + color: removeHover.hovered ? root.foreground : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + opacity: row.removable && (rowHover.hovered || removeHover.hovered) ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { NumberAnimation { duration: 120 } } + } + + // The pointer, not the reorder grab's open hand: the whole + // row body is a drag handle, so without this the button + // that deletes a city looks like one more piece of it. + // Matches the briefcase beside it. + HoverHandler { + id: removeHover + enabled: row.removable + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: row.removable + onTapped: root.removeCityAt(row.index) + } + } + + // Briefcase: marks a city as one of the working group the + // overlap band is computed from. Faint when off so it stays out + // of the way, accent when on. + Item { + id: workSlot + visible: root.showOverlap + width: root.showOverlap ? Style.space(20) : 0 + anchors.right: removeSlot.left + anchors.top: rowLabels.top + anchors.bottom: rowLabels.bottom + + Text { + anchors.centerIn: parent + text: "๏‚ฑ" + color: row.modelData.work ? Color.accent + : (workHover.hovered ? root.dim + : Qt.rgba(root.foreground.r, root.foreground.g, + root.foreground.b, 0.22)) + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + HoverHandler { id: workHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { root.toggleWork(row.index); row.dismissChips() } } + } + + Column { + id: timeBlock + // Flush with the row's own edge rather than tucked in + // behind the remove button's corner. The corner is a + // hit target that is only ever drawn on hover, and + // reserving column width for it all the time set every + // city in from the right-hand edge - which was invisible + // until the Earth row arrived without a remove button + // and stood a clear 16px further out than the rest. + // Nothing overlaps: the cross sits above the meridiem, + // not beside it. + // + // The briefcase is the exception. It is a real item in + // the line when the shelved overlap band is switched on, + // so with that on the old inset stands. + anchors.right: parent.right + anchors.rightMargin: root.showOverlap ? Style.space(48) + : Style.space(12) + anchors.verticalCenter: rowLabels.verticalCenter + spacing: Style.space(1) + + // Click the time to swap the whole list between 12- + // and 24-hour. Every clock here is read against the + // others, so one row on a different notation would be + // the one thing on screen that could not be compared - + // the same reason the offsets move together. + // + // The hour is already the brightest thing in the row, + // so hover cannot brighten it further; the meridiem + // beside it lifts instead, which is also the part that + // is about to disappear. + Row { + anchors.right: parent.right + spacing: Style.space(3) + + Text { + id: bigTime + text: row.rowData.ready ? row.rowData.time : "--:--" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.heading + font.weight: Font.DemiBold + } + + Text { + anchors.baseline: bigTime.baseline + text: row.rowData.meridiem || "" + visible: text !== "" + color: timeHover.hovered ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + HoverHandler { + id: timeHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { onTapped: { root.toggleHour24(); row.dismissChips() } } + } + + // Zone abbreviation and offset as two items in a Row, so the + // gap between them is a pixel value rather than the width of + // however many monospace spaces were in a joined string. + // Tap to swap every row between "how far from me" and + // "where it actually is". Both are the same fact and + // neither is the useful one twice running, so the line + // holds one and hands over the other on a click rather + // than printing both and doubling the width. + // + // Declared after the drag handle, which covers the body + // of the row: later siblings win the tap, the same way + // the remove button and the briefcase do. + Row { + id: offsetLine + anchors.right: parent.right + spacing: Style.space(5) + visible: row.rowData.ready + + Text { + text: row.rowData.ready ? row.rowData.abbr : "" + color: offsetHover.hovered ? root.dim : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: root.offsetTextFor(row.rowData) + visible: text !== "" + color: offsetHover.hovered ? root.foreground : root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + HoverHandler { + id: offsetHover + cursorShape: Qt.PointingHandCursor + } + TapHandler { onTapped: { root.toggleOffsetMode(); row.dismissChips() } } + } + } + } + } + } + + // ---- The Earth, last in the list and not one of the cities. + // Outside the Repeater rather than appended to the model: that + // is what makes it unremovable and permanently last without a + // special case in the drag, the remove button or the settings. + EarthRow { + id: earthRow + visible: root.showEarth && root.zoom < 1 + width: parent.width + fontFamily: root.fontFamily + foreground: root.foreground + dim: root.dim + fainter: root.fainter + hour24: root.hour24 + capGap: root.capGap + moonPhase: root.moonPhase + // A city at a minute to midnight is the darkest row on the + // list, and this row is at a minute to midnight. It earns its + // shade by the same rule as everyone else. + fill: root.solid(0.035) + fillHover: root.solid(0.085) + + // Shoved aside after the cities and before the adder. + readonly property int knockSlot: root.zones.length + // Qualified by id: a Translate is a child object with its own + // scope, so a bare `knockSlot` resolves to nothing there. + transform: Translate { + x: root.knockX(earthRow.knockSlot) + y: root.knockY(earthRow.knockSlot) + } + } + + // ---- Add a city. Rendered inline rather than in a dropdown + // popup: this panel hangs off a vertical bar low on the screen, and + // the shared dropdown only ever opens downward, so its list ran off + // the bottom edge. Inline, the list grows the panel instead, and + // KeyboardPanel already keeps the panel itself on screen. + Column { + id: adderColumn + visible: root.zoom < 1 + width: parent.width + spacing: Style.space(6) + + // Shoved aside last, after every row above it - including + // the Earth, which sits between it and the cities. + readonly property int knockSlot: root.zones.length + (root.showEarth ? 1 : 0) + transform: Translate { + x: root.knockX(adderColumn.knockSlot) + y: root.knockY(adderColumn.knockSlot) + } + opacity: root.knockFade(adderColumn.knockSlot) + + Rectangle { + width: parent.width + visible: !root.adding + implicitHeight: Style.spacing.controlHeight + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, + addHover.hovered ? 0.10 : 0.05) + + Text { + anchors.centerIn: parent + text: "+ Add a city" + color: addHover.hovered ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + HoverHandler { id: addHover } + TapHandler { onTapped: root.startAdding() } + } + + TextField { + id: searchField + visible: root.adding + width: parent.width + placeholderText: "Search cities\u2026" + foreground: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + onTextChanged: root.addQuery = text + Keys.onEscapePressed: root.stopAdding() + Keys.onReturnPressed: root.commitSelectedMatch() + Keys.onEnterPressed: root.commitSelectedMatch() + // A single-line field does not use the vertical arrows, so + // they are free to drive the list underneath it - which is + // where the eye is anyway once the typing has started. + Keys.onUpPressed: root.moveAddSelection(-1) + Keys.onDownPressed: root.moveAddSelection(1) + } + + Column { + width: parent.width + visible: root.adding + spacing: Style.space(2) + + Repeater { + model: root.addMatches + + Rectangle { + id: match + required property var modelData + required property int index + + // Where the keyboard is. Drawn distinctly from hover + // rather than sharing one highlight with it: the mouse + // resting over the list while someone types must not + // drag the selection out from under the arrow keys, and + // two marks that mean two different things are clearer + // than one that changes hands. + readonly property bool selected: root.addIndex === match.index + + width: parent.width + implicitHeight: Style.spacing.popupRowHeight + radius: Style.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, + match.selected ? 0.20 : (matchHover.hovered ? 0.10 : 0.0)) + + Text { + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: matchZone.left + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + text: match.modelData.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + // The zone and where it is, together: two cities in one + // zone are told apart by name, and two zones with the + // same name by their offset. A Row so the gap is a + // pixel value, and the offset arrives a moment after + // the list does - it costs a process to find out. + Row { + id: matchZone + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(6) + + Text { + text: match.modelData.value + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + + Text { + text: root.utcLabelFor(match.modelData.value) + visible: text !== "" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + HoverHandler { id: matchHover } + TapHandler { + onTapped: root.commitMatch(match.modelData.value, + match.modelData.label) + } + } + } + + Text { + visible: root.addMatches.length === 0 + width: parent.width + horizontalAlignment: Text.AlignHCenter + topPadding: Style.space(6) + text: root.zoneCatalogText === "" ? "Loading zones\u2026" : "No matches" + color: root.fainter + font.family: root.fontFamily + font.pixelSize: Style.font.caption + } + } + + } + } + } + + // ---- Globe mode. Loaded only while it is on screen, so the panel + // pays nothing for it the rest of the time. + Loader { + id: globeLoader + // Kept alive for the whole flight, and loaded early on hover, so + // reading two data files never lands in the middle of the + // animation. + active: root.globeEnabled + && (root.globeMode || root.zoom > 0 || heroHover.hovered) + visible: root.zoom > 0 + opacity: root.bigGlobeOpacity + width: parent.width + height: root.globeStageHeight + source: "Globe.qml" + + // Where the disc sits inside this item: horizontally centred, and + // vertically centred in the canvas above the 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 + + // Scaled about the disc's own centre rather than the item's, so + // the globe grows from where it is drawn and not from the corner + // of the box it happens to live in. The translation then carries + // that centre from the little circle to its resting place. + readonly property real startScale: root.heroDiscRadius / discR + readonly property real zoomScale: + startScale + (1 - startScale) * root.zoom + + transform: [ + Scale { + origin.x: globeLoader.discCX + origin.y: globeLoader.discCY + xScale: globeLoader.zoomScale + yScale: globeLoader.zoomScale + }, + Translate { + x: (root.heroCentreX - globeLoader.discCX) * (1 - root.zoom) + y: (root.heroCentreY - globeLoader.discCY) * (1 - root.zoom) + } + ] + + onLoaded: { + item.bar = Qt.binding(function() { return root.bar }) + 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() { + return root.homeKnown + ? [root.homeCity, root.localZone, root.homeLat, root.homeLon, 0] + : [] + }) + item.skyTint = Qt.binding(function() { return root.skyTint }) + item.offsetMode = Qt.binding(function() { return root.offsetMode }) + item.homeOffsetMinutes = Qt.binding(function() { return root.localOffsetMinutes }) + item.offsetModeToggleRequested.connect(function() { root.toggleOffsetMode() }) + item.smoothMotion = Qt.binding(function() { return root.smoothMotion }) + // Anything but a settled list or a settled globe is a + // transition, in either direction. + item.transitioning = Qt.binding(function() { return !root.zoomIdle }) + item.zoomLevel = Qt.binding(function() { return root.zoom }) + // The footer and the jump bar would be unreadable specks for + // most of the flight; they arrive once the globe has landed. + item.chromeOpacity = Qt.binding(function() { + return Math.max(0, Math.min(1, (root.zoom - 0.74) / 0.26)) + }) + item.jumpOptions = Qt.binding(function() { return root.allZoneOptions }) + item.jumpRequested.connect(function(label, zone) { + root.addSessionCity(label, zone) + }) + item.exitRequested.connect(function() { root.setGlobeMode(false, false) }) + // The globe's search hands the keyboard back the same way the + // panel's own does - otherwise the hidden field keeps it and + // the next Escape goes nowhere. + item.jumpDismissed.connect(function() { + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + }) + item.citySelected.connect(function(label, zone) { + root.focusFromGlobe(label, zone) + }) + // Covers the case where the globe finishes loading after the + // mode was already switched on - a cold start, where the two + // data files land late. + if (root.globeMode) root.showFocusOnGlobe() + } + } + } + } + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..976a87b --- /dev/null +++ b/README.md @@ -0,0 +1,1263 @@ +# Elsewhen + +A world clock for the Omarchy shell: a globe in the bar that opens a panel of +clocks, one row per city, with a spinnable globe behind it. + +## Installing + +```bash +omarchy plugin add https://github.com/omacom/elsewhen.git --enable +``` + +That clones this repository into `~/.config/omarchy/plugins/omacom.elsewhen` +and places the widget in the bar's right section. Without `--enable` it asks +first. To take it out again: + +```bash +omarchy plugin remove omacom.elsewhen +``` + +Everything it needs is already on an Omarchy install: `date` and `timedatectl` +for zone offsets, and `python3` (standard library only) for the temperature and +currency script. That script is the only thing that touches the network, and +it fetches from [Open-Meteo](https://open-meteo.com) (geocoding, weather) and +[open.er-api.com](https://www.exchangerate-api.com/docs/free) (exchange rates) +without an API key; results are cached under `~/.cache/omacom-elsewhen/`. The +globe's coastlines are [Natural Earth](https://www.naturalearthdata.com) 110m +(public domain), shipped as `world.json`. Settings live inline on the widget's +`shell.json` entry; see [Settings](#settings). + +`tests/run` runs every check in `tests/`; pass `--offline` to skip the ones +that need the network. + +## Why it shells out to `date` + +Qt's QML engine has no `Intl`, so JavaScript cannot be asked for the time in +an arbitrary IANA zone. Instead a single `date` probe reports each zone's +current UTC offset and abbreviation, and the rows tick locally against those +offsets. Offsets only move at a DST boundary, so the probe re-runs whenever +the panel opens and every five minutes while it stays open. + +## Reordering + +Grab the body of a row and drag it up or down. The row lifts and follows the +pointer while the ones it passes step aside by exactly one row; the new order +is written to `shell.json` on release. + +Three things make it feel solid, each of which was wrong first time: + +**The pointer is measured in the Column, not in the row.** The grab area sits +inside the row, and the row is translated as it is dragged - so reading +`mouse.y` directly is a feedback loop: the local frame slides out from under a +stationary pointer, the offset chases itself, and the row stutters. +`mapToItem` into the (never-moving) Column gives a stable reading. + +**The target slot has hysteresis.** Recomputing it freely means a pointer +resting near a boundary flips between two slots on sub-pixel movement. The +target now only changes once the pointer is 0.6 of a row past the current +slot, so jitter at a midpoint holds steady. + +**The drop is animated.** On release the row glides the remaining distance +into its slot and the reorder commits when it arrives, rather than the row +vanishing from under the pointer. Rows making room ease aside too - but the +dragged row itself is excluded from that easing, or it lags the cursor. + +The list itself is **not** touched while the pointer moves - rows are +displaced with a `Translate` transform, which is purely visual and leaves the +Column's layout alone. Reordering the model mid-drag would replace the array +the Repeater is built from, rebuild every delegate, and drop the gesture +half-way through. That is exactly the bug that once made the time scrubber +behave like a click. + +The grab area stops above the daylight strip, so vertical reordering never +competes with the strip's horizontal scrub, and it is declared before the +briefcase and remove buttons so those keep their taps. A few pixels of slack +are required before a drag arms, so a click is never a reorder. + +## The first run + +A fresh install has no configuration and asks for none. The first time the +panel opens it writes its own starting list: **the city you are in, plus four +well-known destinations spread round the clock from it** - five rows, so it +looks like a world clock immediately rather than an empty box with an "add a +city" button. + +The four are chosen **relative to home**, which is the whole point. A fixed +list would hand someone in Paris a second Paris, and would give a reader in +Tokyo a spread that is really a spread around California. From Los Angeles the +list comes out as New York, London, Dubai, Tokyo; from Copenhagen as Delhi, +Tokyo, Los Angeles, New York. + +Candidates are taken in order of how well known they are, and one is kept only +if it is at least three hours from home *and* from every city already picked - +so the gap does the spreading rather than a rule about spacing. The obvious +alternative, spacing four cities evenly round the dial and taking whoever is +nearest each mark, gives a tidier spread but a stranger list: from Los Angeles +it produces Sao Paulo, Cairo, Bangkok and Auckland, which is even but reads +like a lottery. If somewhere has too little of the world far enough away - the +Pacific, mostly - the three-hour gap relaxes rather than returning a short list. +The result is sorted eastward, so the list walks round the world. + +It costs one process: the same `date` probe that reads the local zone also +prices the candidate cities on that first run, and never again. The local zone +is emitted twice in that probe - once as the `LOCAL` marker and once as an +ordinary row - so home's own offset is available like any other city's, which +the picker needs and cannot ask for in advance. + +Everything is written to `shell.json` as a normal list, so the first thing +anyone can do is delete or reorder it. `tests/seed_check.js` runs the whole +thing from fifteen different home cities, including UTC, Kathmandu's +forty-five-minute offset, and the Pacific. + +## Adding and removing cities + +The search is driven from the keyboard: type, walk the results with the up and +down arrows, Return adds the highlighted one, Escape closes. The selection +starts on the first match, so the common case - type three letters, press +Return - never needs an arrow at all, and it wraps at both ends because the +list is six long and entirely on screen, with no edge worth protecting anyone +from. A changed query puts the selection back on the first row: the list under +it has been replaced, and Return should not add a city nobody looked at. + +Hover and keyboard selection are drawn as two different marks rather than +sharing one. If they shared, a mouse resting anywhere over the results while +someone typed would drag the selection out from under the arrow keys. + +Both lists name the zone and say where it is: `Asia/Kathmandu UTC+5:45`. The +name alone does not settle it - half a dozen entries share +`America/Los_Angeles`, and `Asia/Kolkata` tells you nothing about India being +half an hour off the hour until it says `UTC+5:30`. The offset is absolute +rather than relative to you, unlike the `+9h` on a tracked row: a city you have +not added yet has no relationship to you yet. + +Search results are not covered by the probe that feeds the rows - that one only +knows the cities you track - so the offsets come from a second `date` call over +whatever the list is currently showing, at most six zones, coalesced while one +is in flight. The answers are merged into what is already known rather than +replacing it, because replacing blanked the column on every keystroke and read +as flicker. + +The globe's own **jump box** works the same way, for the same reasons - arrows, +Return, Escape, selection on the first match, wrapping over five results. Its +results grow upward out of the field rather than down from it, but the first +match is still at the top of them, so Down moves down the screen and down the +list at once and nothing needed inverting. It was already committing the label +alongside the zone, so it never had the bug below: typing "Ber" and pressing +Return on the fifth result goes to Cologne rather than to Berlin. + +**A zone is not a city**, and this is where that bites. Six of the picker's +entries share `America/Los_Angeles`; the name is the only thing telling Oakland +from Las Vegas from Los Angeles. Committing a choice used to pass the zone +alone, so picking Oakland put "Los Angeles" on the list - true of clicking too, +and quietly wrong for as long as the picker has existed. It took keyboard +selection to make it obvious, because the highlight says out loud which row you +chose. `Model.addZone` had always accepted a label; the panel was dropping it on +the way in. + + +`+ Add a city` opens an inline search over the system's zone list. Clicking a +result adds it; hovering a row reveals a `ร—` in its top-right corner to remove +it. The last row cannot be removed. + +The `ร—` is placed from the corner rather than centred in its target, so how +tucked-in it reads does not depend on how big the target is. The target itself +is anchored flush into the corner and is larger than the mark, because a +corner is the easiest place on a row to hit. Changes are written straight back to this widget's entry in +`~/.config/omarchy/shell.json`, so they survive a restart. + +The search is rendered inline rather than in a dropdown popup on purpose: the +shared `SearchableDropdown` only ever opens downward, and this panel hangs off +a vertical bar low on the screen, so its list ran off the bottom edge. + +Inline, the list grows the panel instead - but only up to the card's cap, the +screen's height or `space(680)`, whichever is smaller. Past that the column +used to keep growing and paint straight over the border, which is what the +search results made obvious and what a long enough list of cities would have +done on its own. The content is now clipped to the card and scrolls, and while +the search is open the panel follows the results down so the last one is not +left clipped against the bottom edge. + +Scrolling is the wheel only. The rows own the pointer - drag-to-reorder and +the scrub strip are `MouseArea`s with `preventStealing` - so an interactive +`Flickable` would be fighting them for every gesture. + +### City aliases + +The tz database ships one representative city per zone, so most places people +actually search for are missing โ€” there is no `Miami`, only +`America/New_York`. `CITY_ALIASES` in `Model.js` adds extra search entries +pointing at the zone that governs them. Adding a city is a one-line change; +the only rule is that the zone must be the one that place actually observes, +DST rules included. + +Two cities may share a zone (Miami and Boca Raton are both +`America/New_York`), so a row is identified by its label and zone together, +and removal is by position. + +## Day and night + +Each row carries a strip showing that city's own 24 hours, midnight to +midnight. The civil-daylight window is lit, and a marker sits at the city's +current time โ€” so reading down the column shows who is awake: markers inside +the lit band are in daylight, markers out at the dark ends are not. Rows are +also filled by phase, lightest at midday and darkest at night, and the marker +turns gold while it is inside the lit band and stays white outside it. + +Whether the marker is "in daylight" is decided geometrically, from its +position against the band, not from the phase name: the band ends at 18:00 but +`dusk` runs to 20:00, so a phase test would paint the dot gold while it sat +visibly out in the dark end of the strip. The gold is a literal colour rather +than a theme role, because several themes map the palette name `yellow` to +something that isn't yellow (this one uses it for a green). + +The lit band is fixed civil hours (06:00-18:00), not real sunrise. Without a +latitude and a network call there is no true sunrise to plot, and inventing +one would be worse than an honest convention. + +## The daylight strip + +Each row carries a 24-hour bar, midnight to midnight in that city's own clock, +with the daylight lit and a marker at now. Read down the column and you can see +who is awake. + +The lit band is the city's real day. It was a fixed 06-18 convention for a long +time, which was honest while the panel had no coordinates - but the fetcher +geocodes every city for the weather, so they were already there. Reykjavik's +four-hour December day and Auckland's long January one are different shapes, and +that difference is most of what a daylight bar is worth looking at. A city the +geocoder has not placed yet keeps the old fixed band, so a row without +coordinates looks like the old convention rather than like a broken new feature. + +`Sun.js` computes it, built on the globe's own `subsolarPoint` rather than a +second copy of the astronomy. Declination comes straight off that function and +the equation of time is recovered from it - `subsolarPoint` builds its meridian +as `lon = -15 * (utcHours - 12) - eot`, so running that relation backwards hands +the correction back with nothing new to keep in step. Local solar noon is found +by iterating: the subsolar meridian sweeps west at a steady 15 degrees an hour, +so the gap between where the sun is and where you want it converts straight into +a time correction, and three passes put the residual under a second. + +`tests/sun_check.js` checks it against Open-Meteo's published sunrise and sunset +for thirteen cities - both hemispheres, both solstices, the equator, and +Kashgar, which runs on Beijing time and sees the sun rise at 08:23 by the clock. +The reference rows are held in the test verbatim so it stays offline. + +Inside a minute at mid-latitudes, and about four minutes at Nuuk and Anadyr, +both a little past 64 degrees north. That is the shared low-precision solar +position doing what it says: it is good to a fraction of a degree, and near the +poles the sun crosses the horizon at such a shallow angle that a fraction of a +degree is minutes of time. Four minutes is invisible on a three-pixel bar, but +the chip prints a time, so the two high-latitude cities are in the test with the +tolerance they actually need rather than left out to keep the number tidy. + +A bar can be lit at both ends, and that is not a drawing fault. Reykjavik on the +June solstice sets four minutes after midnight, so the first four minutes of the +same day are lit too - by the sun that rose the morning before. The band was +clipped to the bar at first, with a comment explaining that a bar lit at both +ends reads as two days; the reasoning was wrong, and it drew the sun below the +horizon at an hour when the solar model puts it at -0.689 degrees, above it. The +day is now drawn where it falls and again a day either side, and only the parts +that land on the bar survive. + +The arrows do not wrap with it. A sunset at 00:04 belongs to the next bar along, +and a mark pinned to this one's edge would claim the sun set at midnight. + +Above the Arctic circle there is no sunrise to plot, and that is a real answer +rather than an error: `kind` comes back as `midnightSun` or `polarNight`, the +band is the whole bar or none of it, and no arrows are drawn. Longyearbyen in +June and in December are both in the test. + +### The arrows + +An up arrow just before the band and a down arrow just after it, shown while the +pointer is on that row and outside the lit part rather than on its edge - a mark sitting on the boundary reads as part of +the band and gets lost in it, and the arrow's job is to point at that boundary. +The band already says how long the day is; the arrows say which end is which, +which is the one thing a bare band cannot. They keep off the resting state: five +rows each carrying two arrows all the time is a lot of furniture for something +looked up rarely. + +They are tucked in close to the band. The tap target is finger-sized and the +glyph sits in the middle of it, so a box placed flush against the crossing left +the arrow itself half a box away, pointing at the boundary from across a gap. +The nudge closes most of that without letting the glyph touch the band - which +is the one thing it must not do, since a mark on the edge reads as part of it. + +Sunrise sits two pixels high and sunset two pixels low. The glyphs are the same +height and the same shape reversed, which is the pair the eye is worst at +telling apart at this size - it has to stop and read the arrowhead. The offset +gives a second, coarser cue that needs no reading: the one above the line is the +one going up. The asymmetry is the information. + +Click one and it prints `Sunrise 6:16 AM` in a small dark chip above the bar - +named, not just numbered, because a bare time floating over a row that already +has a clock on it has to be worked out from which arrow was clicked. One at a +time, and it clears when the pointer leaves the row: five rows each printing two +more clock times is a table, and this panel is not one. + +Above and not below, because below is where the pointer is - the hand cursor +that just clicked the arrow lands squarely on top of the answer. The chip floats +over the date line, which is why it has a ground of its own: text over text is +unreadable whatever the two colours are. + +The chip was opaque from the start and still had the zone line showing through +it. That was stacking, not transparency: `sunArrows` is declared before +`timeBlock`, so the offset painted over the top of it. `z: 1` on the arrows +fixes the paint order without touching the tap order, which still comes from +declaration order. Worth remembering that a stacking bug and an alpha bug are +the same picture from the reader's side. Its two colours are literal rather than +theme roles, because a tooltip is inverted everywhere - a dark chip with light +text is the same shape in a light theme as in a dark one, while the theme's own +foreground would be dark text on a dark chip half the time. + +They are declared after the strip's scrub `MouseArea`. That one covers the whole +bar to drag time, and an earlier sibling would never see a press - the same +later-sibling rule the remove button and the briefcase rely on. + +An arrow hides while the marker is standing on it. Two things drawn at the same +point read as a printing fault rather than as two things at the same place, and +the arrow is the one that can be spared: it marks a boundary that is not going +anywhere, while the marker is the only thing on the bar that says when now is. +It follows `nowMarker.x` and not the row's progress, so the arrow comes back +exactly as the sun clears it rather than while the sun is still sliding over - +the marker's motion is eased, and progress is where it is going, not where it +is. Hidden rather than faded, because invisible is also untappable: a click on +the sun should scrub time the way it does everywhere else on the bar. If the +marker covers an arrow whose time is open, the chip closes with it. + +### Naming the moon (built, then taken out) + +Clicking the marker named the moon's phase in the same chip. It lasted a day. +The marker is also the scrub handle, so aiming at a ten-pixel dot to read a +label meant nudging the whole list two minutes off the hour on every miss - and +the scrub cursor sitting over the marker is a promise about what a press there +does. The label was worth less than the accident cost. + +Shift-clicking the moon still runs the phase animation. That is a deliberate +gesture rather than one you land on by aiming badly. + +`moonPhaseName` stays in `GlobeModel.js`, tested and unused: the naming was +never the part that was wrong. It is checked in `tests/moon_check.js` against +the same eclipses that pin the phase itself - a solar eclipse can only happen at +new moon and a lunar one only at full, so the name at those instants is not a +matter of taste. The four principal phases are instants rather than eighths of a +cycle, so each is given a day either side; nobody says "waning gibbous" about a +disc that is 99.9% lit. + +Clicking anywhere else on the row puts the chip away. A tooltip that can only be +dismissed by hitting the same few pixels that opened it is a trap - clicking +elsewhere is what everyone tries first. + +The arrows sit over the strip's scrub area and over the row's reorder grab, so +it looked as though a press on an arrow would reach all three, and that clicking +"sunrise" would drag every clock in the list back to sunrise. It does not: a +click on the arrow stops at the arrow, and the bar underneath records no press +at all. That is measured rather than reasoned - see **Testing what the pointer +does** below - and it means the scrub bar needs no guard against the arrows +drawn on top of it. + +A press on the row body does still reach the grab, which dismisses, so the two +decisions can meet on one click. So neither rule reads the live value. Both are answered from what was showing +when the press began, which is the same number whichever runs first. The two +rules are `Model.chipAfterTap` and `Model.chipAfterRelease`, and +`tests/selection_check.js` plays a click through both delivery orders across +every case: opening, closing, swapping one chip for another, and a release that +must not undo a chip opened during the same press. + +The marker reads its lit state from the same span the band draws, so the sun can +never be painted sitting in the dark half of its own bar. + +## Weather + +Each row shows one glyph beside its temperature: sun, partly cloudy, cloud, +rain, or snow. It sits with the temperature because the two are the same fact +about being outside. + +Open-Meteo reports WMO present-weather codes - nearly a hundred of them, +separating drizzle from freezing drizzle from rain showers. `Model.weatherKind` +collapses them to those five, which is all a row has space for; fog joins cloud +rather than getting a symbol nobody could read at this size. The code travels +with the temperature in the same reading, so the two cannot disagree. + +The sun is `white-balance-sunny`, not `weather-sunny`. The obvious one is a +hollow ring inside a six-point burst, which at eleven pixels is a snowflake - +sitting directly beside an actual snowflake. Candidates were rendered at the +real size and compared before choosing; the one in use is a solid disc with +short rays, which cannot be mistaken for anything else in the set. + +One trap worth naming, caught by `tests/weather_check.js`: `Number(null)` is +`0`, and `0` is the WMO code for *clear sky* - so a missing reading would have +quietly rendered a sun. Absent values are rejected before the conversion. + +## The hour's greeting + +Point at a row and its date line turns into what people there would actually +be saying to each other at that moment: Tokyo at midday says ใ“ใ‚“ใซใกใฏ, at +eight in the evening ใ“ใ‚“ใฐใ‚“ใฏ, at three in the morning ใŠใ‚„ใ™ใฟ. The +pronunciation follows it in grey, because a greeting you cannot say out loud is +only a decoration. + +This exists because the number is the one thing about a time zone that does not +travel. "18:40" is the same symbol in every city on the list and says nothing +about what 18:40 *is* there. The greeting is the hour as the people in it +experience it, and it turns a column of digits into six places where the +evening is arriving at different times. + +The table is three levels deep - a zone belongs to a country, a country is +greeted in a language, and a handful of cities override their country - and it +is baked into `Greetings.js` and never fetched. Greetings do not +change, and a network round trip on a hover would be absurd - the panel's +standing rule is that nothing it draws needs the network. + +Two decisions inside it are judgement calls, and are meant to be: + +**It picks the language you would hear, not the one on the paperwork.** +Brussels is French, Dublin is Irish, Hong Kong is Cantonese rather than +Mandarin, Nairobi is Swahili. An official-languages list would have made +several of these duller and one or two of them wrong. + +**It does not invent an hourly split where a language has none.** Burmese +greets you with แ€™แ€„แ€บแ€นแ€‚แ€œแ€ฌแ€•แ€ซ at any hour and Thai barely moves, so those tables +are one and two bands long. A short table is a fact about the language, not a +gap in the data. + +The boundaries are where the language moves rather than where a clock does, +and the most interesting thing in the file is that the same language disagrees +with itself: Spanish in Madrid is still saying *buenas tardes* at 20:00, while +Spanish in Lima gave it up an hour earlier. Vienna gets *GrรผรŸ Gott* and Zurich +*Grรผezi* where Berlin gets *Guten Tag*. Indonesian has five bands where English +has four - *siang* and *sore* split an afternoon English keeps whole. + +The panel's own monospace family carries none of these scripts. Fontconfig +substitutes per character, so a Japanese greeting is set in whatever the system +has for Japanese and only the Latin ones stay monospaced; Arabic sits +right-to-left with its pronunciation in a separate item beside it, which is +what keeps the two from being reordered into each other. + +**The bug that shaped the file.** The first version mapped the 74 cities in +`cities.json` and greeted everything else in English, which held up until +somebody added Tel Aviv and was wished "Good morning". The picker does not +offer `cities.json`; it offers every zone `timedatectl list-timezones` returns, +which is 598 of them. The fix was the whole list, by way of the system's own +`zone.tab` - and the more useful half of the fix was in the test, which had +been checking coverage against the wrong list and passing. English is now +checked at the source rather than in the result: a zone may only be greeted in +English because some country asked for English, and a zone missing from the +table is a failure rather than a silent fall-through. + +The overrides earn their place by being few. Honolulu is Hawaiian though the +United States is English, Montreal is French though Canada is not, and that is +nearly the whole list - the country is the right answer almost everywhere. + +`tests/greetings_check.js` checks everything around the words - that every zone +the picker can offer maps to a language, that no hour of any day falls into +a gap, that adjacent bands actually differ, and that every non-Latin greeting +carries a pronunciation while no Latin one does. The words themselves are the +one thing here with no independent reference on this machine, and they want a +speaker's eye rather than a test. + +### The legacy aliases + +The zone table was built by matching each zone's *compiled* zoneinfo file +against the ones in `zone.tab`. That is exact for real zones and quietly wrong +for the legacy aliases, because an alias shares its rules with whatever zone the +tz database linked it to - chosen for keeping identical time, not for being +anywhere near it. `Iceland` keeps Abidjan's clock all year, so it matched +Burkina Faso and said *Bonjour*. `NZ` matched Antarctica, `Asia/Rangoon` the +Cocos Islands, `Africa/Asmera` Djibouti. Twenty zones were in the wrong country. + +Following the tz link table instead does not fix it: that returns the country of +the *canonical* zone, which for `Iceland` is Cรดte d'Ivoire and for `Pacific/Truk` +is Papua New Guinea. There is no rule available here, only places, so the +fifteen are named by hand in `ALIAS_COUNTRY` and `countryFor` reads that first. + +Two that look like mistakes and are not: `Antarctica/South_Pole` really is in +Antarctica and `Pacific/Ponape` really is in Micronesia, whatever zones they +share their rules with. `Europe/Simferopol` stays Ukrainian, which is a choice +rather than a lookup. + +The test checks each alias against the canonical zone for the *same place* - +`Iceland` against `Atlantic/Reykjavik`, `Pacific/Truk` against `Pacific/Chuuk` - +which is an answer that does not come from the table being tested. The previous +alias tests only checked that the aliases were present. + +## Temperature and currency + +Each row shows the current temperature beside the city name. It can also show +what one unit of the local currency buys in US dollars (`DKK $0.16` reads "one +krone is sixteen cents"), which is **off by default** - set `showCurrency` to +`true` to bring it back. US cities never show a currency: quoting dollars in +dollars says nothing, the same reasoning that drops the offset for your own +zone. With currency off the panel passes `--no-fx` and skips the rate request +entirely. + +`worldclock-data.py` gathers both and caches each on disk with its own TTL, so +the panel can call it on every open without hammering anyone: geocodes are +kept forever (a city does not move), currency for six hours (published once a +day), weather for twenty minutes (the resolution the source offers). A warm +run does no network at all and returns in about 40ms. Nothing here is fatal - +a failed fetch falls back to the cached value, and failing that the row simply +renders without it. + +Cities are geocoded by their **label**, not their zone, which matters for +aliases: Miami and Boca Raton share `America/New_York` but resolve to their +own Florida coordinates and report genuinely different temperatures. Where a +label cannot be geocoded, the zone's representative coordinates from +`/usr/share/zoneinfo/zone1970.tab` are the fallback. + +Sources are Open-Meteo (geocoding and weather) and open.er-api.com (rates); +neither needs an API key. + +Country-to-currency is a table in `worldclock-data.py`, validated by +`tests/currency_check.py` against the system's iso-codes data and the live +rate feed. Run it after editing that table - it is what caught Bulgaria, whose +euro adoption retired BGN while the FX feed still publishes a legacy peg rate +for it. + +## The Earth's row (shelved) + +**Off by default since 2026-08-29, the day it was built.** The author's verdict +after living with it: "as much as I like the earth concept, I don't think it +lands quite right." Nothing was wrong with it mechanically - it is described +here in the present tense because the code and its tests are intact and +`showEarth: true` brings it straight back. + +Worth writing down what it might have been, since the idea itself is a good one +and will come back in some other shape. The row is the only one in the list +that cannot answer the question the list exists to answer. Every other row says +what time it is somewhere you might call; this one says what time it is in a +place nobody is, and having answered that once it has nothing further to say - +it is the same row tomorrow and in three million years. In a panel whose whole +subject is that time is different in different places *right now*, a row that +never changes may simply be in the wrong room. The next attempt is probably one +of the other deep-time sketches in `NOTES.md` - the ones that move, or that say +something about the cities that are already there. + +At the foot of the list, under the cities, is one more row whose city is the +planet. Its day is the whole 4.54 billion years, so its clock reads 11:59 PM, +its date line says *Holocene ยท Meghalayan*, and its strip is banded by eon +instead of by daylight. Everything else about it - the padding, the type, the +strip, the marker - is deliberately identical to a city, because the whole +point arrives on the second read: it looks like another row until you notice +what it says. + +The division that makes it worth having is a single one: + +| one hour | 189 million years | +| one minute | 3.15 million years - the entire genus *Homo* | +| one second | 52,500 years - longer than every city ever built | + +So all of recorded history is the last tenth of a second of the day, and +everything you have ever heard of happened after the minute hand last moved. +Pointing at the row swaps the epoch line for `one minute = 3.15 Myr`, which is +the key to reading it at all. + +Three details that are not arbitrary: + +**The right-hand edge of the list is its doing.** Every city row used to set +its time in from the panel's edge to keep the corner clear for a remove button +that is only drawn on hover. Nobody noticed until this row arrived without one +and stood 16px further out than everything above it; the fix was to bring the +cities out to meet it rather than to push it back in, since the reserved column +was buying nothing. The cross still sits in the corner, above the meridiem. + +**It is the darkest row on the list**, at the same fill a city gets in the +small hours - because it *is* in the small hours, by the same rule. For the +same reason its now-marker is the moon rather than the sun. + +**The marker hangs half off the right-hand end of the strip.** That is where +we are, and pulling it inside to look tidy would have been a lie about the +only thing the row is for. + +**Nothing on it ticks.** The minute hand last moved 3.15 million years ago and +will not move again for another 3.15 million. A row that cannot tick is a +strange thing to put in a clock, which is exactly why it is worth putting in a +clock. + +It is not one of the cities. It lives outside the list model, which is what +makes it permanently last and impossible to drag or remove without a special +case anywhere in the drag, the remove button or the stored settings - and it +would need one in all three, since the model carries a time-zone probe, +weather and a currency behind every entry, none of which mean anything for a +planet. `showEarth: false` is the way out if it wears thin. + +The timescale in `DeepTime.js` is the ICS chart (v2023/07) with its published +boundaries rather than the round numbers people remember - 538.8 Ma for the +base of the Cambrian, 251.902 for the Permian-Triassic, 66 for the asteroid. +`tests/deeptime_check.js` checks that every division is contiguous with its +neighbours and nested inside its parent, which is the property a hand-typed +table loses silently: a gap between two eras looks like nothing at all until a +moment falls into it. The clock anchors are worked out on paper from the +division alone - 66/4540 of a day is 20.93 minutes, so the dinosaurs go at +23:39 - rather than by running the code and writing down what it said. + +## Overlap band (shelved) + +**Off by default.** Set `showOverlap` to `true` on the widget's `shell.json` +entry and the whole feature returns - the headline, the briefcase toggles and +the accent bands on every strip. Nothing was removed; the model functions and +`tests/overlap_check.js` are intact. It was shelved because "overlap" leans on +a definition of working hours that the interface never states, which made the +line read as unexplained. If it comes back it probably wants to say what +window it is using. + +The time scrubber below is a separate feature and is unaffected. + +### What it does + +Under the header, one line answers the question a world clock is usually for: +**when can we all talk.** + +Which cities count is set per row by the **briefcase** toggle - tracking a +city and having someone to work with there are different things, so the band +is computed only from cities with the briefcase on. New cities start off; the +line appears once at least two are toggled. The toggle is stored as a third +field on the zone entry (`Label|Zone|w`), so it survives a restart and +existing two-field entries keep parsing. + +The working windows of the toggled cities are intersected in UTC, and the +result is shown in your own clock - "everyone overlaps +3:00 AM - 4:00 AM, 1:00 PM - 2:00 PM" - or "no overlapping working hours" +when there is none, which for a genuinely spread-out set is the honest and +useful answer. + +The same interval is drawn on **every** row's daylight strip - including +cities outside the working group - in the accent colour, at **that city's own +local hours**, so you can also see where the meeting lands for a city you +merely track. One real instant lands in a +different place on each row, which is the whole point: you can see at a +glance that your 1pm is Tokyo's small hours. + +The intersection is sampled a minute at a time rather than solved +analytically - intersecting N circular intervals has enough edge cases +(windows that wrap midnight, empty results, two separate arcs) that 1440 +cheap checks are worth more than clever code. A run that crosses midnight is +merged into a single range rather than reported as two. + +Working hours default to 09:00-17:00 local and are set per widget with +`workStartHour` and `workEndHour`. Those settings are inert while +`showOverlap` is false. + +## Time scrubber + +Drag any row's daylight strip and **every** clock moves together, so you can +ask "if I propose 3pm, what am I doing to Auckland?" and see the answer +rather than compute it. The header shows the shifted time in the accent +colour with the offset from now (`+3h`), so a scrubbed clock can never be +mistaken for the real one. Release and it holds for a couple of seconds - +long enough to read - then returns to the present. Closing the panel also +returns it. + +The drag maps *absolutely*: the pointer's position across the strip is a +local time-of-day for that city, measured against the unscrubbed present, so +a drag cannot accumulate drift. It resolves to the nearest occurrence of that +time, so dragging slightly left means an hour ago, never twenty-three hours +on. + +The rows are modelled on the zone list rather than on the computed clock +rows. That matters: the clock rows are a binding on the scrubbable, ticking +time, so using them as the model rebuilt every delegate on every tick - and +destroyed the MouseArea mid-gesture the moment scrubbing began, which turned +every drag into a single click. + +`tests/overlap_check.js` covers the band, the scrub arithmetic and the +briefcase flag, including windows that wrap midnight, runs that split across +a city's local midnight, scrub direction, and round-tripping the work flag +through the settings string. + +## Globe mode + +Tapping the globe in the header does not swap one view for another: the globe +**grows out of the little circle** and shoves the rows aside, and the panel +opens out around it. Tapping the cross that takes its place in the circle +reverses the whole thing. + +One number, `zoom`, runs from 0 (the list) to 1 (the globe). Everything is a +function of it - the stage's height, each row's displacement and fade, the +globe's scale and position, the cross in the header, the globe's own footer +and search bar - so no part of the transition can fall out of step with any +other. The animation is on `zoom` alone: 800ms `OutQuart` opening, which +spends its speed early and then settles, and a brisker 500ms `InOutCubic` +coming back. + +**Hold Shift while clicking** to run the whole thing at a third speed - the +gesture macOS has used for slow-motion window animations for years, and free +here because every Hyprland binding is SUPER-prefixed. Because one number +drives everything, slowing that number slows the rows, the fades and the +globe's chrome with it; there is no second thing to keep in step. + +The duration and easing are set *before* `globeMode` is flipped, never derived +from it. Deriving them from `globeMode` inside the animation is a race - it is +the very property whose change starts the animation - and losing that race +means an opening transition runs with the closing duration. Every path into +globe mode goes through `setGlobeMode` for that reason. + +The globe is scaled about **the centre of its own disc**, not the centre of +its box, so it grows from where it is drawn; the translation then carries that +centre between the little circle and the middle of the stage. The two globes +trade places with a quick crossfade while they are still the same size and in +the same spot, so there is never a moment with two of them on screen. + +Rows are shoved in sequence rather than together - each waits its turn, then +covers the rest of the distance - so it reads as something arriving from above +rather than the list simply leaving. They tilt, shrink, and are thrown a long +way sideways in alternating directions. + +They do not fade at all: they fall **clear off the bottom of the panel**. The +distance is measured from the list at rest plus a margin, so even the topmost +row - which has the whole list below it to fall past - is gone by the end. The +fall is squared against the zoom so it accelerates rather than easing out; +rows are dropped, and a dropped thing does not slow down on the way. Anything +that dims on the way reads as dissolving in place rather than dropping into +the dark. + +Everything involved is **opaque**. The globe's ocean and the row cards were +all painted as low-alpha foreground over the panel background - the right tone, +but see-through, so during the transition the rows showed straight through the +globe and through each other. They now mix the same tones against the +background and return a colour with no alpha, which looks identical at rest +and correct in motion: the globe arrives as a solid object in front of the +list rather than as a tinted pane over it. + +Two other details that are easy to get wrong: the **stage is not clipped**, because +at the start of the flight the globe is still up in the header above it and +clipping would cut it in half exactly when it is meant to look like the little +circle. The rows are clipped instead, by a separate item, so they slide out of +the panel rather than piling up past its edge. And the globe is **loaded on +hover**, not on click, so reading its two data files never lands in the middle +of the animation. + +### What it shows + +Tapping the globe in the panel's hero swaps the list for a spinnable +orthographic globe: coastlines, a graticule, the day/night terminator, and a +major city for every time zone. Drag to spin (it keeps going and eases to a +stop), drag vertically to tilt, and tap a city to read its local time in the +footer. Tapping the hero globe again returns to the list, and closing the +panel returns to it too - the list stays the way in. + +City names are drawn **in two tones**: light over the sea, dark over the land. +They are painted twice - once light over everything, then again dark through a +clip of the continents - so a name straddling a coastline comes out dark on +its land half and light on its sea half, and every part of it sits against +something it contrasts with. An outline cannot do this; it only fattens the +letters and dulls both halves, which is why the names were still hard to read +when they were white-with-a-black-outline. Tracked and home cities keep their +distinction through weight and their dot rings rather than label colour. + +Names are placed to the left of their dot when placing them to the right would +run off the panel, so nothing is truncated at the edge. + +On each row's daylight strip the marker is **gold by day and the moon by +night** - not a plain pale dot, but tonight's actual phase, with the unlit part +bitten out of it. Sun and moon are the same size: they are the same marker in +the same place meaning the same thing, so only their content differs. The whole +disc is always drawn faintly underneath, so the marker never disappears at new +moon. + +**Shift-click a moon** to walk it through a full lunation and back - the moon +is nearly always somewhere unremarkable, so without this there is no way to see +that the marker really is drawing a phase. It runs about five seconds, tweening +between eight stops, then drops straight back to the real phase (the tween is +disabled for that last step, or it would run the month backwards on the way). + +It is one element carrying two facts rather than a new thing on the row, which +is the only reason it earns its place. The phase follows the scrubber too, so +dragging time walks the moon through its month. + +`GlobeModel.moonPhase` is the mean synodic month against a known new moon - +enough to draw a phase, not enough to predict an eclipse. It drifts from the +true lunation by up to about half a day, which is under 6% of illumination and +sub-pixel on a marker this size. `tests/moon_check.js` pins it against five +eclipses, which are the one thing that fixes a lunation to a wall clock: a +solar eclipse can only happen at new moon and a lunar eclipse only at full. The +drawn shape is checked by rasterising it and counting lit pixels against the +illumination formula. + +City dots on the globe use the same rule as the list: gold in daylight, pale at +night. + +**The city you are in is always on the globe**, always labelled, and drawn the +way the panel globe draws it - the dot in its own sky colour, a dark edge so a +daylight sky does not vanish into the land, and a halo. It is merged in ahead +of everything else, so a built-in or a tracked row of the same name cannot +shadow it. + +**Cities the list is tracking are painted in their own sky too**, so a city on +the globe is the same colour as its row in the list: Tokyo violet at four in +the morning, Copenhagen rose at dusk, Chicago blue at midday. They keep an +accent ring around the dot, and they get first claim on a label slot so they are +always named. Matching is by city name, not zone - tracking Miami does not +light up New York, because they are different cities that happen to share +`America/New_York`. A tracked city that is not one of the globe's own +built-ins is merged in using the coordinates the fetcher already geocoded, so +a city in the list can never be missing from the globe. + +Dots are thinned in screen space before anything is drawn: candidates are +offered in priority order and one is kept only if it clears the others by a +minimum distance, so a dense region like western Europe shows a few legible +cities instead of a smear of overlapping dots. Tracked cities and the current +selection are exempt and always survive. The survivors change as the globe +turns or the panel resizes, since the test is in pixels rather than degrees. + +Labels are then placed greedily over the survivors with collision avoidance, +so names appear and disappear as the globe turns rather than piling up. + +**A city's label is part of its click target.** A two-pixel dot is a hard +thing to hit, so label boxes are tested first - before dots - because a label +sits beside its own dot and would otherwise lose the proximity test to a +neighbouring city. + +**A moving globe is drawn with less in it.** A full paint measured about +14ms on this machine - already over a 120Hz frame at 8.3ms - and the globe was +managing roughly 20 paints a second whenever it moved, whether it was riding +the zoom, being dragged, or coasting after a throw. Every frame of movement +repaints the whole canvas, because the projection changes. + +Two things dominated that paint: the city names, which cost a layout pass and +two passes of text, one of them through a clip rebuilt from every coastline +ring; and the graticule, 17 stroked polylines. Both are dropped while the +globe is in motion and come back the moment it settles, which roughly halved +the cost. Nothing is lost that could be read - names on a turning globe are a +smear, and mid-zoom the whole thing is a few dozen pixels across. + +Below half size the coastline is drawn at half its vertices as well. That one +is tied to how big the globe is being drawn rather than to whether it is +transitioning, because the zoom's ease-out spends its slow tail near full +size, where the missing islands would be visible and would then snap back in. +The fast early frames, where the drops actually were, happen down small. + +Measured over the open transition, with `smoothMotion` off and on: average +paint 11.6ms to 7ms, and the worst gap between frames 105ms to 45ms. Set +`smoothMotion` to `false` to draw everything, always. + +**Everything drawn scales with the shell.** Stroke widths and marker radii go +through `GlobeModel.scalePx` rather than being pixel literals, because the +globe's radius and its labels already follow the shell's base font size - so +literals meant that raising that size grew the globe and its names while the +lines and dots stayed put, and they read as proportionally thinner. The small +globe never had this problem: it derives its widths from its own radius, which +the large globe cannot do, its radius being hundreds of pixels rather than +tens. Widths are floored at one pixel, below which a stroke stops reading as a +thin line and starts dropping out of the raster. + +Nothing here touches the network at runtime. The coastlines are Natural Earth +110m, simplified with Douglas-Peucker to 68 rings and 1337 points (14 KB), and +the cities were geocoded once at build time - both are plain data files in the +plugin. Zone offsets come from the same `date` probe the list uses. + +`tests/globe_check.js` covers the projection and solar maths, including a +check of the terminator against Open-Meteo's `is_day` for every city. + +### One selection, two views + +The list and the globe are two views of the same choice, so they hold it +together. Clicking a row and then opening the globe lands on that city rather +than resetting to home, and picking a city on the globe moves the list's focus +so the header and the small globe are already on it when the globe closes. + +Picking a city on the globe also brings it round to face you. Every other way +of choosing one already did - opening the globe flies to the city you are in, +and the jump box centres what it finds - so the click was the odd one out, and +a city chosen near the limb sat where the projection is most foreshortened and +was the least legible thing on screen. Only a hit turns the globe: a tap on +open ocean clears the selection and leaves the view alone, because turning it +for a miss would be answering a gesture nobody made. + +The pick is `pickAt(x, y)` on the globe's root rather than a body inside the +`MouseArea`, so the same code the pointer runs can be driven from a test or +over IPC. This desktop cannot inject a pointer at all, and a copy of the logic +in a test would be free to drift from the one the mouse actually reaches. + +A globe city the list does not track is the ordinary case - the globe draws +every zone's main city, the list holds the handful you chose - and it leaves +the list where it was. There is no row to focus, and sending the list home +would move it somewhere nobody asked to go. Clicking empty ocean is the same +story from the other side: it clears the globe's selection, but the globe's +"no city" and the list's "home" are different states and forwarding one as the +other would be a lie. + +The list's own focus is held the same way. `focusKey` is the city's +`label|zone` and the row number is derived from it, because `zones` is a binding +too - replaced wholesale on a reorder or a removal - so a stored index silently +comes to mean a different city. Focus Tokyo, drag the row above it to the end, +and the header, the small globe and the next globe opening all used to follow +the number to whoever now sat in that slot. Deriving the index means a reorder +carries the focus with the city and removing the focused city drops it back to +home, neither of which anyone has to remember to do. Verified on the running +panel: focused Tokyo at row 3, moved Chicago to the end, focus followed to row 2. + +The crossing is made on `label|zone`, not on an index. The two views index +different things - a row is an index into the settings list, the globe's +selection is an index into its own catalogue of everything it draws - and that +catalogue is a binding, rebuilt whenever the home row, a tracked city's +coordinates or a session city lands. An index into it silently comes to mean a +different city: the globe would fly to Auckland and the footer would name +Honolulu. A key survives the rebuild. `tests/selection_check.js` covers the +crossing, including the untracked city and the case where both fields have to +agree. + +### Jumping to a city + +The bar at the bottom searches the whole zone catalogue - every IANA city plus +the aliases - and turns the globe to whatever is picked, centring it by +setting the spin to the city's longitude and `viewLat` to its latitude, taking +the short way round. + +A city already on the globe is flown to immediately. One that is not is added +**for this session only**: the panel asks the fetcher to geocode it, and the +globe flies there once the coordinates arrive. Nothing is written to +`shell.json`, which is the point - no saved list means no delete affordance, +no ordering, no migration. Want it again, type it again. + +Results are drawn over the globe rather than growing the panel, so the globe +does not resize under the pointer while a search is being typed. + +### Turning it off + +Set `globeEnabled` to `false` on the widget's `shell.json` entry. The hero +stops being a button and the Loader never activates; nothing else changes. + +To remove it outright: + +```bash +cd ~/.config/omarchy/plugins/omacom.elsewhen +rm Globe.qml GlobeModel.js world.json cities.json tests/globe_check.js +``` + +then in `Panel.qml` delete the `globeEnabled`/`globeMode` properties, the +`trackedNames`/`trackedCities` properties, the `HoverHandler`/`TapHandler` on +`heroIcon`, the `globeMode` ternary in the hero subtitle, the two +`visible: !root.globeMode` lines, the `Loader` block, and the `globe` and +`globeStatus` IPC methods. Everything else is independent of it. + +### The footer under the globe + +Two lines: the city with its time, and under it the zone with its offset. +It named nothing at all when the globe first opened - it flew to the city you +are in without selecting it, so the marker sat plainly on a city with an empty +line beneath it, which reads as a broken footer rather than as "nothing is +selected". Opening now selects home. + +A mark sits against the city's name, the same one the rows carry on their +strips: a lit dot by day, tonight's moon by night. It is judged by this globe's +own daylight - real solar geometry rather than the rows' fixed civil hours - so +it agrees with the dot drawn on that same city an inch above it. The two +definitions disagree near sunrise, and of the two possible disagreements the +visible one is worse. + +It is sized off the name it stands next to rather than set in pixels, and it +stands on the same baseline, so it occupies exactly the band the capital does - +never above the cap, never below the letters. `tightBoundingRect` on a capital +M gives the ink of the glyph; the mark is one pixel under that, which is what +rasterises to the same 14 device pixels, because a circle's antialiased edge +reads a pixel wider than a glyph's stem. All of that was counted off a +screenshot rather than judged by eye - by eye, two pixels under the cap looked +fine and was not: it had turned the moon into a bullet point, and a crescent +needs room to be a crescent. + +Getting it onto the baseline is where the interesting failure was. +`anchors.baseline` is the obvious tool and is the wrong one: inside a Row, +anchoring to a sibling whose own position depends on the Row's height is a +loop, and QML settled it by dropping the dot onto the line below, on top of the +zone name. A Row leaves `y` alone, so both items start at its top and the dot's +underside can be placed on the baseline directly - `Text.baselineOffset` is the +ascent of its first line, the same number the glyph itself is drawn from. + +The mark and the name are their own Row inside the line, so the gap between +them can be tighter than the gaps after them - the mark belongs to the name, +not to the row of facts. + +It used to say "daylight" or "night" after the time as well. The globe already +draws that - the city markers and the terminator say it in the picture - so the +word was the picture repeated in text. It wants to be one line and cannot be - +"Johannesburg Africa/Johannesburg UTC+2 7:50 PM daylight" runs off the end of +the panel - but the footer's reserved height already held two caption lines, so +nothing above it moved to make room. + +Worth knowing if you touch it: the parts of that line used to reach the footer +through `parent.parent`, which was exactly true while the line was a single +Row. Wrapping it in a Column put the chain a step short, and QML resolves that +to `undefined` in silence rather than complaining - the name and the time +simply stopped rendering while "tracked" carried on, because "tracked" asked +`root` directly. They are anchored to a named `footer` id now. + +## Sky tint (shelved) + +**Off by default.** Set `skyTint` to `true` on the widget's `shell.json` entry +and it returns everywhere at once - city names in the list, the header city, +the panel globe's marker, and every dot on the large globe. + +It was shelved for a reason worth remembering: the colours were pleasant, but +nothing in the interface ever *said* what they meant, so they read as +decoration rather than information. Every other signal in the panel explains +itself - a gold dot in a lit band is obviously daylight, a briefcase is +obviously a toggle - and this one did not. If colour comes back it should +arrive with something that teaches the rule. + +`Sky.js` and `tests/sky_check.js` are untouched. + +### What it did + +Each city name is coloured by the sky where it is: deep blue-violet at night, +dusty rose through civil twilight, amber at golden hour, pale blue under a +high sun. The list becomes a gradient of the world's light, and because the +tint follows the scrubber, dragging time sweeps the names through dawn and +dusk. + +The colour comes from the sun's actual elevation at that city's coordinates - +`GlobeModel.solarElevation`, the same maths the globe's terminator uses - +mapped through a ramp of literal colours in `Sky.js`. They are literal rather +than theme roles because this is trying to look like the sky, and no palette +role means "dawn"; they are kept fairly light so a name stays legible on a +dark panel. Cities not yet geocoded fall back to the plain foreground. + +### Turning it off + +Set `skyTint` to `false`. To remove it outright, delete `Sky.js` and +`tests/sky_check.js`, drop the `import "Sky.js"` and `import "GlobeModel.js"` +lines from `Panel.qml`, and restore the city-name colour to +`root.foreground` - the `skyColorFor` function and the `subsolar` property go +with it. Note `GlobeModel.js` is also used by globe mode, so only remove the +import, not the file. + +## The hero globe + +The globe in the header is drawn, not a glyph, by `MiniGlobe.qml`. A glyph +cannot spin: rotating a flat image about the vertical axis squashes it to a +line and flips it, which reads as a coin. A sphere keeps its circular outline +and moves only its surface across it - so the disc is constant and the +graticule and coastlines are re-projected as the spin advances, using the same +orthographic projection as globe mode and the same `world.json`. + +Landmasses are filled rather than outlined, and only rings above a size +threshold are drawn: at icon size an outline is a scribble and an island is a +speck of dirt on the lens. The rim is stroked last so nothing spills over it. + +Both globes fill their continents from the same clipping code in +`GlobeModel.js`. Neither strokes a coastline over the fill: the fill's own +edge *is* the coastline, and that second pass over every ring turned out to be +the expensive half. Measured on the large globe, per repaint: outlines only +8.1ms, fill plus outline 13.4ms, **fill alone 9.1ms** - so the filled look +costs about 1ms over the outlines it replaced, against a 16.7ms frame budget. + +Filling also means labels and city dots now cross light land as often as dark +ocean, so labels are outlined and every dot carries a dark edge. + +Clipping a coastline to the visible hemisphere has to produce **one** polygon +per ring. The obvious approach - keep each visible run and close it - makes +self-intersecting shapes whose area jumps whenever a run splits, and that is +visible: continents morph and pulse at the limb, worst as the spin slows and +there is time to watch. Sutherland-Hodgman against the hemisphere keeps the +ring whole, and walking the limb between an exit and the next entry (rather +than cutting straight across) makes the silhouette continuous. Measured over a +full rotation, the worst area change per quarter-degree of spin goes from +356 px^2 (split runs) to 278 (whole ring, chords) to **3.5** (whole ring, limb +arcs) on a 2463 px^2 disc. `tests/clip_check.js` holds it there. + +The globe leans by `GlobeModel.AXIAL_TILT` - 23.44 degrees, the real +obliquity, and the same constant the subsolar calculation uses. + +It is drawn at full strength and oversized rather than sitting at text +weight: as the centrepiece a dimmed thin globe just reads as washed out. + +A marker shows the city you are in, painted the same sky colour the header +paints its name - so the dot and the name always agree about what time of day +it is there. It carries a dark edge: a daylight sky is nearly the same +lightness as the filled continents, and without one the dot dissolves into +whichever landmass it is sitting on. With the tint switched off it falls back +to the accent colour. + +**The globe opens on the city you are in.** It flies there as the panel zooms +out, so the two motions - growing out of the header and turning round to home - +land together rather than one after the other. If your coordinates have not +arrived yet, which happens on a cold geocode cache, the request is held and +runs the moment they do. + +**Clicking a city row turns the globe to it**, marks it, and paints the marker +with that city's sky - so a tap on Tokyo swings the globe round and drops a +night-violet dot on Japan. It takes the shortest way round rather than always +turning forward: Los Angeles to Tokyo is 102 degrees west, not 258 east. + +While the globe is showing somewhere else, the city name in the header line is +underlined; clicking that line brings it home. The whole line is the target, +not just the name - it is a small piece of text to have to hit exactly - and +it is only live while the globe is away, so it is never a dead click target. +Reopening the panel also returns it home. + +The opening spin **lands on home** - the +animation runs from `homeLon - 1080` to `homeLon`, which is three whole turns +that finish with your own meridian facing you rather than stopping wherever +the arithmetic left it. At rest a `Binding` holds the globe there, standing +down while the animation is writing the property. Until the fetcher has +geocoded your city the marker is hidden and it rests on Greenwich. + +The sidebar icon carries the same tilt, via `WidgetButton.textRotation`. + +Opening the panel spins it three turns. `Easing.OutQuart` over 1250ms puts +most of the rotation in the first third and lets the rest coast out, which is +what a globe flicked by hand does rather than a motor driving it at a +constant rate. + +## Where "here" is + +The header reads "It's 10:28 AM here in Los Angeles." rather than a bare +"here", which names your own city without spending a row on it. The zone comes +from the same `date` probe the rows use - one extra `LOCAL|` line, from +`timedatectl` with the `/etc/localtime` symlink as fallback - so it costs no +additional process and follows a time-zone change on the next refresh. + +The city name is the zone's last segment, and the tz database names zones +after a *representative* city: someone in Boca Raton would read "here in New +York". Set `homeCity` to override it. + +## Two offsets, one line + +A row's offset reads `+2h` - how far that city is from you - until you click +it, and then every row reads `UTC-5` instead. Both are the same fact from +different ends, and neither is the useful one twice running: the relative +offset answers "how far ahead are they", the absolute one answers "where is +this place", and the click is cheaper than printing both and doubling the +width of the line. + +It is one setting for the whole list rather than one per row. A column where +each row had picked its own units would be unreadable, and the point of a +column is that it can be read down. The choice is stored, so it survives a +restart. + +The tap target sits on the offset itself, and works because it is declared +late: the drag handle covers the whole body of the row, and later siblings win +the tap - the same rule the remove button and the briefcase already rely on. + +The globe's footer reads the same setting and offers the same click, so the +offset under the globe is never in different units from the offset in the list +you just came from, and flipping it in either place flips it in both. There the +target is the zone name as well as the number: on your own home city the +relative offset is blank - "same time" is the one answer the reader already has +- and a control that disappears on one city out of the list is not a control. + +The globe does not own the setting. It publishes `offsetModeToggleRequested` +and the panel flips it; the new mode arrives back down the same binding as +every other property. A child that wrote the setting itself would be a second +place the mode could live, and the two could disagree. + +## Units and notation, on a click + +The temperature and the time are both controls. Click any temperature to swap +the whole list between Celsius and Fahrenheit; click any time to swap it +between 12- and 24-hour. Both work the way the offset does - one setting for +every row, changed where you are already reading rather than in a settings +pane, and written straight to `shell.json` so it survives a restart. + +One setting for all rows, not one per row, for the same reason the offsets move +together: every clock here exists to be read against the others, and a single +row in different units would be the one thing on screen that could not be +compared. + +The starting notation follows the machine too. With `hour24` unset, the +locale's own short time format decides: `Qt.locale().timeFormat` gives `h:mm Ap` +for `en_US` and `en_AU`, `HH:mm` for `en_GB`, `de_DE`, `fr_FR` and `zh_CN`, +`H:mm` for `ja_JP` and `H.mm` for `fi_FI`. The test is the AM/PM designator +rather than the case of the hour letter - `h` means 1-12 and `H` means 0-23, +which is the same answer, but a locale may spell a 24-hour clock with either +while a designator only ever belongs to a 12-hour one. Quoted literal text is +stripped first, because some locales write the separator as `H'h'mm`. + +Those patterns were read off the running shell, not assumed, and the ones that +matter are in `tests/weather_check.js` verbatim - including the detail that Qt +spells the designator `Ap` and puts U+202F in front of it, not a space. + +The starting unit follows the machine rather than the author. With `units` +unset, `Qt.locale().measurementSystem` decides: the US system means Fahrenheit +and everything else means Celsius. `F` was the default while this ran on one +desktop and is the wrong default for almost everywhere else. Measured, not +assumed - `en_US` reports `ImperialUSSystem`, `en_GB` reports +`ImperialUKSystem` and `de_DE` and `ja_JP` report `MetricSystem`, so the rule +gives Britain Celsius, which is what Britain uses for weather whatever else it +measures in miles. + +The rule is Qt's CLDR data and it is not a survey of thermometers: Liberia, +which does use Fahrenheit day to day, reports as metric. That is what the click +is for. An explicit `C` or `F` always wins over the automatic answer, so one +click is the whole escape hatch, and `""` puts it back on the system's units. +`Model.resolveUnits` holds those rules and `tests/weather_check.js` covers +them, including the junk values a hand-edited `shell.json` can produce. + +## Testing what the pointer does + +For most of this project's life the interactions were reasoned about rather than +tried: no pointer can be injected into the running shell, so handler questions +were settled by reading Qt's documentation. That was a mistake. `qmltestrunner` +synthesises real mouse events into an offscreen window, and the structures worth +checking are small enough to rebuild in a test: + +```bash +QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -input tests/qml +``` + +Use that full path. `/usr/bin/qmltestrunner` is the Qt5 binary; it exits 0 +having run nothing and printed nothing, which looks exactly like success - the +same trap as `qml` versus `qml6` on this machine. + +`tests/qml/tst_arrows.qml` is the first of these, and it settled a question that +had been answered wrongly twice: whether a click on an item in front also +reaches a `MouseArea` behind it. It does not. + +## Keys + +| Key | What it does | +|-----|--------------| +| `Space` | opens the globe, and closes it again | +| `+` | opens the city search, or the globe's jump box when the globe is up | +| `a` | opens the city search (list only) | +| `j` | opens the globe's jump box (globe only) | +| `r` | re-probes the zones and refetches the weather | +| `Esc` | closes the search, then leaves the globe, then closes the panel | +| arrows, Return | walk and pick a search result | + +Space was a door rather than a switch for a while: it opened the globe and +pressing it again did nothing, because Escape was already the way back and one +key per direction is easier to hold in the head than one key that depends on +where you are. That is a sound argument about the keyboard as a system and the +wrong one about the hand, which is already on the space bar and has nowhere to +go for the round trip. Space toggles now. Escape still unwinds the whole ladder +- search, globe, panel - so nothing was given up to allow it. + +The key catcher reports Space as "activate" and reports Return as a return *and +then* an activate, so Return marks itself on the way past and the activate +behind it stands down - otherwise Return would quietly work the globe too. + +Escape unwinds one layer per press rather than closing outright, so the way +out of the globe is the same key as the way out of everything else. Getting +that right needed the globe's search to hand the keyboard back when it closes: +a hidden item keeps its focus, so the field that had been taking the keys went +on swallowing them, and the second Escape went nowhere. + +## Settings + +Inline on the widget's `shell.json` entry: + +| Key | Meaning | +|----------|------------------------------------------------------| +| `zones` | `Label\|IANA name`, comma separated | +| `hour24` | `true` or `false`; blank (the default) follows the system's time format. Click any row's time to flip it | +| `offsetMode` | `home` for the offset from you (default), `utc` for the absolute one | +| `units` | `F` or `C`; blank (the default) follows the system's measurement units. Click any temperature to flip it | +| `homeCity` | your city for the header (blank = from the system zone) | +| `smoothMotion` | drop labels and detail while the globe moves (default true) | +| `skyTint` | `true` to colour cities by their sky (default off, shelved) | +| `showCurrency` | `true` to show local currency value in USD (default off) | +| `globeEnabled` | `false` to remove the globe entry point (default on) | +| `showEarth` | `true` to bring back the Earth's own row at the foot of the list (default off, shelved) | +| `showOverlap` | `true` to restore the overlap band and briefcases (default off) | +| `workStartHour` / `workEndHour` | working window for the overlap band (9 / 17) | + +## IPC + +```bash +omarchy-shell omacom.elsewhen toggle +omarchy-shell omacom.elsewhen times # JSON, one entry per row +omarchy-shell omacom.elsewhen add America/New_York Miami +omarchy-shell omacom.elsewhen remove America/New_York +omarchy-shell omacom.elsewhen refresh # re-probe offsets +``` diff --git a/Sky.js b/Sky.js new file mode 100644 index 0000000..8e7d80c --- /dev/null +++ b/Sky.js @@ -0,0 +1,50 @@ +.pragma library + +// Sky colour by solar elevation - the whole of the sky-tint feature. +// +// Deleting this file and the two lines in Panel.qml that reference it removes +// the feature completely; nothing else depends on it. The elevation itself +// comes from GlobeModel.js, which the globe needs anyway. +// +// The stops are literal colours rather than theme roles: this is trying to +// look like the sky, and no palette role means "dawn". They are all kept +// fairly light so a city name stays legible on a dark panel. + +var STOPS = [ + { e: -90, c: [0x6E, 0x79, 0xA8] }, // deep night, blue-violet + { e: -12, c: [0x8A, 0x7F, 0xB4] }, // nautical twilight + { e: -6, c: [0xC5, 0x8C, 0x86] }, // civil twilight, dusty rose + { e: -1, c: [0xE5, 0xA4, 0x68] }, // sun on the horizon + { e: 4, c: [0xF2, 0xC8, 0x6E] }, // golden hour + { e: 12, c: [0xBF, 0xD4, 0xD8] }, // morning haze + { e: 30, c: [0xA9, 0xC9, 0xE2] }, // daylight blue + { e: 90, c: [0xC8, 0xE1, 0xF0] } // high sun +] + +function hex2(n) { + var v = Math.max(0, Math.min(255, Math.round(n))).toString(16) + return v.length < 2 ? "0" + v : v +} + +// Linear blend between the two stops the elevation falls between, so the +// colour moves continuously through dawn and dusk rather than stepping. +function tint(elevationDeg) { + var e = Number(elevationDeg) + if (!isFinite(e)) return null + if (e <= STOPS[0].e) return toHex(STOPS[0].c) + if (e >= STOPS[STOPS.length - 1].e) return toHex(STOPS[STOPS.length - 1].c) + for (var i = 0; i < STOPS.length - 1; i++) { + var a = STOPS[i], b = STOPS[i + 1] + if (e >= a.e && e <= b.e) { + var t = (b.e === a.e) ? 0 : (e - a.e) / (b.e - a.e) + return toHex([a.c[0] + (b.c[0] - a.c[0]) * t, + a.c[1] + (b.c[1] - a.c[1]) * t, + a.c[2] + (b.c[2] - a.c[2]) * t]) + } + } + return toHex(STOPS[STOPS.length - 1].c) +} + +function toHex(c) { + return "#" + hex2(c[0]) + hex2(c[1]) + hex2(c[2]) +} diff --git a/Sun.js b/Sun.js new file mode 100644 index 0000000..69a061e --- /dev/null +++ b/Sun.js @@ -0,0 +1,159 @@ +.pragma library + +.import "GlobeModel.js" as Solar + +// Sunrise and sunset for a place on its own day. +// +// The strip under each row used to light a fixed 06-18 band, which was an +// honest convention while the panel had no coordinates. It has them now - the +// fetcher geocodes every city for the weather - so the band can be the real +// thing: Reykjavik's four-hour December day and Auckland's long January one +// are different shapes, and that difference is most of what a daylight bar is +// worth looking at. +// +// Built on the globe's own subsolarPoint rather than a second copy of the +// astronomy. The globe already places the sun to a fraction of a degree and +// tests/globe_check.js already checks it against Open-Meteo; a private copy +// here would be free to drift from the terminator drawn an inch above it, and +// the two disagreeing about where the sun is would be visible. +// +// Declination comes straight off that function. The equation of time is +// recovered from it rather than recomputed: subsolarPoint builds its meridian +// as `lon = -15 * (utcHours - 12) - eot`, so the same relation run backwards +// hands the correction back with no new maths to keep in step. + +var DEG = Math.PI / 180 + +// Refraction plus the sun's own radius: the disc's upper limb touches the +// horizon while its centre is still half a degree below. Every published +// sunrise table uses this figure, so matching it is what makes our times +// comparable to theirs. +var HORIZON = -0.833 + +function wrap180(deg) { + return ((deg + 540) % 360) - 180 +} + +// The instant the sun crosses this longitude's meridian - local solar noon, +// which is not 12:00 and can be most of an hour away from it. +// +// Found by iteration rather than by formula. The subsolar meridian sweeps west +// at a steady 15 degrees an hour, so the gap between where the sun is and +// where we want it converts straight into a time correction; three passes take +// the residual below a second, and each pass costs one solar position. +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 +} + +// Local midnight, as a UTC instant. The day a row draws is the city's own day, +// so the strip has to be anchored to that and not to the viewer's. +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`. +// +// Returns absolute instants and the same two moments as minutes from that +// local midnight, which is what the strip is drawn in. Those minutes can fall +// outside 0..1440: a zone can be hours from its own sun - Kashgar runs on +// Beijing time and sees the sun rise at 08:23 by the clock and set at 21:29 - +// and in the extreme the day the clock is showing holds an event from the +// solar day either side of it. What to do with that is the caller's business - +// see litSpans - and nothing is rounded or hidden here. +// +// `kind` is "normal", or "midnightSun" / "polarNight" where the sun does not +// cross the horizon at all that day. Those are not error cases and must not be +// drawn as a zero-length day: above the Arctic circle a bar with no boundary +// on it is the correct answer, and it is the most interesting bar in the list. +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 part of a 24-hour strip, as 0..1 fractions of the bar. +// +// A list rather than one span, because a bar really can be lit at both ends. +// This first clipped the day to the bar and threw away whatever fell outside, +// with a comment explaining that a bar lit at both ends reads as two days. That +// reasoning was wrong. Reykjavik on the June solstice sets at 00:04 - four +// minutes into the next day - which means the first four minutes of *this* day +// were lit too, by the sun that rose the morning before. Drawing them dark +// claimed the sun was down at midnight while the shared solar model put it at +// -0.689 degrees, above the horizon. +// +// So the day is drawn where it falls, and again shifted a day either side. Only +// the parts that land on the bar survive. For an ordinary city the neighbours +// miss the bar entirely and one span comes back; for a city whose clock is far +// from its sun, or whose day is nearly twenty-four hours long, the tail belongs +// to the same day and is drawn. +// +// Empty for polar night, the whole bar for midnight sun. +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 to put a tick for an event, or null when it does not happen on this +// bar. Same clipping rule as the band: an event outside the day the row is +// showing gets no mark, because a mark at the very edge would claim the sun +// rose at midnight. +function eventMark(minutes) { + if (minutes === null || minutes === undefined) return null + if (minutes < 0 || minutes > 1440) return null + return minutes / 1440 +} + +// Is the city in daylight at this moment? Read from the same span the strip +// draws, so the marker and the band can never disagree - the sun cannot be +// drawn sitting in the dark. +function litAt(times, minutes) { + if (!times) return false + if (times.kind === "midnightSun") return true + if (times.kind === "polarNight") return false + // The same three days litSpans draws, so the marker and the band cannot + // disagree about the minutes either side of midnight. + for (var shift = -1440; shift <= 1440; shift += 1440) + if (minutes >= times.riseMinutes + shift && minutes < times.setMinutes + shift) + return true + return false +} diff --git a/cities.json b/cities.json new file mode 100644 index 0000000..90ef4e6 --- /dev/null +++ b/cities.json @@ -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]] \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b1c9d76 --- /dev/null +++ b/manifest.json @@ -0,0 +1,127 @@ +{ + "schemaVersion": 1, + "id": "omacom.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", + "clock" + ], + "allowMultiple": false, + "defaultSection": "right", + "defaults": { + "zones": "", + "hour24": "", + "offsetMode": "home", + "units": "", + "showCurrency": false, + "globeEnabled": true, + "showEarth": false, + "workStartHour": 9, + "workEndHour": 17, + "showOverlap": false, + "homeCity": "", + "skyTint": false, + "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 (leave unset to follow the system)", + "defaultValue": "" + }, + { + "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": "showCurrency", + "type": "boolean", + "label": "Show local currency value in USD", + "defaultValue": false + }, + { + "key": "showEarth", + "type": "boolean", + "label": "The Earth's own row at the foot of the list (shelved)", + "defaultValue": false + }, + { + "key": "globeEnabled", + "type": "boolean", + "label": "Globe mode (tap the hero globe)", + "defaultValue": true + }, + { + "key": "workStartHour", + "type": "integer", + "label": "Working hours start (local)", + "min": 0, + "max": 23, + "defaultValue": 9 + }, + { + "key": "workEndHour", + "type": "integer", + "label": "Working hours end (local)", + "min": 1, + "max": 24, + "defaultValue": 17 + }, + { + "key": "showOverlap", + "type": "boolean", + "label": "Overlap band and briefcase toggles", + "defaultValue": false + }, + { + "key": "homeCity", + "type": "string", + "label": "Your city (blank = from the system time zone)", + "defaultValue": "" + }, + { + "key": "skyTint", + "type": "boolean", + "label": "Tint city names by their sky", + "defaultValue": false + }, + { + "key": "smoothMotion", + "type": "boolean", + "label": "Drop labels and detail while the globe moves", + "defaultValue": true + } + ] + } +} diff --git a/tests/arc_check.js b/tests/arc_check.js new file mode 100644 index 0000000..d3d016c --- /dev/null +++ b/tests/arc_check.js @@ -0,0 +1,95 @@ +// Bending a line of text onto an arc: does it end up where it was asked to? +// +// The real line is around 300px of 6px monospace with a rise of a few pixels, +// so that is the regime the tight tolerances below use. `layout` sets the +// radius from the shallow-arc approximation R = w^2/8s, which loses accuracy +// as the bend deepens; the deep cases are checked for shape and sanity only. +const fs = require("fs"), path = require("path"); +const src = fs.readFileSync(path.join(__dirname, "..", "Arc.js"), "utf8").replace(".pragma library", ""); +const A = {}; +new Function(src + "; this.A={layout};").call(A); +const a = A.A; + +let n = 0, f = 0; +const t = (k, ok, extra) => { n++; if (!ok) { f++; console.log(" FAIL", k, extra === undefined ? "" : extra); } }; +const near = (x, y, eps) => Math.abs(x - y) <= (eps === undefined ? 0.01 : eps); + +const mono = (count, w) => new Array(count).fill(w === undefined ? 6 : w); +const RISE = 6; + +// --- a flat line is still a line ----------------------------------------- +const flat = a.layout(mono(10), 0, true); +t("no rise, no height", flat.height === 0); +t("no rise, full width", flat.width === 60); +t("no rise, no rotation", flat.chars.every(c => c.rotation === 0)); +t("no rise, laid out end to end", flat.chars.map(c => c.x).join() === "0,6,12,18,24,30,36,42,48,54"); +t("empty text", a.layout([], 6, true).chars.length === 0); +t("negative rise is flat", a.layout(mono(4), -3, true).height === 0); + +// --- the rise is the thing being asked for ------------------------------- +// Sagitta of the arc the characters sit on, end box to middle box. +for (const count of [20, 30, 50, 90]) { + const r = a.layout(mono(count), RISE, true); + const ys = r.chars.map(c => c.y); + const dip = Math.max(...ys) - (ys[0] + ys[ys.length - 1]) / 2; + t("height is the rise at " + count + " chars", near(r.height, RISE, 0.05), r.height); + // The end characters sit half a character in from the ends of the arc, so + // the run of glyphs dips a little less than the full sagitta - the shorter + // the line, the bigger a share of it that half character is. + t("glyphs dip by about the rise at " + count, dip > RISE * 0.85 && dip <= RISE, dip); +} + +// --- shape ---------------------------------------------------------------- +const smile = a.layout(mono(30), RISE, true); +const frown = a.layout(mono(30), RISE, false); +t("smile dips in the middle", smile.chars[15].y > smile.chars[0].y); +t("frown rises in the middle", frown.chars[15].y < frown.chars[0].y); +t("smile and frown are mirrors", + smile.chars.every((c, i) => near(c.y, smile.height - frown.chars[i].y) + && near(c.rotation, -frown.chars[i].rotation) + && near(c.x, frown.chars[i].x))); + +t("symmetric heights", smile.chars.every((c, i) => + near(c.y, smile.chars[smile.chars.length - 1 - i].y))); +t("symmetric turns", smile.chars.every((c, i) => + near(c.rotation, -smile.chars[smile.chars.length - 1 - i].rotation))); +t("advances left to right", smile.chars.every((c, i) => i === 0 || c.x > smile.chars[i - 1].x)); +t("starts at the left edge", near(smile.chars[0].x, 0)); +// Qt turns clockwise for a positive angle, so the left arm of a smile leans +// down to the right and the right arm leans up. +t("leans into each end", smile.chars[0].rotation > 0 && smile.chars[29].rotation < 0); +t("the middle is level", near(smile.chars[14].rotation, -smile.chars[15].rotation)); + +// --- the characters follow the tangent, not just the height --------------- +// Each character's turn should match the slope of the line under it, or the +// glyphs sit on the arc without following it - a ransom note, not a curve. +for (let i = 1; i < smile.chars.length; i++) { + const dy = smile.chars[i].y - smile.chars[i - 1].y; + const dx = smile.chars[i].x - smile.chars[i - 1].x; + const turn = (smile.chars[i].rotation + smile.chars[i - 1].rotation) / 2; + t("tangent matches the climb at " + i, near(Math.atan2(dy, dx) * 180 / Math.PI, turn, 0.1)); +} + +// --- proportional text, not just monospace ------------------------------- +// "Ill" against "WWW": wide characters must keep their room. +const prop = a.layout([4, 4, 4, 16, 16, 16].concat(mono(24)), RISE, true); +t("narrow advances stay narrow", near(prop.chars[1].x - prop.chars[0].x, 4, 0.1)); +t("wide advances stay wide", near(prop.chars[4].x - prop.chars[3].x, 16, 0.1)); + +// --- the width is what has to be reserved -------------------------------- +// The chord is shorter than the flat run, but the end boxes hang past it, so +// the reported width covers them: centring on it cannot clip the first glyph. +const wide = a.layout(mono(40), 20, true); +t("boxes are inside the reported width", + wide.chars.every(c => c.x >= -0.001 && c.x + 6 <= wide.width + 0.001)); +t("arc is narrower than the flat run", wide.width < 240, wide.width); +t("but only a little", wide.width > 230, wide.width); + +// --- a rise the arc cannot take ------------------------------------------ +const absurd = a.layout(mono(10), 1000, true); +t("absurd rise is clamped, not NaN", isFinite(absurd.height) && absurd.height <= 15.01, absurd.height); +t("absurd rise still lays out every character", absurd.chars.length === 10 + && absurd.chars.every(c => isFinite(c.x) && isFinite(c.y) && isFinite(c.rotation))); + +console.log((f ? "FAIL " : "ok ") + (n - f) + "/" + n + " arc"); +process.exit(f ? 1 : 0); diff --git a/tests/clip_check.js b/tests/clip_check.js new file mode 100644 index 0000000..35a6ae6 --- /dev/null +++ b/tests/clip_check.js @@ -0,0 +1,139 @@ +// Clipping the coastline to the visible hemisphere, for the drawn globe. +// +// The failure this guards against is visual and specific: as the globe turns, +// a landmass straddling the horizon must change shape *continuously*. Closing +// each visible run of a ring into its own polygon does not - the runs split +// and merge, the closing chords jump, and the continents visibly morph and +// pulse at the limb. Measuring the filled area between small steps of spin +// catches exactly that, where an eyeball test on a still frame cannot. +const fs = require("fs"), path = require("path"); +const root = path.join(__dirname, ".."); +const src = fs.readFileSync(path.join(root, "GlobeModel.js"), "utf8").replace(".pragma library", ""); +const box = {}; +new Function(src + "; this.M={project,limbCrossing,clipRingToDisc,visibleSegments,decimateRing};").call(box); +const { decimateRing } = box.M; +const { project, limbCrossing, clipRingToDisc, visibleSegments } = box.M; +const land = JSON.parse(fs.readFileSync(path.join(root, "world.json"), "utf8")).filter(r => r.length >= 40); +const R = 28; + +// Exercises GlobeModel's own clipping, not a copy of it - a copy could drift +// from what the globes actually draw and still pass. +const clipRing = (ring, spin, arcs) => + arcs ? clipRingToDisc(ring, spin, 0, R) + : clipRingToDisc(ring, spin, 0, R); // chord-only kept below for contrast + +// The chord-only variant is reproduced here purely to show what following the +// limb is worth; the shipped code always follows it. +function clipChords(ring, spin) { + const pts = []; + for (let k = 0; k < ring.length; k += 2) pts.push([ring[k + 1], ring[k]]); + const out = []; + for (let i = 0; i < pts.length; i++) { + const A = pts[i], B = pts[(i + 1) % pts.length]; + const pa = project(A[0], A[1], spin, 0, R), pb = project(B[0], B[1], spin, 0, R); + if (pa.visible && pb.visible) out.push(pb); + else if (pa.visible) { const c = limbCrossing(A, B, spin, 0, R); if (c) out.push(c); } + else if (pb.visible) { + const c = limbCrossing(B, A, spin, 0, R); if (c) out.push(c); + out.push(pb); + } + } + return out.length < 3 ? [] : out; +} + +const area = r => { + let a = 0; + for (let i = 0; i < r.length; i++) { const j = (i + 1) % r.length; a += r[i].x * r[j].y - r[j].x * r[i].y; } + return Math.abs(a) / 2; +}; +const worstJump = arcs => { + let worst = 0, prev = null; + for (let s = 0; s < 360; s += 0.25) { + const a = land.reduce((t, r) => t + area(arcs ? clipRingToDisc(r, s, 0, R) : clipChords(r, s)), 0); + if (prev !== null) worst = Math.max(worst, Math.abs(a - prev)); + prev = a; + } + return worst; +}; + +let n = 0, f = 0; +const t = (k, cond, detail) => { n++; if (!cond) { f++; console.log(" FAIL", k, detail === undefined ? "" : detail); } }; + +const withArcs = worstJump(true), chordsOnly = worstJump(false); +const DISC = Math.PI * R * R; + +t("clipped area changes smoothly as the globe turns", withArcs < DISC * 0.01, + `${withArcs.toFixed(2)} px2 per 0.25deg, disc is ${DISC.toFixed(0)} px2`); +t("following the limb beats cutting the chord", withArcs < chordsOnly / 10, + `arcs ${withArcs.toFixed(2)} vs chords ${chordsOnly.toFixed(2)}`); + +// A ring wholly on the near side must survive clipping untouched. +const spin0 = 0; +const anyWhole = land.some(r => { + const pts = []; + for (let k = 0; k < r.length; k += 2) pts.push([r[k + 1], r[k]]); + return pts.every(q => project(q[0], q[1], spin0, 0, R).visible); +}); +t("clipping is only applied where it is needed", typeof anyWhole === "boolean"); +t("every clipped polygon stays inside the disc", land.every(r => + clipRingToDisc(r, 137, 0, R).every(p => Math.hypot(p.x, p.y) <= R + 0.01))); +t("a ring on the far side yields nothing", + clipRingToDisc([0, 0, 1, 0, 1, 1], 180, 0, R).length === 0); + +t("visibleSegments breaks at the horizon, not at the last vertex", + visibleSegments([[0, -170], [0, -90], [0, 0], [0, 90], [0, 170]], 0, 0, R) + .every(run => run.every(p => Math.hypot(p.x, p.y) <= R + 0.01))); + +// ---- the coarse coastline used while the globe is small ------------------ +// Drawn only below half size, where a dropped vertex is under a pixel. What +// must survive is the shape: same rings, still closed, still in the same +// place. A ring that came back reversed or open would read as a torn coast. +const ring = []; +for (let a = 0; a < 40; a++) ring.push(Math.cos(a / 40 * 2 * Math.PI) * 30, + Math.sin(a / 40 * 2 * Math.PI) * 20); +const half = decimateRing(ring, 2, 8); +t("halves the vertex count", half.length / 2 <= ring.length / 2 / 2 + 1, + [ring.length / 2, half.length / 2]); +t("keeps flat lon,lat pairs", half.length % 2 === 0); +t("starts on the same vertex", half[0] === ring[0] && half[1] === ring[1]); +t("ends on the ring's own last vertex, not a chord back to the start", + half[half.length - 2] === ring[ring.length - 2] + && half[half.length - 1] === ring[ring.length - 1]); +t("every kept vertex is one of the original ones", (() => { + const orig = new Set(); + for (let i = 0; i < ring.length; i += 2) orig.add(ring[i] + "," + ring[i + 1]); + for (let i = 0; i < half.length; i += 2) + if (!orig.has(half[i] + "," + half[i + 1])) return false; + return true; +})()); + +// A small island must not be decimated into a triangle. +const tiny = [0, 0, 1, 0, 1, 1, 0, 1]; +t("a ring at or under the floor is returned whole", decimateRing(tiny, 2, 8) === tiny); +t("step below 2 is a no-op", decimateRing(ring, 1, 8) === ring); + +// The point of the thing: it still clips to the same disc, and covers the +// same ground. Compared by bounding box, not by centroid of the vertices - +// a vertex centroid moves when the points are respaced, which is precisely +// what decimating does, so it would fail on a shape that is drawn correctly. +const bbox = (poly) => poly.reduce((b, p) => [Math.min(b[0], p.x), Math.min(b[1], p.y), + Math.max(b[2], p.x), Math.max(b[3], p.y)], + [Infinity, Infinity, -Infinity, -Infinity]); +const fullPoly = clipRingToDisc(ring, 10, 0, R); +const coarsePoly = clipRingToDisc(half, 10, 0, R); +t("the coarse ring still clips to something", coarsePoly.length >= 3, coarsePoly.length); +const bf = bbox(fullPoly), bc = bbox(coarsePoly); +t("and covers the same ground", bf.every((v, i) => Math.abs(v - bc[i]) < R * 0.03), + [bf, bc]); +t("and stays inside the disc", + coarsePoly.every(p => Math.hypot(p.x, p.y) <= R + 0.01)); + +// Every real coastline ring survives the round trip. +t("every world.json ring decimates without breaking", land.every(r => { + const d = decimateRing(r, 2, 8); + return d.length % 2 === 0 && d.length >= 6 && d.length <= r.length; +})); + +console.log(` worst area jump per 0.25deg of spin: ${withArcs.toFixed(2)} px2 (chords only: ${chordsOnly.toFixed(2)})`); +console.log(` -> ${n - f}/${n} clipping assertions passed`); +if (f) process.exitCode = 1; diff --git a/tests/currency_check.py b/tests/currency_check.py new file mode 100755 index 0000000..6b51d7e --- /dev/null +++ b/tests/currency_check.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Validate the country -> currency table in worldclock-data.py. + +Three checks, each of which has already caught a real bug: + + * every currency is a current ISO 4217 code, per the system's iso-codes + package. This is what flagged BGN: Bulgaria has adopted the euro, and the + code is retired, but the FX feed still publishes a legacy peg rate for it. + * every country is a real ISO 3166-1 alpha-2 code (XK, user-assigned for + Kosovo, is allowed - the geocoder emits it). + * every currency has a live rate, or it would render as a bare code. + +The third check needs the network; pass --offline to skip it. +""" + +import json +import os +import sys +import urllib.request + +sys.dont_write_bytecode = True + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import importlib.util + +spec = importlib.util.spec_from_file_location( + "wcdata", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "worldclock-data.py")) +wcdata = importlib.util.module_from_spec(spec) +spec.loader.exec_module(wcdata) + +ISO_DIR = "/usr/share/iso-codes/json" +ALLOWED_NON_ISO_COUNTRIES = {"XK"} # user-assigned, Kosovo +# Newer than the iso-codes a distribution may still ship. ZWG (Zimbabwe Gold) +# entered ISO 4217 in 2024; Ubuntu 24.04's package predates it. The live-rate +# check below still has to know these, so a retired one would not go unseen. +ALLOWED_NEWER_CURRENCIES = {"ZWG"} + + +def main(): + mapping = wcdata.COUNTRY_CURRENCY + failures = [] + + currencies = {c["alpha_3"] for c in json.load( + open(os.path.join(ISO_DIR, "iso_4217.json")))["4217"]} + countries = {c["alpha_2"] for c in json.load( + open(os.path.join(ISO_DIR, "iso_3166-1.json")))["3166-1"]} + + retired = sorted({v for v in mapping.values() + if v not in currencies and v not in ALLOWED_NEWER_CURRENCIES}) + if retired: + failures.append(f"not current ISO 4217 codes: {retired}") + + unknown = sorted({k for k in mapping + if k not in countries and k not in ALLOWED_NON_ISO_COUNTRIES}) + if unknown: + failures.append(f"not ISO 3166-1 alpha-2 codes: {unknown}") + + if "--offline" not in sys.argv: + try: + payload = json.loads(urllib.request.urlopen(wcdata.FX, timeout=15).read()) + rates = set(payload.get("rates") or {}) + missing = sorted({v for v in mapping.values() if v not in rates}) + if missing: + failures.append(f"no live FX rate: {missing}") + except Exception as exc: + print(f" skipped live-rate check: {exc}") + + print(f" {len(mapping)} countries -> {len(set(mapping.values()))} currencies") + for line in failures: + print(f" FAIL {line}") + if failures: + return 1 + print(" all currency-table checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/deeptime_check.js b/tests/deeptime_check.js new file mode 100644 index 0000000..5383c09 --- /dev/null +++ b/tests/deeptime_check.js @@ -0,0 +1,103 @@ +// The Earth row's clock: does 4.54 billion years actually land where it says? +// +// Two kinds of check. The structural ones are the reason this file exists at +// all - a hand-typed geological table loses contiguity and nesting silently, +// and a gap between two eras looks like nothing until a moment falls into it. +// The arithmetic ones are anchored on values worked out by hand from the +// division alone (66/4540 of a day is 20.93 minutes, so the dinosaurs go at +// 23:39), not by running the code and writing down what it said. +const fs = require("fs"), path = require("path"); +const src = fs.readFileSync(path.join(__dirname, "..", "DeepTime.js"), "utf8").replace(".pragma library", ""); +const D = {}; +new Function(src + "; this.D={AGE_MA,EONS,ERAS,PERIODS,EPOCHS,AGES,LEVELS,divisionAt,breadcrumb,dayFraction,clockAt,yearsPer,asClockSpan,bands};").call(D); +const d = D.D; + +let n = 0, f = 0; +const t = (k, ok, extra) => { n++; if (!ok) { f++; console.log(" FAIL", k, extra === undefined ? "" : extra); } }; +const near = (x, y, eps) => Math.abs(x - y) <= eps; +const hm = (ma) => { const c = d.clockAt(ma); return c.hour + ":" + String(c.minute).padStart(2, "0"); }; + +// --- the table holds together --------------------------------------------- +const levelNames = ["eons", "eras", "periods", "epochs", "ages"]; +d.LEVELS.forEach((level, li) => { + const name = levelNames[li]; + t(name + ": run oldest to youngest", level.every(x => x.from > x.to)); + t(name + ": contiguous", level.every((x, i) => i === 0 || x.from === level[i - 1].to), + level.map(x => x.from + ">" + x.to).join(" ")); + t(name + ": end at the present", level[level.length - 1].to === 0); + t(name + ": named", level.every(x => typeof x.name === "string" && x.name.length > 2)); +}); +t("eons span the whole Earth", d.EONS[0].from === d.AGE_MA); +t("eons end now", d.EONS[d.EONS.length - 1].to === 0); + +// Every division must sit inside one division of the level above it, or the +// breadcrumb would claim a Cenozoic Cambrian. +for (let li = 1; li < d.LEVELS.length; li++) { + for (const child of d.LEVELS[li]) { + const parent = d.divisionAt(d.LEVELS[li - 1], child.from - 1e-9); + t("nested: " + child.name, parent !== null && child.from <= parent.from && child.to >= parent.to, + child.name + " in " + (parent ? parent.name : "nothing")); + } +} + +// --- published boundaries, not round numbers ------------------------------ +// Spot values from the ICS chart. Wrong-by-rounding is the failure this +// catches: 540 for the Cambrian and 250 for the Permian-Triassic are the +// numbers people remember, and neither is the boundary. +const boundary = (level, name) => level.find(x => x.name === name); +t("Cambrian base is 538.8", boundary(d.PERIODS, "Cambrian").from === 538.8); +t("Permian-Triassic is 251.902", boundary(d.PERIODS, "Triassic").from === 251.902); +t("K-Pg is 66", boundary(d.PERIODS, "Paleogene").from === 66); +t("Quaternary base is 2.58", boundary(d.PERIODS, "Quaternary").from === 2.58); +t("Holocene base is 11,700 years", boundary(d.EPOCHS, "Holocene").from === 0.0117); +t("Meghalayan base is 4,200 years", boundary(d.AGES, "Meghalayan").from === 0.0042); + +// --- the clock ------------------------------------------------------------ +// 4540 Ma over 1440 minutes is 3.1528 Ma per minute; each anchor below is +// (ma / 4540) * 1440 minutes back from midnight, worked out on paper. +t("formation is midnight", hm(4540) === "0:00"); +t("the present is a minute short of midnight", hm(0) === "23:59"); +t("the present never reads 24:00", d.clockAt(0).hour === 23); +t("K-Pg at 23:39", hm(66) === "23:39"); // 20.93 min back +t("Cambrian at 21:09", hm(538.8) === "21:09"); // 170.9 min back +t("end of the Archean at 10:47", hm(2500) === "10:47"); // 792.9 min in +t("Pleistocene opens at 23:59", hm(2.58) === "23:59"); // 49 s back +t("halfway is 2270 Ma", near(d.dayFraction(2270), 0.5, 1e-12)); + +// --- the units that make the row worth reading ---------------------------- +t("an hour is 189 Myr", near(d.yearsPer("hour") / 1e6, 189.17, 0.01), d.yearsPer("hour") / 1e6); +t("a minute is 3.15 Myr", near(d.yearsPer("minute") / 1e6, 3.1528, 0.001)); +t("a second is 52,546 years", near(d.yearsPer("second"), 52546, 1)); +t("a day is the whole Earth", d.yearsPer("day") === d.AGE_MA * 1e6); +t("nonsense unit", d.yearsPer("fortnight") === 0); + +// The three facts the row is for, each to a tenth of a unit. +t("the genus Homo is about a minute", near(d.asClockSpan(2.8e6) / 60, 0.89, 0.01)); +t("our own species is six seconds", near(d.asClockSpan(300000), 5.71, 0.01)); +t("recorded history is a tenth of a second", near(d.asClockSpan(5000), 0.095, 0.001)); +t("a human life is a twentieth of a millisecond", d.asClockSpan(80) < 0.002); + +// --- the breadcrumb ------------------------------------------------------- +t("now", d.breadcrumb(0).join(" ") === "Phanerozoic Cenozoic Quaternary Holocene Meghalayan"); +t("the age of dinosaurs", d.breadcrumb(100).join(" ") === "Phanerozoic Mesozoic Cretaceous"); +t("the day the asteroid hit", d.breadcrumb(66.01).join(" ") === "Phanerozoic Mesozoic Cretaceous"); +t("the day after", d.breadcrumb(65.99).join(" ") === "Phanerozoic Cenozoic Paleogene"); +t("the Hadean has no smaller divisions", d.breadcrumb(4400).join(" ") === "Hadean"); +t("deep in the Archean", d.breadcrumb(3000).join(" ") === "Archean Mesoarchean"); +t("before the Earth", d.breadcrumb(5000).length === 0); + +// --- the strip ------------------------------------------------------------ +const bands = d.bands(); +t("four bands", bands.length === 4); +t("bands start at the left edge", bands[0].x0 === 0); +t("bands end at the right edge", bands[bands.length - 1].x1 === 1); +t("bands are contiguous", bands.every((b, i) => i === 0 || near(b.x0, bands[i - 1].x1, 1e-12))); +t("bands cover the width", near(bands.reduce((s, b) => s + (b.x1 - b.x0), 0), 1, 1e-12)); +// Every band has to be wide enough to see at the width of a panel: 300px of +// strip means anything under 1% is a hairline that reads as an artefact. +t("no band is invisible", bands.every(b => b.x1 - b.x0 > 0.02), + bands.map(b => b.name + " " + ((b.x1 - b.x0) * 100).toFixed(1) + "%").join(" ")); +t("the Phanerozoic is a ninth of the day", near(bands[3].x1 - bands[3].x0, 0.1187, 0.0005)); + +console.log((f ? "FAIL " : "ok ") + (n - f) + "/" + n + " deep time"); +process.exit(f ? 1 : 0); diff --git a/tests/globe_check.js b/tests/globe_check.js new file mode 100644 index 0000000..da48f11 --- /dev/null +++ b/tests/globe_check.js @@ -0,0 +1,151 @@ +// Validates GlobeModel's solar maths against Open-Meteo's is_day flag for +// every city on the globe, plus projection invariants. Run: node tests/globe_check.js +const fs = require("fs"), path = require("path"), https = require("https"); +const root = path.join(__dirname, ".."); +const src = fs.readFileSync(path.join(root, "GlobeModel.js"), "utf8").replace(".pragma library", ""); +const M = {}; +new Function(src + "; this.M={project,subsolarPoint,solarElevation,isDaylight,terminator,layoutLabels,scalePx};").call(M); +const G = M.M; +const cities = JSON.parse(fs.readFileSync(path.join(root, "cities.json"), "utf8")); + +let fails = 0, checks = 0; +const ok = (name, cond, detail) => { + checks++; + if (!cond) { fails++; console.log(" FAIL", name, detail === undefined ? "" : detail); } +}; + +// ---- projection invariants ------------------------------------------------ +const p = G.project(0, 0, 0, 0, 100); +ok("centre projects to origin", Math.abs(p.x) < 1e-9 && Math.abs(p.y) < 1e-9 && p.visible); +ok("antipode hidden", G.project(0, 180, 0, 0, 100).visible === false); +ok("north pole up at zero tilt", G.project(90, 0, 0, 0, 100).y < -99); +ok("east is +x", G.project(0, 45, 0, 0, 100).x > 0); +ok("spin follows the point", Math.abs(G.project(0, 45, 45, 0, 100).x) < 1e-9); +for (const [lat, lon] of [[12, 34], [-56, 78], [80, -170]]) { + const q = G.project(lat, lon, 20, 15, 100); + ok(`inside disc ${lat},${lon}`, Math.hypot(q.x, q.y) <= 100.0001, Math.hypot(q.x, q.y)); +} + +// ---- subsolar point ------------------------------------------------------- +const jun = G.subsolarPoint(Date.UTC(2026, 5, 21, 12, 0, 0)); +ok("june solstice declination ~ +23.4", Math.abs(jun.lat - 23.44) < 0.5, jun.lat); +const dec = G.subsolarPoint(Date.UTC(2026, 11, 21, 12, 0, 0)); +ok("december solstice declination ~ -23.4", Math.abs(dec.lat + 23.44) < 0.5, dec.lat); +const mar = G.subsolarPoint(Date.UTC(2026, 2, 20, 12, 0, 0)); +ok("march equinox declination ~ 0", Math.abs(mar.lat) < 1.0, mar.lat); +const noonUTC = G.subsolarPoint(Date.UTC(2026, 5, 21, 12, 0, 0)); +ok("noon UTC subsolar near Greenwich meridian", Math.abs(noonUTC.lon) < 5, noonUTC.lon); +const sixUTC = G.subsolarPoint(Date.UTC(2026, 5, 21, 18, 0, 0)); +ok("18:00 UTC subsolar near 90W", Math.abs(sixUTC.lon + 90) < 5, sixUTC.lon); + +// ---- terminator ----------------------------------------------------------- +const sub = G.subsolarPoint(Date.now()); +const term = G.terminator(sub, 60); +ok("terminator closes", term.length === 61); +ok("terminator is the 90-degree circle", + term.every(([la, lo]) => { + const cosz = Math.sin(la * Math.PI / 180) * Math.sin(sub.lat * Math.PI / 180) + + Math.cos(la * Math.PI / 180) * Math.cos(sub.lat * Math.PI / 180) + * Math.cos((lo - sub.lon) * Math.PI / 180); + return Math.abs(cosz) < 1e-9; + })); + +// ---- label collision ------------------------------------------------------ +const cand = [ + { index: 0, name: "AAAA", x: 0, y: 0, rank: 1, cosc: 1 }, + { index: 1, name: "BBBB", x: 2, y: 2, rank: 1, cosc: 0.9 }, // overlaps 0 + { index: 2, name: "CCCC", x: 0, y: 80, rank: 1, cosc: 0.8 }, // clear +]; +const placed = G.layoutLabels(cand, 7, 12); +ok("overlapping label dropped", placed.length === 2, placed.map(p => p.index)); +ok("clear label kept", placed.some(p => p.index === 2)); + +// A label that would run off the right edge is placed to the left of its dot +// instead of overflowing the panel. +const wide = [{ index: 0, name: "Bangkok", x: 120, y: 0, rank: 1, cosc: 1 }]; +const free = G.layoutLabels(wide, 7, 12, 10)[0].box; +const bounded = G.layoutLabels(wide, 7, 12, 10, 150)[0].box; +ok("unbounded label may overflow", free.x + free.w > 150, free.x + free.w); +ok("bounded label flips to the left of its dot", bounded.x + bounded.w <= 150, + bounded.x + bounded.w); +ok("flipped label still sits beside its dot", bounded.x < 120 && bounded.x + bounded.w > 60, + bounded.x); +const near = [{ index: 0, name: "Lagos", x: -40, y: 0, rank: 1, cosc: 1 }]; +ok("a label that fits is left alone", G.layoutLabels(near, 7, 12, 10, 150)[0].box.x === -34); + +// ---- drawn constants follow the UI scale --------------------------------- +// The large globe's radius and labels scale with the shell's base font size; +// its strokes and marker radii did not, so a bigger font gave it thinner +// lines. Every drawn constant now goes through scalePx. +ok("scale 1 is the literal", G.scalePx(1.4, 1) === 1.4); +ok("a bigger shell draws heavier", G.scalePx(1.4, 2) === 2.8); +ok("radii scale too", G.scalePx(7.5, 1.5) === 11.25); +// Proportion is the whole point: a selection ring must stay the same multiple +// of a city dot at any scale, which a per-call rounding would break. +const ratio = s => G.scalePx(7.5, s) / G.scalePx(2.2, s); +ok("ring keeps its proportion to the dot", + Math.abs(ratio(1) - ratio(1.67)) < 1e-9, [ratio(1), ratio(1.67)]); +// Below a pixel a stroke drops out of the raster rather than reading thin, +// which is why the small globe floors its widths the same way. +ok("hairlines floor at one pixel", G.scalePx(1, 0.5) === 1); +ok("the floor is overridable", G.scalePx(1, 0.5, 0.25) === 0.5); +ok("a nonsense scale falls back to 1", G.scalePx(2.5, 0) === 2.5 + && G.scalePx(2.5, undefined) === 2.5 && G.scalePx(2.5, NaN) === 2.5); + +// The gap from a dot to its name is now scalable, but an omitted gap must +// leave every existing caller's layout untouched. +const gapCity = [{ index: 0, name: "Lagos", x: -40, y: 0, rank: 1, cosc: 1 }]; +ok("omitted gap keeps the old 6px", + G.layoutLabels(gapCity, 7, 12, 10, 150)[0].box.x === -34); +ok("a scaled gap moves the name out with the dot", + G.layoutLabels(gapCity, 7, 12, 10, 150, 10)[0].box.x === -30); +ok("a nonsense gap falls back to 6", + G.layoutLabels(gapCity, 7, 12, 10, 150, 0)[0].box.x === -34); + +// ---- day/night against Open-Meteo is_day --------------------------------- +const lats = cities.map(c => c[2]).join(","), lons = cities.map(c => c[3]).join(","); +const url = `https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=is_day`; +https.get(url, res => { + let buf = ""; + res.on("data", d => buf += d); + res.on("end", () => { + let feed; + try { feed = JSON.parse(buf); } catch { console.log(" skipped is_day check (bad response)"); return done(); } + if (!Array.isArray(feed)) { console.log(" skipped is_day check"); return done(); } + const now = G.subsolarPoint(Date.now()); + // Open-Meteo reports `current` on a 15-minute interval, so near sunrise + // or sunset its flag can be up to a quarter hour stale - it has been + // wrong and this code right every time that has come up. So a + // disagreement is judged by *where the sun actually is*: within a few + // degrees of the horizon it is the reference lagging and is expected; + // far from the horizon it would be a real error in this code, and fails. + const NEAR_HORIZON_DEG = 4; + let expected = [], real = []; + feed.forEach((entry, i) => { + const theirs = (entry.current || {}).is_day; + if (theirs === undefined) return; + const elev = G.solarElevation(cities[i][2], cities[i][3], now); + const mine = G.isDaylight(cities[i][2], cities[i][3], now) ? 1 : 0; + checks++; + if (mine === theirs) return; + const note = `${cities[i][0]} mine=${mine} theirs=${theirs} elev=${elev.toFixed(2)}`; + if (Math.abs(elev) <= NEAR_HORIZON_DEG) expected.push(note); else real.push(note); + }); + if (real.length) { + fails += real.length; + console.log(` day/night: ${real.length} disagreement(s) away from the horizon ->`, real.join("; ")); + } + if (expected.length) { + console.log(` day/night: ${expected.length} at the horizon (reference lag, expected) ->`, expected.join("; ")); + } + if (!real.length && !expected.length) { + console.log(` day/night: all ${feed.length} cities agree with Open-Meteo is_day`); + } + done(); + }); +}).on("error", () => { console.log(" skipped is_day check (offline)"); done(); }); + +function done() { + console.log(` -> ${checks - fails}/${checks} globe assertions passed`); + process.exit(fails ? 1 : 0); +} diff --git a/tests/greetings_check.js b/tests/greetings_check.js new file mode 100644 index 0000000..bd7998c --- /dev/null +++ b/tests/greetings_check.js @@ -0,0 +1,195 @@ +// The greeting table: shape, coverage, and the claims it makes about +// languages. +// +// There is no independent oracle for "is this what people say in Lagos at +// 8am" on this machine, so what is checked here is everything around that: no +// city can be greeted in nothing, no hour of the day can fall in a gap, and +// every non-Latin greeting carries a pronunciation. The words themselves want +// a speaker's eye, not a test - see the note in NOTES.md. +const fs = require("fs"), path = require("path"); +const root = path.join(__dirname, ".."); +const load = (file, exports) => { + const src = fs.readFileSync(path.join(root, file), "utf8").replace(".pragma library", ""); + const box = {}; + new Function(src + "; this.X={" + exports + "};").call(box); + return box.X; +}; +const g = load("Greetings.js", "greeting,languageFor,languageKeys,zoneIds,countryCodes,countryFor,bandsOf"); + +let n = 0, f = 0; +const t = (k, ok, extra) => { n++; if (!ok) { f++; console.log(" FAIL", k, extra === undefined ? "" : extra); } }; + +// --- every zone the picker can offer can be greeted ---------------------- +// The picker does not offer the shipped catalogue. It offers whatever +// `timedatectl list-timezones` returns - 598 zones on this machine - and the +// first version of this file checked coverage against cities.json instead, +// which is exactly why Tel Aviv said "Good morning" in English for a day. +// Check the list the panel actually uses, from the same command it uses. +const { execSync } = require("child_process"); +let offered = []; +try { + offered = execSync("timedatectl list-timezones", { encoding: "utf8" }).trim().split("\n"); +} catch (e) { + try { + offered = execSync("find /usr/share/zoneinfo -type f -printf '%P\\n' | grep /", { encoding: "utf8" }) + .trim().split("\n"); + } catch (e2) { offered = []; } +} +// Zones that are offsets rather than places: Etc/GMT+5, UTC, Zulu. Nobody +// lives in one, so nobody is greeted in one. +const placeless = z => /^(Etc\/|GMT|UCT$|UTC$|Universal$|Zulu$|Greenwich$|Factory$)/.test(z); +const places = offered.filter(z => !placeless(z)); + +t("the system offers a zone list at all", places.length > 300, places.length); +// English is a real answer in a great many places, so it cannot be checked by +// looking at the result. It is checked at the source instead: a zone is only +// greeted in English if some country explicitly asked for English. A zone that +// is missing from the table gets English too, and that is the accident. +const stranded = places.filter(z => g.countryFor(z) === ""); +t("every place has a country", stranded.length === 0, stranded.slice(0, 10).join(", ")); +const orphan = places.filter(z => g.countryFor(z) !== "" && g.languageFor(z) === "en" + && g.countryCodes().indexOf(g.countryFor(z)) === -1); +t("no zone falls through to the default", orphan.length === 0, orphan.slice(0, 10).join(", ")); +t("the offset-only zones are still answered", + g.greeting("Etc/GMT+5", 9).text === "Good morning" && g.countryFor("Etc/GMT+5") === ""); + +// Every offered zone must be in the baked table, not merely resolve to +// something: a zone missing from ZONE_COUNTRY still "works", in English. +const absent = places.filter(z => g.zoneIds().indexOf(z) === -1); +t("every offered zone is in the table", absent.length === 0, absent.slice(0, 10).join(", ")); + +// And the shipped catalogue, which is the subset most people will ever add. +const cities = JSON.parse(fs.readFileSync(path.join(root, "cities.json"), "utf8")); +for (const zone of [...new Set(cities.map(c => c[1]))]) + t("catalogue city mapped: " + zone, g.zoneIds().indexOf(zone) !== -1); + +// --- the ones that were wrong --------------------------------------------- +t("Tel Aviv is greeted in Hebrew", g.greeting("Asia/Jerusalem", 7).language === "Hebrew"); +t("Tel Aviv at 7am", g.greeting("Asia/Jerusalem", 7).roman === "boker tov"); +t("Singapore is greeted in Malay", g.greeting("Asia/Singapore", 14).language === "Malay"); +t("Singapore at 2pm", g.greeting("Asia/Singapore", 14).text === "Selamat tengah hari"); + +// --- overrides, aliases and countries ------------------------------------- +t("Honolulu overrides the United States", g.greeting("Pacific/Honolulu", 8).language === "Hawaiian"); +t("but the mainland does not", g.greeting("America/Denver", 8).language === "English"); +t("Montreal overrides Canada", g.greeting("America/Montreal", 8).language === "French"); +t("Toronto does not", g.greeting("America/Toronto", 8).language === "English"); +t("an alias resolves like its target", g.greeting("Asia/Calcutta", 8).language === "Hindi"); +t("so does a legacy US alias", g.greeting("US/Pacific", 8).language === "English"); +t("so does a renamed capital", g.greeting("Europe/Kiev", 8).language === "Ukrainian"); +t("every country code is spoken for", g.countryCodes().length > 240); + +// --- the fallback is a fallback, not a crash ----------------------------- +t("unknown zone", g.greeting("Mars/Olympus_Mons", 9).text === "Good morning"); +t("empty zone", g.greeting("", 9).language === "English"); +t("undefined zone", g.greeting(undefined, 9).text.length > 0); + +// --- every hour of every language lands in a band ------------------------ +// Latin script, generously: Latin-1 and both Extended blocks for the European +// diacritics, IPA Extensions for Azerbaijani's schwa, combining marks for +// Yoruba's tones, and the okina for Hawaiian. +const LATIN = /^[\u0020-\u007e\u00a0-\u024f\u0250-\u02ff\u0300-\u036f\u1e00-\u1eff\u2018\u2019]+$/; +for (const key of g.languageKeys()) { + const bands = g.bandsOf(key); + t(key + ": starts at midnight", bands[0].from === 0, bands[0].from); + t(key + ": bands ascend", bands.every((b, i) => i === 0 || b.from > bands[i - 1].from)); + t(key + ": bands are inside a day", bands.every(b => b.from >= 0 && b.from <= 23)); + t(key + ": no empty greeting", bands.every(b => b.text.trim() === b.text && b.text.length > 0)); + + // Adjacent bands must actually say something different, or the boundary is + // a claim about the language that the words do not back up. The first and + // last may repeat - that is the night wrapping around midnight. + for (let i = 1; i < bands.length; i++) { + const wrap = i === bands.length - 1 && bands[i].text === bands[0].text; + t(key + ": band " + i + " differs from the one before", + wrap || bands[i].text !== bands[i - 1].text, bands[i].text); + } + + // Pronunciation exactly where the script is not Latin, and nowhere else. + for (const b of bands) { + const latin = LATIN.test(b.text); + t(key + ": " + (latin ? "Latin script needs no roman" : "non-Latin script carries a roman"), + latin ? b.roman === "" : b.roman.length > 0, b.text + " / " + b.roman); + t(key + ": roman is sayable", b.roman === "" || /^[a-z' -]+$/.test(b.roman), b.roman); + } + + // Every hour resolves, and to one of this language's own bands. + const texts = new Set(bands.map(b => b.text)); + for (let h = 0; h < 24; h++) { + const zone = g.zoneIds().find(z => g.languageFor(z) === key); + if (!zone) continue; + t(key + ": hour " + h + " has a greeting", texts.has(g.greeting(zone, h).text), h); + } +} + +// --- out-of-range hours --------------------------------------------------- +t("hour 24 clamps into the day", g.greeting("Asia/Tokyo", 24).text === g.greeting("Asia/Tokyo", 23).text); +t("negative hour clamps to midnight", g.greeting("Asia/Tokyo", -3).text === g.greeting("Asia/Tokyo", 0).text); +t("fractional hour truncates", g.greeting("Asia/Tokyo", 11.9).text === g.greeting("Asia/Tokyo", 11).text); + +// --- the boundaries the table exists to express -------------------------- +// If these ever collapse into each other the feature has stopped saying +// anything: the whole point is that the hour means different things. +const at = (z, h) => g.greeting(z, h).text; +t("Tokyo walks through its day", + at("Asia/Tokyo", 7) !== at("Asia/Tokyo", 13) && at("Asia/Tokyo", 13) !== at("Asia/Tokyo", 20)); +t("Madrid is still in the afternoon at 20:00", at("Europe/Madrid", 20) === "Buenas tardes"); +t("Lima is not", at("America/Lima", 20) === "Buenas noches"); +t("both are Spanish", g.greeting("Europe/Madrid", 20).language.indexOf("Spanish") === 0 + && g.greeting("America/Lima", 20).language === "Spanish"); +t("Vienna and Berlin differ at midday", at("Europe/Vienna", 13) !== at("Europe/Berlin", 13)); +t("Zurich too", at("Europe/Zurich", 13) !== at("Europe/Berlin", 13)); +t("Jakarta has an afternoon and a late afternoon", + at("Asia/Jakarta", 12) !== at("Asia/Jakarta", 16)); +t("Yangon says the same thing all day", + new Set([0, 6, 12, 18, 23].map(h => at("Asia/Yangon", h))).size === 1); +t("Hong Kong is not greeted in Mandarin", at("Asia/Hong_Kong", 8) !== at("Asia/Shanghai", 8)); +t("Honolulu is Hawaiian", g.greeting("Pacific/Honolulu", 8).language === "Hawaiian"); + +// --- the legacy aliases name places, not rules ----------------------------- +// +// The zone table was built by matching compiled zoneinfo files, which puts an +// alias in whatever country happens to keep the same time. Iceland kept +// Abidjan's clock and was greeted in French; NZ matched Antarctica. Checked +// here against the canonical zone for the *same place*, which is an answer that +// does not come from the same table - not against the tz link table, which +// would hand back Cรดte d'Ivoire for Iceland and Papua New Guinea for Truk. +const samePlace = [ + ["Iceland", "Atlantic/Reykjavik"], + ["NZ", "Pacific/Auckland"], + ["Singapore", "Asia/Singapore"], + ["Asia/Rangoon", "Asia/Yangon"], + ["Africa/Asmera", "Africa/Asmara"], + ["Africa/Timbuktu", "Africa/Bamako"], + ["America/Virgin", "America/St_Thomas"], + ["Pacific/Truk", "Pacific/Chuuk"], + ["Pacific/Yap", "Pacific/Chuuk"], + ["US/Arizona", "America/Phoenix"], + ["MST", "America/Phoenix"], + ["Canada/Eastern", "America/Toronto"], + ["America/Nipigon", "America/Toronto"], + ["America/Thunder_Bay", "America/Toronto"], +]; +for (const [alias, canonical] of samePlace) { + t(alias + " is in the same country as " + canonical, + g.countryFor(alias) === g.countryFor(canonical)); + t(alias + " is greeted like " + canonical, + at(alias, 9) === at(canonical, 9)); +} + +// The two that look like alias mistakes and are not: the South Pole really is +// in Antarctica and Pohnpei really is in Micronesia, whatever the link table +// says about the zones they share their rules with. +t("the South Pole stays in Antarctica", g.countryFor("Antarctica/South_Pole") === "AQ"); +t("Pohnpei stays in Micronesia", g.countryFor("Pacific/Ponape") === "FM"); +// A choice rather than a lookup, and worth failing loudly if it is ever flipped +// by a regenerated table. +t("Simferopol is greeted in Ukrainian", g.greeting("Europe/Simferopol", 9).language === "Ukrainian"); + +// America/Montreal is the exception to the rule above: same country as Toronto, +// different language, because it has an explicit override. +t("Montreal is Canadian", g.countryFor("America/Montreal") === g.countryFor("America/Toronto")); +t("but greeted in French", g.greeting("America/Montreal", 9).language === "French"); + +console.log((f ? "FAIL " : "ok ") + (n - f) + "/" + n + " greetings"); +process.exit(f ? 1 : 0); diff --git a/tests/moon_check.js b/tests/moon_check.js new file mode 100644 index 0000000..c97cce3 --- /dev/null +++ b/tests/moon_check.js @@ -0,0 +1,121 @@ +// The moon phase shown on each row's night marker. +// +// The phase itself is checked against eclipses, which are the one thing that +// pins a lunation to a wall clock: a solar eclipse can only happen at new +// moon and a lunar eclipse only at full. The drawn shape is checked by +// rasterising it and counting lit pixels against the illumination formula. +const fs = require("fs"), path = require("path"); +const src = fs.readFileSync(path.join(__dirname, "..", "GlobeModel.js"), "utf8") + .replace(".pragma library", ""); +const box = {}; +new Function(src + "; this.M={moonPhase,moonIllumination,moonLitOutline,moonPhaseName,SYNODIC_MONTH};").call(box); +const { moonPhase, moonIllumination, moonLitOutline, moonPhaseName, SYNODIC_MONTH } = box.M; + +let n = 0, f = 0; +const t = (k, cond, detail) => { n++; if (!cond) { f++; console.log(" FAIL", k, detail === undefined ? "" : detail); } }; + +// --- phase against known eclipses ----------------------------------------- +// A mean synodic month drifts from the true one by a few hours either way, so +// these are allowed a day. That is 3% of a cycle, and at most a few percent +// of illumination - far finer than a dot a few pixels across can show. +const TOLERANCE_DAYS = 1.0; +const eclipses = [ + ["2017-08-21T18:26Z", 0.0, "total solar, Wyoming"], + ["2024-04-08T18:17Z", 0.0, "total solar, Mexico"], + ["2019-01-21T05:12Z", 0.5, "total lunar"], + ["2022-11-08T10:59Z", 0.5, "total lunar"], + ["2021-05-26T11:19Z", 0.5, "total lunar"], +]; +for (const [iso, want, label] of eclipses) { + const p = moonPhase(Date.parse(iso)); + let d = Math.abs(p - want); + if (d > 0.5) d = 1 - d; + t(`${label} falls at phase ${want}`, d * SYNODIC_MONTH <= TOLERANCE_DAYS, + `phase ${p.toFixed(3)}, off by ${(d * SYNODIC_MONTH * 24).toFixed(1)}h`); +} + +// --- the names, on the same eclipses --------------------------------------- +// The eclipses pin the naming as well as the number: a solar eclipse can only +// happen at new moon and a lunar one only at full, so the name at those +// instants is not a matter of taste. +for (const [iso, want, label] of eclipses) { + t(`${label} is named for its phase`, + moonPhaseName(moonPhase(Date.parse(iso))) === (want === 0 ? "New moon" : "Full moon"), + moonPhaseName(moonPhase(Date.parse(iso)))); +} + +// The exact instants, and the mid-points between them. +const named = [ + [0, "New moon"], [0.25, "First quarter"], [0.5, "Full moon"], [0.75, "Last quarter"], + [0.125, "Waxing crescent"], [0.375, "Waxing gibbous"], + [0.625, "Waning gibbous"], [0.875, "Waning crescent"], +]; +for (const [p, want] of named) + t(`phase ${p} is ${want}`, moonPhaseName(p) === want, moonPhaseName(p)); + +// The principal phases hold for a day either side and not a moment longer, and +// the wrap at the top of the cycle is a new moon rather than a waning one. +const day = 1 / SYNODIC_MONTH; +t("a day past full is still full", moonPhaseName(0.5 + day * 0.9) === "Full moon"); +t("two days past full is not", moonPhaseName(0.5 + day * 2) === "Waning gibbous"); +t("just before new is still new", moonPhaseName(1 - day * 0.5) === "New moon"); +t("phase 1 wraps to new", moonPhaseName(1) === "New moon"); +t("negative phases wrap too", moonPhaseName(-0.25) === "Last quarter"); +t("nonsense names nothing", moonPhaseName(NaN) === ""); + +// --- phase arithmetic ------------------------------------------------------ +t("phase always in [0,1)", [0, 1e12, Date.now(), Date.UTC(1970, 0, 1)] + .every(ms => { const p = moonPhase(ms); return p >= 0 && p < 1; })); +t("a synodic month later is the same phase", (() => { + const now = Date.now(); + const a = moonPhase(now), b = moonPhase(now + SYNODIC_MONTH * 86400000); + return Math.abs(a - b) < 1e-6 || Math.abs(a - b) > 1 - 1e-6; +})()); +t("illumination: new is dark, full is lit", + moonIllumination(0) < 1e-9 && Math.abs(moonIllumination(0.5) - 1) < 1e-9); +t("illumination: quarters are half", + Math.abs(moonIllumination(0.25) - 0.5) < 1e-9 + && Math.abs(moonIllumination(0.75) - 0.5) < 1e-9); + +// --- the drawn shape ------------------------------------------------------- +const R = 40; +const inside = (poly, x, y) => { // even-odd + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + return c; +}; +function measure(phase) { + const poly = moonLitOutline(phase, R, 96); + let lit = 0, disc = 0, litLeft = 0, litRight = 0; + for (let y = -R; y <= R; y += 0.5) + for (let x = -R; x <= R; x += 0.5) { + if (x * x + y * y > R * R) continue; + disc++; + if (!inside(poly, x, y)) continue; + lit++; + if (x < 0) litLeft++; else litRight++; + } + return { frac: lit / disc, litLeft, litRight }; +} +for (const p of [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]) { + const m = measure(p); + t(`drawn area matches the formula at phase ${p}`, + Math.abs(m.frac - moonIllumination(p)) < 0.03, + `drawn ${(m.frac * 100).toFixed(1)}% vs ${(moonIllumination(p) * 100).toFixed(1)}%`); +} +// Waxing lights the right limb, waning the left - the northern view. +t("waxing crescent is lit on the right", measure(0.125).litRight > measure(0.125).litLeft * 20); +t("waning crescent is lit on the left", measure(0.875).litLeft > measure(0.875).litRight * 20); +t("first quarter lights the right half", measure(0.25).litLeft < measure(0.25).litRight * 0.05); +t("last quarter lights the left half", measure(0.75).litRight < measure(0.75).litLeft * 0.05); +t("new moon draws nothing", measure(0).frac < 0.01); +t("full moon draws everything", measure(0.5).frac > 0.99); +// The outline must never leave the disc it is drawn on. +t("outline stays within the disc", [0, 0.2, 0.4, 0.6, 0.8].every(p => + moonLitOutline(p, R, 48).every(q => Math.hypot(q.x, q.y) <= R + 1e-6))); + +console.log(` -> ${n - f}/${n} moon assertions passed`); +if (f) process.exitCode = 1; diff --git a/tests/overlap_check.js b/tests/overlap_check.js new file mode 100644 index 0000000..7dfc4c3 --- /dev/null +++ b/tests/overlap_check.js @@ -0,0 +1,168 @@ +// Circular-time maths for the overlap band and the time scrubber. +const fs = require("fs"), path = require("path"); +const src = fs.readFileSync(path.join(__dirname, "..", "Model.js"), "utf8").replace(".pragma library", ""); +const M = {}; +new Function(src + "; this.M={overlapRuns,localSegments,overlapMinutes,scrubDeltaMinutes,withinWindow,localMinuteOfDay,formatScrubDelta,formatMinuteOfDay,utcOffsetLabel,relativeOffsetLabel};").call(M); +const m = M.M; + +let n = 0, f = 0; +const t = (k, a, b) => { n++; if (JSON.stringify(a) !== JSON.stringify(b)) { f++; console.log(" FAIL", k, JSON.stringify(a), "!=", JSON.stringify(b)); } }; +const W0 = 9 * 60, W1 = 17 * 60; + +// --- window containment, including windows that run past midnight --------- +t("inside window", m.withinWindow(10 * 60, W0, W1), true); +t("outside window", m.withinWindow(8 * 60, W0, W1), false); +t("end is exclusive", m.withinWindow(17 * 60, W0, W1), false); +t("overnight window, after midnight", m.withinWindow(30, 22 * 60, 6 * 60), true); +t("overnight window, midday", m.withinWindow(12 * 60, 22 * 60, 6 * 60), false); +t("empty window", m.withinWindow(600, 600, 600), false); +t("local minute wraps negative", m.localMinuteOfDay(30, -60), 1410); +t("local minute wraps positive", m.localMinuteOfDay(1400, 120), 80); + +// --- intersecting the windows -------------------------------------------- +t("single zone is its own window", m.overlapRuns([0], W0, W1), [{ start: 540, end: 1020 }]); +t("duplicate zones change nothing", m.overlapRuns([0, 0], W0, W1), [{ start: 540, end: 1020 }]); +// A is UTC 540-1020, B (+1h) is UTC 480-960; they meet at 540-960. +t("one hour apart", m.overlapRuns([0, 60], W0, W1), [{ start: 540, end: 960 }]); +t("one hour apart is 7h long", m.overlapMinutes(m.overlapRuns([0, 60], W0, W1)), 420); +t("twelve hours apart: nothing", m.overlapRuns([0, 720], W0, W1), []); +t("eight hours apart: nothing", m.overlapRuns([0, 480], W0, W1), []); +t("no zones", m.overlapRuns([], W0, W1), []); +// A run crossing midnight comes back as one range past 1440, not two. +t("run merged across midnight", m.overlapRuns([-720], W0, W1), [{ start: 1260, end: 1740 }]); +t("merged run still 8h", m.overlapMinutes(m.overlapRuns([-720], W0, W1)), 480); + +// --- drawing a run on one city's strip ------------------------------------ +t("segment, no wrap", m.localSegments([{ start: 540, end: 1020 }], 0), + [{ x0: 540 / 1440, x1: 1020 / 1440 }]); +const seg = m.localSegments([{ start: 1260, end: 1740 }], 0); +t("segment splits at local midnight", seg.length, 2); +t("segment tail", seg[0], { x0: 1260 / 1440, x1: 1 }); +t("segment head", seg[1], { x0: 0, x1: 300 / 1440 }); +t("offset shifts the segment", m.localSegments([{ start: 0, end: 60 }], 120), + [{ x0: 120 / 1440, x1: 180 / 1440 }]); + +// --- scrubbing ------------------------------------------------------------ +t("scrub forward", Math.round(m.scrubDeltaMinutes(0.5, 600)), 120); +t("scrub backward", Math.round(m.scrubDeltaMinutes(0.5, 840)), -120); +t("scrub takes the short way round midnight", Math.round(m.scrubDeltaMinutes(0.0, 1380)), 60); +t("scrub clamps past the right edge", Math.round(m.scrubDeltaMinutes(2, 720)), 720); +// Exactly half a day away is genuinely ambiguous - forward and back are the +// same distance, and strip position 0 and 1 are the same instant on a +// circular day. The function resolves consistently into (-720, +720]. +t("half-day is resolved forward, consistently", Math.round(m.scrubDeltaMinutes(-1, 720)), 720); +t("and the same from the other edge", Math.round(m.scrubDeltaMinutes(1, 720)), 720); +t("scrub at current time is zero", Math.round(m.scrubDeltaMinutes(600 / 1440, 600)), 0); +t("delta format", [m.formatScrubDelta(0), m.formatScrubDelta(45), m.formatScrubDelta(-90), m.formatScrubDelta(180)], + ["", "+45m", "-1h 30m", "+3h"]); +t("minute-of-day format", [m.formatMinuteOfDay(0, false), m.formatMinuteOfDay(13 * 60 + 5, false), m.formatMinuteOfDay(13 * 60, true)], + ["12:00 AM", "1:05 PM", "13:00"]); + +// --- absolute offsets, for cities you have not added yet ----------------- +// The rows say "+9h", meaning nine hours from here. A search result has no +// "here" to be relative to, so it says where it is instead. +t("Greenwich", m.utcOffsetLabel(0), "UTC"); +t("whole hours drop the minutes", m.utcOffsetLabel(120), "UTC+2"); +t("west of Greenwich", m.utcOffsetLabel(-300), "UTC-5"); +t("the three-quarter zones keep theirs", m.utcOffsetLabel(345), "UTC+5:45"); +t("Newfoundland", m.utcOffsetLabel(-210), "UTC-3:30"); +t("Adelaide", m.utcOffsetLabel(570), "UTC+9:30"); +t("the far end of the line", m.utcOffsetLabel(840), "UTC+14"); +t("and the other far end", m.utcOffsetLabel(-720), "UTC-12"); +t("a single-digit minute pads", m.utcOffsetLabel(65), "UTC+1:05"); +// --- fractional minutes, which is what a sunrise is ----------------------- +// The hour and the minute have to be carried together. Rounding them apart +// printed "6:60 AM" for 12 seconds before seven, and rolled neither the hour +// nor, at the end of the day, the date. +t("a few seconds short of the hour rolls the hour", + m.formatMinuteOfDay(419.81, false), "7:00 AM"); +t("and does the same on a 24-hour clock", + m.formatMinuteOfDay(419.81, true), "07:00"); +t("exactly half a minute rounds up", + m.formatMinuteOfDay(419.5, false), "7:00 AM"); +t("just under half stays put", + m.formatMinuteOfDay(419.49, false), "6:59 AM"); +t("noon is not midnight", + m.formatMinuteOfDay(719.7, false), "12:00 PM"); +t("the last minute of the day wraps to the first", + m.formatMinuteOfDay(1439.7, false), "12:00 AM"); +t("and wraps on a 24-hour clock too", + m.formatMinuteOfDay(1439.7, true), "00:00"); +t("whole minutes are unchanged", + m.formatMinuteOfDay(375, false), "6:15 AM"); + +t("nothing known yet", m.utcOffsetLabel(undefined), ""); +t("nothing at all", m.utcOffsetLabel(null), ""); +t("not a number", m.utcOffsetLabel(NaN), ""); + +// --- the same offset read the other way ---------------------------------- +// The globe's footer prints one of these two, chosen by the same setting the +// rows use. The blank case is the one that matters to the interface: a city +// on your own offset has no relative label, which is why the zone name beside +// it is part of the click target and not just decoration. +const rel = m.relativeOffsetLabel; +t("nine hours ahead", rel(540, 0), "+9h"); +t("three behind", rel(-300, -120), "-3h"); +t("quarter zones round to a tenth", rel(345, 0), "+5.8h"); +t("half-hour zones keep the half", rel(330, 0), "+5.5h"); +t("Adelaide from Sydney", rel(570, 600), "-0.5h"); +t("your own offset says nothing", rel(-480, -480), ""); +t("same offset, different zone, still nothing", rel(0, 0), ""); + +console.log(` -> ${n - f}/${n} overlap/scrub assertions passed`); +// exitCode rather than exit(), so the sections below still run. +if (f) process.exitCode = 1; + +// --- the working group (briefcase toggles) -------------------------------- +const M2 = {}; +new Function(require("fs").readFileSync(require("path").join(__dirname, "..", "Model.js"), "utf8") + .replace(".pragma library", "") + + "; this.M={parseZones,serializeZones,toggleWorkAt,workZones,addZone};").call(M2); +const w = M2.M; + +let n2 = 0, f2 = 0; +const t2 = (k, a, b) => { n2++; if (JSON.stringify(a) !== JSON.stringify(b)) { f2++; console.log(" FAIL", k, JSON.stringify(a), "!=", JSON.stringify(b)); } }; + +t2("plain entries parse as non-working", w.parseZones("Paris|Europe/Paris").map(z => z.work), [false]); +t2("the w flag parses", w.parseZones("Paris|Europe/Paris|w").map(z => z.work), [true]); +t2("mixed", w.parseZones("A|X/a|w, B|X/b").map(z => z.work), [true, false]); +t2("bare zone still works", w.parseZones("Asia/Tokyo")[0].id, "Asia/Tokyo"); +t2("serialize keeps the flag", w.serializeZones(w.parseZones("A|X/a|w, B|X/b")), "A|X/a|w, B|X/b"); +t2("round trip", w.parseZones(w.serializeZones(w.parseZones("A|X/a|w, B|X/b"))), + w.parseZones("A|X/a|w, B|X/b")); +t2("new cities start off", w.addZone([], "Europe/Rome", "Rome")[0].work, false); +t2("toggle on", w.toggleWorkAt(w.parseZones("A|X/a"), 0)[0].work, true); +t2("toggle off again", w.toggleWorkAt(w.toggleWorkAt(w.parseZones("A|X/a"), 0), 0)[0].work, false); +t2("toggle leaves others alone", w.toggleWorkAt(w.parseZones("A|X/a, B|X/b"), 1).map(z => z.work), [false, true]); +t2("toggle out of range is a no-op", w.toggleWorkAt(w.parseZones("A|X/a"), 5).map(z => z.work), [false]); +t2("filter picks the group", w.workZones(w.parseZones("A|X/a|w, B|X/b, C|X/c|w")).map(z => z.label), ["A", "C"]); +t2("empty group", w.workZones(w.parseZones("A|X/a")).length, 0); + +console.log(` -> ${n2 - f2}/${n2} working-group assertions passed`); +if (f2) process.exitCode = 1; + +// --- drag-to-reorder ------------------------------------------------------ +const M3 = {}; +new Function(require("fs").readFileSync(require("path").join(__dirname, "..", "Model.js"), "utf8") + .replace(".pragma library", "") + "; this.M={parseZones,serializeZones,moveZone};").call(M3); +const r = M3.M; +const four = r.parseZones("A|X/a, B|X/b, C|X/c, D|X/d"); +const names = zs => zs.map(z => z.label).join(""); + +let n3 = 0, f3 = 0; +const t3 = (k, a, b) => { n3++; if (JSON.stringify(a) !== JSON.stringify(b)) { f3++; console.log(" FAIL", k, JSON.stringify(a), "!=", JSON.stringify(b)); } }; + +t3("move down one", names(r.moveZone(four, 0, 1)), "BACD"); +t3("move to the end", names(r.moveZone(four, 0, 3)), "BCDA"); +t3("move to the front", names(r.moveZone(four, 3, 0)), "DABC"); +t3("move up one", names(r.moveZone(four, 2, 1)), "ACBD"); +t3("same position is a no-op", r.moveZone(four, 2, 2), four); +t3("out of range low", r.moveZone(four, -1, 2), four); +t3("out of range high", r.moveZone(four, 0, 9), four); +t3("length is preserved", r.moveZone(four, 0, 3).length, 4); +t3("original is untouched", names(four), "ABCD"); +t3("the work flag travels with the row", + r.serializeZones(r.moveZone(r.parseZones("A|X/a, B|X/b|w"), 1, 0)), "B|X/b|w, A|X/a"); + +console.log(` -> ${n3 - f3}/${n3} reorder assertions passed`); +if (f3) process.exitCode = 1; diff --git a/tests/qml/tst_arrows.qml b/tests/qml/tst_arrows.qml new file mode 100644 index 0000000..b98d80e --- /dev/null +++ b/tests/qml/tst_arrows.qml @@ -0,0 +1,101 @@ +import QtQuick +import QtTest + +// Pointer behaviour, with synthetic mouse events. +// +// This project spent a long time believing its interactions could not be +// tested: no pointer can be injected into the running shell, so every handler +// question was answered by reading Qt's documentation and reasoning. That was +// wrong. `qmltestrunner` synthesises real mouse events into an offscreen +// window, and the structures worth checking are small enough to rebuild here. +// +// QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -input tests/qml +// +// Use that path. `/usr/bin/qmltestrunner` is the Qt5 binary, and it exits 0 +// having run nothing and printed nothing, which looks exactly like success. +// +// What is modelled: the daylight strip. A MouseArea fills the bar and drags +// time; the sunrise and sunset arrows are separate Items drawn on top of it, +// declared later and raised with z. The question that mattered was whether a +// click on an arrow also reaches the bar - if it did, clicking "sunrise" would +// scrub every clock in the list back to sunrise. +Item { + id: root + width: 300 + height: 40 + + property int barPresses: 0 + property int barReleases: 0 + property int barCancels: 0 + property int arrowTaps: 0 + + function reset() { + barPresses = 0; barReleases = 0; barCancels = 0; arrowTaps = 0 + } + + MouseArea { + id: bar + anchors.fill: parent + preventStealing: true + onPressed: root.barPresses++ + onReleased: root.barReleases++ + onCanceled: root.barCancels++ + } + + Item { + id: arrow + x: 100 + width: 14 + height: parent.height + z: 1 + visible: true + TapHandler { onTapped: root.arrowTaps++ } + } + + TestCase { + name: "ArrowsOverTheScrubBar" + when: windowShown + + // The finding this file exists for. A TapHandler takes only a passive grab, + // which reads in the documentation as though the item underneath must see + // the press as well. It does not: the click stops at the arrow. + function test_a_click_on_the_arrow_does_not_reach_the_bar() { + root.reset() + mouseClick(root, arrow.x + arrow.width / 2, 20) + compare(root.arrowTaps, 1, "the arrow's tap fires") + compare(root.barPresses, 0, "and the bar underneath sees no press at all") + compare(root.barReleases, 0, "nor a release") + } + + function test_a_click_on_the_bar_is_a_press_on_the_bar() { + root.reset() + mouseClick(root, 20, 20) + compare(root.arrowTaps, 0, "no tap away from the arrow") + compare(root.barPresses, 1, "the bar gets its own press") + compare(root.barReleases, 1, "and its own release") + } + + // A hidden arrow is not a hole in the bar. The arrows are only drawn while + // the pointer is on the row, and they disappear under the now-marker; the + // bar has to keep working in both cases. + function test_a_hidden_arrow_lets_the_bar_through() { + root.reset() + arrow.visible = false + mouseClick(root, arrow.x + arrow.width / 2, 20) + arrow.visible = true + compare(root.arrowTaps, 0, "an invisible arrow is not tapped") + compare(root.barPresses, 1, "the press belongs to the bar") + } + + // Dragging from an arrow is a drag, not a tap: the handler gives up past + // the drag threshold, so a gesture that starts on an arrow can still become + // a scrub. + function test_a_drag_from_the_arrow_is_not_a_tap() { + root.reset() + mousePress(root, arrow.x + arrow.width / 2, 20) + mouseMove(root, arrow.x + 60, 20) + mouseRelease(root, arrow.x + 60, 20) + compare(root.arrowTaps, 0, "a drag is not a tap") + } + } +} diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..650df5c --- /dev/null +++ b/tests/run @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Runs everything under tests/. CI calls this; so should you, before pushing. +# +# tests/run # all of it +# tests/run --offline # skip the checks that need the network +# +# Each node script is self-contained and exits non-zero on a failure. The +# currency table is checked against the system's iso-codes data (and, unless +# --offline, the live FX feed). The QML test needs the Qt 6 qmltestrunner; +# on Arch that is /usr/lib/qt6/bin/qmltestrunner, not /usr/bin/qmltestrunner, +# which is the Qt 5 binary and exits 0 having run nothing. +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +offline="" +[[ "${1:-}" == "--offline" ]] && offline="--offline" +status=0 + +run() { + echo "== $*" + if ! "$@"; then + echo "!! FAILED: $*" + status=1 + fi +} + +for f in "$root"/tests/*_check.js; do + run node "$f" +done + +if [[ -n $offline ]]; then + run python3 "$root/tests/currency_check.py" --offline +else + run python3 "$root/tests/currency_check.py" +fi + +qml_test_runner="${QML_TEST_RUNNER:-}" +if [[ -z $qml_test_runner ]]; then + if [[ -x /usr/lib/qt6/bin/qmltestrunner ]]; then + qml_test_runner=/usr/lib/qt6/bin/qmltestrunner + elif [[ -x /usr/lib/qt6/qmltestrunner ]]; then + qml_test_runner=/usr/lib/qt6/qmltestrunner + else + qml_test_runner="$(command -v qmltestrunner-qt6 || command -v qmltestrunner || true)" + fi +fi +if [[ -n $qml_test_runner ]]; then + run env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qml_test_runner" -input "$root/tests/qml" -o -,txt +else + echo "!! qmltestrunner not found; skipping tests/qml" + status=1 +fi + +exit $status diff --git a/tests/seed_check.js b/tests/seed_check.js new file mode 100644 index 0000000..3930b81 --- /dev/null +++ b/tests/seed_check.js @@ -0,0 +1,89 @@ +// The list a fresh install starts with: the city you are in, plus four +// well-known places spread round the clock from it. +// +// The point of these is that the four are chosen *relative to home*. A fixed +// list would hand someone in Paris a second Paris, and would give a reader in +// Tokyo a spread that is really a spread around California - so every check +// below is run from several different home cities. +const fs = require("fs"), path = require("path"), cp = require("child_process"); +const src = fs.readFileSync(path.join(__dirname, "..", "Model.js"), "utf8") + .replace(".pragma library", ""); +const box = {}; +new Function(src + "; this.M={seedZones,pickSeedZones,seedCandidateZones,SEED_CANDIDATES,parseZones,serializeZones,labelForZoneId};").call(box); +const M = box.M; + +let n = 0, f = 0; +const t = (k, cond, detail) => { n++; if (!cond) { f++; console.log(" FAIL", k, detail === undefined ? "" : detail); } }; + +// Real offsets, so the checks account for whatever DST is in force today. +const offsets = {}; +const zoneOffset = z => { + const o = cp.execSync(`TZ=${z} date +%z`).toString().trim(); + return (o[0] === "-" ? -1 : 1) * (parseInt(o.slice(1, 3), 10) * 60 + parseInt(o.slice(3, 5), 10)); +}; +const HOMES = [ + "America/Los_Angeles", "America/New_York", "Europe/London", "Europe/Paris", + "Europe/Copenhagen", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney", + "America/Sao_Paulo", "Africa/Nairobi", "Pacific/Honolulu", "Pacific/Auckland", + "Atlantic/Reykjavik", "Asia/Kathmandu", "UTC", +]; +for (const z of new Set(M.seedCandidateZones().concat(HOMES))) offsets[z] = zoneOffset(z); + +const dial = (a, b) => { const d = Math.abs(a - b) % 1440; return Math.min(d, 1440 - d); }; + +// An unset setting is a fresh install, not the old hardcoded trio. +t("blank setting yields no cities", M.parseZones("").length === 0); +t("blank setting is what triggers seeding", M.parseZones(undefined).length === 0); + +for (const home of HOMES) { + const label = M.labelForZoneId(home); + const list = M.seedZones({ label: label, id: home }, offsets, 4); + const name = `[${label}]`; + + t(`${name} five cities`, list.length === 5, list.length); + t(`${name} home comes first`, list[0] && list[0].id === home); + + const ids = list.map(z => z.id); + t(`${name} no repeated zone`, new Set(ids).size === ids.length, ids); + const labels = list.map(z => String(z.label).toLowerCase()); + t(`${name} no repeated name`, new Set(labels).size === labels.length, labels); + t(`${name} home is not also a destination`, + ids.slice(1).every(id => id !== home)); + + // Every pick must be a real separation from home and from the others - + // otherwise the list is five clocks showing nearly the same time. + const offs = list.map(z => offsets[z.id]); + t(`${name} destinations differ from home by 3h or more`, + offs.slice(1).every(o => dial(o, offs[0]) >= 180), + offs.slice(1).map(o => (dial(o, offs[0]) / 60).toFixed(1))); + let closest = 1e9; + for (let i = 1; i < offs.length; i++) + for (let j = i + 1; j < offs.length; j++) + closest = Math.min(closest, dial(offs[i], offs[j])); + t(`${name} destinations differ from each other by 3h or more`, + closest >= 180, (closest / 60).toFixed(1) + "h"); + + // Reading order: eastward from home, so the list walks round the world. + const east = offs.map(o => ((o - offs[0]) % 1440 + 1440) % 1440); + t(`${name} sorted eastward`, + east.slice(1).every((e, i) => i === 0 || e >= east[i]), east.map(e => (e / 60).toFixed(1))); + + // The four should be places people have heard of. + const known = new Set(M.SEED_CANDIDATES.map(c => c.label)); + t(`${name} destinations come from the curated list`, + list.slice(1).every(z => known.has(z.label))); + + // It must survive a round trip through the settings string. + const round = M.parseZones(M.serializeZones(list)); + t(`${name} round trips through settings`, + round.length === 5 && round.every((z, i) => z.id === list[i].id)); +} + +// Missing home offset must not produce a broken list. +t("unknown home zone yields nothing", + M.seedZones({ label: "Nowhere", id: "Not/AZone" }, offsets, 4).length === 1); +t("no offsets at all yields nothing", + M.pickSeedZones({ label: "X", id: "UTC" }, {}, 4).length === 0); + +console.log(` -> ${n - f}/${n} first-run seed assertions passed`); +if (f) process.exitCode = 1; diff --git a/tests/selection_check.js b/tests/selection_check.js new file mode 100644 index 0000000..50e869f --- /dev/null +++ b/tests/selection_check.js @@ -0,0 +1,212 @@ +// The list and the globe select the same city through two different index +// spaces: a row is an index into `zones`, the globe's `selected` is an index +// into its own catalogue of every city it draws. Model.indexOfZone is the +// crossing between them. Run: node tests/selection_check.js +const fs = require("fs"), path = require("path"); +const root = path.join(__dirname, ".."); +const box = {}; +const src = fs.readFileSync(path.join(root, "Model.js"), "utf8").replace(".pragma library", ""); +new Function(src + "; this.M={parseZones,indexOfZone,indexOfZoneKey,factsKey,moveZone,removeZoneAt,labelForZoneId,addZone,serializeZones,zoneOptions,chipAfterTap,chipAfterRelease,NO_CHIP,arrowBox,arrowCovered};").call(box); +const M = box.M; + +let n = 0, f = 0; +const t = (k, a, b) => { + n++; + if (JSON.stringify(a) !== JSON.stringify(b)) { f++; console.log(" FAIL", k, JSON.stringify(a), "!=", JSON.stringify(b)); } +}; + +const zones = M.parseZones("Paris|Europe/Paris, Tokyo|Asia/Tokyo|w, New York|America/New_York"); + +// --- the crossing itself --------------------------------------------------- +t("first row found", M.indexOfZone(zones, "Paris", "Europe/Paris"), 0); +t("middle row found", M.indexOfZone(zones, "Tokyo", "Asia/Tokyo"), 1); +t("last row found", M.indexOfZone(zones, "New York", "America/New_York"), 2); + +// A globe city the list does not track has no row to focus. This is the +// common case - the globe draws every zone's main city - and it must read as +// "no row", never as row 0 or as home. +t("untracked globe city is not a row", M.indexOfZone(zones, "Lagos", "Africa/Lagos"), -1); + +// Both fields have to agree. The globe carries a label and a zone precisely +// because either alone is ambiguous: zones share city names, and a renamed +// row keeps its zone. +t("right label, wrong zone", M.indexOfZone(zones, "Tokyo", "Asia/Osaka"), -1); +t("right zone, wrong label", M.indexOfZone(zones, "Tokyo City", "Asia/Tokyo"), -1); +t("empty list", M.indexOfZone([], "Paris", "Europe/Paris"), -1); +t("missing list", M.indexOfZone(undefined, "Paris", "Europe/Paris"), -1); + +// --- the round trip the two views actually make --------------------------- +// Panel builds the globe's tracked rows as [label, id, lat, lon, 0], and the +// globe hands back element 0 and element 1 of whichever it selected. Building +// the rows the same way Panel.qml does is the point: a hand-written pair +// could agree with indexOfZone while the real one did not. +const facts = { + "Europe/Paris": { lat: 48.86, lon: 2.35 }, + "Asia/Tokyo": { lat: 35.68, lon: 139.69 }, + "America/New_York": { lat: 40.71, lon: -74.01 }, +}; +const trackedCities = zones + .filter(z => facts[z.id] !== undefined) + .map(z => [z.label, z.id, facts[z.id].lat, facts[z.id].lon, 0]); + +t("every tracked row survives the round trip", + trackedCities.map(c => M.indexOfZone(zones, c[0], c[1])), [0, 1, 2]); + +// Selecting on the globe and then reading the row back must land on the row +// that was clicked, for every row, not just the first. +for (let i = 0; i < trackedCities.length; i++) { + const c = trackedCities[i]; + t(`row ${i} round trips`, M.indexOfZone(zones, c[0], c[1]), i); +} + +// A city added with no label of its own still round trips: parseZones fills +// the label in from the zone id, and Panel builds the globe row from that +// same filled-in value. +const added = M.parseZones("Europe/Rome"); +t("a bare zone gets a label", added[0].label, "Rome"); +t("and still round trips", M.indexOfZone(added, added[0].label, added[0].id), 0); +t("labelForZoneId agrees", M.labelForZoneId("Europe/Rome"), added[0].label); + +// Reordering the list moves the row a globe selection resolves to; the pair +// is positional in nothing, so it follows the city rather than the slot. +const reordered = M.parseZones("Tokyo|Asia/Tokyo|w, Paris|Europe/Paris, New York|America/New_York"); +t("selection follows the city, not the slot", + M.indexOfZone(reordered, "Paris", "Europe/Paris"), 1); + +// A zone is not a city. Six of the picker's entries share +// America/Los_Angeles, so the name the user pointed at is the only thing that +// distinguishes them - committing the zone alone put "Los Angeles" on the list +// when Oakland was chosen, which the keyboard selection made impossible to +// miss. The pure function was always right; the panel was dropping the label +// on the way in. These pin the contract it relies on. +const catalog = M.zoneOptions("America/Los_Angeles\nAsia/Tokyo", []); +const sharing = catalog.filter(o => o.value === "America/Los_Angeles"); +t("one zone, several cities", sharing.length > 1, true, sharing.length > 1); +t("and they are told apart by name", + new Set(sharing.map(o => o.label)).size, sharing.length); +t("the chosen name is kept", M.addZone([], "America/Los_Angeles", "Oakland")[0].label, "Oakland"); +t("a blank name still falls back to the zone", + M.addZone([], "America/Los_Angeles", "")[0].label, "Los Angeles"); +t("two cities in one zone can both be tracked", + M.addZone(M.addZone([], "America/Los_Angeles", "Oakland"), "America/Los_Angeles", "Las Vegas").length, 2); + +// The IPC `add` takes any two strings. What goes in must come back out of the +// "Label|Zone, Label|Zone" setting as the row that was asked for. +const roundTrip = zs => M.parseZones(M.serializeZones(zs)); +t("a label with a comma survives the setting", + roundTrip(M.addZone([], "Asia/Tokyo", "Tokyo, Japan")).map(z => z.label + "@" + z.id), ["Tokyo Japan@Asia/Tokyo"]); +t("a label with a pipe survives the setting", + roundTrip(M.addZone([], "Asia/Tokyo", "Tokyo|HQ")).map(z => z.label + "@" + z.id), ["Tokyo HQ@Asia/Tokyo"]); +t("a label that is only delimiters falls back to the zone's name", + M.addZone([], "Asia/Tokyo", ",|,")[0].label, "Tokyo"); +t("an id that could not name a zone is refused", M.addZone([], "evil id, with|pipe", "x").length, 0); +t("an id with a space is refused", M.addZone([], "Asia/Tokyo x", "x").length, 0); +t("refusing returns the same array, so nothing is written", (() => { const z = []; return M.addZone(z, "bad id", "x") === z; })(), true); +t("markup is kept as text for the row to draw plainly", + M.addZone([], "Asia/Tokyo", "")[0].label, ""); +// And the picker stops offering the one already taken, by name and not by zone. +const remaining = M.zoneOptions("America/Los_Angeles", M.addZone([], "America/Los_Angeles", "Oakland")); +t("the tracked city drops out of the picker", + remaining.some(o => o.label === "Oakland"), false); +t("its neighbours stay in", + remaining.some(o => o.label === "Las Vegas"), true); + +// --- the list's focus survives the list changing --------------------------- +// +// focusIndex used to be a stored row number, and `zones` is a binding replaced +// wholesale on a reorder or a removal - so the number silently came to mean a +// different city. It is a key now, and the index is derived from it. +const focusOf = (list, key) => M.indexOfZoneKey(list, key); +const tokyoKey = M.factsKey(zones[1]); + +t("the key finds its city", focusOf(zones, tokyoKey), 1); +t("an empty key is no focus", focusOf(zones, ""), -1); +t("a key for a city that is gone is no focus either", + focusOf(zones, "Lagos|Africa/Lagos"), -1); +t("a missing list does not throw", focusOf(null, tokyoKey), -1); + +// Drag the row above it away and the focus goes with the city, not the slot. +const moved = M.moveZone(zones, 0, 2); +t("a reorder moves the city off slot 1", moved[1].label !== "Tokyo", true); +t("and the focus follows the city", moved[focusOf(moved, tokyoKey)].label, "Tokyo"); + +// Remove the row above it and the same holds: Tokyo is now row 0. +const shorter = M.removeZoneAt(zones, 0); +t("a removal shifts the rows up", shorter[0].label, "Tokyo"); +t("and the focus is still on Tokyo", focusOf(shorter, tokyoKey), 0); + +// Remove the focused city itself and the focus falls back to home, which is +// what -1 means everywhere it is read. +const withoutTokyo = M.removeZoneAt(zones, 1); +t("losing the focused city drops the focus", focusOf(withoutTokyo, tokyoKey), -1); + +// --- where the arrows sit -------------------------------------------------- +// +// The glyph has to land outside the band it points at. A mark on the boundary +// reads as part of the band, which is what putting the arrows outside it was +// for, and the tuck that pulls them closer is three pixels from undoing that. +const { arrowBox, arrowCovered } = box.M; +const BAR = 567, BOX = 14, TUCK = 3, MARK = 10, SLACK = 4; + +t("the up arrow sits before the band", arrowBox(0.26, BAR, BOX, TUCK, true), 136); +t("the down arrow sits after it", arrowBox(0.81, BAR, BOX, TUCK, false), 456); +t("the up glyph stays before its crossing", + arrowBox(0.26, BAR, BOX, TUCK, true) + BOX / 2 < 0.26 * BAR, true); +t("the down glyph stays after its crossing", + arrowBox(0.81, BAR, BOX, TUCK, false) + BOX / 2 > 0.81 * BAR, true); + +// A sunrise a minute after midnight cannot hang its box off the end of the bar. +t("a box at the very start is held on the bar", arrowBox(0, BAR, BOX, TUCK, true), 0); +t("a box at the very end is held on too", arrowBox(1, BAR, BOX, TUCK, false), BAR - BOX); + +t("the marker covers an arrow it sits on", arrowCovered(136, BOX, 143, MARK, SLACK), true); +t("and not one it has passed", arrowCovered(136, BOX, 160, MARK, SLACK), false); +t("nor one at the other end of the bar", arrowCovered(456, BOX, 143, MARK, SLACK), false); + +// --- the row's popup chips, under either delivery order -------------------- +// +// A press on an arrow reaches only the arrow - tests/qml/tst_arrows.qml proves +// that with synthetic mouse events - but a press on the row body reaches the +// reorder grab, which dismisses. Which of the two runs first is Qt's business +// and not worth depending on, so the pair has to come out right in either +// order. +const { chipAfterTap, chipAfterRelease, NO_CHIP } = box.M; +const SUNRISE = 0, SUNSET = 1; + +// One click, played both ways. `atPress` is what was showing when the press +// began, which is the only thing either rule is allowed to read. +function click(atPress, slot, tapFirst) { + let shown = atPress; + if (tapFirst) { + shown = chipAfterTap(atPress, slot); + shown = chipAfterRelease(shown, atPress); + } else { + shown = chipAfterRelease(shown, atPress); + shown = chipAfterTap(atPress, slot); + } + return shown; +} + +for (const tapFirst of [true, false]) { + const when = tapFirst ? "tap first" : "release first"; + t(`${when}: clicking sunrise from nothing opens it`, + click(NO_CHIP, SUNRISE, tapFirst), SUNRISE); + t(`${when}: clicking sunrise again closes it`, + click(SUNRISE, SUNRISE, tapFirst), NO_CHIP); + t(`${when}: clicking sunset while sunrise is open swaps them`, + click(SUNRISE, SUNSET, tapFirst), SUNSET); + t(`${when}: clicking sunrise while sunset is open swaps back`, + click(SUNSET, SUNRISE, tapFirst), SUNRISE); +} + +// A click on the bare row - no tap handler runs at all, only the release. +t("clicking elsewhere with a chip open closes it", + chipAfterRelease(SUNSET, SUNSET), NO_CHIP); +t("clicking elsewhere with nothing open stays shut", + chipAfterRelease(NO_CHIP, NO_CHIP), NO_CHIP); +// A drag that began before a chip was opened must not undo the opening. +t("a release never undoes a chip opened during the same press", + chipAfterRelease(SUNRISE, NO_CHIP), SUNRISE); + +console.log(` -> ${n - f}/${n} selection assertions passed`); +process.exit(f ? 1 : 0); diff --git a/tests/sky_check.js b/tests/sky_check.js new file mode 100644 index 0000000..9de8048 --- /dev/null +++ b/tests/sky_check.js @@ -0,0 +1,45 @@ +// Solar elevation (GlobeModel) and the sky-tint palette (Sky.js). +const fs = require("fs"), path = require("path"); +const root = path.join(__dirname, ".."); +const load = (file, names) => { + const src = fs.readFileSync(path.join(root, file), "utf8").replace(".pragma library", ""); + const box = {}; + new Function(src + `; this.M={${names}};`).call(box); + return box.M; +}; +const G = load("GlobeModel.js", "subsolarPoint,solarElevation,isDaylight"); +const S = load("Sky.js", "tint,STOPS"); + +let n = 0, f = 0; +const t = (k, cond, detail) => { n++; if (!cond) { f++; console.log(" FAIL", k, detail === undefined ? "" : detail); } }; +const chan = (hex, i) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16); + +// --- elevation ------------------------------------------------------------ +const sub = G.subsolarPoint(Date.UTC(2026, 5, 21, 12, 0, 0)); +t("sun is overhead at the subsolar point", Math.abs(G.solarElevation(sub.lat, sub.lon, sub) - 90) < 0.01); +t("antipode is deepest night", G.solarElevation(-sub.lat, sub.lon + 180, sub) < -89); +t("isDaylight agrees at the subsolar point", G.isDaylight(sub.lat, sub.lon, sub) === true); +t("isDaylight agrees at the antipode", G.isDaylight(-sub.lat, sub.lon + 180, sub) === false); +t("elevation stays in range", [[0, 0], [51, 0], [-33, 151], [78, -68]] + .every(([la, lo]) => { const e = G.solarElevation(la, lo, sub); return e >= -90.01 && e <= 90.01; })); + +// --- palette -------------------------------------------------------------- +t("endpoint: deepest night", S.tint(-90) === "#6e79a8", S.tint(-90)); +t("endpoint: high sun", S.tint(90) === "#c8e1f0", S.tint(90)); +// -40 sits between the -90 and -12 stops, so it interpolates rather than +// matching either endpoint - that is the point of the ramp. +t("between stops it interpolates", S.tint(-40) !== S.tint(-90) && S.tint(-40) !== S.tint(-12), S.tint(-40)); +t("golden hour is warm (red > blue)", chan(S.tint(2), 0) > chan(S.tint(2), 2), S.tint(2)); +t("daylight is cool (blue > red)", chan(S.tint(40), 2) > chan(S.tint(40), 0), S.tint(40)); +t("night is cool (blue > red)", chan(S.tint(-40), 2) > chan(S.tint(-40), 0), S.tint(-40)); +t("continuous across a stop", Math.abs(chan(S.tint(-6.01), 0) - chan(S.tint(-5.99), 0)) < 3); +t("monotone through dawn: night -> twilight -> gold", + chan(S.tint(-20), 0) < chan(S.tint(-6), 0) && chan(S.tint(-6), 0) < chan(S.tint(3), 0)); +t("clamps below", S.tint(-999) === S.tint(-90)); +t("clamps above", S.tint(999) === S.tint(90)); +t("rejects nonsense", S.tint(NaN) === null && S.tint("x") === null); +t("always a 6-digit hex", [-90, -30, -13, -6, 0, 5, 20, 60, 90].every(e => /^#[0-9a-f]{6}$/.test(S.tint(e)))); +t("stops are ordered by elevation", S.STOPS.every((s, i) => i === 0 || s.e > S.STOPS[i - 1].e)); + +console.log(` -> ${n - f}/${n} sky assertions passed`); +if (f) process.exitCode = 1; diff --git a/tests/sun_check.js b/tests/sun_check.js new file mode 100644 index 0000000..37d8f47 --- /dev/null +++ b/tests/sun_check.js @@ -0,0 +1,158 @@ +// Sunrise and sunset, against Open-Meteo's own published times. +// +// Not against ourselves: the whole point of computing this locally is that it +// has to agree with what a person would find if they looked the city up. The +// reference rows below were fetched from Open-Meteo's daily sunrise/sunset +// (api.open-meteo.com for the recent dates, archive-api for the older ones) +// with timezone=UTC, and are held here verbatim so the test stays offline. +// +// The set is chosen for the cases that break naive implementations: both +// hemispheres, both solstices, the equator, a city whose clock is two hours +// from its own sun (Kashgar on Beijing time), and the two polar cases where +// there is no sunrise at all. + +const fs = require("fs"), path = require("path"); +const read = (f) => fs.readFileSync(path.join(__dirname, "..", f), "utf8") + .replace(/^\.pragma library$/m, "") + .replace(/^\.import .*$/gm, ""); + +const box = {}; +new Function(read("GlobeModel.js") + "\nvar Solar = { subsolarPoint: subsolarPoint };\n" + + read("Sun.js") + + "; this.M={sunTimes,litSpans,litAt,eventMark,solarNoonMs};").call(box); +const m = box.M; + +let n = 0, f = 0; +const fail = (label, got, want) => { + n++; f++; + console.log(" FAIL", label, JSON.stringify(got), "!=", JSON.stringify(want)); +}; +const eq = (label, got, want) => { n++; if (got !== want) { n--; fail(label, got, want); } }; +const near = (label, gotMs, wantIso, toleranceMin) => { + n++; + const want = Date.parse(wantIso + "Z"); + const off = Math.abs(gotMs - want) / 60000; + if (!(off <= toleranceMin)) { + n--; + fail(label + ` (out by ${off.toFixed(1)} min)`, + new Date(gotMs).toISOString(), wantIso); + } +}; + +// name, lat, lon, local date, UTC offset in minutes, reference rise, reference set +const REF = [ + ["Chicago", 41.85, -87.65, [2026, 8, 31], -300, "2026-08-31T11:15", "2026-09-01T00:26"], + ["Auckland", -36.85, 174.76, [2026, 8, 31], 720, "2026-08-30T18:43", "2026-08-31T05:59"], + ["Copenhagen", 55.68, 12.57, [2026, 8, 31], 120, "2026-08-31T04:13", "2026-08-31T18:07"], + ["Tokyo", 35.69, 139.69, [2026, 8, 31], 540, "2026-08-30T20:12", "2026-08-31T09:11"], + ["Quito", -0.22, -78.51, [2026, 8, 31], -300, "2026-08-31T11:10", "2026-08-31T23:17"], + ["Kashgar", 39.47, 75.99, [2026, 8, 31], 480, "2026-08-31T00:23", "2026-08-31T13:29"], + ["Reykjavik", 64.15, -21.94, [2025, 12, 21], 0, "2025-12-21T11:21", "2025-12-21T15:30"], + ["Sydney", -33.87, 151.21, [2025, 12, 21], 660, "2025-12-20T18:40", "2025-12-21T09:05"], + ["Nairobi", -1.29, 36.82, [2026, 3, 20], 180, "2026-03-20T03:36", "2026-03-20T15:43"], + ["Anchorage", 61.22, -149.90, [2026, 6, 21], -480, "2026-06-21T12:19", "2026-06-22T07:43"], + ["Ushuaia", -54.80, -68.30, [2026, 6, 21], -180, "2026-06-21T12:58", "2026-06-21T20:11"], + // Past 64 north, where the sun cuts the horizon at a shallow angle and the + // shared solar model's fraction of a degree becomes minutes of time. These + // two are here with the tolerance they need rather than left out to keep the + // headline number tidy - the bar cannot show four minutes, but the chip + // prints a time. + ["Nuuk", 64.18, -51.72, [2026, 8, 31], -120, "2026-08-31T08:06", "2026-08-31T22:47"], + ["Anadyr", 64.73, 177.51, [2026, 8, 31], 720, "2026-08-30T16:47", "2026-08-31T07:35"], +]; +const SHALLOW = { Nuuk: 5, Anadyr: 5 }; + +// Local noon of the reference date, which is the instant a row would be asking +// about in the middle of its own day. +const noonAt = ([y, mo, d], offset) => Date.UTC(y, mo - 1, d, 12) - offset * 60000; + +for (const [name, lat, lon, date, offset, rise, set] of REF) { + const t = m.sunTimes(lat, lon, noonAt(date, offset), offset); + eq(name + " has a sunrise", t.kind, "normal"); + // Two minutes. Open-Meteo publishes to the minute and rounds; the remaining + // difference is the low-precision solar position the globe shares, which is + // good to a fraction of a degree - about a minute of time at these latitudes + // and more where the sun cuts the horizon at a shallow angle. + var slack = SHALLOW[name] || 2; + near(name + " sunrise", t.riseMs, rise, slack); + near(name + " sunset", t.setMs, set, slack); +} + +// --- the poles, where there is no sunrise to be out by -------------------- +const longyear = (date, offset) => + m.sunTimes(78.22, 15.63, noonAt(date, offset), offset); + +eq("Longyearbyen in June is midnight sun", longyear([2026, 6, 21], 120).kind, "midnightSun"); +eq("Longyearbyen in December is polar night", longyear([2025, 12, 21], 60).kind, "polarNight"); +eq("midnight sun lights the whole bar", + JSON.stringify(m.litSpans(longyear([2026, 6, 21], 120))), JSON.stringify([{ x0: 0, x1: 1 }])); +eq("polar night lights none of it", + JSON.stringify(m.litSpans(longyear([2025, 12, 21], 60))), "[]"); +eq("the sun is up all day under midnight sun", + m.litAt(longyear([2026, 6, 21], 120), 3 * 60), true); +eq("and never up under polar night", + m.litAt(longyear([2025, 12, 21], 60), 12 * 60), false); +eq("no tick where there is no sunrise", + m.eventMark(longyear([2026, 6, 21], 120).riseMinutes), null); + +// --- the day the strip actually draws -------------------------------------- +// Chicago on this date: sunrise 06:15 and sunset 19:26 by its own clock, so +// the band starts a quarter of the way along the bar and ends four fifths of +// the way along. +const chicago = m.sunTimes(41.85, -87.65, noonAt([2026, 8, 31], -300), -300); +const span = m.litSpans(chicago)[0]; +// Derived from the reference row above - 11:15 and 00:26 UTC are 06:15 and +// 19:26 on Chicago's clock - with the same minute of slack the instants get. +const within = (label, got, want, slack) => { + n++; + if (Math.abs(got - want) > slack) { n--; fail(label + ` (out by ${(got - want).toFixed(1)})`, got, want); } +}; +within("Chicago's band starts at sunrise", span.x0 * 1440, 375, 2); +within("Chicago's band ends at sunset", span.x1 * 1440, 1166, 2); +within("thirteen hours of daylight", chicago.dayMinutes, 791, 2); +eq("dawn is dark", m.litAt(chicago, 5 * 60), false); +eq("noon is not", m.litAt(chicago, 12 * 60), true); +eq("and so is the evening", m.litAt(chicago, 22 * 60), false); + +// A clock two hours from its own sun still gets one band, not two. Kashgar's +// day runs 08:23 to 21:29 on Beijing time and fits inside the bar; nothing is +// wrapped round to the other end. +const kashgar = m.sunTimes(39.47, 75.99, noonAt([2026, 8, 31], 480), 480); +eq("Kashgar draws one band", m.litSpans(kashgar).length, 1); +within("Kashgar's sun rises after eight", kashgar.riseMinutes, 503, 2); +within("and sets after nine in the evening", kashgar.setMinutes, 1289, 2); + +// --- a bar that is lit at both ends --------------------------------------- +// Reykjavik on the June solstice sets four minutes after midnight, so the first +// four minutes of the same day are lit as well - by the sun that rose the +// morning before. This used to be clipped away and drawn dark, which put the +// sun below the horizon at an hour when the shared solar model has it above. +const solstice = m.sunTimes(64.15, -21.94, noonAt([2026, 6, 21], 0), 0); +eq("its sun sets after midnight", solstice.setMinutes > 1440, true); +const bothEnds = m.litSpans(solstice); +eq("so the bar is drawn in two pieces", bothEnds.length, 2); +within("the first piece starts at midnight", bothEnds[0].x0 * 1440, 0, 0.01); +within("and ends at the small hours' sunset", bothEnds[0].x1 * 1440, 4, 1); +within("the second piece starts at sunrise", bothEnds[1].x0 * 1440, 175, 1); +within("and runs to the end of the bar", bothEnds[1].x1 * 1440, 1440, 0.01); +eq("midnight is lit", m.litAt(solstice, 0), true); +eq("an hour later is not", m.litAt(solstice, 60), false); +eq("and the morning is lit again", m.litAt(solstice, 180), true); + +// An ordinary city is untouched by any of that: one piece, dark at midnight. +eq("Chicago is still one band", m.litSpans(chicago).length, 1); +eq("and dark at midnight", m.litAt(chicago, 0), false); +eq("Kashgar too", m.litSpans(kashgar).length, 1); + +// --- the ticks still clip ------------------------------------------------- +// The band wraps but the arrows do not. A sunset at 00:04 belongs to the next +// bar along; a mark pinned to the edge of this one would claim the sun set at +// midnight, and its printed time would name an hour that is not on this bar. +eq("no tick for a sunset past midnight", m.eventMark(solstice.setMinutes), null); +eq("an event before the bar has no mark", m.eventMark(-30), null); +eq("an event after it has none either", m.eventMark(1500), null); +eq("midnight is on the bar", m.eventMark(0), 0); +eq("and so is the far end", m.eventMark(1440), 1); + +console.log(` -> ${n}/${n + f} sun assertions passed`); +if (f) process.exitCode = 1; diff --git a/tests/weather_check.js b/tests/weather_check.js new file mode 100644 index 0000000..d57a6bc --- /dev/null +++ b/tests/weather_check.js @@ -0,0 +1,115 @@ +// WMO present-weather codes collapsed to the five states a row has room for. +const fs = require("fs"), path = require("path"); +const src = fs.readFileSync(path.join(__dirname, "..", "Model.js"), "utf8") + .replace(".pragma library", ""); +const box = {}; +new Function(src + "; this.M={weatherKind,resolveUnits,formatTemp,usesTwentyFourHour,resolveHour24};").call(box); +const kind = box.M.weatherKind; + +let n = 0, f = 0; +const t = (label, code, want) => { + n++; + const got = kind(code); + if (got !== want) { f++; console.log(" FAIL", label, `(${code}) ->`, JSON.stringify(got), "want", JSON.stringify(want)); } +}; + +t("clear sky", 0, "sunny"); +t("mainly clear", 1, "sunny"); +t("partly cloudy", 2, "partly"); +t("overcast", 3, "cloudy"); +t("fog", 45, "cloudy"); +t("depositing rime fog", 48, "cloudy"); +t("light drizzle", 51, "rain"); +t("dense drizzle", 55, "rain"); +t("freezing drizzle", 57, "rain"); +t("slight rain", 61, "rain"); +t("heavy rain", 65, "rain"); +t("heavy freezing rain", 67, "rain"); +t("slight rain showers", 80, "rain"); +t("violent rain showers", 82, "rain"); +t("thunderstorm", 95, "rain"); +t("thunderstorm with hail", 99, "rain"); +t("slight snow", 71, "snow"); +t("heavy snow", 75, "snow"); +t("snow grains", 77, "snow"); +t("slight snow showers", 85, "snow"); +t("heavy snow showers", 86, "snow"); + +// A missing reading must not become a sun: Number(null) is 0, which is the +// code for clear sky. +t("null", null, ""); +t("undefined", undefined, ""); +t("empty string", "", ""); +t("not a number", "rain", ""); +t("unassigned code", 7, ""); + +const known = ["", "sunny", "partly", "cloudy", "rain", "snow"]; +let all = true; +for (let c = 0; c <= 99; c++) if (!known.includes(kind(c))) all = false; +n++; if (!all) { f++; console.log(" FAIL every code 0..99 maps to a known kind"); } + +// Numeric strings are what a JSON round trip can hand back. +t("numeric string", "61", "rain"); + +// --- which unit, and the conversion --------------------------------------- +// The setting wins when it says something; everything else defers to the +// system's own measurement system, so a fresh install abroad does not read in +// Fahrenheit because that is where this was written. +const eq = (label, got, want) => { + n++; + if (got !== want) { f++; console.log(" FAIL", label, JSON.stringify(got), "!=", JSON.stringify(want)); } +}; +const ru = box.M.resolveUnits, ft = box.M.formatTemp; +eq("explicit C", ru("C", "F"), "C"); +eq("explicit F", ru("F", "C"), "F"); +eq("lower case counts", ru("c", "F"), "C"); +eq("padded counts", ru(" f ", "C"), "F"); +eq("unset follows a metric system", ru("", "C"), "C"); +eq("unset follows a US system", ru("", "F"), "F"); +eq("undefined follows the system", ru(undefined, "C"), "C"); +eq("null follows the system", ru(null, "F"), "F"); +eq("junk follows the system", ru("kelvin", "C"), "C"); +eq("an unknown auto is metric", ru("", "wat"), "C"); + +// Freezing and boiling, and a rounding case in each direction. +eq("freezing in C", ft(0, "C"), "0\u00b0C"); +eq("freezing in F", ft(0, "F"), "32\u00b0F"); +eq("boiling in F", ft(100, "F"), "212\u00b0F"); +eq("body heat in F", ft(37, "F"), "99\u00b0F"); +eq("negative rounds toward zero-ish", ft(-17.8, "F"), "0\u00b0F"); +eq("half rounds up in C", ft(21.5, "C"), "22\u00b0C"); +eq("nothing to show", ft(null, "C"), ""); + +// --- twelve or twenty-four ------------------------------------------------ +// Qt hands over the locale's short time pattern; the AM/PM designator is what +// tells the two clocks apart. +const t24 = box.M.usesTwentyFourHour, rh = box.M.resolveHour24; +eq("US pattern is twelve-hour", t24("h:mm AP"), false); +eq("lower-case designator too", t24("h:mm ap"), false); +eq("German pattern is twenty-four", t24("HH:mm"), true); +eq("seconds do not matter", t24("HH:mm:ss"), true); +eq("a quoted separator is not a designator", t24("H'h'mm"), true); +eq("but a real designator survives stripping", t24("h'h'mm AP"), false); +eq("designator before the hour", t24("AP h:mm"), false); +// The patterns Qt actually hands over, read off the running shell: the +// designator is spelled "Ap" and the space before it is U+202F, not a space. +eq("Qt's own en_US pattern", t24("h:mm\u202fAp"), false); +eq("Qt's own en_GB pattern", t24("HH:mm"), true); +eq("Qt's own ja_JP pattern", t24("H:mm"), true); +eq("Qt's own fi_FI pattern", t24("H.mm"), true); +eq("nothing known is twenty-four", t24(""), true); + +eq("explicit true", rh(true, false), true); +eq("explicit false is not emptiness", rh(false, true), false); +eq("string true", rh("true", false), true); +eq("string false", rh("false", true), false); +eq("24 as a word", rh("24", false), true); +eq("12 as a word", rh("12", true), false); +eq("blank follows the system", rh("", true), true); +eq("blank follows a twelve-hour system", rh("", false), false); +eq("undefined follows the system", rh(undefined, true), true); +eq("null follows the system", rh(null, true), true); +eq("junk follows the system", rh("maybe", true), true); + +console.log(` -> ${n - f}/${n} weather assertions passed`); +if (f) process.exitCode = 1; diff --git a/world.json b/world.json new file mode 100644 index 0000000..63f3f16 --- /dev/null +++ b/world.json @@ -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,46.9,-56.3,47.6,-59.3,47.6,-57.4,50.7,-55.4,51.6,-56.1,50.7],[143.6,50.7,144.7,49.0,143.2,49.3,142.6,47.9,143.5,46.1,142.7,46.7,142.1,46.0,141.6,51.9,142.2,54.2,143.6,50.7],[-6.8,52.3,-10.0,51.8,-9.2,52.9,-9.7,53.9,-6.7,55.2,-5.7,54.6,-6.8,52.3],[-3.0,58.6,-4.1,57.6,-2.0,57.7,-3.1,56.0,1.7,52.7,1.4,51.3,-5.2,50.0,-5.8,50.2,-3.4,51.4,-5.3,52.0,-4.2,52.3,-4.6,53.5,-2.9,54.0,-4.8,54.8,-5.0,55.8,-5.6,55.3,-6.1,56.8,-5.0,58.6,-3.0,58.6],[-85.2,65.7,-80.1,63.7,-87.2,63.5,-85.9,65.7,-85.2,65.7],[-14.5,66.5,-14.7,65.8,-13.6,65.1,-18.7,63.5,-22.8,64.0,-21.8,64.4,-24.0,64.9,-22.2,65.4,-24.3,65.6,-22.1,66.4,-20.6,65.7,-14.5,66.5],[-175.0,66.6,-171.9,66.9,-169.9,66.0,-172.5,65.4,-173.0,64.3,-178.4,65.4,-178.7,66.1,-179.9,65.9,-180,65.0,-180,69.0,-174.9,67.2,-175.0,66.6],[-95.6,69.1,-99.8,69.4,-98.2,70.1,-95.6,69.1],[-90.5,69.5,-90.6,68.5,-89.2,69.3,-87.4,67.2,-85.6,68.8,-85.5,69.9,-82.6,69.7,-81.3,69.2,-82.0,68.1,-81.3,67.6,-83.3,66.4,-85.8,66.6,-87.3,64.8,-93.2,62.0,-94.7,58.9,-93.2,58.8,-92.3,57.1,-82.3,55.1,-82.1,53.3,-79.9,51.2,-78.6,52.6,-79.8,54.7,-76.5,56.5,-78.5,58.8,-77.3,59.9,-78.1,62.3,-73.8,62.4,-69.6,61.1,-69.3,59.0,-67.6,58.2,-64.6,60.3,-61.4,57.0,-61.8,56.3,-57.3,54.6,-55.8,53.3,-55.7,52.1,-60.0,50.2,-66.4,50.2,-71.1,46.8,-65.1,49.2,-64.2,48.7,-65.1,48.1,-64.5,46.2,-61.5,45.9,-60.5,47.0,-59.8,45.9,-65.4,43.5,-66.2,44.5,-64.4,45.3,-67.1,45.1,-70.7,43.0,-70.0,41.6,-73.7,40.9,-71.9,40.9,-74.0,40.8,-74.9,38.9,-75.5,39.5,-75.1,38.4,-75.9,37.2,-76.3,39.2,-76.3,38.1,-77.0,38.2,-75.7,35.6,-81.3,31.4,-80.1,26.9,-80.4,25.2,-81.7,25.9,-84.1,30.1,-89.2,30.3,-89.2,29.3,-90.2,29.1,-93.8,29.7,-96.6,28.3,-97.4,27.4,-97.9,22.4,-96.3,19.3,-94.4,18.1,-92.0,18.7,-90.8,19.3,-90.3,21.0,-87.1,21.5,-88.9,15.9,-83.4,15.3,-83.8,11.1,-81.4,8.8,-79.6,9.6,-76.8,8.6,-74.9,11.1,-71.8,12.4,-71.1,12.1,-71.9,11.4,-71.7,9.1,-71.4,11.0,-69.9,12.2,-68.2,10.6,-64.9,10.1,-61.9,10.7,-62.4,9.9,-57.1,6.0,-54.0,5.8,-51.3,4.2,-50.0,1.7,-50.4,-0.1,-48.6,-0.2,-48.6,-1.2,-47.8,-0.6,-44.9,-1.6,-44.6,-2.7,-40.0,-2.9,-35.6,-5.1,-34.7,-7.3,-35.1,-9.0,-38.7,-13.1,-39.3,-17.9,-40.9,-21.9,-47.6,-24.9,-48.9,-28.7,-53.8,-34.4,-56.2,-34.9,-58.4,-33.9,-56.8,-36.9,-59.2,-38.7,-62.3,-38.8,-62.7,-41.0,-65.1,-41.1,-65.0,-42.1,-63.5,-42.6,-65.2,-43.5,-65.6,-45.0,-67.3,-45.6,-67.6,-46.3,-65.6,-47.2,-66.0,-48.1,-69.1,-50.7,-68.2,-52.3,-70.8,-52.9,-71.0,-53.8,-74.9,-52.3,-75.6,-48.7,-74.1,-46.9,-75.6,-46.6,-74.4,-44.1,-73.2,-44.5,-72.7,-42.4,-74.3,-43.2,-73.2,-39.3,-73.6,-37.2,-71.4,-32.4,-70.2,-19.8,-71.5,-17.4,-76.0,-14.6,-79.8,-7.2,-81.2,-6.1,-81.4,-4.7,-79.8,-2.7,-81.0,-2.2,-80.9,-1.1,-77.1,3.8,-78.2,8.3,-79.6,8.9,-80.9,7.2,-85.7,9.9,-87.5,13.3,-91.2,13.9,-94.7,16.2,-96.6,15.7,-103.5,18.3,-105.5,19.9,-106.0,22.8,-112.2,29.0,-113.1,31.2,-114.8,31.8,-114.7,30.2,-109.4,23.2,-110.0,22.8,-112.2,24.7,-112.3,26.0,-115.1,27.7,-114.2,28.6,-117.3,33.0,-120.6,34.6,-124.4,40.3,-123.9,45.5,-124.7,48.2,-123.1,48.0,-122.6,47.1,-122.8,49.0,-127.4,50.8,-127.9,52.3,-129.1,52.8,-134.1,58.1,-147.1,60.9,-151.7,59.2,-150.6,61.3,-154.0,59.4,-153.3,58.9,-154.2,58.1,-158.4,56.0,-164.8,54.4,-157.7,57.6,-157.0,58.9,-162.0,58.7,-161.9,59.6,-163.8,59.8,-166.1,61.5,-164.6,63.1,-160.8,63.8,-161.5,64.4,-160.8,64.8,-165.0,64.4,-168.1,65.7,-164.5,66.6,-161.7,66.1,-166.8,68.4,-156.6,71.4,-136.5,68.9,-128.1,70.5,-125.8,69.5,-124.4,70.2,-124.3,69.4,-121.5,69.8,-113.9,68.4,-115.3,67.9,-108.9,67.4,-107.8,67.9,-108.8,68.3,-108.2,68.7,-106.2,68.8,-101.5,67.6,-98.4,67.8,-97.7,68.6,-96.1,68.2,-96.1,67.3,-94.2,69.1,-96.5,70.1,-96.4,71.2,-95.2,71.9,-91.5,70.2,-92.4,69.7,-90.5,69.5],[-114.2,73.1,-114.7,72.7,-109.9,73.0,-108.2,71.7,-107.7,72.1,-108.4,73.1,-106.5,73.1,-104.5,71.0,-101.1,69.6,-102.7,69.5,-102.4,68.8,-113.3,68.5,-117.3,70.0,-112.4,70.4,-117.9,70.5,-118.4,70.9,-116.1,71.3,-119.4,71.6,-117.9,72.7,-114.2,73.1],[-86.6,73.2,-85.8,72.5,-82.3,73.8,-80.6,72.7,-80.7,72.1,-77.8,72.7,-74.1,71.3,-72.2,71.6,-67.0,69.2,-68.8,68.7,-61.9,66.9,-63.9,65.0,-68.0,66.3,-64.7,63.4,-65.0,62.7,-68.8,63.7,-66.2,61.9,-74.8,64.7,-77.7,64.2,-78.6,64.6,-77.9,65.3,-74.0,65.5,-72.9,67.7,-79.0,70.2,-84.9,70.0,-89.9,71.2,-89.4,73.1,-85.8,73.8,-86.6,73.2],[-100.4,73.8,-97.4,73.8,-98.1,73.0,-96.5,72.6,-96.7,71.7,-99.3,71.4,-102.5,72.5,-100.4,72.7,-101.5,73.4,-100.4,73.8],[143.6,73.2,139.9,73.4,142.1,73.9,143.6,73.2],[-93.2,72.8,-95.4,72.1,-96.0,73.4,-94.5,74.1,-90.5,73.9,-93.2,72.8],[-120.5,71.4,-123.1,70.9,-125.9,71.9,-123.9,73.7,-124.9,74.3,-115.5,73.5,-119.2,72.5,-120.5,71.4],[-93.6,75.0,-96.8,74.9,-94.9,75.6,-93.6,75.0],[145.1,75.6,144.3,74.8,139.0,74.6,137.0,75.3,138.8,76.1,145.1,75.6],[-98.5,76.7,-97.7,76.3,-98.2,75,-102.5,75.6,-102.6,76.3,-98.5,76.7],[-108.2,76.2,-105.7,75.5,-112.2,74.4,-113.9,74.7,-111.8,75.2,-117.7,75.2,-115.4,76.5,-109.1,75.5,-110.5,76.4,-108.2,76.2],[57.5,70.7,53.7,70.8,51.5,72.0,55.6,75.1,61.2,76.3,68.9,76.5,58.5,74.3,55.4,72.4,57.5,70.7],[-94.7,77.1,-89.2,75.6,-81.1,75.7,-79.8,74.9,-89.8,74.5,-97.1,76.8,-94.7,77.1],[-116.2,77.6,-117.1,76.5,-122.9,76.1,-116.2,77.6],[107.0,77.0,114.1,75.8,109.4,74.2,123.2,73.0,123.3,73.7,127.0,73.6,131.3,70.8,132.3,71.8,139.9,71.5,139.1,72.4,140.5,72.8,149.5,72.2,153.0,70.8,159.0,70.9,160.9,69.4,167.8,69.6,169.6,68.7,170.8,69.0,170.0,69.7,170.5,70.1,175.7,69.9,180,69.0,180,65.0,177.4,64.6,179.2,62.3,173.7,61.7,170.3,59.9,168.9,60.6,163.5,59.9,162.0,58.2,163.2,57.6,162.1,54.9,160.4,54.3,160.0,53.2,158.5,53.0,156.8,51.0,155.4,55.4,155.9,56.8,163.7,61.1,164.5,62.6,160.1,60.5,159.3,61.8,156.7,61.4,154.2,59.8,155.0,59.1,151.3,58.8,151.3,59.5,149.8,59.7,142.2,59.0,135.1,54.7,138.2,53.8,139.9,54.2,141.4,52.2,140.1,48.4,138.2,46.3,134.9,43.4,132.3,43.3,127.5,39.8,129.5,36.8,129.1,35.1,126.5,34.4,126.1,36.7,126.9,36.9,124.7,38.1,125.3,39.6,121.1,38.9,122.2,40.4,121.6,40.9,118.0,39.2,117.5,38.7,118.9,37.4,122.4,37.5,119.2,34.9,121.9,31.7,121.3,30.7,122.1,29.8,121.7,28.2,118.7,24.5,115.9,22.8,110.8,21.4,110.4,20.3,108.5,21.7,105.9,19.8,109.3,13.4,109.2,11.7,105.2,8.6,105.1,9.9,100.1,13.4,99.2,9.2,103.0,5.5,104.2,1.3,101.4,2.8,100.1,6.5,98.5,8.4,98.3,7.8,98.8,11.4,97.2,16.9,95.4,15.7,94.2,16.0,94.3,18.2,91.4,22.8,90.5,22.8,90.3,21.8,87.0,21.5,86.5,20.2,80.3,15.9,79.9,10.4,77.5,8.0,73.5,16.0,72.6,21.4,70.5,20.9,66.4,25.4,57.4,25.7,56.5,27.1,54.7,26.5,51.5,27.9,50.1,30.1,48.0,30.0,50.8,24.8,51.0,26.0,51.6,25.8,51.8,24.0,54.0,24.1,56.4,26.4,56.8,24.2,59.8,22.3,57.8,20.2,57.7,18.9,55.3,17.2,48.7,14.0,43.5,12.6,42.6,16.8,39.1,21.3,38.5,23.7,34.6,28.1,34.9,29.5,33.9,27.6,32.4,29.9,35.5,23.1,36.9,22.0,37.5,18.6,43.3,12.4,42.7,11.7,44.6,10.4,51.1,12.0,51.0,10.6,47.7,4.2,40.3,-2.6,39.2,-4.7,38.8,-6.5,40.5,-10.8,40.8,-14.7,39.5,-16.7,34.8,-19.8,35.6,-23.7,32.6,-25.7,32.2,-28.8,28.2,-32.8,25.8,-33.9,19.6,-34.8,18.4,-34.1,18.2,-31.7,15.2,-27.1,14.3,-22.1,11.8,-18.1,11.8,-15.8,13.7,-10.7,11.9,-5.0,8.8,-1.1,9.4,3.7,8.5,4.8,5.9,4.3,4.3,6.3,-2.0,4.7,-9.0,4.8,-12.4,7.3,-16.6,12.2,-17.6,14.7,-16.1,18.1,-17.0,21.9,-14.4,26.3,-9.6,29.9,-9.3,32.6,-5.9,35.8,-2.2,35.2,1.5,36.6,9.5,37.4,11.1,36.9,10.3,33.8,19.1,30.3,20.1,32.2,21.5,32.8,28.9,30.9,31.0,31.6,33.8,31.0,36.0,34.6,36.2,36.7,27.6,36.7,26.2,39.5,29.2,41.2,33.5,42.0,38.3,40.9,41.7,42.0,36.7,45.2,39.1,47.3,35.0,46.3,36.3,45.1,33.9,44.4,32.5,45.3,33.3,46.1,30.7,46.6,27.7,42.6,28.8,41.1,26.4,40.2,24.9,40.9,23.7,40.7,23.9,40.0,22.6,40.3,24.0,37.7,23.1,37.9,23.2,36.4,22.5,36.4,19.4,40.3,19.5,41.7,13.1,45.7,12.3,45.4,12.6,44.1,18.5,40.2,16.9,40.4,17.1,38.9,16.1,38.0,15.4,40.0,8.9,44.4,6.5,43.1,3.1,43.1,3.0,41.9,0.8,41.0,0.1,38.7,-2.1,36.7,-5.4,35.9,-6.5,36.9,-8.9,36.9,-9.4,43.0,-1.4,44.0,-1.2,46.0,-4.6,48.7,-1.6,48.6,-1.9,49.8,1.3,50.1,4.7,53.1,8.1,53.5,8.8,54.0,8.1,55.5,8.5,57.1,10.6,57.7,10.9,56.5,9.7,55.5,10.9,54.0,19.7,54.4,21.3,55.2,21.6,57.4,24.1,57.0,24.4,58.4,23.3,59.2,29.1,60.0,22.9,59.8,21.3,60.7,21.5,63.2,25.4,65.1,23.9,66.0,22.2,65.7,21.4,64.4,17.8,62.7,17.1,61.3,18.8,60.1,16.8,58.7,15.9,56.1,12.9,55.4,10.4,59.5,8.4,58.3,5.7,58.6,5.0,62.0,10.5,64.5,14.8,67.8,24.5,71.0,28.2,71.2,31.3,70.5,30.0,70.2,31.1,69.6,36.5,69.1,41.1,67.5,41.1,66.8,38.4,66.0,33.2,66.6,34.8,65.9,34.9,64.4,37.0,63.8,36.5,64.8,37.2,65.1,39.6,64.5,40.4,64.8,39.8,65.5,42.1,66.5,43.9,66.1,44.5,66.8,43.5,68.6,46.3,68.3,46.8,67.7,45.6,67.0,46.3,66.7,53.7,68.9,54.5,68.8,53.5,68.2,58.8,68.9,59.9,68.3,61.1,68.9,60.0,69.5,60.6,69.9,68.5,68.1,69.2,68.6,66.9,69.5,66.7,71.0,69.2,72.8,72.6,72.8,71.8,71.4,72.8,70.4,72.6,69.0,73.7,68.4,71.3,66.3,72.4,66.2,75.1,67.8,74.9,69.0,73.6,69.6,74.4,70.6,73.1,71.4,74.9,72.1,74.7,72.8,75.7,72.3,75.3,71.3,76.4,71.2,75.9,71.9,77.6,72.3,81.5,71.8,80.5,73.6,86.8,73.9,86.0,74.5,87.2,75.1,100.8,76.4,104.4,77.7,107.0,77.0],[24.7,77.9,20.7,77.7,22.9,78.5,24.7,77.9],[-95.8,78.1,-98.1,78.1,-98.6,78.9,-95.8,78.1],[-100.1,78.3,-99.7,77.9,-105.2,78.4,-104.2,78.7,-105.5,79.3,-100.1,78.3],[105.1,78.3,99.4,77.9,102.1,79.3,105.1,78.3],[18.3,79.7,21.5,79.0,15.9,76.8,10.4,79.7,18.3,79.7],[25.4,80.4,27.4,80.1,23.0,79.4,17.4,80.3,25.4,80.4],[51.1,80.5,47.6,80.0,44.8,80.6,51.1,80.5],[99.9,78.9,95.0,79.0,91.2,80.3,95.9,81.3,100.2,79.8,99.9,78.9],[-87.0,79.7,-85.8,79.3,-90.8,78.2,-96.7,80.2,-92.4,81.3,-87.0,79.7],[-68.5,83.1,-61.9,82.6,-67.7,81.5,-65.5,81.5,-71.2,79.8,-76.9,79.3,-75.4,78.5,-79.8,77.2,-77.9,76.8,-80.6,76.2,-89.5,76.5,-87.8,77.2,-88.3,77.9,-85.0,77.5,-88.0,78.4,-85.1,79.3,-86.9,80.3,-81.8,80.5,-87.6,80.5,-91.6,81.9,-79.3,83.1,-68.5,83.1],[-27.1,83.5,-20.8,82.7,-31.9,82.2,-22.1,81.7,-23.2,81.2,-15.8,81.9,-12.2,81.3,-20.0,80.2,-17.7,80.1,-19.7,78.8,-19.7,77.6,-18.5,77.0,-21.7,76.6,-19.8,76.1,-19.6,75.2,-20.7,75.2,-19.4,74.3,-23.6,73.3,-22.3,72.2,-24.8,72.3,-21.8,70.7,-25.5,71.4,-25.2,70.8,-26.4,70.2,-22.3,70.1,-39.8,65.5,-42.8,62.7,-42.4,61.9,-43.4,60.1,-48.3,60.9,-51.6,63.6,-54.0,67.2,-50.9,69.9,-54.7,69.6,-54.4,70.8,-51.4,70.6,-55.8,71.7,-54.7,72.6,-58.6,75.5,-68.5,76.1,-71.4,77.0,-66.8,77.4,-73.3,78.0,-65.7,79.4,-68.0,80.1,-62.2,81.3,-62.7,81.8,-50.4,82.4,-44.5,81.7,-46.8,82.6,-38.6,83.5,-27.1,83.5]] \ No newline at end of file diff --git a/worldclock-data.py b/worldclock-data.py new file mode 100755 index 0000000..89939a3 --- /dev/null +++ b/worldclock-data.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Temperature and currency for the world clock's cities. + +Reads a JSON list of {"label", "id"} rows on stdin, writes a JSON map of +per-row facts on stdout. Everything is cached on disk with its own TTL, so the +plugin can call this as often as it likes without hammering anyone: + + geocode forever a city does not move + currency 6 hours published once a day + weather 20 min the resolution the source actually offers + +Network failures are never fatal. Anything that cannot be fetched falls back +to the cached value, and failing that is simply omitted - a row with no +temperature renders without one rather than blocking the panel. +""" + +import json +import os +import sys +import time +import urllib.parse +import urllib.request + +CACHE = os.path.join( + os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")), + "omacom-elsewhen", "data.json", +) +FX_TTL = 6 * 3600 +WX_TTL = 20 * 60 +TIMEOUT = 8 + +GEOCODE = "https://geocoding-api.open-meteo.com/v1/search" +FORECAST = "https://api.open-meteo.com/v1/forecast" +FX = "https://open.er-api.com/v6/latest/USD" + +ZONE_TAB = "/usr/share/zoneinfo/zone1970.tab" + +# ISO 3166-1 alpha-2 -> ISO 4217. Every code here is validated against the +# system's iso-codes data and against the FX feed by tests/currency_check.py. +# Note BG and HR map to EUR: both have adopted the euro, and the FX feed still +# publishes legacy peg rates for BGN and HRK that would otherwise show a +# retired currency. +COUNTRY_CURRENCY = {} + + +def _put(codes, ccy): + for code in codes.split(): + COUNTRY_CURRENCY[code] = ccy + + +_put("AD AT BE BG CY DE EE ES FI FR GR HR IE IT LT LU LV MC ME MT NL PT SI SK SM VA XK", "EUR") +_put("US EC SV PR GU VI AS MP TC VG BQ MH FM PW TL", "USD") +_put("GB", "GBP"); _put("CH LI", "CHF") +_put("AU CX CC NF NR TV KI", "AUD"); _put("NZ CK NU PN TK", "NZD") +_put("DK FO GL", "DKK"); _put("NO SJ BV", "NOK"); _put("SE", "SEK"); _put("IS", "ISK") +_put("ZA", "ZAR"); _put("BJ BF CI GW ML NE SN TG", "XOF"); _put("CM CF TD CG GQ GA", "XAF") +_put("AG DM GD KN LC VC AI MS", "XCD"); _put("PF NC WF", "XPF") +_put("JP", "JPY"); _put("CN", "CNY"); _put("IN", "INR"); _put("RU", "RUB"); _put("BR", "BRL") +_put("MX", "MXN"); _put("CA", "CAD"); _put("KR", "KRW"); _put("SG", "SGD"); _put("HK", "HKD") +_put("TW", "TWD"); _put("TH", "THB"); _put("MY", "MYR"); _put("ID", "IDR"); _put("PH", "PHP") +_put("VN", "VND"); _put("TR", "TRY"); _put("PL", "PLN"); _put("CZ", "CZK"); _put("HU", "HUF") +_put("RO", "RON"); _put("UA", "UAH"); _put("IL", "ILS"); _put("AE", "AED") +_put("SA", "SAR"); _put("QA", "QAR"); _put("KW", "KWD"); _put("BH", "BHD"); _put("OM", "OMR") +_put("JO", "JOD"); _put("LB", "LBP"); _put("EG", "EGP"); _put("MA", "MAD"); _put("DZ", "DZD") +_put("TN", "TND"); _put("LY", "LYD"); _put("NG", "NGN"); _put("KE", "KES"); _put("TZ", "TZS") +_put("UG", "UGX"); _put("GH", "GHS"); _put("ET", "ETB"); _put("RW", "RWF"); _put("ZM", "ZMW") +_put("MU", "MUR"); _put("MZ", "MZN"); _put("AO", "AOA"); _put("BW", "BWP"); _put("MW", "MWK") +_put("SD", "SDG"); _put("SO", "SOS"); _put("CD", "CDF"); _put("NA", "NAD"); _put("LS", "LSL") +_put("SZ", "SZL"); _put("MG", "MGA"); _put("SC", "SCR"); _put("GM", "GMD"); _put("GN", "GNF") +_put("SL", "SLE"); _put("LR", "LRD"); _put("BI", "BIF"); _put("DJ", "DJF"); _put("ER", "ERN") +_put("AR", "ARS"); _put("CL", "CLP"); _put("CO", "COP"); _put("PE", "PEN"); _put("VE", "VES") +_put("UY", "UYU"); _put("PY", "PYG"); _put("BO", "BOB"); _put("CR", "CRC"); _put("GT", "GTQ") +_put("HN", "HNL"); _put("NI", "NIO"); _put("DO", "DOP"); _put("CU", "CUP"); _put("JM", "JMD") +_put("TT", "TTD"); _put("BB", "BBD"); _put("BS", "BSD"); _put("BZ", "BZD"); _put("HT", "HTG") +_put("GY", "GYD"); _put("SR", "SRD"); _put("PA", "PAB") +_put("PK", "PKR"); _put("BD", "BDT"); _put("LK", "LKR"); _put("NP", "NPR"); _put("AF", "AFN") +_put("IR", "IRR"); _put("IQ", "IQD"); _put("KZ", "KZT"); _put("UZ", "UZS"); _put("KG", "KGS") +_put("TJ", "TJS"); _put("TM", "TMT"); _put("AZ", "AZN"); _put("AM", "AMD"); _put("GE", "GEL") +_put("MN", "MNT"); _put("MM", "MMK"); _put("KH", "KHR"); _put("LA", "LAK"); _put("BN", "BND") +_put("MV", "MVR"); _put("BT", "BTN"); _put("SY", "SYP"); _put("YE", "YER") +_put("FJ", "FJD"); _put("PG", "PGK"); _put("SB", "SBD"); _put("VU", "VUV"); _put("WS", "WST") +_put("TO", "TOP"); _put("AL", "ALL"); _put("MK", "MKD"); _put("RS", "RSD"); _put("BA", "BAM") +_put("MD", "MDL"); _put("BY", "BYN"); _put("ZW", "ZWG"); _put("SS", "SSP") + + +def load_cache(): + try: + with open(CACHE) as fh: + data = json.load(fh) + except Exception: + data = {} + data.setdefault("geo", {}) + data.setdefault("wx", {}) + data.setdefault("fx", {}) + return data + + +def save_cache(data): + try: + os.makedirs(os.path.dirname(CACHE), exist_ok=True) + tmp = CACHE + ".tmp" + with open(tmp, "w") as fh: + json.dump(data, fh) + os.replace(tmp, CACHE) + except Exception: + pass + + +def get_json(url): + req = urllib.request.Request(url, headers={"User-Agent": "omarchy-elsewhen/1.0"}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def zone_tab_coords(zone): + """Coordinates and country for an IANA zone, from the local tz database. + + The fallback when a label cannot be geocoded. Less precise than a real + geocode - it returns the zone's representative city, so an alias like + Boca Raton lands on New York - but it needs no network and always + resolves to somewhere in the right country. + """ + try: + with open(ZONE_TAB) as fh: + for line in fh: + if line.startswith("#"): + continue + parts = line.rstrip("\n").split("\t") + if len(parts) < 3 or parts[2] != zone: + continue + coords = parts[1] + # ISO 6709: +DDMM+DDDMM or +DDMMSS+DDDMMSS + sign_positions = [i for i, ch in enumerate(coords) if ch in "+-"] + if len(sign_positions) < 2: + return None + lat_s, lon_s = coords[:sign_positions[1]], coords[sign_positions[1]:] + + def dec(text, deg_digits): + sign = -1 if text[0] == "-" else 1 + body = text[1:] + deg = int(body[:deg_digits]) + minutes = int(body[deg_digits:deg_digits + 2]) + seconds = int(body[deg_digits + 2:deg_digits + 4] or 0) + return sign * (deg + minutes / 60 + seconds / 3600) + + return { + "lat": round(dec(lat_s, 2), 4), + "lon": round(dec(lon_s, 3), 4), + "country": parts[0].split(",")[0], + } + except Exception: + pass + return None + + +def geocode(label, zone, cache): + key = label + "|" + zone + hit = cache["geo"].get(key) + if hit: + return hit + + try: + url = GEOCODE + "?" + urllib.parse.urlencode( + {"name": label, "count": 10, "language": "en", "format": "json"}) + results = get_json(url).get("results") or [] + # Prefer a hit whose timezone is the row's zone - that disambiguates + # the many cities that share a name across countries. + best = next((r for r in results if r.get("timezone") == zone), None) + if best is None and results: + best = results[0] + if best: + found = { + "lat": best["latitude"], + "lon": best["longitude"], + "country": best.get("country_code") or "", + "exact": best.get("timezone") == zone, + } + cache["geo"][key] = found + return found + except Exception: + pass + + fallback = zone_tab_coords(zone) + if fallback: + fallback["exact"] = False + cache["geo"][key] = fallback + return fallback + return None + + +def fetch_rates(cache): + fx = cache.get("fx") or {} + if fx.get("rates") and time.time() - fx.get("at", 0) < FX_TTL: + return fx["rates"], False + try: + payload = get_json(FX) + if payload.get("result") == "success" and payload.get("rates"): + cache["fx"] = {"rates": payload["rates"], "at": time.time()} + return payload["rates"], False + except Exception: + pass + return fx.get("rates") or {}, True + + +def fetch_temps(points, cache): + """One batched call for every distinct coordinate that needs refreshing.""" + now = time.time() + fresh, stale_keys = {}, [] + for key, (lat, lon) in points.items(): + hit = cache["wx"].get(key) + if hit and now - hit.get("at", 0) < WX_TTL: + fresh[key] = hit + else: + stale_keys.append(key) + + if stale_keys: + try: + lats = ",".join(str(points[k][0]) for k in stale_keys) + lons = ",".join(str(points[k][1]) for k in stale_keys) + url = FORECAST + "?" + urllib.parse.urlencode({ + "latitude": lats, "longitude": lons, + "current": "temperature_2m,weather_code", + "temperature_unit": "celsius", + }) + payload = get_json(url) + if isinstance(payload, dict): + payload = [payload] + for key, entry in zip(stale_keys, payload): + cur = entry.get("current") or {} + temp = cur.get("temperature_2m") + if temp is not None: + # The WMO code travels with the temperature; both come + # from the same reading, so they cannot disagree. + row = {"c": temp, "at": now} + code = cur.get("weather_code") + if code is not None: + row["w"] = code + fresh[key] = row + cache["wx"][key] = row + except Exception: + pass + + # Anything still missing falls back to whatever the cache last saw. + for key in stale_keys: + if key not in fresh and key in cache["wx"]: + fresh[key] = cache["wx"][key] + return fresh + + +def main(): + # Rows arrive as argv[1] when the caller finds that easier than a pipe + # (Quickshell's Process does), or on stdin otherwise. + try: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + rows = json.loads(args[0]) if args else json.load(sys.stdin) + except Exception: + rows = [] + + cache = load_cache() + out = {} + points = {} + + for row in rows: + label, zone = str(row.get("label", "")), str(row.get("id", "")) + if not zone: + continue + key = label + "|" + zone + place = geocode(label, zone, cache) + entry = {} + if place: + points[key] = (place["lat"], place["lon"]) + # Coordinates travel with the row so the globe can place a tracked + # city even when it is not one of the globe's own built-ins. + entry["lat"] = place["lat"] + entry["lon"] = place["lon"] + ccy = COUNTRY_CURRENCY.get((place.get("country") or "").upper()) + if ccy: + entry["ccy"] = ccy + out[key] = entry + + # The panel passes --no-fx when it is not drawing currency; there is no + # point spending a request on rates nobody will see. + if "--no-fx" in sys.argv: + rates, fx_stale = {}, False + else: + rates, fx_stale = fetch_rates(cache) + temps = fetch_temps(points, cache) + + for key, entry in out.items(): + if key in temps: + wx = temps[key] + if wx.get("c") is not None: + entry["c"] = round(wx["c"], 1) + # Entries cached before weather codes were fetched have no "w"; + # the row simply renders without an icon until the next refresh. + if wx.get("w") is not None: + entry["w"] = int(wx["w"]) + ccy = entry.get("ccy") + if ccy and ccy != "USD": + rate = rates.get(ccy) + # rates are units-per-USD; invert to price one unit in dollars + if rate: + entry["usd"] = 1.0 / rate + elif ccy == "USD": + entry.pop("ccy", None) # nothing to say about dollars in dollars + + save_cache(cache) + json.dump({"cities": out, "fxStale": fx_stale}, sys.stdout) + + +if __name__ == "__main__": + main()