Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)
* Move Elsewhen into Omarchy as omarchy.elsewhen The world clock ships in the shell tree instead of its own package. A migration renames existing bar entries with their settings and removes the retired package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remove Elsewhen's shelved Earth row, overlap band and sky tint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tidy Elsewhen's models, data helper and docs, and drop currency Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restructure Elsewhen's panel and globe on the shared shell components Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show Elsewhen's bar globe upright Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use the shared city search for Elsewhen's globe jump bar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep legacy Elsewhen entries single and retry offline geocodes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fetch Elsewhen's weather with curl instead of a Python helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c7cef1e78
commit
e7218e36f2
37 files changed
+6780
-155
No files matched your search
@@ -22,7 +22,7 @@
|
||||
"id": "omarchy.indicators"
|
||||
},
|
||||
{
|
||||
"id": "omacom.elsewhen"
|
||||
"id": "omarchy.elsewhen"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.clock",
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# 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. It lives in `shell/plugins/panels/elsewhen/` under the plugin id `omarchy.elsewhen`, and file names below are relative to that directory. Its checks live in `test/shell.d/elsewhen/` and run as part of `./test/shell` through `test/shell.d/elsewhen-test.sh`.
|
||||
|
||||
Everything it needs is already on an Omarchy install: `date` and `timedatectl` for zone offsets, and `curl` for weather. Weather is the only thing that touches the network: [Open-Meteo](https://open-meteo.com) geocoding and forecasts, without an API key. 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).
|
||||
|
||||
## 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:
|
||||
|
||||
**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 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.
|
||||
|
||||
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 remove button so that keeps its 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**. 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. 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. `test/shell.d/elsewhen-model-test.sh` runs the whole thing from fifteen different home cities, including UTC, Kathmandu's forty-five-minute offset, and the Pacific.
|
||||
|
||||
## Adding and removing cities
|
||||
|
||||
`+ 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. Changes are written straight back to this widget's entry in `~/.config/omarchy/shell.json`, so they survive a restart.
|
||||
|
||||
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. 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.
|
||||
|
||||
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, so the column does not blank on every keystroke.
|
||||
|
||||
The globe's own **jump box** works the same way - 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.
|
||||
|
||||
**A zone is not a city.** 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 passes the label alongside the zone, so picking Oakland puts "Oakland" on the list rather than "Los Angeles".
|
||||
|
||||
The `×` is placed from the corner rather than centered 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.
|
||||
|
||||
The search is rendered inline rather than in a dropdown popup: the shared `SearchableDropdown` only ever opens downward, and this panel hangs off a vertical bar low on the screen, so its list would run 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 content is 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.
|
||||
|
||||
## 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: 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.
|
||||
|
||||
The lit band is the city's real day, from the coordinates the weather lookup already geocodes. 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 gets a fixed 06:00-18:00 band until its coordinates arrive.
|
||||
|
||||
The marker turns gold while it is inside the lit band and stays white outside it. That is decided geometrically, from the same span the band draws, not from the phase name - so the sun can never be painted sitting in the dark half of its own bar. The gold is a literal color rather than a theme role, because several themes map the palette name `yellow` to something that isn't yellow.
|
||||
|
||||
`Sun.js` computes the day, 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.
|
||||
|
||||
`test/shell.d/elsewhen-sun-test.sh` 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.
|
||||
|
||||
It is 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. The two high-latitude cities are in the test with the tolerance they actually need rather than left out.
|
||||
|
||||
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 day is 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. The band already says how long the day is; the arrows say which end is which. 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 would leave the arrow half a box away. A nudge closes most of that without letting the glyph touch the band.
|
||||
|
||||
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. The offset gives a second, coarser cue that needs no reading: the one above the line is the one going up.
|
||||
|
||||
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.
|
||||
|
||||
Above and not below, because below is where the pointer is. The chip floats over the date line, which is why it has a ground of its own, and its colors are inverted in every theme the way a tooltip is. The arrows carry `z: 1` so the chip paints over the row's time block, which is declared after them.
|
||||
|
||||
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 relies on.
|
||||
|
||||
An arrow hides while the marker is standing on it. Two things drawn at the same point read as a printing fault, 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 - 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.
|
||||
|
||||
Clicking the marker itself does not name the moon's phase: the marker is also the scrub handle, so aiming at a ten-pixel dot to read a label would nudge the whole list off the hour on every miss. Shift-clicking the moon runs the phase animation instead, which is a deliberate gesture rather than one you land on by aiming badly.
|
||||
|
||||
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.
|
||||
|
||||
A click on an arrow stops at the arrow: the scrub bar and the row's reorder grab underneath record no press at all, so the scrub bar needs no guard against the arrows drawn on top of it. That is measured rather than reasoned - see [Testing what the pointer does](#testing-what-the-pointer-does).
|
||||
|
||||
A press on the row body does still reach the grab, which dismisses, so the two decisions can meet on one click. 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 `test/shell.d/elsewhen-model-test.sh` 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.
|
||||
|
||||
## Weather
|
||||
|
||||
Each row shows the current temperature beside the city name, and one glyph beside it: sun, partly cloudy, cloud, rain, or snow. The glyph 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 reads as a snowflake - sitting directly beside an actual snowflake. The one in use is a solid disc with short rays, which cannot be mistaken for anything else in the set.
|
||||
|
||||
`Number(null)` is `0`, and `0` is the WMO code for *clear sky* - so a missing reading would quietly render a sun. Absent values are rejected before the conversion; `test/shell.d/elsewhen-model-test.sh` covers it.
|
||||
|
||||
The panel fetches it with `curl`, in two steps: one geocoding request per city it has no coordinates for yet, then one batched forecast for every city. Both results live in memory as `facts`, keyed `label|zone`. Geocodes last the session (a city does not move) and weather twenty minutes (the resolution the source offers), so opening the panel again soon does no network at all. Nothing here is fatal - a failed fetch keeps the previous 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; after a network failure that fallback is used for now and the geocode retried on the next refresh.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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 nothing the panel draws on hover should need 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 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.
|
||||
|
||||
**Every zone the picker offers is covered.** The picker offers every zone `timedatectl list-timezones` returns - 598 of them, not just the 74 in `cities.json` - so the table is built from the system's own `zone.tab`. English is 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 to English.
|
||||
|
||||
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.
|
||||
|
||||
`test/shell.d/elsewhen-greetings-test.sh` 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 matches each zone's *compiled* zoneinfo file against the ones in `zone.tab`. That is exact for real zones and 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 would match Burkina Faso and say *Bonjour*; `NZ` would match Antarctica, `Asia/Rangoon` the Cocos Islands, `Africa/Asmera` Djibouti.
|
||||
|
||||
Following the tz link table instead does not help: 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.
|
||||
|
||||
## 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 color 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. The clock rows are a binding on the scrubbable, ticking time, so using them as the model would rebuild every delegate on every tick and destroy the `MouseArea` mid-gesture, turning every drag into a single click.
|
||||
|
||||
`test/shell.d/elsewhen-model-test.sh` covers the scrub arithmetic, including scrub direction and the short way round midnight.
|
||||
|
||||
## 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, 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 uses for slow-motion window animations, 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.
|
||||
|
||||
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 center of its own disc**, not the center of its box, so it grows from where it is drawn; the translation then carries that center 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, the way a dropped thing does.
|
||||
|
||||
Everything involved is **opaque**. The globe's ocean and the row cards mix their tones against the panel background and return a color with no alpha, rather than painting low-alpha foreground over it. That 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 the rows show through.
|
||||
|
||||
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. 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
|
||||
|
||||
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. Tracked and home cities keep their distinction through weight and their dot rings rather than label color.
|
||||
|
||||
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. `test/shell.d/elsewhen-globe-test.sh` 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 the accent color, a dark edge so it 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** get an accent ring around the dot, and 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 weather script 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 measures about 14ms - already over a 120Hz frame at 8.3ms - and every frame of movement repaints the whole canvas, because the projection changes, whether the globe is riding the zoom, being dragged, or coasting after a throw.
|
||||
|
||||
Two things dominate that paint: the city names, which cost a layout pass and two passes of text, one of them through a clip built 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 halves 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.
|
||||
|
||||
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, and literal widths would read as proportionally thinner as that size grows. The small globe 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.
|
||||
|
||||
`test/shell.d/elsewhen-globe-test.sh` 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, as opening the globe does for home and the jump box does for what it finds - a city chosen near the limb would otherwise sit where the projection is most foreshortened. Only a hit turns the globe: a tap on open ocean clears the selection and leaves the view alone.
|
||||
|
||||
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 would silently come to mean a different city. Deriving the index means a reorder carries the focus with the city and removing the focused city drops it back to home.
|
||||
|
||||
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. A key survives the rebuild. `test/shell.d/elsewhen-model-test.sh` 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, centering 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 geocodes it, and the globe flies there once the coordinates arrive. Nothing is written to `shell.json`, so there is no saved list to delete from, order, or migrate. 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 globe's Loader never activates; nothing else changes.
|
||||
|
||||
### The footer under the globe
|
||||
|
||||
Two lines: the city with its time, and under it the zone with its offset. Opening the globe selects home, so the footer is never empty under a marker sitting plainly on a city.
|
||||
|
||||
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, so it agrees with the dot drawn on that same city an inch above it.
|
||||
|
||||
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 rasterises to the same 14 device pixels, because a circle's antialiased edge reads a pixel wider than a glyph's stem. Any smaller and the moon reads as a bullet point; a crescent needs room to be a crescent.
|
||||
|
||||
It is not placed with `anchors.baseline`: inside a Row, anchoring to a sibling whose own position depends on the Row's height is a loop, and QML settles it by dropping the dot onto the line below. A Row leaves `y` alone, so both items start at its top and the dot's underside is 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.
|
||||
|
||||
There is no "daylight" or "night" word after the time: the city markers and the terminator already say it in the picture. The line cannot fit on one row - "Johannesburg Africa/Johannesburg UTC+2 7:50 PM" runs off the end of the panel - so it is two caption lines, which the footer's reserved height already holds.
|
||||
|
||||
The parts of that line are anchored to a named `footer` id rather than reached through `parent.parent`, because QML resolves a chain that falls a step short to `undefined` in silence.
|
||||
|
||||
## 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 a second pass over every ring is 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 outlines, against a 16.7ms frame budget.
|
||||
|
||||
Filling means labels and city dots 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. Keeping each visible run and closing 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. 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. `test/shell.d/elsewhen-globe-test.sh` 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 centerpiece a dimmed thin globe just reads as washed out.
|
||||
|
||||
A marker shows the city you are in, in the accent color. It carries a dark edge: the marker can be nearly the same lightness as the filled continents, and without one the dot dissolves into whichever landmass it is sitting on.
|
||||
|
||||
**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** and marks it - so a tap on Tokyo swings the globe round and drops the marker 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, clicking the header line brings it home. The whole line is the target, not just the name, 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`, three whole turns that finish with your own meridian facing you. At rest a `Binding` holds the globe there, standing down while the animation is writing the property. Until the weather script has geocoded your city the marker is hidden and it rests on Greenwich.
|
||||
|
||||
`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|<zone>` line, from `timedatectl` - 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: 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 relies 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, 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, so there is only one place the mode can live.
|
||||
|
||||
## 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.
|
||||
|
||||
The starting notation follows the machine. 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, and the ones that matter are in `test/shell.d/elsewhen-model-test.sh` 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 too. With `units` unset, `Qt.locale().measurementSystem` decides: the US system means Fahrenheit and everything else means Celsius. `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 `test/shell.d/elsewhen-model-test.sh` covers them, including the junk values a hand-edited `shell.json` can produce.
|
||||
|
||||
## Testing what the pointer does
|
||||
|
||||
No pointer can be injected into the running shell, but `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 test/shell.d/elsewhen/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.
|
||||
|
||||
`test/shell.d/elsewhen/qml/tst_arrows.qml` settles 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 |
|
||||
|
||||
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 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. The globe's search hands the keyboard back when it closes: a hidden item keeps its focus, so otherwise the field would go on swallowing keys and the second Escape would go nowhere.
|
||||
|
||||
## Settings
|
||||
|
||||
Inline on the widget's `shell.json` entry:
|
||||
|
||||
| Key | Meaning |
|
||||
|----------|------------------------------------------------------|
|
||||
| `zones` | `Label\|IANA name`, comma separated; blank seeds on first run |
|
||||
| `hour24` | `true` or `false`; unset (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 |
|
||||
| `globeEnabled` | `false` to remove the globe entry point (default on) |
|
||||
| `homeCity` | your city for the header (blank = from the system zone) |
|
||||
| `smoothMotion` | drop labels and detail while the globe moves (default true) |
|
||||
|
||||
## IPC
|
||||
|
||||
```bash
|
||||
omarchy-shell omarchy.elsewhen toggle
|
||||
omarchy-shell omarchy.elsewhen globe # switch between list and globe
|
||||
omarchy-shell omarchy.elsewhen times # JSON, one entry per row
|
||||
omarchy-shell omarchy.elsewhen add America/New_York Miami
|
||||
omarchy-shell omarchy.elsewhen remove America/New_York
|
||||
omarchy-shell omarchy.elsewhen refresh # re-probe offsets
|
||||
```
|
||||
@@ -100,7 +100,7 @@ The lower-level IPC methods remain available through `omarchy-shell shell ...`.
|
||||
|
||||
## Elsewhen
|
||||
|
||||
Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/shell/plugins/omacom.elsewhen`, where the shell discovers it automatically. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. The normal update flow restarts the shell after migrations; the migration does not interrupt plugin loading with an immediate restart. With no shell to ask, as in an update from a TTY, the migration installs the package and skips the placement rather than failing the update; `omarchy bar put omacom.elsewhen --before omarchy.clock` places the widget later.
|
||||
Elsewhen (`omarchy.elsewhen`), the world clock, is a first-party plugin in `shell/plugins/panels/elsewhen/`; [`elsewhen.md`](elsewhen.md) covers how it works. It shipped as the separate `elsewhen` package under the id `omacom.elsewhen` until it moved in. New installs place it immediately before the clock; the placement migration uses `omarchy bar put omarchy.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. With no shell to ask, as in an update from a TTY, it skips the placement rather than failing the update. A later migration renames existing `omacom.elsewhen` entries in `shell.json`, keeping their settings, and removes the retired package and any dev-checkout link to its `/usr/share/omarchy` path, leaving links and checkouts the user made alone.
|
||||
|
||||
## IPC
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ docker-compose
|
||||
dosfstools
|
||||
dotnet-runtime
|
||||
dua-cli
|
||||
elsewhen
|
||||
evince
|
||||
exfatprogs
|
||||
expac
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
echo "Install Elsewhen, the world clock plugin"
|
||||
|
||||
omarchy-pkg-add elsewhen
|
||||
|
||||
packaged_plugin="/usr/share/omarchy/shell/plugins/omacom.elsewhen"
|
||||
user_plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen"
|
||||
|
||||
# The package moved from plugins/ to shell/plugins/ once, stranding the link an
|
||||
# earlier run made to the old path. A link the user made is left alone.
|
||||
if [[ -L $user_plugin && ! -e $user_plugin && $(readlink "$user_plugin") == /usr/share/omarchy/* && -d $packaged_plugin ]]; then
|
||||
ln -sfn "$packaged_plugin" "$user_plugin"
|
||||
fi
|
||||
|
||||
# Dev checkouts do not contain plugins installed by system packages.
|
||||
if [[ ! $OMARCHY_PATH -ef /usr/share/omarchy && -d $packaged_plugin && ! -e $user_plugin && ! -L $user_plugin ]]; then
|
||||
mkdir -p "${user_plugin%/*}"
|
||||
ln -s "$packaged_plugin" "$user_plugin"
|
||||
fi
|
||||
echo "Put Elsewhen, the world clock, on the bar"
|
||||
|
||||
# Best-effort, like the put below: an update whose shell cannot be asked still
|
||||
# finishes, and restarts the shell once the migrations are through.
|
||||
omarchy-shell -q shell rescanPlugins
|
||||
omarchy-bar put omacom.elsewhen --before omarchy.clock
|
||||
|
||||
# A legacy omacom.elsewhen entry is renamed in place by a later migration.
|
||||
config_file="$HOME/.config/omarchy/shell.json"
|
||||
if [[ -s $config_file ]] && jq -e '[.bar.layout[]?[]? | if type == "object" then .id else . end] | index("omacom.elsewhen")' "$config_file" >/dev/null 2>&1; then
|
||||
echo "Elsewhen is already on the bar"
|
||||
else
|
||||
omarchy-bar put omarchy.elsewhen --before omarchy.clock
|
||||
fi
|
||||
@@ -0,0 +1,30 @@
|
||||
echo "Move Elsewhen, the world clock, into Omarchy as omarchy.elsewhen"
|
||||
|
||||
# The widget keeps its entry, and with it the cities and settings stored there.
|
||||
config_file="$HOME/.config/omarchy/shell.json"
|
||||
if [[ -s $config_file ]]; then
|
||||
tmp=$(mktemp)
|
||||
jq '
|
||||
def rename:
|
||||
if . == "omacom.elsewhen" then "omarchy.elsewhen"
|
||||
elif type == "object" and .id == "omacom.elsewhen" then .id = "omarchy.elsewhen"
|
||||
end;
|
||||
|
||||
if (.bar.layout | type) == "object" then .bar.layout |= map_values(if type == "array" then map(rename) end) end
|
||||
| if (.bar.centerAnchor | type) == "string" then .bar.centerAnchor |= rename end
|
||||
| if (.plugins | type) == "array" then .plugins |= map(rename) end
|
||||
| if (.disabledPlugins | type) == "array" then .disabledPlugins |= map(rename) end
|
||||
' "$config_file" >"$tmp"
|
||||
mv "$tmp" "$config_file"
|
||||
fi
|
||||
|
||||
# Dev checkouts found the packaged plugin through this link; one the user made
|
||||
# elsewhere is left alone.
|
||||
user_plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen"
|
||||
if [[ -L $user_plugin && $(readlink "$user_plugin") == /usr/share/omarchy/* ]]; then
|
||||
rm "$user_plugin"
|
||||
fi
|
||||
|
||||
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/omacom-elsewhen"
|
||||
|
||||
omarchy-pkg-drop elsewhen
|
||||
@@ -23,6 +23,7 @@ User-installed plugins live alongside these conceptually but on disk under
|
||||
| Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` |
|
||||
| Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` |
|
||||
| Clock | `omarchy.clock` | `bar-widget` | `panels/clock/BarWidget.qml` |
|
||||
| Elsewhen | `omarchy.elsewhen` | `bar-widget` | `panels/elsewhen/Panel.qml` |
|
||||
| Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` |
|
||||
| Network | `omarchy.network` | `bar-widget` | `panels/network/Panel.qml` |
|
||||
| Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// A line of text laid along a shallow circular arc. `widths` are character
|
||||
// advances; `rise` is how far the ends sit from the middle, in pixels; `smile`
|
||||
// bends the ends up. Returns { width, height, chars: [{ x, y, rotation }] }
|
||||
// with each box's top-left and its turn in degrees about its center.
|
||||
function layout(widths, rise, smile) {
|
||||
var chars = []
|
||||
var total = 0
|
||||
var i
|
||||
for (i = 0; i < widths.length; i++) total += widths[i]
|
||||
|
||||
if (total <= 0 || rise <= 0) {
|
||||
var x = 0
|
||||
for (i = 0; i < widths.length; i++) {
|
||||
chars.push({ x: x, y: 0, rotation: 0 })
|
||||
x += widths[i]
|
||||
}
|
||||
return { width: total, height: 0, chars: chars }
|
||||
}
|
||||
|
||||
// Keeps the trig in range for absurd rises.
|
||||
var sag = Math.min(rise, total / 4)
|
||||
|
||||
// Shallow-arc sagitta: R = w^2 / 8s.
|
||||
var radius = (total * total) / (8 * sag)
|
||||
var halfAngle = total / (2 * radius)
|
||||
var maxDrop = radius * (1 - Math.cos(halfAngle))
|
||||
var chordWidth = 2 * radius * Math.sin(halfAngle)
|
||||
|
||||
var travelled = 0
|
||||
var left = 0, right = chordWidth
|
||||
for (i = 0; i < widths.length; i++) {
|
||||
var w = widths[i]
|
||||
var a = (travelled + w / 2 - total / 2) / radius
|
||||
var drop = radius * (1 - Math.cos(a))
|
||||
var x = chordWidth / 2 + radius * Math.sin(a) - w / 2
|
||||
chars.push({
|
||||
x: x,
|
||||
y: smile ? maxDrop - drop : drop,
|
||||
rotation: (smile ? -a : a) * 180 / Math.PI
|
||||
})
|
||||
left = Math.min(left, x)
|
||||
right = Math.max(right, x + w)
|
||||
travelled += w
|
||||
}
|
||||
|
||||
// End boxes hang past the chord; shift so x starts at zero and width covers them.
|
||||
for (i = 0; i < chars.length; i++) chars[i].x -= left
|
||||
|
||||
return { width: right - left, height: maxDrop, chars: chars }
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") module.exports = { layout: layout }
|
||||
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "Arc.js" as Arc
|
||||
|
||||
// One line of text arched over what sits below it, each character turned to
|
||||
// follow the curve. A Text per character keeps rendering identical to the
|
||||
// neighbouring Text items, which a Canvas would not. No eliding.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// How far the ends drop below the middle.
|
||||
property real rise: 6
|
||||
property string fontFamily: Style.font.family
|
||||
property int pixelSize: Style.font.caption
|
||||
property color color: Color.foreground
|
||||
|
||||
FontMetrics {
|
||||
id: metrics
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.pixelSize
|
||||
}
|
||||
|
||||
readonly property var glyphs: text.split("")
|
||||
|
||||
readonly property var placed: {
|
||||
// advanceWidth does not register the font as a dependency.
|
||||
var _ = metrics.font.family + metrics.font.pixelSize
|
||||
var widths = []
|
||||
for (var i = 0; i < glyphs.length; i++) widths.push(metrics.advanceWidth(glyphs[i]))
|
||||
return Arc.layout(widths, rise, false)
|
||||
}
|
||||
|
||||
// Room for the end characters' corners once they are turned.
|
||||
readonly property real slack: Math.round(metrics.height / 2)
|
||||
|
||||
implicitWidth: Math.ceil(placed.width) + 2 * slack
|
||||
implicitHeight: Math.ceil(placed.height + metrics.height)
|
||||
|
||||
readonly property real originX: (width - placed.width) / 2
|
||||
|
||||
Repeater {
|
||||
model: root.glyphs.length
|
||||
|
||||
Text {
|
||||
required property int index
|
||||
// The count changes a beat before the arrays behind it do.
|
||||
readonly property string glyph: root.glyphs[index] || ""
|
||||
readonly property var spot: root.placed.chars[index] || { x: 0, y: 0, rotation: 0 }
|
||||
|
||||
// Exactly one advance wide, so the glyph's middle is where the arc put it.
|
||||
width: metrics.advanceWidth(glyph)
|
||||
height: metrics.height
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
x: root.originX + spot.x
|
||||
y: spot.y
|
||||
rotation: spot.rotation
|
||||
transformOrigin: Item.Center
|
||||
|
||||
textFormat: Text.PlainText
|
||||
text: glyph
|
||||
color: root.color
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.pixelSize
|
||||
renderType: Text.QtRendering // native rendering ignores the rotation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
// The matches for a CitySearch; hosts that overlay results place this themselves.
|
||||
Column {
|
||||
id: list
|
||||
|
||||
required property var citySearch
|
||||
|
||||
spacing: Style.space(2)
|
||||
|
||||
Repeater {
|
||||
model: list.citySearch.matches
|
||||
|
||||
CursorSurface {
|
||||
id: match
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: Style.spacing.popupRowHeight
|
||||
foreground: list.citySearch.foreground
|
||||
hasCursor: list.citySearch.selectedIndex === index
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.spacing.xl
|
||||
anchors.right: matchZone.left
|
||||
anchors.rightMargin: Style.spacing.lg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
textFormat: Text.PlainText
|
||||
text: match.modelData.label
|
||||
color: list.citySearch.foreground
|
||||
font.family: list.citySearch.fontFamily
|
||||
font.pixelSize: list.citySearch.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Two cities in one zone differ by name, two zones with one name by offset.
|
||||
Row {
|
||||
id: matchZone
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.spacing.xl
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.spacing.md
|
||||
|
||||
Caption {
|
||||
text: match.modelData.value
|
||||
color: list.citySearch.fainter
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: list.citySearch.offsetLabel(match.modelData.value)
|
||||
visible: text !== ""
|
||||
}
|
||||
}
|
||||
|
||||
// Only movement moves the selection, so a resting pointer cannot
|
||||
// steal it from the arrow keys as the list rebuilds.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: list.citySearch.selectedIndex = match.index
|
||||
onClicked: list.citySearch.pick(match.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Caption {
|
||||
visible: list.citySearch.matches.length === 0
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: Style.spacing.md
|
||||
text: list.citySearch.loading ? list.citySearch.loadingText : "No matches"
|
||||
color: list.citySearch.fainter
|
||||
}
|
||||
|
||||
component Caption: Text {
|
||||
textFormat: Text.PlainText
|
||||
color: list.citySearch.dim
|
||||
font.family: list.citySearch.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
import "Greetings.js" as Greet
|
||||
|
||||
// One city: name, weather, date (or a local greeting on hover) and the time,
|
||||
// over its daylight strip. Drag the body to reorder, click it to turn the
|
||||
// globe there. Every toggle on the row flips the setting for the whole list.
|
||||
Rectangle {
|
||||
id: row
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
// The owning Panel: the row reads its clock, facts and drag state.
|
||||
property var panel
|
||||
|
||||
readonly property color foreground: panel.foreground
|
||||
readonly property color dim: panel.dim
|
||||
readonly property color fainter: panel.fainter
|
||||
readonly property string fontFamily: panel.fontFamily
|
||||
|
||||
readonly property var rowData: panel.clockRows[index]
|
||||
|| ({ ready: false, label: modelData.label, id: modelData.id })
|
||||
readonly property bool ready: rowData.ready
|
||||
readonly property var greeting: Greet.greeting(rowData.id, ready ? rowData.hour : 0)
|
||||
readonly property bool removable: panel.clockRows.length > 1
|
||||
readonly property bool dragged: panel.dragIndex === index
|
||||
|
||||
// Lightest at midday, darkest at night, so the list dims into the small hours.
|
||||
readonly property real phaseFill: {
|
||||
var p = ready ? rowData.phase : "day"
|
||||
if (p === "day") return 0.10
|
||||
if (p === "night") return 0.035
|
||||
return 0.07
|
||||
}
|
||||
|
||||
readonly property int pad: Style.space(15)
|
||||
readonly property int stripGap: Style.space(9)
|
||||
|
||||
width: parent.width
|
||||
// The top pad is measured to the cap height, not the line box, so the row
|
||||
// looks as tall above the name as below the strip.
|
||||
implicitHeight: (pad - panel.capGap) + rowLabels.implicitHeight + stripGap + strip.trackHeight + pad
|
||||
radius: Style.cornerRadius
|
||||
// Opaque: knocked-aside rows pass over one another and over the globe.
|
||||
color: Model.mix(Color.popups.background, foreground, rowHover.hovered ? phaseFill + 0.05 : phaseFill)
|
||||
|
||||
// Transforms leave the Column's layout alone: the knock that clears the
|
||||
// globe's way, then the drag offset.
|
||||
transform: [
|
||||
Translate {
|
||||
x: Model.knockX(row.index, row.panel.zoom, Style.space(95))
|
||||
y: Model.knockY(row.index, row.panel.zoom, row.panel.knockFall)
|
||||
},
|
||||
Rotation {
|
||||
origin.x: row.width / 2
|
||||
origin.y: row.height / 2
|
||||
angle: Model.knockTilt(row.index, row.panel.zoom)
|
||||
},
|
||||
Scale {
|
||||
origin.x: row.width / 2
|
||||
origin.y: row.height / 2
|
||||
xScale: Model.knockShrink(row.index, row.panel.zoom)
|
||||
yScale: Model.knockShrink(row.index, row.panel.zoom)
|
||||
},
|
||||
Translate {
|
||||
y: Model.rowShift(row.index, row.panel.dragIndex, row.panel.dragTarget,
|
||||
row.panel.dragOffset, row.panel.rowPitch)
|
||||
// The dragged row follows the pointer one-to-one; the others ease aside.
|
||||
Behavior on y {
|
||||
enabled: !row.dragged
|
||||
NumberAnimation { duration: 130; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
]
|
||||
z: dragged ? 2 : 0
|
||||
opacity: dragged ? 0.9 : 1
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
|
||||
HoverHandler { id: rowHover }
|
||||
|
||||
// The row body is the reorder grab; later siblings keep their own taps.
|
||||
MouseArea {
|
||||
id: grab
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: strip.top
|
||||
preventStealing: true
|
||||
cursorShape: row.dragged ? Qt.ClosedHandCursor : Qt.OpenHandCursor
|
||||
|
||||
property real pressY: 0
|
||||
property bool armed: false
|
||||
property int chipAtPress: Model.NO_CHIP
|
||||
|
||||
// Measured in the Column, which does not move; the row's own frame slides
|
||||
// with the drag and would feed the offset back into itself.
|
||||
function pointerY(mouse) { return grab.mapToItem(row.parent, 0, mouse.y).y }
|
||||
|
||||
function finish() {
|
||||
armed = false
|
||||
strip.dismissUnlessChanged(chipAtPress)
|
||||
}
|
||||
|
||||
onPressed: function(mouse) {
|
||||
pressY = pointerY(mouse)
|
||||
armed = false
|
||||
chipAtPress = strip.shownChip
|
||||
}
|
||||
|
||||
onPositionChanged: function(mouse) {
|
||||
var dy = pointerY(mouse) - pressY
|
||||
// A few pixels of slack, so a click is never a reorder.
|
||||
if (!armed) {
|
||||
if (Math.abs(dy) < Style.space(4)) return
|
||||
armed = true
|
||||
row.panel.beginRowDrag(row.index, row.height + row.parent.spacing)
|
||||
}
|
||||
row.panel.moveRowDrag(dy)
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
if (armed) row.panel.releaseRowDrag()
|
||||
else row.panel.focusOn(row.index)
|
||||
finish()
|
||||
}
|
||||
onCanceled: {
|
||||
row.panel.cancelRowDrag()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: rowLabels
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.spacing.rowPaddingX
|
||||
anchors.right: timeBlock.left
|
||||
anchors.rightMargin: Style.spacing.xl
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: row.pad - row.panel.capGap
|
||||
spacing: Style.space(2)
|
||||
|
||||
// A layout so the name is what gives way when the line is tight.
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Style.space(7)
|
||||
|
||||
// Caps the name at its unelided width; the Text's own implicitWidth
|
||||
// shrinks as it elides, which would make the cap circular.
|
||||
TextMetrics {
|
||||
id: nameMetrics
|
||||
font.family: row.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
font.weight: Font.DemiBold
|
||||
text: row.rowData.label
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: Math.ceil(nameMetrics.advanceWidth) + 2
|
||||
Layout.minimumWidth: 0
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
textFormat: Text.PlainText
|
||||
text: row.rowData.label
|
||||
color: row.foreground
|
||||
font.family: row.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Caption {
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
text: Model.tempLabel(row.panel.facts[Model.factsKey(row.rowData)], row.panel.units)
|
||||
visible: text !== ""
|
||||
color: tempHover.hovered ? row.foreground : row.dim
|
||||
|
||||
HoverHandler {
|
||||
id: tempHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
TapHandler { onTapped: { row.panel.toggleUnits(); strip.dismissChips() } }
|
||||
}
|
||||
|
||||
Caption {
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
text: row.panel.weatherGlyph(row.rowData)
|
||||
visible: text !== ""
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
}
|
||||
|
||||
// The date, and the greeting in its place while hovered. Stacked in one
|
||||
// slot so the row never changes size under the pointer.
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: dateLine.implicitHeight
|
||||
|
||||
// No greeting before the probe lands: a wrong one is worse than none.
|
||||
readonly property bool greeting: row.ready && rowHover.hovered
|
||||
|
||||
Row {
|
||||
id: dateLine
|
||||
spacing: Style.spacing.sm
|
||||
opacity: parent.greeting ? 0 : 1
|
||||
visible: opacity > 0
|
||||
Behavior on opacity { NumberAnimation { duration: 110 } }
|
||||
|
||||
Caption {
|
||||
text: row.ready ? row.rowData.date : "\u2026"
|
||||
color: row.ready && row.rowData.dayLabel !== "" ? row.dim : row.fainter
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "\u00b7"
|
||||
visible: row.ready && row.rowData.dayLabel !== ""
|
||||
color: row.fainter
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: row.ready ? row.rowData.dayLabel : ""
|
||||
visible: text !== ""
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Style.space(5)
|
||||
opacity: parent.greeting ? 1 : 0
|
||||
visible: opacity > 0
|
||||
Behavior on opacity { NumberAnimation { duration: 110 } }
|
||||
|
||||
// Non-Latin scripts fall back per character through fontconfig.
|
||||
Caption {
|
||||
text: row.greeting.text
|
||||
color: row.foreground
|
||||
}
|
||||
|
||||
// A pronunciation, not a translation; absent for Latin scripts.
|
||||
Caption {
|
||||
text: row.greeting.roman
|
||||
visible: text !== ""
|
||||
color: row.fainter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DaylightStrip {
|
||||
id: strip
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: Style.spacing.rowPaddingX
|
||||
anchors.rightMargin: Style.spacing.rowPaddingX
|
||||
anchors.verticalCenter: parent.bottom
|
||||
anchors.verticalCenterOffset: -(row.pad + trackHeight / 2)
|
||||
|
||||
ready: row.ready
|
||||
offsetMinutes: row.ready ? row.rowData.offsetMinutes : 0
|
||||
progress: row.ready ? row.rowData.progress : 0
|
||||
fallbackLit: row.ready && row.rowData.lit
|
||||
place: row.panel.facts[Model.factsKey(row.modelData)] || null
|
||||
nowMs: row.panel.effectiveMs
|
||||
hovered: rowHover.hovered
|
||||
hour24: row.panel.hour24
|
||||
moonPhase: row.panel.moonPhase
|
||||
foreground: row.foreground
|
||||
fainter: row.fainter
|
||||
daylightMarker: row.panel.daylightMarker
|
||||
fontFamily: row.fontFamily
|
||||
|
||||
onScrubStarted: function(fraction) { row.panel.beginScrub(row.rowData, fraction) }
|
||||
onScrubMoved: function(fraction) { row.panel.scrubTo(row.rowData, fraction) }
|
||||
onScrubEnded: row.panel.endScrub()
|
||||
onMoonShowRequested: row.panel.startMoonShow()
|
||||
}
|
||||
|
||||
// The last city stays; removing it would leave no way back.
|
||||
PanelActionButton {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.space(1)
|
||||
iconText: "\u00d7"
|
||||
tooltipText: "Remove"
|
||||
foreground: row.fainter
|
||||
hoverColor: row.foreground
|
||||
fontFamily: row.fontFamily
|
||||
fontSize: Style.font.bodySmall
|
||||
enabled: row.removable
|
||||
opacity: row.removable && rowHover.hovered ? 1 : 0
|
||||
visible: opacity > 0
|
||||
Behavior on opacity { NumberAnimation { duration: 120 } }
|
||||
onClicked: row.panel.removeCityAt(row.index)
|
||||
}
|
||||
|
||||
Column {
|
||||
id: timeBlock
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.spacing.rowPaddingX
|
||||
anchors.verticalCenter: rowLabels.verticalCenter
|
||||
spacing: Style.space(1)
|
||||
|
||||
// The hour is already the brightest thing on the row, so hover lifts the meridiem.
|
||||
LinkRow {
|
||||
id: timeLine
|
||||
anchors.right: parent.right
|
||||
spacing: Style.spacing.xs
|
||||
onClicked: { row.panel.toggleHour24(); strip.dismissChips() }
|
||||
|
||||
Text {
|
||||
id: bigTime
|
||||
textFormat: Text.PlainText
|
||||
text: row.ready ? row.rowData.time : "--:--"
|
||||
color: row.foreground
|
||||
font.family: row.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Caption {
|
||||
anchors.baseline: bigTime.baseline
|
||||
text: row.rowData.meridiem || ""
|
||||
visible: text !== ""
|
||||
color: timeLine.hovered ? row.foreground : row.dim
|
||||
}
|
||||
}
|
||||
|
||||
LinkRow {
|
||||
id: offsetLine
|
||||
anchors.right: parent.right
|
||||
spacing: Style.space(5)
|
||||
visible: row.ready
|
||||
onClicked: { row.panel.toggleOffsetMode(); strip.dismissChips() }
|
||||
|
||||
Caption {
|
||||
text: row.ready ? row.rowData.abbr : ""
|
||||
color: offsetLine.hovered ? row.dim : row.fainter
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: row.panel.offsetTextFor(row.rowData)
|
||||
visible: text !== ""
|
||||
color: offsetLine.hovered ? row.foreground : row.fainter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Caption: Text {
|
||||
textFormat: Text.PlainText
|
||||
color: row.dim
|
||||
font.family: row.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
component LinkRow: Row {
|
||||
id: link
|
||||
signal clicked()
|
||||
readonly property bool hovered: linkHover.hovered
|
||||
HoverHandler {
|
||||
id: linkHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
TapHandler { onTapped: link.clicked() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
|
||||
// A button that opens into a city search: a field, then the matching cities
|
||||
// with their zone and offset. Arrows move the selection, Return picks it,
|
||||
// Escape backs out. Inline rather than a dropdown so the results grow the
|
||||
// panel instead of running off the bottom of the screen.
|
||||
Column {
|
||||
id: search
|
||||
|
||||
// [{ label, value }] from Model.zoneOptions.
|
||||
property var options: []
|
||||
property int limit: 6
|
||||
// Zone id -> offset text; read inside a binding, so its dependencies track.
|
||||
property var offsetLabel: function(zoneId) { return "" }
|
||||
property bool loading: false
|
||||
property string buttonText: "+ Add a city"
|
||||
property string placeholderText: "Search cities\u2026"
|
||||
property string loadingText: "Loading zones\u2026"
|
||||
property real fontSize: Style.font.bodySmall
|
||||
property color foreground: Color.foreground
|
||||
property color dim: Qt.darker(foreground, 1.55)
|
||||
property color fainter: Qt.darker(foreground, 2.1)
|
||||
property string fontFamily: Style.font.family
|
||||
|
||||
// False when the host shows CityMatches elsewhere.
|
||||
property bool inlineResults: true
|
||||
|
||||
property bool active: false
|
||||
readonly property string query: field.text
|
||||
readonly property var matches: active ? Model.searchZones(options, query, limit) : []
|
||||
// An index, not the match itself: the list is rebuilt on every keystroke.
|
||||
property int selectedIndex: 0
|
||||
|
||||
signal picked(string label, string id)
|
||||
signal dismissed()
|
||||
|
||||
onQueryChanged: selectedIndex = 0
|
||||
onMatchesChanged: if (selectedIndex >= matches.length) selectedIndex = 0
|
||||
|
||||
function start() {
|
||||
selectedIndex = 0
|
||||
active = true
|
||||
Qt.callLater(function() { field.text = ""; field.forceActiveFocus() })
|
||||
}
|
||||
|
||||
// The hidden field would keep the keyboard, so the host takes it back on dismissed.
|
||||
function stop() {
|
||||
active = false
|
||||
field.text = ""
|
||||
dismissed()
|
||||
}
|
||||
|
||||
function pick(match) {
|
||||
picked(match.label, match.value)
|
||||
stop()
|
||||
}
|
||||
|
||||
function pickSelected() {
|
||||
if (matches.length > 0) pick(matches[Util.clamp(selectedIndex, 0, matches.length - 1)])
|
||||
}
|
||||
|
||||
spacing: Style.spacing.md
|
||||
|
||||
Button {
|
||||
width: parent.width
|
||||
// Matches the field, so opening the search does not shift the layout.
|
||||
height: field.implicitHeight
|
||||
visible: !search.active
|
||||
text: search.buttonText
|
||||
fontSize: search.fontSize
|
||||
foreground: search.foreground
|
||||
fontFamily: search.fontFamily
|
||||
bordered: true
|
||||
onClicked: search.start()
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: field
|
||||
visible: search.active
|
||||
width: parent.width
|
||||
placeholderText: search.placeholderText
|
||||
foreground: search.foreground
|
||||
Keys.onEscapePressed: search.stop()
|
||||
Keys.onReturnPressed: search.pickSelected()
|
||||
Keys.onEnterPressed: search.pickSelected()
|
||||
Keys.onUpPressed: search.selectedIndex = Model.moveSelection(search.selectedIndex, -1, search.matches.length)
|
||||
Keys.onDownPressed: search.selectedIndex = Model.moveSelection(search.selectedIndex, 1, search.matches.length)
|
||||
}
|
||||
|
||||
CityMatches {
|
||||
width: parent.width
|
||||
visible: search.active && search.inlineResults
|
||||
citySearch: search
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
import "Sun.js" as Sun
|
||||
|
||||
// A city's 24 hours, midnight to midnight: its real daylight lit, a marker at
|
||||
// now (the sun by day, tonight's moon by night), and sunrise/sunset arrows
|
||||
// while the row is hovered. Dragging along it scrubs every clock.
|
||||
Item {
|
||||
id: strip
|
||||
|
||||
property bool ready: false
|
||||
property int offsetMinutes: 0
|
||||
property real progress: 0
|
||||
// The fixed civil band's answer, for a city with no coordinates yet.
|
||||
property bool fallbackLit: false
|
||||
// { lat, lon } from the fetcher's geocode, or null.
|
||||
property var place: null
|
||||
property double nowMs: 0
|
||||
property bool hovered: false
|
||||
property bool hour24: false
|
||||
property real moonPhase: 0
|
||||
property color foreground: Color.foreground
|
||||
property color fainter: Qt.darker(foreground, 2.1)
|
||||
property color daylightMarker: "#E5C736"
|
||||
property string fontFamily: Style.font.family
|
||||
|
||||
readonly property int trackHeight: Math.max(2, Style.space(3))
|
||||
|
||||
// Which arrow's time is showing: its index, or Model.NO_CHIP.
|
||||
property int shownChip: Model.NO_CHIP
|
||||
|
||||
signal scrubStarted(real fraction)
|
||||
signal scrubMoved(real fraction)
|
||||
signal scrubEnded()
|
||||
signal moonShowRequested()
|
||||
|
||||
function dismissChips() { shownChip = Model.NO_CHIP }
|
||||
|
||||
// Decided against the value read on press, so either delivery order of a
|
||||
// tap and the release underneath it lands the same way.
|
||||
function dismissUnlessChanged(atPress) {
|
||||
shownChip = Model.chipAfterRelease(shownChip, atPress)
|
||||
}
|
||||
|
||||
onHoveredChanged: if (!hovered) dismissChips()
|
||||
|
||||
height: Style.space(18)
|
||||
|
||||
// Keyed to local midnight, not the ticking clock, so the sun and the
|
||||
// Repeaters below rebuild once a day rather than once a second.
|
||||
readonly property double dayMs: ready ? Sun.localMidnightMs(nowMs, offsetMinutes) : 0
|
||||
|
||||
readonly property var sun: {
|
||||
if (!ready || !place || place.lat === undefined || place.lon === undefined) return null
|
||||
// Local noon: the instant furthest from either edge of the day.
|
||||
return Sun.sunTimes(place.lat, place.lon, dayMs + 43200000, offsetMinutes)
|
||||
}
|
||||
|
||||
// Empty in polar day and night, when the sun does not cross the horizon.
|
||||
readonly property var sunMarks: {
|
||||
if (!sun || sun.kind !== "normal") return []
|
||||
var out = []
|
||||
var up = Sun.eventMark(sun.riseMinutes)
|
||||
var down = Sun.eventMark(sun.setMinutes)
|
||||
if (up !== null) out.push({ x: up, minutes: sun.riseMinutes, rising: true, name: "Sunrise" })
|
||||
if (down !== null) out.push({ x: down, minutes: sun.setMinutes, rising: false, name: "Sunset" })
|
||||
return out
|
||||
}
|
||||
|
||||
// Read from the band that is drawn, so the sun never sits in the dark.
|
||||
readonly property bool litNow: sun ? Sun.litAt(sun, ready ? progress * 1440 : 0)
|
||||
: (ready && fallbackLit)
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
visible: strip.ready
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: strip.trackHeight
|
||||
radius: height / 2
|
||||
color: Util.alpha(strip.foreground, 0.10)
|
||||
|
||||
// Polar night has no band at all, rather than a zero-width one.
|
||||
Repeater {
|
||||
model: strip.sun ? Sun.litSpans(strip.sun)
|
||||
: [{ x0: Model.daylightStart(), x1: Model.daylightEnd() }]
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
x: parent.width * modelData.x0
|
||||
width: Math.max(1, parent.width * (modelData.x1 - modelData.x0))
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Util.alpha(strip.foreground, 0.28)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: nowMarker
|
||||
width: Math.max(8, Style.space(10))
|
||||
height: width
|
||||
radius: width / 2
|
||||
x: Math.round(parent.width * (strip.ready ? strip.progress : 0) - width / 2)
|
||||
y: (parent.height - height) / 2
|
||||
color: strip.litNow ? strip.daylightMarker : "transparent"
|
||||
|
||||
MoonDot {
|
||||
anchors.fill: parent
|
||||
visible: !strip.litNow
|
||||
phase: strip.moonPhase
|
||||
color: strip.foreground
|
||||
}
|
||||
|
||||
Behavior on x { NumberAnimation { duration: 400; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub, or Shift-click the moon to run its phase show. The arrows below are
|
||||
// later siblings, so their taps never reach this.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.SizeHorCursor
|
||||
preventStealing: true
|
||||
enabled: strip.ready
|
||||
|
||||
property bool showing: false
|
||||
property int chipAtPress: Model.NO_CHIP
|
||||
|
||||
function onMoon(mouse) {
|
||||
if (strip.litNow) return false
|
||||
return Math.abs(mouse.x - (nowMarker.x + nowMarker.width / 2)) <= nowMarker.width
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (!showing) strip.scrubEnded()
|
||||
strip.dismissUnlessChanged(chipAtPress)
|
||||
showing = false
|
||||
}
|
||||
|
||||
onPressed: function(mouse) {
|
||||
showing = (mouse.modifiers & Qt.ShiftModifier) !== 0 && onMoon(mouse)
|
||||
chipAtPress = strip.shownChip
|
||||
if (showing) strip.moonShowRequested()
|
||||
else strip.scrubStarted(mouse.x / width)
|
||||
}
|
||||
onPositionChanged: function(mouse) {
|
||||
if (!showing) strip.scrubMoved(mouse.x / width)
|
||||
}
|
||||
onReleased: finish()
|
||||
onCanceled: finish()
|
||||
}
|
||||
|
||||
// Sunrise and sunset, just outside the lit band they point at.
|
||||
Repeater {
|
||||
model: strip.sunMarks
|
||||
|
||||
Item {
|
||||
id: arrow
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool rising: modelData.rising
|
||||
|
||||
// A finger-sized box around a caption-sized glyph, tucked towards the band.
|
||||
width: Style.space(14)
|
||||
height: Style.space(16)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Model.arrowBox(arrow.modelData.x, strip.width, arrow.width, Style.space(3), arrow.rising)
|
||||
|
||||
// Hidden while the now marker stands on it. nowMarker.x, not progress,
|
||||
// so it reappears exactly as the eased marker clears.
|
||||
readonly property bool covered: {
|
||||
var markerX = nowMarker.x
|
||||
if (!strip.ready) return false
|
||||
return Model.arrowCovered(arrow.x, arrow.width, markerX + nowMarker.width / 2,
|
||||
nowMarker.width, Style.space(4))
|
||||
}
|
||||
|
||||
// Gone rather than faded, so a hidden arrow is also untappable.
|
||||
opacity: strip.hovered && !arrow.covered ? 1 : 0
|
||||
visible: opacity > 0
|
||||
Behavior on opacity { NumberAnimation { duration: 160 } }
|
||||
|
||||
// Sunrise sits a little high and sunset a little low: a cue that needs no reading.
|
||||
transform: Translate { y: arrow.rising ? -Style.space(2) : Style.space(2) }
|
||||
|
||||
onCoveredChanged: if (arrow.covered && strip.shownChip === arrow.index) strip.dismissChips()
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
textFormat: Text.PlainText
|
||||
text: arrow.rising ? "\u2191" : "\u2193"
|
||||
color: arrowHover.hovered || strip.shownChip === arrow.index ? strip.foreground : strip.fainter
|
||||
font.family: strip.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: arrowHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
// Read on press, so a dismissal that already ran underneath cannot
|
||||
// make an open chip look shut.
|
||||
property int atPress: Model.NO_CHIP
|
||||
onPressedChanged: if (pressed) atPress = strip.shownChip
|
||||
onTapped: strip.shownChip = Model.chipAfterTap(atPress, arrow.index)
|
||||
}
|
||||
|
||||
PanelToolTip {
|
||||
visible: strip.shownChip === arrow.index
|
||||
delay: 0
|
||||
closePolicy: Popup.NoAutoClose
|
||||
text: arrow.modelData.name + " " + Model.formatMinuteOfDay(arrow.modelData.minutes, strip.hour24)
|
||||
fontFamily: strip.fontFamily
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "GlobeModel.js" as Solar
|
||||
import "Model.js" as Model
|
||||
|
||||
// A spinnable orthographic globe: coastlines, the day/night terminator and the
|
||||
// major city of every zone. Owns its data files and clock probe; Panel mounts it.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color dim: Qt.darker(foreground, 1.55)
|
||||
property color fainter: Qt.darker(foreground, 2.1)
|
||||
property color daylightMarker
|
||||
// Passed in so the footer's moon agrees with the rows' strips.
|
||||
property real moonPhase: 0.5
|
||||
// Night dots are dark so they read against the bright continents.
|
||||
readonly property color nightMarker: Util.alpha(Color.background, 0.92)
|
||||
property string fontFamily: Style.font.family
|
||||
property bool hour24: false
|
||||
// The panel's offset setting, and the offset "home" is measured from.
|
||||
property string offsetMode: "home"
|
||||
property int homeOffsetMinutes: 0
|
||||
// Cities the list tracks, lower-cased: accent color and first label slots.
|
||||
property var trackedNames: []
|
||||
// Faded in near the end of the zoom, where footer and jump bar are legible.
|
||||
property real chromeOpacity: 1
|
||||
|
||||
// Opaque tones mixed against the background, so the rows behind never show through.
|
||||
readonly property color surfaceBase: Color.popups.background
|
||||
// Light names over sea, dark over land, split at the coastline.
|
||||
readonly property color seaInk: foreground
|
||||
readonly property color landInk: surfaceBase
|
||||
|
||||
// Merged into the built-ins so a tracked city always appears. [name, zone, lat, lon, rank]
|
||||
property var trackedCities: []
|
||||
// Reached with the jump box; the panel never saves them.
|
||||
property var sessionCities: []
|
||||
property var jumpOptions: [] // the whole zone catalogue, unfiltered
|
||||
|
||||
// Always shown, always labelled, drawn in the accent like the hero globe's marker.
|
||||
property var homeRow: []
|
||||
readonly property string homeName:
|
||||
homeRow.length > 0 ? String(homeRow[0]).toLowerCase() : ""
|
||||
|
||||
property string pendingJump: "" // waiting on coordinates to arrive
|
||||
|
||||
signal jumpRequested(string label, string zone)
|
||||
// The jump box closed; the host should take the keyboard back.
|
||||
signal jumpDismissed()
|
||||
signal exitRequested()
|
||||
// The footer offset was clicked; the panel owns the setting.
|
||||
signal offsetModeToggleRequested()
|
||||
// Only real selections: -1 means "no city" here but "home" in the list.
|
||||
signal citySelected(string label, string zone)
|
||||
|
||||
// ---- data -------------------------------------------------------------
|
||||
property var land: [] // coastline rings, flat [lon,lat,...]
|
||||
property var landCoarse: [] // half the vertices, for the small end of the zoom
|
||||
property var cities: [] // [name, zone, lat, lon, rank]
|
||||
property var offsets: ({}) // zone -> minutes east of UTC
|
||||
|
||||
property real spin: 20 // degrees; the meridian facing the viewer
|
||||
// Latitude under the viewer. Not "tilt": the hero globe uses that for the axial lean.
|
||||
property real viewLat: 20
|
||||
property real velocity: 0 // degrees per tick, for the throw
|
||||
|
||||
readonly property real dragSpinRate: 0.45 // degrees per pixel
|
||||
readonly property real dragTiltRate: 0.35
|
||||
readonly property real dragLatLimit: 80
|
||||
readonly property real flyLatLimit: 70
|
||||
readonly property real throwDecay: 0.96 // per 16 ms tick
|
||||
|
||||
// A "label|zone" key, not an index: allCities is rebuilt as its inputs land
|
||||
// and an index would silently move to another city.
|
||||
property string selectedKey: ""
|
||||
readonly property int selected: {
|
||||
if (selectedKey === "") return -1
|
||||
for (var i = 0; i < allCities.length; i++)
|
||||
if (keyAt(i) === selectedKey) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
function keyAt(i) {
|
||||
var c = allCities[i]
|
||||
return (c === undefined || c === null) ? "" : Model.factsKey({ label: c[0], id: c[1] })
|
||||
}
|
||||
|
||||
function selectAt(i) { selectedKey = i >= 0 ? keyAt(i) : "" }
|
||||
|
||||
// A click at disc-centered coordinates: select the hit and turn it to face
|
||||
// you. A miss only clears. On root so tests share the pointer's code path.
|
||||
function pickAt(cx, cy) {
|
||||
var hit = hitAt(cx, cy)
|
||||
selectAt(hit)
|
||||
if (hit >= 0) flyTo(allCities[hit][2], allCities[hit][3])
|
||||
return hit
|
||||
}
|
||||
|
||||
property double nowMs: Date.now()
|
||||
property bool dragging: false
|
||||
|
||||
// Both set from Panel, which owns the zoom: 0 in the header, 1 full size.
|
||||
property bool transitioning: false
|
||||
property real zoomLevel: 1
|
||||
property bool smoothMotion: true
|
||||
|
||||
// A full paint is ~14 ms; names and the graticule are the expensive parts,
|
||||
// so they drop while the globe moves and nobody can read them anyway.
|
||||
readonly property bool reduced: smoothMotion
|
||||
&& (dragging || flight.running || transitioning || Math.abs(velocity) > 0.01)
|
||||
|
||||
onReducedChanged: canvas.requestPaint()
|
||||
|
||||
readonly property var allCities:
|
||||
Model.mergeCities(homeRow, cities, trackedCities, sessionCities)
|
||||
|
||||
readonly property var jumpMatches: jumpSearch.matches
|
||||
onJumpMatchesChanged: probeZones()
|
||||
|
||||
// Centering a city is setting spin and viewLat to its own coordinates.
|
||||
function flyTo(lat, lon) {
|
||||
flight.stop()
|
||||
velocity = 0
|
||||
flightSpin.from = spin
|
||||
flightSpin.to = spin + Solar.shortestTurn(spin, lon)
|
||||
flightViewLat.from = viewLat
|
||||
flightViewLat.to = Util.clamp(lat, -flyLatLimit, flyLatLimit)
|
||||
flight.restart()
|
||||
}
|
||||
|
||||
// Home's coordinates can land after the globe on a cold cache; hold the request.
|
||||
property bool homePending: false
|
||||
|
||||
// Opening lands on home and selects it, so the footer names it.
|
||||
function showHome() {
|
||||
if (homeRow.length < 4 || homeRow[2] === undefined || homeRow[2] === null) {
|
||||
homePending = true
|
||||
return
|
||||
}
|
||||
homePending = false
|
||||
selectAt(indexOfCity(homeRow[0], homeRow[1]))
|
||||
flyTo(homeRow[2], homeRow[3])
|
||||
}
|
||||
|
||||
onHomeRowChanged: if (homePending) showHome()
|
||||
|
||||
function indexOfCity(label, zone) {
|
||||
for (var i = 0; i < allCities.length; i++)
|
||||
if (allCities[i][0] === label && allCities[i][1] === zone) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
function goTo(label, zone) {
|
||||
var i = indexOfCity(label, zone)
|
||||
if (i >= 0) {
|
||||
selectedKey = keyAt(i)
|
||||
flyTo(allCities[i][2], allCities[i][3])
|
||||
pendingJump = ""
|
||||
return
|
||||
}
|
||||
// Not on the globe yet: ask for it, and fly once its coordinates land.
|
||||
pendingJump = label + "|" + zone
|
||||
jumpRequested(label, zone)
|
||||
}
|
||||
|
||||
onAllCitiesChanged: {
|
||||
probeZones()
|
||||
canvas.requestPaint()
|
||||
if (pendingJump === "") return
|
||||
var parts = pendingJump.split("|")
|
||||
if (indexOfCity(parts[0], parts[1]) >= 0) goTo(parts[0], parts[1])
|
||||
}
|
||||
|
||||
function startJump() {
|
||||
jumpSearch.start()
|
||||
}
|
||||
|
||||
// Sub-pixel scale (spaceReal, not space) so thin strokes keep their weights.
|
||||
readonly property real uiScale: Style.spaceReal(1)
|
||||
|
||||
function scaled(px) { return Solar.scalePx(px, uiScale, 1) }
|
||||
|
||||
readonly property real footerHeight: Style.space(34)
|
||||
readonly property real jumpHeight: Style.space(4) + jumpSearch.implicitHeight
|
||||
readonly property real radius: Math.max(40,
|
||||
Math.min(width, height - footerHeight - jumpHeight) / 2 - Style.space(6))
|
||||
readonly property var sub: Solar.subsolarPoint(nowMs)
|
||||
|
||||
// FileView, not XMLHttpRequest: XHR on file:// comes back empty in the shell.
|
||||
readonly property string pluginDir: Quickshell.env("OMARCHY_PATH") + "/shell/plugins/panels/elsewhen"
|
||||
|
||||
// "UTC+2" or "+9h", per the panel's setting, exactly as the rows print it.
|
||||
function offsetLabelFor(zone) {
|
||||
var off = offsets[zone]
|
||||
if (off === undefined) return ""
|
||||
return offsetMode === "utc" ? Model.utcOffsetLabel(off)
|
||||
: Model.relativeOffsetLabel(off, homeOffsetMinutes)
|
||||
}
|
||||
|
||||
function isTracked(i) {
|
||||
var c = allCities[i]
|
||||
return c !== undefined
|
||||
&& trackedNames.indexOf(String(c[0]).toLowerCase()) >= 0
|
||||
}
|
||||
|
||||
// Guarded: read from paints while the city list is still being assembled.
|
||||
function isHome(i) {
|
||||
var c = allCities[i]
|
||||
return c !== undefined && homeName !== ""
|
||||
&& String(c[0]).toLowerCase() === homeName
|
||||
}
|
||||
|
||||
function cityDaylight(i) {
|
||||
var c = allCities[i]
|
||||
if (c === undefined) return false
|
||||
return Solar.isDaylight(c[2], c[3], sub)
|
||||
}
|
||||
|
||||
// Labels count as part of their city and win over a neighbouring dot.
|
||||
function hitAt(px, py) {
|
||||
var pad = Style.space(3)
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
var b = labels[i].box
|
||||
if (px >= b.x - pad && px <= b.x + b.w + pad
|
||||
&& py >= b.y - pad && py <= b.y + b.h + pad) return labels[i].index
|
||||
}
|
||||
var best = -1, bestD = Style.space(13)
|
||||
for (var j = 0; j < plotted.length; j++) {
|
||||
var d = Math.hypot(px - plotted[j].x, py - plotted[j].y)
|
||||
if (d < bestD) { bestD = d; best = plotted[j].index }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ---- what actually gets drawn -----------------------------------------
|
||||
// Near-side cities in priority order, thinned; `keep` always survives.
|
||||
readonly property var plotted: {
|
||||
if (allCities.length === 0 || radius <= 0) return []
|
||||
var cand = []
|
||||
for (var i = 0; i < allCities.length; i++) {
|
||||
var p = Solar.project(allCities[i][2], allCities[i][3], spin, viewLat, radius)
|
||||
if (!p.visible) continue
|
||||
var must = isHome(i) || isTracked(i) || i === selected
|
||||
cand.push({ index: i, x: p.x, y: p.y, cosc: p.cosc,
|
||||
rank: allCities[i][4], keep: must })
|
||||
}
|
||||
cand.sort(function(a, b) {
|
||||
if (a.keep !== b.keep) return a.keep ? -1 : 1
|
||||
if (a.rank !== b.rank) return a.rank - b.rank
|
||||
return b.cosc - a.cosc
|
||||
})
|
||||
return Solar.declutter(cand, Style.space(19))
|
||||
}
|
||||
|
||||
// ---- labels -----------------------------------------------------------
|
||||
readonly property var labels: {
|
||||
if (reduced) return []
|
||||
var cand = []
|
||||
for (var i = 0; i < plotted.length; i++) {
|
||||
var p = plotted[i]
|
||||
if (p.cosc < 0.12) continue // the rim; labels run off
|
||||
cand.push({ index: p.index, name: allCities[p.index][0], x: p.x, y: p.y,
|
||||
rank: p.keep ? 0 : p.rank, cosc: p.cosc })
|
||||
}
|
||||
var charW = Style.font.caption * 0.62
|
||||
return Solar.layoutLabels(cand, charW, Style.font.caption + Style.space(3), 14,
|
||||
width / 2 - Style.space(4), scaled(6))
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.pluginDir + "/world.json"
|
||||
printErrors: true
|
||||
onLoaded: {
|
||||
try {
|
||||
root.land = JSON.parse(text())
|
||||
var coarse = []
|
||||
for (var i = 0; i < root.land.length; i++)
|
||||
coarse.push(Solar.decimateRing(root.land[i], 2, 8))
|
||||
root.landCoarse = coarse
|
||||
canvas.requestPaint()
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.pluginDir + "/cities.json"
|
||||
printErrors: true
|
||||
onLoaded: {
|
||||
try { root.cities = JSON.parse(text()); root.probeZones() } catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
property bool probeQueued: false
|
||||
|
||||
// Queued rather than dropped while running, or late zones never get an offset.
|
||||
// Includes the jump matches, whose offsets tell two results apart.
|
||||
function probeZones() {
|
||||
if (allCities.length === 0) return
|
||||
if (zoneProc.running) { probeQueued = true; return }
|
||||
var zones = [], seen = {}
|
||||
function add(id) { if (!seen[id]) { seen[id] = true; zones.push(id) } }
|
||||
for (var i = 0; i < allCities.length; i++) add(allCities[i][1])
|
||||
for (var j = 0; j < jumpMatches.length; j++) add(jumpMatches[j].value)
|
||||
zoneProc.command = Model.probeCommand(zones, false)
|
||||
zoneProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: zoneProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var probe = Model.parseProbe(text)
|
||||
var map = {}
|
||||
for (var id in probe) map[id] = probe[id].offsetMinutes
|
||||
root.offsets = map
|
||||
canvas.requestPaint()
|
||||
Qt.callLater(function() {
|
||||
if (!root.probeQueued) return
|
||||
root.probeQueued = false
|
||||
root.probeZones()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer { interval: 20000; running: true; repeat: true
|
||||
onTriggered: { root.nowMs = Date.now(); canvas.requestPaint() } }
|
||||
Timer { interval: 300000; running: true; repeat: true; onTriggered: root.probeZones() }
|
||||
|
||||
ParallelAnimation {
|
||||
id: flight
|
||||
NumberAnimation { id: flightSpin; target: root; property: "spin"
|
||||
duration: 800; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { id: flightViewLat; target: root; property: "viewLat"
|
||||
duration: 800; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// The throw: spin keeps going after the drag and eases to a stop.
|
||||
Timer {
|
||||
interval: 16
|
||||
running: !root.dragging && Math.abs(root.velocity) > 0.01
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.spin += root.velocity
|
||||
root.velocity *= root.throwDecay
|
||||
canvas.requestPaint()
|
||||
}
|
||||
}
|
||||
|
||||
onPlottedChanged: canvas.requestPaint()
|
||||
onTrackedNamesChanged: canvas.requestPaint()
|
||||
onSpinChanged: canvas.requestPaint()
|
||||
onViewLatChanged: canvas.requestPaint()
|
||||
onSelectedChanged: {
|
||||
canvas.requestPaint()
|
||||
var c = selected >= 0 ? allCities[selected] : null
|
||||
if (c !== null && c !== undefined) citySelected(String(c[0]), String(c[1]))
|
||||
}
|
||||
|
||||
// ---- the globe --------------------------------------------------------
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: parent.height - root.footerHeight - root.jumpHeight
|
||||
renderStrategy: Canvas.Cooperative
|
||||
|
||||
// The clipped continents from the last paint, reused by the label pass.
|
||||
property var landPolys: []
|
||||
|
||||
function paintLabels(ctx, ink) {
|
||||
ctx.fillStyle = ink
|
||||
for (var i = 0; i < root.labels.length; i++) {
|
||||
var L = root.labels[i]
|
||||
var idx = L.index
|
||||
var city = root.allCities[idx]
|
||||
if (city === undefined) continue
|
||||
var strong = root.isHome(idx) || root.isTracked(idx)
|
||||
ctx.font = (strong ? "bold " : "") + Style.font.caption
|
||||
+ "px \"" + root.fontFamily + "\""
|
||||
ctx.fillText(city[0], L.box.x, L.box.y + L.box.h / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Points arrive as [lat, lon]; runs end exactly on the horizon.
|
||||
function strokePath(ctx, pts) {
|
||||
var segs = Solar.visibleSegments(pts, root.spin, root.viewLat, root.radius)
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
ctx.moveTo(segs[i][0].x, segs[i][0].y)
|
||||
for (var j = 1; j < segs[i].length; j++) ctx.lineTo(segs[i][j].x, segs[i][j].y)
|
||||
}
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.translate(width / 2, height / 2)
|
||||
var r = root.radius
|
||||
var fg = root.foreground
|
||||
|
||||
// Ocean disc.
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = Model.mix(root.surfaceBase, fg, 0.05)
|
||||
ctx.fill()
|
||||
ctx.lineWidth = root.scaled(1)
|
||||
ctx.strokeStyle = Util.alpha(fg, 0.22)
|
||||
ctx.stroke()
|
||||
|
||||
// Graticule every 30 degrees, or 60 while moving.
|
||||
var gStep = root.reduced ? 60 : 30
|
||||
ctx.beginPath()
|
||||
var lat, lon, pts, i
|
||||
for (lon = -180; lon < 180; lon += gStep) {
|
||||
pts = []
|
||||
for (lat = -90; lat <= 90; lat += 3) pts.push([lat, lon])
|
||||
strokePath(ctx, pts)
|
||||
}
|
||||
for (lat = -60; lat <= 60; lat += gStep) {
|
||||
pts = []
|
||||
for (lon = -180; lon <= 180; lon += 3) pts.push([lat, lon])
|
||||
strokePath(ctx, pts)
|
||||
}
|
||||
ctx.lineWidth = root.scaled(1)
|
||||
ctx.strokeStyle = Util.alpha(fg, 0.10)
|
||||
ctx.stroke()
|
||||
|
||||
// Land, filled bright with no coastline stroke (the stroke pass cost ~4 ms).
|
||||
// Coarse rings only below half size, where the dropped islands are invisible.
|
||||
var coarseOk = root.reduced && root.zoomLevel < 0.5 && root.landCoarse.length > 0
|
||||
var rings = coarseOk ? root.landCoarse : root.land
|
||||
var landPolys = []
|
||||
ctx.beginPath()
|
||||
for (i = 0; i < rings.length; i++) {
|
||||
var poly = Solar.clipRingToDisc(rings[i], root.spin, root.viewLat, root.radius)
|
||||
if (poly.length < 3) continue
|
||||
landPolys.push(poly)
|
||||
ctx.moveTo(poly[0].x, poly[0].y)
|
||||
for (var q = 1; q < poly.length; q++) ctx.lineTo(poly[q].x, poly[q].y)
|
||||
ctx.closePath()
|
||||
}
|
||||
ctx.fillStyle = Model.mix(root.surfaceBase, fg, 0.92)
|
||||
ctx.fill()
|
||||
canvas.landPolys = landPolys
|
||||
|
||||
// Day/night line.
|
||||
ctx.beginPath()
|
||||
strokePath(ctx, Solar.terminator(root.sub, 180))
|
||||
ctx.lineWidth = root.scaled(1)
|
||||
ctx.strokeStyle = Util.alpha(root.daylightMarker, 0.55)
|
||||
ctx.stroke()
|
||||
|
||||
// Cities: gold in daylight, dark at night, each edged in the opposite tone.
|
||||
for (var pi = 0; pi < root.plotted.length; pi++) {
|
||||
var p = root.plotted[pi]
|
||||
i = p.index
|
||||
var day = root.cityDaylight(i)
|
||||
var isSel = (i === root.selected)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, root.scaled(isSel ? 3.6 : 2.2), 0, Math.PI * 2)
|
||||
ctx.fillStyle = day ? root.daylightMarker : root.nightMarker
|
||||
ctx.fill()
|
||||
ctx.lineWidth = root.scaled(1.2)
|
||||
ctx.strokeStyle = day ? Util.alpha(Color.background, 0.7) : Util.alpha(fg, 0.85)
|
||||
ctx.stroke()
|
||||
if (root.isHome(i)) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, root.scaled(3.4), 0, Math.PI * 2)
|
||||
ctx.fillStyle = Color.accent
|
||||
ctx.fill()
|
||||
ctx.lineWidth = root.scaled(1.2)
|
||||
ctx.strokeStyle = Util.alpha(Color.background, 0.5)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, root.scaled(6.4), 0, Math.PI * 2)
|
||||
ctx.lineWidth = root.scaled(1.4)
|
||||
ctx.strokeStyle = Util.alpha(Color.accent, 0.65)
|
||||
ctx.stroke()
|
||||
} else if (root.isTracked(i)) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, root.scaled(4.6), 0, Math.PI * 2)
|
||||
ctx.lineWidth = root.scaled(1.3)
|
||||
ctx.strokeStyle = Color.accent
|
||||
ctx.stroke()
|
||||
}
|
||||
if (isSel) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, root.scaled(7.5), 0, Math.PI * 2)
|
||||
ctx.lineWidth = root.scaled(1.4)
|
||||
ctx.strokeStyle = day ? root.daylightMarker : fg
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- city names: light everywhere, then dark clipped to the land ----
|
||||
ctx.textBaseline = "middle"
|
||||
paintLabels(ctx, root.seaInk)
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
for (var lp = 0; lp < landPolys.length; lp++) {
|
||||
var poly2 = landPolys[lp]
|
||||
ctx.moveTo(poly2[0].x, poly2[0].y)
|
||||
for (var r2 = 1; r2 < poly2.length; r2++) ctx.lineTo(poly2[r2].x, poly2[r2].y)
|
||||
ctx.closePath()
|
||||
}
|
||||
ctx.clip()
|
||||
paintLabels(ctx, root.landInk)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
property real lastX: 0
|
||||
property real lastY: 0
|
||||
property bool moved: false
|
||||
|
||||
onPressed: function(mouse) {
|
||||
lastX = mouse.x; lastY = mouse.y
|
||||
moved = false
|
||||
root.dragging = true
|
||||
root.velocity = 0
|
||||
}
|
||||
onPositionChanged: function(mouse) {
|
||||
var dx = mouse.x - lastX, dy = mouse.y - lastY
|
||||
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true
|
||||
root.spin -= dx * root.dragSpinRate
|
||||
root.viewLat = Util.clamp(root.viewLat + dy * root.dragTiltRate,
|
||||
-root.dragLatLimit, root.dragLatLimit)
|
||||
root.velocity = -dx * root.dragSpinRate
|
||||
lastX = mouse.x; lastY = mouse.y
|
||||
}
|
||||
onReleased: function(mouse) {
|
||||
root.dragging = false
|
||||
if (moved) return
|
||||
root.velocity = 0
|
||||
root.pickAt(mouse.x - canvas.width / 2, mouse.y - canvas.height / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlobeFooter {
|
||||
id: footer
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: jumpSearch.top
|
||||
anchors.bottomMargin: Style.space(4)
|
||||
height: root.footerHeight
|
||||
opacity: root.chromeOpacity
|
||||
|
||||
city: root.selected >= 0 ? root.allCities[root.selected] : null
|
||||
// The globe's solar daylight, so the mark agrees with the city's dot.
|
||||
daylight: footer.has && root.cityDaylight(root.selected)
|
||||
clock: footer.has ? Model.formatClock(root.nowMs, root.offsets[footer.city[1]], root.hour24) : ""
|
||||
offsetLabel: footer.has ? root.offsetLabelFor(footer.city[1]) : ""
|
||||
badge: root.isHome(root.selected) ? "home" : (root.isTracked(root.selected) ? "tracked" : "")
|
||||
moonPhase: root.moonPhase
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fainter: root.fainter
|
||||
daylightMarker: root.daylightMarker
|
||||
fontFamily: root.fontFamily
|
||||
onOffsetClicked: root.offsetModeToggleRequested()
|
||||
}
|
||||
|
||||
// ---- jump to a city -----------------------------------------------------
|
||||
// Searches the whole catalogue; a city not on the globe joins for this session.
|
||||
CitySearch {
|
||||
id: jumpSearch
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
opacity: root.chromeOpacity
|
||||
options: root.jumpOptions
|
||||
limit: 5
|
||||
inlineResults: false
|
||||
loading: root.jumpOptions.length === 0
|
||||
buttonText: "Jump to a city"
|
||||
loadingText: "Loading cities\u2026"
|
||||
offsetLabel: function(zoneId) { return Model.utcOffsetLabel(root.offsets[zoneId]) }
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fainter: root.fainter
|
||||
fontFamily: root.fontFamily
|
||||
onPicked: function(label, id) { root.goTo(label, id) }
|
||||
onDismissed: root.jumpDismissed()
|
||||
}
|
||||
|
||||
// Results overlay the globe so it never resizes under the pointer mid-search.
|
||||
Rectangle {
|
||||
visible: jumpSearch.active
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: jumpSearch.top
|
||||
anchors.bottomMargin: Style.space(4)
|
||||
height: Math.min(results.implicitHeight + Style.space(8),
|
||||
parent.height - root.jumpHeight - Style.space(20))
|
||||
radius: Style.cornerRadius
|
||||
color: root.surfaceBase
|
||||
|
||||
CityMatches {
|
||||
id: results
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.space(4)
|
||||
citySearch: jumpSearch
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// The selected city under the globe: name, local time and badge, then its
|
||||
// zone and offset. Blank without a selection; the parent reserves the height.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var city: null // [name, zone, lat, lon, rank]
|
||||
property bool daylight: false
|
||||
property string clock: ""
|
||||
property string offsetLabel: ""
|
||||
property string badge: "" // "home", "tracked" or ""
|
||||
property real moonPhase: 0.5
|
||||
property color foreground: Color.foreground
|
||||
property color dim: Qt.darker(foreground, 1.55)
|
||||
property color fainter: Qt.darker(foreground, 2.1)
|
||||
property color daylightMarker
|
||||
property string fontFamily: Style.font.family
|
||||
|
||||
signal offsetClicked()
|
||||
|
||||
readonly property bool has: city !== null && city !== undefined
|
||||
|
||||
// The mark matches the ink height of the name's capital; a pixel less, since
|
||||
// a circle's antialiased edge reads a pixel wider than a glyph's.
|
||||
TextMetrics {
|
||||
id: capHeight
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.weight: Font.DemiBold
|
||||
text: "M"
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(1)
|
||||
visible: root.has
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(7)
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(4)
|
||||
|
||||
// A lit dot by day, tonight's moon by night, as on the rows' strips.
|
||||
// Placed on the name's baseline by y: anchors.baseline inside a Row loops.
|
||||
Rectangle {
|
||||
y: cityName.baselineOffset - height
|
||||
width: Math.max(6, Math.round(capHeight.tightBoundingRect.height) - 1)
|
||||
height: width
|
||||
radius: width / 2
|
||||
visible: root.has
|
||||
color: root.daylight ? root.daylightMarker : "transparent"
|
||||
|
||||
MoonDot {
|
||||
anchors.fill: parent
|
||||
visible: !root.daylight
|
||||
phase: root.moonPhase
|
||||
color: root.foreground
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: cityName
|
||||
textFormat: Text.PlainText
|
||||
text: root.has ? root.city[0] : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
textFormat: Text.PlainText
|
||||
text: root.clock
|
||||
visible: text !== ""
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
textFormat: Text.PlainText
|
||||
text: root.badge
|
||||
visible: root.has && text !== ""
|
||||
color: Color.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// Zone and offset together are the toggle, so it survives a blank offset.
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(6)
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.has ? root.city[1] : ""
|
||||
color: offsetHover.hovered ? root.dim : root.fainter
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
text: root.offsetLabel
|
||||
visible: text !== ""
|
||||
color: offsetHover.hovered ? root.foreground : root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: offsetHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
TapHandler { onTapped: root.offsetClicked() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
.pragma library
|
||||
|
||||
// Orthographic globe math: projection, the day/night terminator, the moon,
|
||||
// and label placement.
|
||||
|
||||
var DEG = Math.PI / 180
|
||||
|
||||
// Earth's obliquity, also the hero icon's lean.
|
||||
var AXIAL_TILT = 23.44
|
||||
|
||||
// Every `keepEvery`th vertex of a flat [lon, lat, ...] ring, for drawing while
|
||||
// the globe is scaled down. Rings at or under `minPoints` are returned whole.
|
||||
function decimateRing(ring, keepEvery, minPoints) {
|
||||
var step = keepEvery === undefined ? 2 : keepEvery
|
||||
var floor = minPoints === undefined ? 8 : minPoints
|
||||
var n = ring.length / 2
|
||||
if (step < 2 || n <= floor) return ring
|
||||
var out = []
|
||||
for (var i = 0; i < n; i += step) out.push(ring[i * 2], ring[i * 2 + 1])
|
||||
// End on the original last vertex, not a chord back to the start.
|
||||
var lastI = (n - 1) * 2
|
||||
if (out[out.length - 2] !== ring[lastI] || out[out.length - 1] !== ring[lastI + 1])
|
||||
out.push(ring[lastI], ring[lastI + 1])
|
||||
return out
|
||||
}
|
||||
|
||||
// A drawn pixel size that follows the shell's UI scale, floored so hairlines
|
||||
// do not drop out of the raster.
|
||||
function scalePx(px, scale, minPx) {
|
||||
var s = (typeof scale === "number" && isFinite(scale) && scale > 0) ? scale : 1
|
||||
var n = px * s
|
||||
var floor = (minPx === undefined) ? 1 : minPx
|
||||
return n < floor ? floor : n
|
||||
}
|
||||
|
||||
// Orthographic projection onto a disc of radius r seen from over (viewLat, spin).
|
||||
function project(lat, lon, spin, viewLat, r) {
|
||||
var phi = lat * DEG
|
||||
var lam = (lon - spin) * DEG
|
||||
var p0 = viewLat * DEG
|
||||
var cosc = Math.sin(p0) * Math.sin(phi) + Math.cos(p0) * Math.cos(phi) * Math.cos(lam)
|
||||
return {
|
||||
x: r * Math.cos(phi) * Math.sin(lam),
|
||||
y: -r * (Math.cos(p0) * Math.sin(phi) - Math.sin(p0) * Math.cos(phi) * Math.cos(lam)),
|
||||
visible: cosc > 0,
|
||||
cosc: cosc
|
||||
}
|
||||
}
|
||||
|
||||
// The point with the sun directly overhead, good to a fraction of a degree.
|
||||
function subsolarPoint(ms) {
|
||||
var d = new Date(ms)
|
||||
var jd = ms / 86400000 + 2440587.5
|
||||
var n = jd - 2451545.0
|
||||
var L = (280.460 + 0.9856474 * n) % 360 // mean longitude
|
||||
var g = ((357.528 + 0.9856003 * n) % 360) * DEG // mean anomaly
|
||||
var lambda = (L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)) * DEG // ecliptic longitude
|
||||
var eps = (AXIAL_TILT - 0.0000004 * n) * DEG // obliquity, slowly drifting
|
||||
|
||||
var decl = Math.asin(Math.sin(eps) * Math.sin(lambda)) / DEG
|
||||
|
||||
// Equation of time, in minutes, then the subsolar meridian.
|
||||
var alpha = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)) / DEG
|
||||
var eot = (L - alpha + 540) % 360 - 180 // degrees, wrapped to +-180
|
||||
var utcHours = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600
|
||||
var lon = -15 * (utcHours - 12) - eot
|
||||
lon = ((lon + 540) % 360) - 180
|
||||
|
||||
return { lat: decl, lon: lon }
|
||||
}
|
||||
|
||||
// Degrees above the horizon, negative below it.
|
||||
function solarElevation(lat, lon, sub) {
|
||||
var cosz = Math.sin(lat * DEG) * Math.sin(sub.lat * DEG)
|
||||
+ Math.cos(lat * DEG) * Math.cos(sub.lat * DEG) * Math.cos((lon - sub.lon) * DEG)
|
||||
return Math.asin(Math.max(-1, Math.min(1, cosz))) / DEG
|
||||
}
|
||||
|
||||
// -0.833 allows for refraction and the sun's disc, as sunrise tables do.
|
||||
function isDaylight(lat, lon, sub) {
|
||||
return solarElevation(lat, lon, sub) > -0.833
|
||||
}
|
||||
|
||||
// The great circle 90 degrees from the subsolar point: the day/night line.
|
||||
function terminator(sub, steps) {
|
||||
var n = steps || 180
|
||||
var out = []
|
||||
var slat = sub.lat * DEG, slon = sub.lon * DEG
|
||||
// Build an orthonormal frame around the subsolar axis and sweep a circle.
|
||||
var s = [Math.cos(slat) * Math.cos(slon), Math.cos(slat) * Math.sin(slon), Math.sin(slat)]
|
||||
var up = Math.abs(s[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]
|
||||
var a = norm(cross(up, s))
|
||||
var b = norm(cross(s, a))
|
||||
for (var i = 0; i <= n; i++) {
|
||||
var t = i / n * 2 * Math.PI
|
||||
var v = [a[0] * Math.cos(t) + b[0] * Math.sin(t),
|
||||
a[1] * Math.cos(t) + b[1] * Math.sin(t),
|
||||
a[2] * Math.cos(t) + b[2] * Math.sin(t)]
|
||||
out.push([Math.asin(v[2]) / DEG, Math.atan2(v[1], v[0]) / DEG])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function cross(u, v) {
|
||||
return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
|
||||
}
|
||||
|
||||
function norm(v) {
|
||||
var m = Math.hypot(v[0], v[1], v[2]) || 1
|
||||
return [v[0] / m, v[1] / m, v[2] / m]
|
||||
}
|
||||
|
||||
// Keep points, in priority order, that clear every kept point by minDist.
|
||||
// Points marked `keep` always survive.
|
||||
function declutter(points, minDist) {
|
||||
var kept = []
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
var p = points[i]
|
||||
if (p.keep) { kept.push(p); continue }
|
||||
var clash = false
|
||||
for (var j = 0; j < kept.length; j++) {
|
||||
if (Math.hypot(p.x - kept[j].x, p.y - kept[j].y) < minDist) { clash = true; break }
|
||||
}
|
||||
if (!clash) kept.push(p)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// ---- the moon
|
||||
|
||||
// The mean synodic month against a known new moon: good to a few hours.
|
||||
var SYNODIC_MONTH = 29.530588853 // days
|
||||
var KNOWN_NEW_MOON_JD = 2451550.1 // 2000-01-06 18:14 UTC
|
||||
|
||||
// Position through the lunation: 0 new, 0.25 first quarter, 0.5 full,
|
||||
// 0.75 last quarter.
|
||||
function moonPhase(ms) {
|
||||
var jd = Number(ms) / 86400000 + 2440587.5
|
||||
var p = ((jd - KNOWN_NEW_MOON_JD) / SYNODIC_MONTH) % 1
|
||||
return p < 0 ? p + 1 : p
|
||||
}
|
||||
|
||||
// The lit part of the moon on a disc of radius r at the origin: the limb, then
|
||||
// the terminator back, a semicircle squashed by the signed cos(2*pi*phase).
|
||||
function moonLitOutline(phase, r, steps) {
|
||||
var n = steps || 24
|
||||
var theta = 2 * Math.PI * Number(phase)
|
||||
var squash = Math.cos(theta)
|
||||
var side = Number(phase) > 0.5 ? -1 : 1 // waning lights the other limb
|
||||
var out = []
|
||||
var i, t
|
||||
for (i = 0; i <= n; i++) {
|
||||
t = Math.PI * i / n
|
||||
out.push({ x: side * r * Math.sin(t), y: -r * Math.cos(t) })
|
||||
}
|
||||
for (i = n; i >= 0; i--) {
|
||||
t = Math.PI * i / n
|
||||
out.push({ x: side * r * squash * Math.sin(t), y: -r * Math.cos(t) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- clipping to the disc
|
||||
|
||||
// Where a [lat, lon] segment crosses the horizon, by bisection.
|
||||
function limbCrossing(a, b, spin, viewLat, r) {
|
||||
// Segments spanning the antimeridian cannot be interpolated in lat/lon.
|
||||
if (Math.abs(b[1] - a[1]) > 180) return null
|
||||
var lo = 0, hi = 1
|
||||
for (var i = 0; i < 8; i++) {
|
||||
var m = (lo + hi) / 2
|
||||
var p = project(a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, spin, viewLat, r)
|
||||
if (p.visible) lo = m; else hi = m
|
||||
}
|
||||
return project(a[0] + (b[0] - a[0]) * lo, a[1] + (b[1] - a[1]) * lo, spin, viewLat, r)
|
||||
}
|
||||
|
||||
// The near-side runs of a polyline, each ending exactly on the horizon.
|
||||
function visibleSegments(pts, spin, viewLat, r) {
|
||||
var out = [], run = [], prev = null, prevVis = false
|
||||
function flush() { if (run.length > 1) out.push(run); run = [] }
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
var p = project(pts[i][0], pts[i][1], spin, viewLat, r)
|
||||
if (p.visible) {
|
||||
if (run.length === 0 && prev !== null && !prevVis) {
|
||||
var enter = limbCrossing(pts[i], prev, spin, viewLat, r)
|
||||
if (enter) run.push(enter)
|
||||
}
|
||||
run.push(p)
|
||||
} else {
|
||||
if (run.length > 0 && prev !== null) {
|
||||
var exit = limbCrossing(prev, pts[i], spin, viewLat, r)
|
||||
if (exit) run.push(exit)
|
||||
}
|
||||
flush()
|
||||
}
|
||||
prev = pts[i]; prevVis = p.visible
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// A flat [lon, lat, ...] ring clipped to the visible hemisphere as one polygon
|
||||
// that follows the limb, so its area changes smoothly as the globe turns.
|
||||
function clipRingToDisc(ring, spin, viewLat, r) {
|
||||
var pts = []
|
||||
for (var k = 0; k < ring.length; k += 2) pts.push([ring[k + 1], ring[k]])
|
||||
var out = []
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
var A = pts[i], B = pts[(i + 1) % pts.length]
|
||||
var pa = project(A[0], A[1], spin, viewLat, r)
|
||||
var pb = project(B[0], B[1], spin, viewLat, r)
|
||||
if (pa.visible && pb.visible) out.push({ p: pb, limb: false })
|
||||
else if (pa.visible) {
|
||||
var ex = limbCrossing(A, B, spin, viewLat, r)
|
||||
if (ex) out.push({ p: ex, limb: true })
|
||||
} else if (pb.visible) {
|
||||
var en = limbCrossing(B, A, spin, viewLat, r)
|
||||
if (en) out.push({ p: en, limb: true })
|
||||
out.push({ p: pb, limb: false })
|
||||
}
|
||||
}
|
||||
if (out.length < 3) return []
|
||||
|
||||
var res = []
|
||||
for (var j = 0; j < out.length; j++) {
|
||||
res.push(out[j].p)
|
||||
var nx = out[(j + 1) % out.length]
|
||||
if (!out[j].limb || !nx.limb) continue
|
||||
var a0 = Math.atan2(out[j].p.y, out[j].p.x)
|
||||
var a1 = Math.atan2(nx.p.y, nx.p.x)
|
||||
var d = a1 - a0
|
||||
while (d > Math.PI) d -= 2 * Math.PI
|
||||
while (d < -Math.PI) d += 2 * Math.PI
|
||||
var steps = Math.max(1, Math.round(Math.abs(d) / 0.15))
|
||||
for (var t = 1; t < steps; t++) {
|
||||
var a = a0 + d * t / steps
|
||||
res.push({ x: r * Math.cos(a), y: r * Math.sin(a) })
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Greedy label placement by rank, then nearest the disc center; a label that
|
||||
// would pass `maxX` flips to the left of its dot. `gap` is dot to name.
|
||||
function layoutLabels(candidates, charWidth, lineHeight, limit, maxX, gap) {
|
||||
var g = (typeof gap === "number" && isFinite(gap) && gap > 0) ? gap : 6
|
||||
var placed = []
|
||||
var sorted = candidates.slice().sort(function (p, q) {
|
||||
if (p.rank !== q.rank) return p.rank - q.rank
|
||||
return q.cosc - p.cosc
|
||||
})
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var c = sorted[i]
|
||||
var w = c.name.length * charWidth
|
||||
var x = c.x + g
|
||||
if (maxX !== undefined && x + w > maxX) x = c.x - g - w
|
||||
var box = { x: x, y: c.y - lineHeight / 2, w: w, h: lineHeight }
|
||||
var clash = false
|
||||
for (var j = 0; j < placed.length; j++) {
|
||||
var o = placed[j].box
|
||||
if (box.x < o.x + o.w && box.x + box.w > o.x && box.y < o.y + o.h && box.y + box.h > o.y) {
|
||||
clash = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (clash) continue
|
||||
placed.push({ index: c.index, box: box })
|
||||
if (limit && placed.length >= limit) break
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// Signed degrees in (-180, 180] to turn from one longitude to another.
|
||||
function shortestTurn(from, to) {
|
||||
var d = ((to - from) % 360 + 360) % 360
|
||||
return d > 180 ? d - 360 : d
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
AXIAL_TILT: AXIAL_TILT,
|
||||
SYNODIC_MONTH: SYNODIC_MONTH,
|
||||
decimateRing: decimateRing,
|
||||
scalePx: scalePx,
|
||||
project: project,
|
||||
subsolarPoint: subsolarPoint,
|
||||
solarElevation: solarElevation,
|
||||
isDaylight: isDaylight,
|
||||
terminator: terminator,
|
||||
declutter: declutter,
|
||||
moonPhase: moonPhase,
|
||||
moonLitOutline: moonLitOutline,
|
||||
limbCrossing: limbCrossing,
|
||||
visibleSegments: visibleSegments,
|
||||
clipRingToDisc: clipRingToDisc,
|
||||
layoutLabels: layoutLabels,
|
||||
shortestTurn: shortestTurn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
// What people say to each other at this hour where a clock points: zone ->
|
||||
// country -> the language you would hear spoken there, with a few city
|
||||
// overrides. Bands are local hours ascending from 0; `roman` is a
|
||||
// pronunciation, given only where the script is not Latin.
|
||||
|
||||
var LANGUAGES = {
|
||||
en: { name: "English", bands: [
|
||||
{ from: 0, text: "Good night", roman: "" },
|
||||
{ from: 5, text: "Good morning", roman: "" },
|
||||
{ from: 12, text: "Good afternoon", roman: "" },
|
||||
{ from: 18, text: "Good evening", roman: "" }
|
||||
] },
|
||||
enAU: { name: "Australian English", bands: [
|
||||
{ from: 0, text: "Night", roman: "" },
|
||||
{ from: 5, text: "Morning", roman: "" },
|
||||
{ from: 12, text: "G'day", roman: "" },
|
||||
{ from: 18, text: "Evening", roman: "" }
|
||||
] },
|
||||
ga: { name: "Irish", bands: [
|
||||
{ from: 0, text: "O\u00edche mhaith", roman: "" },
|
||||
{ from: 6, text: "Maidin mhaith", roman: "" },
|
||||
{ from: 12, text: "Tr\u00e1thn\u00f3na maith", roman: "" },
|
||||
{ from: 19, text: "O\u00edche mhaith", roman: "" }
|
||||
] },
|
||||
es: { name: "Spanish", bands: [
|
||||
{ from: 0, text: "Buenas noches", roman: "" },
|
||||
{ from: 6, text: "Buenos d\u00edas", roman: "" },
|
||||
{ from: 12, text: "Buenas tardes", roman: "" },
|
||||
{ from: 19, text: "Buenas noches", roman: "" }
|
||||
] },
|
||||
esES: { name: "Spanish (Spain)", bands: [
|
||||
{ from: 0, text: "Buenas noches", roman: "" },
|
||||
{ from: 6, text: "Buenos d\u00edas", roman: "" },
|
||||
{ from: 14, text: "Buenas tardes", roman: "" },
|
||||
{ from: 21, text: "Buenas noches", roman: "" }
|
||||
] },
|
||||
ca: { name: "Catalan", bands: [
|
||||
{ from: 0, text: "Bona nit", roman: "" },
|
||||
{ from: 6, text: "Bon dia", roman: "" },
|
||||
{ from: 13, text: "Bona tarda", roman: "" },
|
||||
{ from: 20, text: "Bona nit", roman: "" }
|
||||
] },
|
||||
pt: { name: "Portuguese", bands: [
|
||||
{ from: 0, text: "Boa noite", roman: "" },
|
||||
{ from: 6, text: "Bom dia", roman: "" },
|
||||
{ from: 12, text: "Boa tarde", roman: "" },
|
||||
{ from: 20, text: "Boa noite", roman: "" }
|
||||
] },
|
||||
fr: { name: "French", bands: [
|
||||
{ from: 0, text: "Bonne nuit", roman: "" },
|
||||
{ from: 6, text: "Bonjour", roman: "" },
|
||||
{ from: 18, text: "Bonsoir", roman: "" },
|
||||
{ from: 22, text: "Bonne nuit", roman: "" }
|
||||
] },
|
||||
it: { name: "Italian", bands: [
|
||||
{ from: 0, text: "Buonanotte", roman: "" },
|
||||
{ from: 5, text: "Buongiorno", roman: "" },
|
||||
{ from: 14, text: "Buon pomeriggio", roman: "" },
|
||||
{ from: 18, text: "Buonasera", roman: "" },
|
||||
{ from: 23, text: "Buonanotte", roman: "" }
|
||||
] },
|
||||
ro: { name: "Romanian", bands: [
|
||||
{ from: 0, text: "Noapte bun\u0103", roman: "" },
|
||||
{ from: 5, text: "Bun\u0103 diminea\u021ba", roman: "" },
|
||||
{ from: 12, text: "Bun\u0103 ziua", roman: "" },
|
||||
{ from: 18, text: "Bun\u0103 seara", roman: "" }
|
||||
] },
|
||||
de: { name: "German", bands: [
|
||||
{ from: 0, text: "Gute Nacht", roman: "" },
|
||||
{ from: 5, text: "Guten Morgen", roman: "" },
|
||||
{ from: 11, text: "Guten Tag", roman: "" },
|
||||
{ from: 18, text: "Guten Abend", roman: "" },
|
||||
{ from: 22, text: "Gute Nacht", roman: "" }
|
||||
] },
|
||||
deAT: { name: "Austrian German", bands: [
|
||||
{ from: 0, text: "Gute Nacht", roman: "" },
|
||||
{ from: 5, text: "Guten Morgen", roman: "" },
|
||||
{ from: 11, text: "Gr\u00fc\u00df Gott", roman: "" },
|
||||
{ from: 18, text: "Guten Abend", roman: "" },
|
||||
{ from: 22, text: "Gute Nacht", roman: "" }
|
||||
] },
|
||||
deCH: { name: "Swiss German", bands: [
|
||||
{ from: 0, text: "Gute Nacht", roman: "" },
|
||||
{ from: 5, text: "Guete Morge", roman: "" },
|
||||
{ from: 11, text: "Gr\u00fcezi", roman: "" },
|
||||
{ from: 18, text: "Guete Abig", roman: "" },
|
||||
{ from: 22, text: "Gute Nacht", roman: "" }
|
||||
] },
|
||||
lb: { name: "Luxembourgish", bands: [
|
||||
{ from: 0, text: "Gutt Nuecht", roman: "" },
|
||||
{ from: 5, text: "Moien", roman: "" },
|
||||
{ from: 12, text: "Gudde M\u00ebtteg", roman: "" },
|
||||
{ from: 18, text: "Gudden Owend", roman: "" }
|
||||
] },
|
||||
nl: { name: "Dutch", bands: [
|
||||
{ from: 0, text: "Goedenacht", roman: "" },
|
||||
{ from: 6, text: "Goedemorgen", roman: "" },
|
||||
{ from: 12, text: "Goedemiddag", roman: "" },
|
||||
{ from: 18, text: "Goedenavond", roman: "" }
|
||||
] },
|
||||
da: { name: "Danish", bands: [
|
||||
{ from: 0, text: "Godnat", roman: "" },
|
||||
{ from: 5, text: "Godmorgen", roman: "" },
|
||||
{ from: 10, text: "Goddag", roman: "" },
|
||||
{ from: 18, text: "Godaften", roman: "" }
|
||||
] },
|
||||
no: { name: "Norwegian", bands: [
|
||||
{ from: 0, text: "God natt", roman: "" },
|
||||
{ from: 5, text: "God morgen", roman: "" },
|
||||
{ from: 10, text: "God dag", roman: "" },
|
||||
{ from: 18, text: "God kveld", roman: "" }
|
||||
] },
|
||||
sv: { name: "Swedish", bands: [
|
||||
{ from: 0, text: "God natt", roman: "" },
|
||||
{ from: 5, text: "God morgon", roman: "" },
|
||||
{ from: 10, text: "God dag", roman: "" },
|
||||
{ from: 18, text: "God kv\u00e4ll", roman: "" }
|
||||
] },
|
||||
fi: { name: "Finnish", bands: [
|
||||
{ from: 0, text: "Hyv\u00e4\u00e4 y\u00f6t\u00e4", roman: "" },
|
||||
{ from: 5, text: "Huomenta", roman: "" },
|
||||
{ from: 11, text: "P\u00e4iv\u00e4\u00e4", roman: "" },
|
||||
{ from: 18, text: "Iltaa", roman: "" },
|
||||
{ from: 23, text: "Hyv\u00e4\u00e4 y\u00f6t\u00e4", roman: "" }
|
||||
] },
|
||||
is: { name: "Icelandic", bands: [
|
||||
{ from: 0, text: "G\u00f3\u00f0a n\u00f3tt", roman: "" },
|
||||
{ from: 6, text: "G\u00f3\u00f0an daginn", roman: "" },
|
||||
{ from: 18, text: "Gott kv\u00f6ld", roman: "" }
|
||||
] },
|
||||
fo: { name: "Faroese", bands: [
|
||||
{ from: 0, text: "G\u00f3\u00f0a n\u00e1tt", roman: "" },
|
||||
{ from: 5, text: "G\u00f3\u00f0an morgun", roman: "" },
|
||||
{ from: 10, text: "G\u00f3\u00f0an dag", roman: "" },
|
||||
{ from: 18, text: "Gott kv\u00f8ld", roman: "" }
|
||||
] },
|
||||
kl: { name: "Greenlandic", bands: [
|
||||
{ from: 0, text: "Inuugujoq", roman: "" }
|
||||
] },
|
||||
et: { name: "Estonian", bands: [
|
||||
{ from: 0, text: "Head \u00f6\u00f6d", roman: "" },
|
||||
{ from: 5, text: "Tere hommikust", roman: "" },
|
||||
{ from: 12, text: "Tere p\u00e4evast", roman: "" },
|
||||
{ from: 18, text: "Tere \u00f5htust", roman: "" }
|
||||
] },
|
||||
lv: { name: "Latvian", bands: [
|
||||
{ from: 0, text: "Ar labu nakti", roman: "" },
|
||||
{ from: 5, text: "Labr\u012bt", roman: "" },
|
||||
{ from: 12, text: "Labdien", roman: "" },
|
||||
{ from: 18, text: "Labvakar", roman: "" }
|
||||
] },
|
||||
lt: { name: "Lithuanian", bands: [
|
||||
{ from: 0, text: "Labanakt", roman: "" },
|
||||
{ from: 5, text: "Labas rytas", roman: "" },
|
||||
{ from: 12, text: "Laba diena", roman: "" },
|
||||
{ from: 18, text: "Labas vakaras", roman: "" }
|
||||
] },
|
||||
pl: { name: "Polish", bands: [
|
||||
{ from: 0, text: "Dobranoc", roman: "" },
|
||||
{ from: 5, text: "Dzie\u0144 dobry", roman: "" },
|
||||
{ from: 18, text: "Dobry wiecz\u00f3r", roman: "" }
|
||||
] },
|
||||
cs: { name: "Czech", bands: [
|
||||
{ from: 0, text: "Dobrou noc", roman: "" },
|
||||
{ from: 5, text: "Dobr\u00e9 r\u00e1no", roman: "" },
|
||||
{ from: 10, text: "Dobr\u00fd den", roman: "" },
|
||||
{ from: 18, text: "Dobr\u00fd ve\u010der", roman: "" }
|
||||
] },
|
||||
sk: { name: "Slovak", bands: [
|
||||
{ from: 0, text: "Dobr\u00fa noc", roman: "" },
|
||||
{ from: 5, text: "Dobr\u00e9 r\u00e1no", roman: "" },
|
||||
{ from: 10, text: "Dobr\u00fd de\u0148", roman: "" },
|
||||
{ from: 18, text: "Dobr\u00fd ve\u010der", roman: "" }
|
||||
] },
|
||||
sl: { name: "Slovenian", bands: [
|
||||
{ from: 0, text: "Lahko no\u010d", roman: "" },
|
||||
{ from: 5, text: "Dobro jutro", roman: "" },
|
||||
{ from: 10, text: "Dober dan", roman: "" },
|
||||
{ from: 18, text: "Dober ve\u010der", roman: "" }
|
||||
] },
|
||||
hu: { name: "Hungarian", bands: [
|
||||
{ from: 0, text: "J\u00f3 \u00e9jszak\u00e1t", roman: "" },
|
||||
{ from: 5, text: "J\u00f3 reggelt", roman: "" },
|
||||
{ from: 10, text: "J\u00f3 napot", roman: "" },
|
||||
{ from: 18, text: "J\u00f3 est\u00e9t", roman: "" }
|
||||
] },
|
||||
hr: { name: "Croatian", bands: [
|
||||
{ from: 0, text: "Laku no\u0107", roman: "" },
|
||||
{ from: 5, text: "Dobro jutro", roman: "" },
|
||||
{ from: 12, text: "Dobar dan", roman: "" },
|
||||
{ from: 18, text: "Dobra ve\u010der", roman: "" }
|
||||
] },
|
||||
bs: { name: "Bosnian", bands: [
|
||||
{ from: 0, text: "Laku no\u0107", roman: "" },
|
||||
{ from: 5, text: "Dobro jutro", roman: "" },
|
||||
{ from: 12, text: "Dobar dan", roman: "" },
|
||||
{ from: 18, text: "Dobro ve\u010de", roman: "" }
|
||||
] },
|
||||
sq: { name: "Albanian", bands: [
|
||||
{ from: 0, text: "Nat\u00ebn e mir\u00eb", roman: "" },
|
||||
{ from: 5, text: "Mir\u00ebm\u00ebngjes", roman: "" },
|
||||
{ from: 12, text: "Mir\u00ebdita", roman: "" },
|
||||
{ from: 18, text: "Mir\u00ebmbr\u00ebma", roman: "" }
|
||||
] },
|
||||
mt: { name: "Maltese", bands: [
|
||||
{ from: 0, text: "Il-lejl it-tajjeb", roman: "" },
|
||||
{ from: 5, text: "Bon\u0121u", roman: "" },
|
||||
{ from: 13, text: "Bonsw\u00e0", roman: "" }
|
||||
] },
|
||||
tr: { name: "Turkish", bands: [
|
||||
{ from: 0, text: "\u0130yi geceler", roman: "" },
|
||||
{ from: 5, text: "G\u00fcnayd\u0131n", roman: "" },
|
||||
{ from: 12, text: "\u0130yi g\u00fcnler", roman: "" },
|
||||
{ from: 18, text: "\u0130yi ak\u015famlar", roman: "" }
|
||||
] },
|
||||
af: { name: "Afrikaans", bands: [
|
||||
{ from: 0, text: "Goeienag", roman: "" },
|
||||
{ from: 5, text: "Goeiem\u00f4re", roman: "" },
|
||||
{ from: 12, text: "Goeiemiddag", roman: "" },
|
||||
{ from: 18, text: "Goeienaand", roman: "" }
|
||||
] },
|
||||
yo: { name: "Yoruba", bands: [
|
||||
{ from: 0, text: "O d\u00e0\u00e1r\u1ecd\u0300", roman: "" },
|
||||
{ from: 5, text: "\u1eb8 k\u00e1\u00e0\u00e1r\u1ecd\u0300", roman: "" },
|
||||
{ from: 12, text: "\u1eb8 k\u00e1\u00e0s\u00e1n", roman: "" },
|
||||
{ from: 16, text: "\u1eb8 k\u00fa\u00f9r\u1ecd\u0300l\u1eb9\u0301", roman: "" },
|
||||
{ from: 20, text: "\u1eb8 k\u00fa\u00f9\u00e1l\u1eb9\u0301", roman: "" }
|
||||
] },
|
||||
tw: { name: "Twi", bands: [
|
||||
{ from: 0, text: "Maadwo", roman: "" },
|
||||
{ from: 5, text: "Maakye", roman: "" },
|
||||
{ from: 12, text: "Maaha", roman: "" },
|
||||
{ from: 18, text: "Maadwo", roman: "" }
|
||||
] },
|
||||
ha: { name: "Hausa", bands: [
|
||||
{ from: 0, text: "Barka da dare", roman: "" },
|
||||
{ from: 5, text: "Barka da safiya", roman: "" },
|
||||
{ from: 12, text: "Barka da rana", roman: "" },
|
||||
{ from: 16, text: "Barka da yamma", roman: "" },
|
||||
{ from: 20, text: "Barka da dare", roman: "" }
|
||||
] },
|
||||
wo: { name: "Wolof", bands: [
|
||||
{ from: 0, text: "Fanaanal j\u00e0mm", roman: "" },
|
||||
{ from: 5, text: "J\u00e0mm nga fanaan", roman: "" },
|
||||
{ from: 12, text: "Naka nga def", roman: "" },
|
||||
{ from: 18, text: "J\u00e0mm nga yendoo", roman: "" }
|
||||
] },
|
||||
sw: { name: "Swahili", bands: [
|
||||
{ from: 0, text: "Usiku mwema", roman: "" },
|
||||
{ from: 5, text: "Habari za asubuhi", roman: "" },
|
||||
{ from: 12, text: "Habari za mchana", roman: "" },
|
||||
{ from: 16, text: "Habari za jioni", roman: "" },
|
||||
{ from: 20, text: "Usiku mwema", roman: "" }
|
||||
] },
|
||||
rw: { name: "Kinyarwanda", bands: [
|
||||
{ from: 0, text: "Ijoro ryiza", roman: "" },
|
||||
{ from: 5, text: "Mwaramutse", roman: "" },
|
||||
{ from: 12, text: "Mwiriwe", roman: "" },
|
||||
{ from: 18, text: "Ijoro ryiza", roman: "" }
|
||||
] },
|
||||
lg: { name: "Luganda", bands: [
|
||||
{ from: 0, text: "Sula bulungi", roman: "" },
|
||||
{ from: 5, text: "Wasuze otya", roman: "" },
|
||||
{ from: 12, text: "Osiibye otya", roman: "" },
|
||||
{ from: 18, text: "Sula bulungi", roman: "" }
|
||||
] },
|
||||
ny: { name: "Chichewa", bands: [
|
||||
{ from: 0, text: "Gonani bwino", roman: "" },
|
||||
{ from: 5, text: "Mwadzuka bwanji", roman: "" },
|
||||
{ from: 12, text: "Mwaswera bwanji", roman: "" },
|
||||
{ from: 18, text: "Gonani bwino", roman: "" }
|
||||
] },
|
||||
sn: { name: "Shona", bands: [
|
||||
{ from: 0, text: "Manheru", roman: "" },
|
||||
{ from: 5, text: "Mangwanani", roman: "" },
|
||||
{ from: 12, text: "Masikati", roman: "" },
|
||||
{ from: 18, text: "Manheru", roman: "" }
|
||||
] },
|
||||
st: { name: "Sesotho", bands: [
|
||||
{ from: 0, text: "Lumela", roman: "" }
|
||||
] },
|
||||
ss: { name: "Siswati", bands: [
|
||||
{ from: 0, text: "Sawubona", roman: "" }
|
||||
] },
|
||||
mg: { name: "Malagasy", bands: [
|
||||
{ from: 0, text: "Manao ahoana", roman: "" }
|
||||
] },
|
||||
so: { name: "Somali", bands: [
|
||||
{ from: 0, text: "Habeen wanaagsan", roman: "" },
|
||||
{ from: 5, text: "Subax wanaagsan", roman: "" },
|
||||
{ from: 12, text: "Galab wanaagsan", roman: "" },
|
||||
{ from: 18, text: "Habeen wanaagsan", roman: "" }
|
||||
] },
|
||||
id: { name: "Indonesian", bands: [
|
||||
{ from: 0, text: "Selamat malam", roman: "" },
|
||||
{ from: 4, text: "Selamat pagi", roman: "" },
|
||||
{ from: 11, text: "Selamat siang", roman: "" },
|
||||
{ from: 15, text: "Selamat sore", roman: "" },
|
||||
{ from: 18, text: "Selamat malam", roman: "" }
|
||||
] },
|
||||
ms: { name: "Malay", bands: [
|
||||
{ from: 0, text: "Selamat malam", roman: "" },
|
||||
{ from: 5, text: "Selamat pagi", roman: "" },
|
||||
{ from: 12, text: "Selamat tengah hari", roman: "" },
|
||||
{ from: 15, text: "Selamat petang", roman: "" },
|
||||
{ from: 19, text: "Selamat malam", roman: "" }
|
||||
] },
|
||||
tl: { name: "Filipino", bands: [
|
||||
{ from: 0, text: "Magandang gabi", roman: "" },
|
||||
{ from: 5, text: "Magandang umaga", roman: "" },
|
||||
{ from: 12, text: "Magandang tanghali", roman: "" },
|
||||
{ from: 13, text: "Magandang hapon", roman: "" },
|
||||
{ from: 18, text: "Magandang gabi", roman: "" }
|
||||
] },
|
||||
vi: { name: "Vietnamese", bands: [
|
||||
{ from: 0, text: "Ch\u00fac ng\u1ee7 ngon", roman: "" },
|
||||
{ from: 5, text: "Ch\u00e0o bu\u1ed5i s\u00e1ng", roman: "" },
|
||||
{ from: 12, text: "Ch\u00e0o bu\u1ed5i chi\u1ec1u", roman: "" },
|
||||
{ from: 18, text: "Ch\u00e0o bu\u1ed5i t\u1ed1i", roman: "" }
|
||||
] },
|
||||
sm: { name: "Samoan", bands: [
|
||||
{ from: 0, text: "Manuia le po", roman: "" },
|
||||
{ from: 5, text: "Manuia le taeao", roman: "" },
|
||||
{ from: 12, text: "Manuia le aoauli", roman: "" },
|
||||
{ from: 17, text: "Manuia le afiafi", roman: "" },
|
||||
{ from: 21, text: "Manuia le po", roman: "" }
|
||||
] },
|
||||
mi: { name: "Maori", bands: [
|
||||
{ from: 0, text: "P\u014d m\u0101rie", roman: "" },
|
||||
{ from: 5, text: "M\u014drena", roman: "" },
|
||||
{ from: 11, text: "Kia ora", roman: "" },
|
||||
{ from: 17, text: "Ahiahi m\u0101rie", roman: "" },
|
||||
{ from: 21, text: "P\u014d m\u0101rie", roman: "" }
|
||||
] },
|
||||
rar: { name: "Cook Islands Maori", bands: [
|
||||
{ from: 0, text: "Kia orana", roman: "" }
|
||||
] },
|
||||
niu: { name: "Niuean", bands: [
|
||||
{ from: 0, text: "Fakalofa lahi atu", roman: "" }
|
||||
] },
|
||||
tkl: { name: "Tokelauan", bands: [
|
||||
{ from: 0, text: "M\u0101l\u014d ni", roman: "" }
|
||||
] },
|
||||
tvl: { name: "Tuvaluan", bands: [
|
||||
{ from: 0, text: "T\u0101lofa", roman: "" }
|
||||
] },
|
||||
to: { name: "Tongan", bands: [
|
||||
{ from: 0, text: "M\u0101l\u014d e lelei", roman: "" }
|
||||
] },
|
||||
ty: { name: "Tahitian", bands: [
|
||||
{ from: 0, text: "Ia ora na", roman: "" }
|
||||
] },
|
||||
fj: { name: "Fijian", bands: [
|
||||
{ from: 0, text: "Ni sa moce", roman: "" },
|
||||
{ from: 5, text: "Ni sa yadra", roman: "" },
|
||||
{ from: 11, text: "Bula", roman: "" },
|
||||
{ from: 19, text: "Ni sa moce", roman: "" }
|
||||
] },
|
||||
haw: { name: "Hawaiian", bands: [
|
||||
{ from: 0, text: "Aloha p\u014d", roman: "" },
|
||||
{ from: 5, text: "Aloha kakahiaka", roman: "" },
|
||||
{ from: 11, text: "Aloha awakea", roman: "" },
|
||||
{ from: 14, text: "Aloha \u02bbauinal\u0101", roman: "" },
|
||||
{ from: 18, text: "Aloha ahiahi", roman: "" },
|
||||
{ from: 23, text: "Aloha p\u014d", roman: "" }
|
||||
] },
|
||||
gil: { name: "Gilbertese", bands: [
|
||||
{ from: 0, text: "Mauri", roman: "" }
|
||||
] },
|
||||
ch: { name: "Chamorro", bands: [
|
||||
{ from: 0, text: "H\u00e5fa adai", roman: "" }
|
||||
] },
|
||||
mh: { name: "Marshallese", bands: [
|
||||
{ from: 0, text: "Iakwe", roman: "" }
|
||||
] },
|
||||
pau: { name: "Palauan", bands: [
|
||||
{ from: 0, text: "Alii", roman: "" }
|
||||
] },
|
||||
tpi: { name: "Tok Pisin", bands: [
|
||||
{ from: 0, text: "Gutnait", roman: "" },
|
||||
{ from: 5, text: "Moning", roman: "" },
|
||||
{ from: 12, text: "Apinun", roman: "" },
|
||||
{ from: 18, text: "Gutnait", roman: "" }
|
||||
] },
|
||||
bi: { name: "Bislama", bands: [
|
||||
{ from: 0, text: "Gudnaet", roman: "" },
|
||||
{ from: 5, text: "Gude", roman: "" },
|
||||
{ from: 18, text: "Gudnaet", roman: "" }
|
||||
] },
|
||||
ht: { name: "Haitian Creole", bands: [
|
||||
{ from: 0, text: "B\u00f2nwit", roman: "" },
|
||||
{ from: 5, text: "Bonjou", roman: "" },
|
||||
{ from: 12, text: "Bon apre midi", roman: "" },
|
||||
{ from: 18, text: "Bonswa", roman: "" }
|
||||
] },
|
||||
pap: { name: "Papiamento", bands: [
|
||||
{ from: 0, text: "Bon nochi", roman: "" },
|
||||
{ from: 5, text: "Bon dia", roman: "" },
|
||||
{ from: 12, text: "Bon tardi", roman: "" },
|
||||
{ from: 18, text: "Bon nochi", roman: "" }
|
||||
] },
|
||||
gn: { name: "Guarani", bands: [
|
||||
{ from: 0, text: "Mba'\u00e9ichapa", roman: "" }
|
||||
] },
|
||||
el: { name: "Greek", bands: [
|
||||
{ from: 0, text: "\u039a\u03b1\u03bb\u03b7\u03bd\u03cd\u03c7\u03c4\u03b1", roman: "kalinihta" },
|
||||
{ from: 5, text: "\u039a\u03b1\u03bb\u03b7\u03bc\u03ad\u03c1\u03b1", roman: "kalimera" },
|
||||
{ from: 12, text: "\u039a\u03b1\u03bb\u03b7\u03c3\u03c0\u03ad\u03c1\u03b1", roman: "kalispera" },
|
||||
{ from: 21, text: "\u039a\u03b1\u03bb\u03b7\u03bd\u03cd\u03c7\u03c4\u03b1", roman: "kalinihta" }
|
||||
] },
|
||||
ru: { name: "Russian", bands: [
|
||||
{ from: 0, text: "\u0421\u043f\u043e\u043a\u043e\u0439\u043d\u043e\u0439 \u043d\u043e\u0447\u0438", roman: "spokoynoy nochi" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e\u0435 \u0443\u0442\u0440\u043e", roman: "dobroye utro" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c", roman: "dobryy den" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u0440\u044b\u0439 \u0432\u0435\u0447\u0435\u0440", roman: "dobryy vecher" }
|
||||
] },
|
||||
uk: { name: "Ukrainian", bands: [
|
||||
{ from: 0, text: "\u041d\u0430\u0434\u043e\u0431\u0440\u0430\u043d\u0456\u0447", roman: "nadobranich" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0440\u0430\u043d\u043a\u0443", roman: "dobroho ranku" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0434\u043d\u044f", roman: "dobroho dnya" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e\u0433\u043e \u0432\u0435\u0447\u043e\u0440\u0430", roman: "dobroho vechora" }
|
||||
] },
|
||||
be: { name: "Belarusian", bands: [
|
||||
{ from: 0, text: "\u0414\u0430\u0431\u0440\u0430\u043d\u0430\u0447", roman: "dabranach" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u0430\u0439 \u0440\u0430\u043d\u0456\u0446\u044b", roman: "dobray ranitsy" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u0440\u044b \u0434\u0437\u0435\u043d\u044c", roman: "dobry dzen" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u0440\u044b \u0432\u0435\u0447\u0430\u0440", roman: "dobry vechar" }
|
||||
] },
|
||||
bg: { name: "Bulgarian", bands: [
|
||||
{ from: 0, text: "\u041b\u0435\u043a\u0430 \u043d\u043e\u0449", roman: "leka nosht" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0443\u0442\u0440\u043e", roman: "dobro utro" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u044a\u0440 \u0434\u0435\u043d", roman: "dobar den" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u044a\u0440 \u0432\u0435\u0447\u0435\u0440", roman: "dobar vecher" }
|
||||
] },
|
||||
sr: { name: "Serbian", bands: [
|
||||
{ from: 0, text: "\u041b\u0430\u043a\u0443 \u043d\u043e\u045b", roman: "laku noc" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0458\u0443\u0442\u0440\u043e", roman: "dobro jutro" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u0430\u0440 \u0434\u0430\u043d", roman: "dobar dan" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e \u0432\u0435\u0447\u0435", roman: "dobro vece" }
|
||||
] },
|
||||
mk: { name: "Macedonian", bands: [
|
||||
{ from: 0, text: "\u0414\u043e\u0431\u0440\u0430 \u043d\u043e\u045c", roman: "dobra nok" },
|
||||
{ from: 5, text: "\u0414\u043e\u0431\u0440\u043e \u0443\u0442\u0440\u043e", roman: "dobro utro" },
|
||||
{ from: 12, text: "\u0414\u043e\u0431\u0430\u0440 \u0434\u0435\u043d", roman: "dobar den" },
|
||||
{ from: 18, text: "\u0414\u043e\u0431\u0440\u043e \u0432\u0435\u0447\u0435\u0440", roman: "dobro vecher" }
|
||||
] },
|
||||
kk: { name: "Kazakh", bands: [
|
||||
{ from: 0, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u0442\u04af\u043d", roman: "qaiyrly tun" },
|
||||
{ from: 5, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u0442\u0430\u04a3", roman: "qaiyrly tang" },
|
||||
{ from: 12, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u043a\u04af\u043d", roman: "qaiyrly kun" },
|
||||
{ from: 18, text: "\u049a\u0430\u0439\u044b\u0440\u043b\u044b \u043a\u0435\u0448", roman: "qaiyrly kesh" }
|
||||
] },
|
||||
ky: { name: "Kyrgyz", bands: [
|
||||
{ from: 0, text: "\u0416\u0430\u043a\u0448\u044b \u0436\u0430\u0442\u044b\u043f \u0442\u0443\u0440\u0443\u04a3\u0443\u0437", roman: "jakshy jatyp turunguz" },
|
||||
{ from: 5, text: "\u041a\u0430\u0439\u044b\u0440\u043b\u0443\u0443 \u0442\u0430\u04a3", roman: "kaiyrluu tang" },
|
||||
{ from: 12, text: "\u041a\u0443\u0442\u043c\u0430\u043d\u0434\u0443\u0443 \u043a\u04af\u043d", roman: "kutmanduu kun" },
|
||||
{ from: 18, text: "\u041a\u0443\u0442\u043c\u0430\u043d\u0434\u0443\u0443 \u043a\u0435\u0447", roman: "kutmanduu kech" }
|
||||
] },
|
||||
tg: { name: "Tajik", bands: [
|
||||
{ from: 0, text: "\u0428\u0430\u0431 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "shab ba khayr" },
|
||||
{ from: 5, text: "\u0421\u0443\u0431\u04b3 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "subh ba khayr" },
|
||||
{ from: 12, text: "\u0420\u04ef\u0437 \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "ruz ba khayr" },
|
||||
{ from: 18, text: "\u0428\u043e\u043c \u0431\u0430 \u0445\u0430\u0439\u0440", roman: "shom ba khayr" }
|
||||
] },
|
||||
mn: { name: "Mongolian", bands: [
|
||||
{ from: 0, text: "\u0421\u0430\u0439\u0445\u0430\u043d \u0430\u043c\u0440\u0430\u0430\u0440\u0430\u0439", roman: "saikhan amraarai" },
|
||||
{ from: 5, text: "\u04e8\u0433\u043b\u04e9\u04e9\u043d\u0438\u0439 \u043c\u044d\u043d\u0434", roman: "ogloonii mend" },
|
||||
{ from: 12, text: "\u04e8\u0434\u0440\u0438\u0439\u043d \u043c\u044d\u043d\u0434", roman: "odriin mend" },
|
||||
{ from: 18, text: "\u041e\u0440\u043e\u0439\u043d \u043c\u044d\u043d\u0434", roman: "oroin mend" }
|
||||
] },
|
||||
ka: { name: "Georgian", bands: [
|
||||
{ from: 0, text: "\u10e6\u10d0\u10db\u10d4 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "ghame mshvidobisa" },
|
||||
{ from: 5, text: "\u10d3\u10d8\u10da\u10d0 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "dila mshvidobisa" },
|
||||
{ from: 12, text: "\u10d3\u10e6\u10d4 \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "dghe mshvidobisa" },
|
||||
{ from: 18, text: "\u10e1\u10d0\u10e6\u10d0\u10db\u10dd \u10db\u10e8\u10d5\u10d8\u10d3\u10dd\u10d1\u10d8\u10e1\u10d0", roman: "saghamo mshvidobisa" }
|
||||
] },
|
||||
hy: { name: "Armenian", bands: [
|
||||
{ from: 0, text: "\u0562\u0561\u0580\u056b \u0563\u056b\u0577\u0565\u0580", roman: "bari gisher" },
|
||||
{ from: 5, text: "\u0562\u0561\u0580\u056b \u056c\u0578\u0582\u0575\u057d", roman: "bari luys" },
|
||||
{ from: 12, text: "\u0562\u0561\u0580\u056b \u0585\u0580", roman: "bari or" },
|
||||
{ from: 18, text: "\u0562\u0561\u0580\u056b \u0565\u0580\u0565\u056f\u0578", roman: "bari ereko" }
|
||||
] },
|
||||
he: { name: "Hebrew", bands: [
|
||||
{ from: 0, text: "\u05dc\u05d9\u05dc\u05d4 \u05d8\u05d5\u05d1", roman: "laila tov" },
|
||||
{ from: 5, text: "\u05d1\u05d5\u05e7\u05e8 \u05d8\u05d5\u05d1", roman: "boker tov" },
|
||||
{ from: 12, text: "\u05e6\u05d4\u05e8\u05d9\u05d9\u05dd \u05d8\u05d5\u05d1\u05d9\u05dd", roman: "tzohorayim tovim" },
|
||||
{ from: 17, text: "\u05e2\u05e8\u05d1 \u05d8\u05d5\u05d1", roman: "erev tov" }
|
||||
] },
|
||||
ar: { name: "Arabic", bands: [
|
||||
{ from: 0, text: "\u062a\u0635\u0628\u062d \u0639\u0644\u0649 \u062e\u064a\u0631", roman: "tusbih ala khayr" },
|
||||
{ from: 5, text: "\u0635\u0628\u0627\u062d \u0627\u0644\u062e\u064a\u0631", roman: "sabah al-khayr" },
|
||||
{ from: 12, text: "\u0645\u0633\u0627\u0621 \u0627\u0644\u062e\u064a\u0631", roman: "masaa al-khayr" }
|
||||
] },
|
||||
fa: { name: "Persian", bands: [
|
||||
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bekheir" },
|
||||
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "sobh bekheir" },
|
||||
{ from: 12, text: "\u0639\u0635\u0631 \u0628\u062e\u06cc\u0631", roman: "asr bekheir" },
|
||||
{ from: 20, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bekheir" }
|
||||
] },
|
||||
ur: { name: "Urdu", bands: [
|
||||
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bakhair" },
|
||||
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "subah bakhair" },
|
||||
{ from: 12, text: "\u0627\u0644\u0633\u0644\u0627\u0645 \u0639\u0644\u06cc\u06a9\u0645", roman: "assalam-o-alaikum" },
|
||||
{ from: 18, text: "\u0634\u0627\u0645 \u0628\u062e\u06cc\u0631", roman: "shaam bakhair" }
|
||||
] },
|
||||
prs: { name: "Dari", bands: [
|
||||
{ from: 0, text: "\u0634\u0628 \u0628\u062e\u06cc\u0631", roman: "shab bakhair" },
|
||||
{ from: 5, text: "\u0635\u0628\u062d \u0628\u062e\u06cc\u0631", roman: "sobh bakhair" },
|
||||
{ from: 12, text: "\u0631\u0648\u0632 \u0628\u062e\u06cc\u0631", roman: "roz bakhair" },
|
||||
{ from: 18, text: "\u0634\u0627\u0645 \u0628\u062e\u06cc\u0631", roman: "sham bakhair" }
|
||||
] },
|
||||
dv: { name: "Dhivehi", bands: [
|
||||
{ from: 0, text: "\u0787\u07a6\u0787\u07b0\u0790\u07a6\u078d\u07a7\u0789\u07aa \u07a2\u07a6\u078d\u07a6\u0787\u07a8\u0786\u07aa\u0789\u07b0", roman: "assalaamu alaikum" }
|
||||
] },
|
||||
hi: { name: "Hindi", bands: [
|
||||
{ from: 0, text: "\u0936\u0941\u092d \u0930\u093e\u0924\u094d\u0930\u093f", roman: "shubh ratri" },
|
||||
{ from: 5, text: "\u0938\u0941\u092a\u094d\u0930\u092d\u093e\u0924", roman: "suprabhat" },
|
||||
{ from: 12, text: "\u0928\u092e\u0938\u094d\u0924\u0947", roman: "namaste" },
|
||||
{ from: 17, text: "\u0936\u0941\u092d \u0938\u0902\u0927\u094d\u092f\u093e", roman: "shubh sandhya" }
|
||||
] },
|
||||
bn: { name: "Bengali", bands: [
|
||||
{ from: 0, text: "\u09b6\u09c1\u09ad \u09b0\u09be\u09a4\u09cd\u09b0\u09bf", roman: "shubho ratri" },
|
||||
{ from: 5, text: "\u09b8\u09c1\u09aa\u09cd\u09b0\u09ad\u09be\u09a4", roman: "suprobhat" },
|
||||
{ from: 12, text: "\u09a8\u09ae\u09b8\u09cd\u0995\u09be\u09b0", roman: "nomoshkar" },
|
||||
{ from: 17, text: "\u09b6\u09c1\u09ad \u09b8\u09a8\u09cd\u09a7\u09cd\u09af\u09be", roman: "shubho shondha" }
|
||||
] },
|
||||
ne: { name: "Nepali", bands: [
|
||||
{ from: 0, text: "\u0936\u0941\u092d \u0930\u093e\u0924\u094d\u0930\u0940", roman: "shubha ratri" },
|
||||
{ from: 5, text: "\u0936\u0941\u092d \u092a\u094d\u0930\u092d\u093e\u0924", roman: "shubha prabhat" },
|
||||
{ from: 12, text: "\u0928\u092e\u0938\u094d\u0924\u0947", roman: "namaste" },
|
||||
{ from: 17, text: "\u0936\u0941\u092d \u0938\u0928\u094d\u0927\u094d\u092f\u093e", roman: "shubha sandhya" }
|
||||
] },
|
||||
si: { name: "Sinhala", bands: [
|
||||
{ from: 0, text: "\u0dc3\u0dd4\u0db6 \u0dbb\u0dcf\u0dad\u0dca\u200d\u0dbb\u0dd2\u0dba\u0d9a\u0dca", roman: "suba rathriyak" },
|
||||
{ from: 5, text: "\u0dc3\u0dd4\u0db6 \u0d8b\u0daf\u0dd1\u0dc3\u0db1\u0d9a\u0dca", roman: "suba udaesanak" },
|
||||
{ from: 12, text: "\u0dc3\u0dd4\u0db6 \u0daf\u0dc0\u0dc3\u0d9a\u0dca", roman: "suba dawasak" },
|
||||
{ from: 18, text: "\u0dc3\u0dd4\u0db6 \u0dc3\u0db1\u0dca\u0db0\u0dca\u200d\u0dba\u0dcf\u0dc0\u0d9a\u0dca", roman: "suba sandhyawak" }
|
||||
] },
|
||||
ta: { name: "Tamil", bands: [
|
||||
{ from: 0, text: "\u0b87\u0bb0\u0bb5\u0bc1 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "iravu vanakkam" },
|
||||
{ from: 5, text: "\u0b95\u0bbe\u0bb2\u0bc8 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "kaalai vanakkam" },
|
||||
{ from: 12, text: "\u0bae\u0ba4\u0bbf\u0baf \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "madhiya vanakkam" },
|
||||
{ from: 17, text: "\u0bae\u0bbe\u0bb2\u0bc8 \u0bb5\u0ba3\u0b95\u0bcd\u0b95\u0bae\u0bcd", roman: "maalai vanakkam" }
|
||||
] },
|
||||
dz: { name: "Dzongkha", bands: [
|
||||
{ from: 0, text: "\u0f40\u0f74\u0f0b\u0f5f\u0f74\u0f0b\u0f5f\u0f44\u0f0b\u0f54\u0f7c\u0f0b\u0f63\u0f42\u0f66", roman: "kuzuzangpo la" }
|
||||
] },
|
||||
my: { name: "Burmese", bands: [
|
||||
{ from: 0, text: "\u1019\u1004\u103a\u1039\u1002\u101c\u102c\u1015\u102b", roman: "mingalaba" }
|
||||
] },
|
||||
th: { name: "Thai", bands: [
|
||||
{ from: 0, text: "\u0e23\u0e32\u0e15\u0e23\u0e35\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e34\u0e4c", roman: "ratri sawat" },
|
||||
{ from: 5, text: "\u0e2d\u0e23\u0e38\u0e13\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e34\u0e4c", roman: "arun sawat" },
|
||||
{ from: 12, text: "\u0e2a\u0e27\u0e31\u0e2a\u0e14\u0e35", roman: "sawatdee" }
|
||||
] },
|
||||
lo: { name: "Lao", bands: [
|
||||
{ from: 0, text: "\u0eaa\u0eb0\u0e9a\u0eb2\u0e8d\u0e94\u0eb5", roman: "sabaidee" }
|
||||
] },
|
||||
km: { name: "Khmer", bands: [
|
||||
{ from: 0, text: "\u179a\u17b6\u178f\u17d2\u179a\u17b8\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "reatrei suostei" },
|
||||
{ from: 5, text: "\u17a2\u179a\u17bb\u178e\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "arun suostei" },
|
||||
{ from: 12, text: "\u1791\u17b7\u179c\u17b6\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "tivea suostei" },
|
||||
{ from: 18, text: "\u179f\u17b6\u1799\u17d0\u178e\u17d2\u17a0\u179f\u17bd\u179f\u17d2\u178f\u17b8", roman: "sayoanh suostei" }
|
||||
] },
|
||||
zh: { name: "Mandarin", bands: [
|
||||
{ from: 0, text: "\u665a\u5b89", roman: "wan an" },
|
||||
{ from: 5, text: "\u65e9\u4e0a\u597d", roman: "zaoshang hao" },
|
||||
{ from: 11, text: "\u4e2d\u5348\u597d", roman: "zhongwu hao" },
|
||||
{ from: 14, text: "\u4e0b\u5348\u597d", roman: "xiawu hao" },
|
||||
{ from: 18, text: "\u665a\u4e0a\u597d", roman: "wanshang hao" }
|
||||
] },
|
||||
yue: { name: "Cantonese", bands: [
|
||||
{ from: 0, text: "\u65e9\u551e", roman: "jou tau" },
|
||||
{ from: 5, text: "\u65e9\u6668", roman: "jou san" },
|
||||
{ from: 12, text: "\u5348\u5b89", roman: "ng on" },
|
||||
{ from: 18, text: "\u665a\u5b89", roman: "maan on" }
|
||||
] },
|
||||
ko: { name: "Korean", bands: [
|
||||
{ from: 0, text: "\uc548\ub155\ud788 \uc8fc\ubb34\uc138\uc694", roman: "annyeonghi jumuseyo" },
|
||||
{ from: 5, text: "\uc88b\uc740 \uc544\uce68", roman: "joeun achim" },
|
||||
{ from: 11, text: "\uc548\ub155\ud558\uc138\uc694", roman: "annyeonghaseyo" },
|
||||
{ from: 18, text: "\uc88b\uc740 \uc800\ub141", roman: "joeun jeonyeok" }
|
||||
] },
|
||||
ja: { name: "Japanese", bands: [
|
||||
{ from: 0, text: "\u304a\u3084\u3059\u307f", roman: "oyasumi" },
|
||||
{ from: 5, text: "\u304a\u306f\u3088\u3046", roman: "ohayo" },
|
||||
{ from: 11, text: "\u3053\u3093\u306b\u3061\u306f", roman: "konnichiwa" },
|
||||
{ from: 18, text: "\u3053\u3093\u3070\u3093\u306f", roman: "konbanwa" }
|
||||
] },
|
||||
am: { name: "Amharic", bands: [
|
||||
{ from: 0, text: "\u12f0\u1205\u1293 \u12a5\u12f0\u1229", roman: "dehna ederu" },
|
||||
{ from: 5, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12a0\u12f0\u1229", roman: "endemin aderu" },
|
||||
{ from: 12, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12cb\u1209", roman: "endemin walu" },
|
||||
{ from: 18, text: "\u12a5\u1295\u12f0\u121d\u1295 \u12a0\u1218\u1239", roman: "endemin ameshu" }
|
||||
] },
|
||||
ti: { name: "Tigrinya", bands: [
|
||||
{ from: 0, text: "\u1230\u120b\u121d", roman: "selam" }
|
||||
] },
|
||||
az: { name: "Azerbaijani", bands: [
|
||||
{ from: 0, text: "Gec\u0259niz xeyr\u0259 qals\u0131n", roman: "" },
|
||||
{ from: 5, text: "Sabah\u0131n\u0131z xeyir", roman: "" },
|
||||
{ from: 12, text: "G\u00fcnortan\u0131z xeyir", roman: "" },
|
||||
{ from: 18, text: "Ax\u015fam\u0131n\u0131z xeyir", roman: "" }
|
||||
] },
|
||||
uz: { name: "Uzbek", bands: [
|
||||
{ from: 0, text: "Xayrli tun", roman: "" },
|
||||
{ from: 5, text: "Xayrli tong", roman: "" },
|
||||
{ from: 12, text: "Xayrli kun", roman: "" },
|
||||
{ from: 18, text: "Xayrli kech", roman: "" }
|
||||
] },
|
||||
tk: { name: "Turkmen", bands: [
|
||||
{ from: 0, text: "Gij\u00e4\u0148iz rahat", roman: "" },
|
||||
{ from: 5, text: "Ertiri\u0148iz ha\u00fdyrly", roman: "" },
|
||||
{ from: 12, text: "Salam", roman: "" },
|
||||
{ from: 18, text: "Ag\u015famy\u0148yz ha\u00fdyrly", roman: "" }
|
||||
] }
|
||||
}
|
||||
|
||||
var COUNTRIES = {
|
||||
"AD": "ca", "AE": "ar", "AF": "prs", "AG": "en", "AI": "en", "AL": "sq",
|
||||
"AM": "hy", "AO": "pt", "AQ": "en", "AR": "es", "AS": "sm", "AT": "deAT",
|
||||
"AU": "enAU", "AW": "pap", "AX": "sv", "AZ": "az", "BA": "bs", "BB": "en",
|
||||
"BD": "bn", "BE": "fr", "BF": "fr", "BG": "bg", "BH": "ar", "BI": "fr",
|
||||
"BJ": "fr", "BL": "fr", "BM": "en", "BN": "ms", "BO": "es", "BQ": "pap",
|
||||
"BR": "pt", "BS": "en", "BT": "dz", "BW": "en", "BY": "be", "BZ": "en",
|
||||
"CA": "en", "CC": "en", "CD": "fr", "CF": "fr", "CG": "fr", "CH": "deCH",
|
||||
"CI": "fr", "CK": "rar", "CL": "es", "CM": "fr", "CN": "zh", "CO": "es",
|
||||
"CR": "es", "CU": "es", "CV": "pt", "CW": "pap", "CX": "en", "CY": "el",
|
||||
"CZ": "cs", "DE": "de", "DJ": "fr", "DK": "da", "DM": "en", "DO": "es",
|
||||
"DZ": "ar", "EC": "es", "EE": "et", "EG": "ar", "EH": "ar", "ER": "ti",
|
||||
"ES": "esES", "ET": "am", "FI": "fi", "FJ": "fj", "FK": "en", "FM": "en",
|
||||
"FO": "fo", "FR": "fr", "GA": "fr", "GB": "en", "GD": "en", "GE": "ka",
|
||||
"GF": "fr", "GG": "en", "GH": "tw", "GI": "en", "GL": "kl", "GM": "en",
|
||||
"GN": "fr", "GP": "fr", "GQ": "es", "GR": "el", "GS": "en", "GT": "es",
|
||||
"GU": "ch", "GW": "pt", "GY": "en", "HK": "yue", "HN": "es", "HR": "hr",
|
||||
"HT": "ht", "HU": "hu", "ID": "id", "IE": "ga", "IL": "he", "IM": "en",
|
||||
"IN": "hi", "IO": "en", "IQ": "ar", "IR": "fa", "IS": "is", "IT": "it",
|
||||
"JE": "en", "JM": "en", "JO": "ar", "JP": "ja", "KE": "sw", "KG": "ky",
|
||||
"KH": "km", "KI": "gil", "KM": "fr", "KN": "en", "KP": "ko", "KR": "ko",
|
||||
"KW": "ar", "KY": "en", "KZ": "kk", "LA": "lo", "LB": "ar", "LC": "en",
|
||||
"LI": "deCH", "LK": "si", "LR": "en", "LS": "st", "LT": "lt", "LU": "lb",
|
||||
"LV": "lv", "LY": "ar", "MA": "ar", "MC": "fr", "MD": "ro", "ME": "hr",
|
||||
"MF": "fr", "MG": "mg", "MH": "mh", "MK": "mk", "ML": "fr", "MM": "my",
|
||||
"MN": "mn", "MO": "yue", "MP": "ch", "MQ": "fr", "MR": "ar", "MS": "en",
|
||||
"MT": "mt", "MU": "fr", "MV": "dv", "MW": "ny", "MX": "es", "MY": "ms",
|
||||
"MZ": "pt", "NA": "af", "NC": "fr", "NE": "fr", "NF": "en", "NG": "yo",
|
||||
"NI": "es", "NL": "nl", "NO": "no", "NP": "ne", "NR": "en", "NU": "niu",
|
||||
"NZ": "mi", "OM": "ar", "PA": "es", "PE": "es", "PF": "ty", "PG": "tpi",
|
||||
"PH": "tl", "PK": "ur", "PL": "pl", "PM": "fr", "PN": "en", "PR": "es",
|
||||
"PS": "ar", "PT": "pt", "PW": "pau", "PY": "gn", "QA": "ar", "RE": "fr",
|
||||
"RO": "ro", "RS": "sr", "RU": "ru", "RW": "rw", "SA": "ar", "SB": "bi",
|
||||
"SC": "fr", "SD": "ar", "SE": "sv", "SG": "ms", "SH": "en", "SI": "sl",
|
||||
"SJ": "no", "SK": "sk", "SL": "en", "SM": "it", "SN": "wo", "SO": "so",
|
||||
"SR": "nl", "SS": "en", "ST": "pt", "SV": "es", "SX": "nl", "SY": "ar",
|
||||
"SZ": "ss", "TC": "en", "TD": "fr", "TF": "fr", "TG": "fr", "TH": "th",
|
||||
"TJ": "tg", "TK": "tkl", "TL": "pt", "TM": "tk", "TN": "ar", "TO": "to",
|
||||
"TR": "tr", "TT": "en", "TV": "tvl", "TW": "zh", "TZ": "sw", "UA": "uk",
|
||||
"UG": "lg", "UM": "en", "US": "en", "UY": "es", "UZ": "uz", "VA": "it",
|
||||
"VC": "en", "VE": "es", "VG": "en", "VI": "en", "VN": "vi", "VU": "bi",
|
||||
"WF": "fr", "WS": "sm", "YE": "ar", "YT": "fr", "ZA": "af", "ZM": "en",
|
||||
"ZW": "sn"
|
||||
}
|
||||
|
||||
// Cities whose own language is not their country's.
|
||||
var OVERRIDES = {
|
||||
"America/Montreal": "fr",
|
||||
"Asia/Kolkata": "hi",
|
||||
"Pacific/Honolulu": "haw"
|
||||
}
|
||||
|
||||
// Legacy aliases that matching compiled zoneinfo put in the wrong country.
|
||||
// Europe/Simferopol stays UA in ZONE_COUNTRY by choice.
|
||||
var ALIAS_COUNTRY = {
|
||||
"Africa/Asmera": "ER", // Asmara, Eritrea
|
||||
"Africa/Timbuktu": "ML", // Timbuktu, Mali
|
||||
"America/Montreal": "CA",
|
||||
"America/Nipigon": "CA",
|
||||
"America/Thunder_Bay": "CA",
|
||||
"America/Virgin": "VI", // the US Virgin Islands
|
||||
"Asia/Rangoon": "MM", // Yangon, Myanmar
|
||||
"Canada/Eastern": "CA",
|
||||
"EST": "US", // a North American offset, not a place
|
||||
"Iceland": "IS",
|
||||
"MST": "US",
|
||||
"NZ": "NZ",
|
||||
"Pacific/Truk": "FM", // Chuuk, Micronesia
|
||||
"Pacific/Yap": "FM", // Yap, Micronesia
|
||||
"Singapore": "SG",
|
||||
"US/Arizona": "US"
|
||||
}
|
||||
|
||||
// Every zone the picker can offer, baked from the system's zone.tab.
|
||||
var ZONE_COUNTRY = {
|
||||
"Africa/Abidjan": "CI", "Africa/Accra": "GH", "Africa/Addis_Ababa": "ET",
|
||||
"Africa/Algiers": "DZ", "Africa/Asmara": "ER", "Africa/Asmera": "DJ",
|
||||
"Africa/Bamako": "ML", "Africa/Bangui": "CF", "Africa/Banjul": "GM",
|
||||
"Africa/Bissau": "GW", "Africa/Blantyre": "MW",
|
||||
"Africa/Brazzaville": "CG", "Africa/Bujumbura": "BI",
|
||||
"Africa/Cairo": "EG", "Africa/Casablanca": "MA", "Africa/Ceuta": "ES",
|
||||
"Africa/Conakry": "GN", "Africa/Dakar": "SN",
|
||||
"Africa/Dar_es_Salaam": "TZ", "Africa/Djibouti": "DJ",
|
||||
"Africa/Douala": "CM", "Africa/El_Aaiun": "EH", "Africa/Freetown": "SL",
|
||||
"Africa/Gaborone": "BW", "Africa/Harare": "ZW",
|
||||
"Africa/Johannesburg": "ZA", "Africa/Juba": "SS", "Africa/Kampala": "UG",
|
||||
"Africa/Khartoum": "SD", "Africa/Kigali": "RW", "Africa/Kinshasa": "CD",
|
||||
"Africa/Lagos": "NG", "Africa/Libreville": "GA", "Africa/Lome": "TG",
|
||||
"Africa/Luanda": "AO", "Africa/Lubumbashi": "CD", "Africa/Lusaka": "ZM",
|
||||
"Africa/Malabo": "GQ", "Africa/Maputo": "MZ", "Africa/Maseru": "LS",
|
||||
"Africa/Mbabane": "SZ", "Africa/Mogadishu": "SO", "Africa/Monrovia": "LR",
|
||||
"Africa/Nairobi": "KE", "Africa/Ndjamena": "TD", "Africa/Niamey": "NE",
|
||||
"Africa/Nouakchott": "MR", "Africa/Ouagadougou": "BF",
|
||||
"Africa/Porto-Novo": "BJ", "Africa/Sao_Tome": "ST",
|
||||
"Africa/Timbuktu": "BF", "Africa/Tripoli": "LY", "Africa/Tunis": "TN",
|
||||
"Africa/Windhoek": "NA", "America/Adak": "US", "America/Anchorage": "US",
|
||||
"America/Anguilla": "AI", "America/Antigua": "AG",
|
||||
"America/Araguaina": "BR", "America/Argentina/Buenos_Aires": "AR",
|
||||
"America/Argentina/Catamarca": "AR",
|
||||
"America/Argentina/ComodRivadavia": "AR",
|
||||
"America/Argentina/Cordoba": "AR", "America/Argentina/Jujuy": "AR",
|
||||
"America/Argentina/La_Rioja": "AR", "America/Argentina/Mendoza": "AR",
|
||||
"America/Argentina/Rio_Gallegos": "AR", "America/Argentina/Salta": "AR",
|
||||
"America/Argentina/San_Juan": "AR", "America/Argentina/San_Luis": "AR",
|
||||
"America/Argentina/Tucuman": "AR", "America/Argentina/Ushuaia": "AR",
|
||||
"America/Aruba": "AW", "America/Asuncion": "PY", "America/Atikokan": "CA",
|
||||
"America/Atka": "US", "America/Bahia": "BR",
|
||||
"America/Bahia_Banderas": "MX", "America/Barbados": "BB",
|
||||
"America/Belem": "BR", "America/Belize": "BZ",
|
||||
"America/Blanc-Sablon": "CA", "America/Boa_Vista": "BR",
|
||||
"America/Bogota": "CO", "America/Boise": "US",
|
||||
"America/Buenos_Aires": "AR", "America/Cambridge_Bay": "CA",
|
||||
"America/Campo_Grande": "BR", "America/Cancun": "MX",
|
||||
"America/Caracas": "VE", "America/Catamarca": "AR",
|
||||
"America/Cayenne": "GF", "America/Cayman": "KY", "America/Chicago": "US",
|
||||
"America/Chihuahua": "MX", "America/Ciudad_Juarez": "MX",
|
||||
"America/Coral_Harbour": "CA", "America/Cordoba": "AR",
|
||||
"America/Costa_Rica": "CR", "America/Coyhaique": "CL",
|
||||
"America/Creston": "CA", "America/Cuiaba": "BR", "America/Curacao": "CW",
|
||||
"America/Danmarkshavn": "GL", "America/Dawson": "CA",
|
||||
"America/Dawson_Creek": "CA", "America/Denver": "US",
|
||||
"America/Detroit": "US", "America/Dominica": "DM",
|
||||
"America/Edmonton": "CA", "America/Eirunepe": "BR",
|
||||
"America/El_Salvador": "SV", "America/Ensenada": "MX",
|
||||
"America/Fort_Nelson": "CA", "America/Fort_Wayne": "US",
|
||||
"America/Fortaleza": "BR", "America/Glace_Bay": "CA",
|
||||
"America/Godthab": "GL", "America/Goose_Bay": "CA",
|
||||
"America/Grand_Turk": "TC", "America/Grenada": "GD",
|
||||
"America/Guadeloupe": "GP", "America/Guatemala": "GT",
|
||||
"America/Guayaquil": "EC", "America/Guyana": "GY",
|
||||
"America/Halifax": "CA", "America/Havana": "CU",
|
||||
"America/Hermosillo": "MX", "America/Indiana/Indianapolis": "US",
|
||||
"America/Indiana/Knox": "US", "America/Indiana/Marengo": "US",
|
||||
"America/Indiana/Petersburg": "US", "America/Indiana/Tell_City": "US",
|
||||
"America/Indiana/Vevay": "US", "America/Indiana/Vincennes": "US",
|
||||
"America/Indiana/Winamac": "US", "America/Indianapolis": "US",
|
||||
"America/Inuvik": "CA", "America/Iqaluit": "CA", "America/Jamaica": "JM",
|
||||
"America/Jujuy": "AR", "America/Juneau": "US",
|
||||
"America/Kentucky/Louisville": "US", "America/Kentucky/Monticello": "US",
|
||||
"America/Knox_IN": "US", "America/Kralendijk": "BQ",
|
||||
"America/La_Paz": "BO", "America/Lima": "PE", "America/Los_Angeles": "US",
|
||||
"America/Louisville": "US", "America/Lower_Princes": "SX",
|
||||
"America/Maceio": "BR", "America/Managua": "NI", "America/Manaus": "BR",
|
||||
"America/Marigot": "MF", "America/Martinique": "MQ",
|
||||
"America/Matamoros": "MX", "America/Mazatlan": "MX",
|
||||
"America/Mendoza": "AR", "America/Menominee": "US",
|
||||
"America/Merida": "MX", "America/Metlakatla": "US",
|
||||
"America/Mexico_City": "MX", "America/Miquelon": "PM",
|
||||
"America/Moncton": "CA", "America/Monterrey": "MX",
|
||||
"America/Montevideo": "UY", "America/Montreal": "BS",
|
||||
"America/Montserrat": "MS", "America/Nassau": "BS",
|
||||
"America/New_York": "US", "America/Nipigon": "BS", "America/Nome": "US",
|
||||
"America/Noronha": "BR", "America/North_Dakota/Beulah": "US",
|
||||
"America/North_Dakota/Center": "US",
|
||||
"America/North_Dakota/New_Salem": "US", "America/Nuuk": "GL",
|
||||
"America/Ojinaga": "MX", "America/Panama": "PA",
|
||||
"America/Pangnirtung": "CA", "America/Paramaribo": "SR",
|
||||
"America/Phoenix": "US", "America/Port-au-Prince": "HT",
|
||||
"America/Port_of_Spain": "TT", "America/Porto_Acre": "BR",
|
||||
"America/Porto_Velho": "BR", "America/Puerto_Rico": "PR",
|
||||
"America/Punta_Arenas": "CL", "America/Rainy_River": "CA",
|
||||
"America/Rankin_Inlet": "CA", "America/Recife": "BR",
|
||||
"America/Regina": "CA", "America/Resolute": "CA",
|
||||
"America/Rio_Branco": "BR", "America/Rosario": "AR",
|
||||
"America/Santa_Isabel": "MX", "America/Santarem": "BR",
|
||||
"America/Santiago": "CL", "America/Santo_Domingo": "DO",
|
||||
"America/Sao_Paulo": "BR", "America/Scoresbysund": "GL",
|
||||
"America/Shiprock": "US", "America/Sitka": "US",
|
||||
"America/St_Barthelemy": "BL", "America/St_Johns": "CA",
|
||||
"America/St_Kitts": "KN", "America/St_Lucia": "LC",
|
||||
"America/St_Thomas": "VI", "America/St_Vincent": "VC",
|
||||
"America/Swift_Current": "CA", "America/Tegucigalpa": "HN",
|
||||
"America/Thule": "GL", "America/Thunder_Bay": "BS",
|
||||
"America/Tijuana": "MX", "America/Toronto": "CA", "America/Tortola": "VG",
|
||||
"America/Vancouver": "CA", "America/Virgin": "AG",
|
||||
"America/Whitehorse": "CA", "America/Winnipeg": "CA",
|
||||
"America/Yakutat": "US", "America/Yellowknife": "CA",
|
||||
"Antarctica/Casey": "AQ", "Antarctica/Davis": "AQ",
|
||||
"Antarctica/DumontDUrville": "AQ", "Antarctica/Macquarie": "AU",
|
||||
"Antarctica/Mawson": "AQ", "Antarctica/McMurdo": "AQ",
|
||||
"Antarctica/Palmer": "AQ", "Antarctica/Rothera": "AQ",
|
||||
"Antarctica/South_Pole": "AQ", "Antarctica/Syowa": "AQ",
|
||||
"Antarctica/Troll": "AQ", "Antarctica/Vostok": "AQ",
|
||||
"Arctic/Longyearbyen": "SJ", "Asia/Aden": "YE", "Asia/Almaty": "KZ",
|
||||
"Asia/Amman": "JO", "Asia/Anadyr": "RU", "Asia/Aqtau": "KZ",
|
||||
"Asia/Aqtobe": "KZ", "Asia/Ashgabat": "TM", "Asia/Ashkhabad": "TM",
|
||||
"Asia/Atyrau": "KZ", "Asia/Baghdad": "IQ", "Asia/Bahrain": "BH",
|
||||
"Asia/Baku": "AZ", "Asia/Bangkok": "TH", "Asia/Barnaul": "RU",
|
||||
"Asia/Beirut": "LB", "Asia/Bishkek": "KG", "Asia/Brunei": "BN",
|
||||
"Asia/Calcutta": "IN", "Asia/Chita": "RU", "Asia/Choibalsan": "MN",
|
||||
"Asia/Chongqing": "CN", "Asia/Chungking": "CN", "Asia/Colombo": "LK",
|
||||
"Asia/Dacca": "BD", "Asia/Damascus": "SY", "Asia/Dhaka": "BD",
|
||||
"Asia/Dili": "TL", "Asia/Dubai": "AE", "Asia/Dushanbe": "TJ",
|
||||
"Asia/Famagusta": "CY", "Asia/Gaza": "PS", "Asia/Harbin": "CN",
|
||||
"Asia/Hebron": "PS", "Asia/Ho_Chi_Minh": "VN", "Asia/Hong_Kong": "HK",
|
||||
"Asia/Hovd": "MN", "Asia/Irkutsk": "RU", "Asia/Istanbul": "TR",
|
||||
"Asia/Jakarta": "ID", "Asia/Jayapura": "ID", "Asia/Jerusalem": "IL",
|
||||
"Asia/Kabul": "AF", "Asia/Kamchatka": "RU", "Asia/Karachi": "PK",
|
||||
"Asia/Kashgar": "CN", "Asia/Kathmandu": "NP", "Asia/Katmandu": "NP",
|
||||
"Asia/Khandyga": "RU", "Asia/Kolkata": "IN", "Asia/Krasnoyarsk": "RU",
|
||||
"Asia/Kuala_Lumpur": "MY", "Asia/Kuching": "MY", "Asia/Kuwait": "KW",
|
||||
"Asia/Macao": "MO", "Asia/Macau": "MO", "Asia/Magadan": "RU",
|
||||
"Asia/Makassar": "ID", "Asia/Manila": "PH", "Asia/Muscat": "OM",
|
||||
"Asia/Nicosia": "CY", "Asia/Novokuznetsk": "RU", "Asia/Novosibirsk": "RU",
|
||||
"Asia/Omsk": "RU", "Asia/Oral": "KZ", "Asia/Phnom_Penh": "KH",
|
||||
"Asia/Pontianak": "ID", "Asia/Pyongyang": "KP", "Asia/Qatar": "QA",
|
||||
"Asia/Qostanay": "KZ", "Asia/Qyzylorda": "KZ", "Asia/Rangoon": "CC",
|
||||
"Asia/Riyadh": "SA", "Asia/Saigon": "VN", "Asia/Sakhalin": "RU",
|
||||
"Asia/Samarkand": "UZ", "Asia/Seoul": "KR", "Asia/Shanghai": "CN",
|
||||
"Asia/Singapore": "SG", "Asia/Srednekolymsk": "RU", "Asia/Taipei": "TW",
|
||||
"Asia/Tashkent": "UZ", "Asia/Tbilisi": "GE", "Asia/Tehran": "IR",
|
||||
"Asia/Tel_Aviv": "IL", "Asia/Thimbu": "BT", "Asia/Thimphu": "BT",
|
||||
"Asia/Tokyo": "JP", "Asia/Tomsk": "RU", "Asia/Ujung_Pandang": "ID",
|
||||
"Asia/Ulaanbaatar": "MN", "Asia/Ulan_Bator": "MN", "Asia/Urumqi": "CN",
|
||||
"Asia/Ust-Nera": "RU", "Asia/Vientiane": "LA", "Asia/Vladivostok": "RU",
|
||||
"Asia/Yakutsk": "RU", "Asia/Yangon": "MM", "Asia/Yekaterinburg": "RU",
|
||||
"Asia/Yerevan": "AM", "Atlantic/Azores": "PT", "Atlantic/Bermuda": "BM",
|
||||
"Atlantic/Canary": "ES", "Atlantic/Cape_Verde": "CV",
|
||||
"Atlantic/Faeroe": "FO", "Atlantic/Faroe": "FO",
|
||||
"Atlantic/Jan_Mayen": "DE", "Atlantic/Madeira": "PT",
|
||||
"Atlantic/Reykjavik": "IS", "Atlantic/South_Georgia": "GS",
|
||||
"Atlantic/St_Helena": "SH", "Atlantic/Stanley": "FK",
|
||||
"Australia/ACT": "AU", "Australia/Adelaide": "AU",
|
||||
"Australia/Brisbane": "AU", "Australia/Broken_Hill": "AU",
|
||||
"Australia/Canberra": "AU", "Australia/Currie": "AU",
|
||||
"Australia/Darwin": "AU", "Australia/Eucla": "AU",
|
||||
"Australia/Hobart": "AU", "Australia/LHI": "AU",
|
||||
"Australia/Lindeman": "AU", "Australia/Lord_Howe": "AU",
|
||||
"Australia/Melbourne": "AU", "Australia/NSW": "AU",
|
||||
"Australia/North": "AU", "Australia/Perth": "AU",
|
||||
"Australia/Queensland": "AU", "Australia/South": "AU",
|
||||
"Australia/Sydney": "AU", "Australia/Tasmania": "AU",
|
||||
"Australia/Victoria": "AU", "Australia/West": "AU",
|
||||
"Australia/Yancowinna": "AU", "Brazil/Acre": "BR",
|
||||
"Brazil/DeNoronha": "BR", "Brazil/East": "BR", "Brazil/West": "BR",
|
||||
"CET": "BE", "CST6CDT": "US", "Canada/Atlantic": "CA",
|
||||
"Canada/Central": "CA", "Canada/Eastern": "BS", "Canada/Mountain": "CA",
|
||||
"Canada/Newfoundland": "CA", "Canada/Pacific": "CA",
|
||||
"Canada/Saskatchewan": "CA", "Canada/Yukon": "CA",
|
||||
"Chile/Continental": "CL", "Chile/EasterIsland": "CL", "Cuba": "CU",
|
||||
"EET": "GR", "EST": "CA", "EST5EDT": "US", "Egypt": "EG", "Eire": "IE",
|
||||
"Europe/Amsterdam": "NL", "Europe/Andorra": "AD",
|
||||
"Europe/Astrakhan": "RU", "Europe/Athens": "GR", "Europe/Belfast": "GB",
|
||||
"Europe/Belgrade": "RS", "Europe/Berlin": "DE", "Europe/Bratislava": "SK",
|
||||
"Europe/Brussels": "BE", "Europe/Bucharest": "RO",
|
||||
"Europe/Budapest": "HU", "Europe/Busingen": "DE", "Europe/Chisinau": "MD",
|
||||
"Europe/Copenhagen": "DK", "Europe/Dublin": "IE",
|
||||
"Europe/Gibraltar": "GI", "Europe/Guernsey": "GG",
|
||||
"Europe/Helsinki": "FI", "Europe/Isle_of_Man": "IM",
|
||||
"Europe/Istanbul": "TR", "Europe/Jersey": "JE",
|
||||
"Europe/Kaliningrad": "RU", "Europe/Kiev": "UA", "Europe/Kirov": "RU",
|
||||
"Europe/Kyiv": "UA", "Europe/Lisbon": "PT", "Europe/Ljubljana": "SI",
|
||||
"Europe/London": "GB", "Europe/Luxembourg": "LU", "Europe/Madrid": "ES",
|
||||
"Europe/Malta": "MT", "Europe/Mariehamn": "AX", "Europe/Minsk": "BY",
|
||||
"Europe/Monaco": "MC", "Europe/Moscow": "RU", "Europe/Nicosia": "CY",
|
||||
"Europe/Oslo": "NO", "Europe/Paris": "FR", "Europe/Podgorica": "ME",
|
||||
"Europe/Prague": "CZ", "Europe/Riga": "LV", "Europe/Rome": "IT",
|
||||
"Europe/Samara": "RU", "Europe/San_Marino": "SM", "Europe/Sarajevo": "BA",
|
||||
"Europe/Saratov": "RU", "Europe/Simferopol": "UA", "Europe/Skopje": "MK",
|
||||
"Europe/Sofia": "BG", "Europe/Stockholm": "SE", "Europe/Tallinn": "EE",
|
||||
"Europe/Tirane": "AL", "Europe/Tiraspol": "MD", "Europe/Ulyanovsk": "RU",
|
||||
"Europe/Uzhgorod": "UA", "Europe/Vaduz": "LI", "Europe/Vatican": "VA",
|
||||
"Europe/Vienna": "AT", "Europe/Vilnius": "LT", "Europe/Volgograd": "RU",
|
||||
"Europe/Warsaw": "PL", "Europe/Zagreb": "HR", "Europe/Zaporozhye": "UA",
|
||||
"Europe/Zurich": "CH", "GB": "GB", "GB-Eire": "GB", "HST": "US",
|
||||
"Hongkong": "HK", "Iceland": "BF", "Indian/Antananarivo": "MG",
|
||||
"Indian/Chagos": "IO", "Indian/Christmas": "CX", "Indian/Cocos": "CC",
|
||||
"Indian/Comoro": "KM", "Indian/Kerguelen": "TF", "Indian/Mahe": "SC",
|
||||
"Indian/Maldives": "MV", "Indian/Mauritius": "MU", "Indian/Mayotte": "YT",
|
||||
"Indian/Reunion": "RE", "Iran": "IR", "Israel": "IL", "Jamaica": "JM",
|
||||
"Japan": "JP", "Kwajalein": "MH", "Libya": "LY", "MET": "BE", "MST": "CA",
|
||||
"MST7MDT": "US", "Mexico/BajaNorte": "MX", "Mexico/BajaSur": "MX",
|
||||
"Mexico/General": "MX", "NZ": "AQ", "NZ-CHAT": "NZ", "Navajo": "US",
|
||||
"PRC": "CN", "PST8PDT": "US", "Pacific/Apia": "WS",
|
||||
"Pacific/Auckland": "NZ", "Pacific/Bougainville": "PG",
|
||||
"Pacific/Chatham": "NZ", "Pacific/Chuuk": "FM", "Pacific/Easter": "CL",
|
||||
"Pacific/Efate": "VU", "Pacific/Enderbury": "KI", "Pacific/Fakaofo": "TK",
|
||||
"Pacific/Fiji": "FJ", "Pacific/Funafuti": "TV", "Pacific/Galapagos": "EC",
|
||||
"Pacific/Gambier": "PF", "Pacific/Guadalcanal": "SB",
|
||||
"Pacific/Guam": "GU", "Pacific/Honolulu": "US", "Pacific/Johnston": "US",
|
||||
"Pacific/Kanton": "KI", "Pacific/Kiritimati": "KI",
|
||||
"Pacific/Kosrae": "FM", "Pacific/Kwajalein": "MH", "Pacific/Majuro": "MH",
|
||||
"Pacific/Marquesas": "PF", "Pacific/Midway": "UM", "Pacific/Nauru": "NR",
|
||||
"Pacific/Niue": "NU", "Pacific/Norfolk": "NF", "Pacific/Noumea": "NC",
|
||||
"Pacific/Pago_Pago": "AS", "Pacific/Palau": "PW",
|
||||
"Pacific/Pitcairn": "PN", "Pacific/Pohnpei": "FM", "Pacific/Ponape": "FM",
|
||||
"Pacific/Port_Moresby": "PG", "Pacific/Rarotonga": "CK",
|
||||
"Pacific/Saipan": "MP", "Pacific/Samoa": "AS", "Pacific/Tahiti": "PF",
|
||||
"Pacific/Tarawa": "KI", "Pacific/Tongatapu": "TO", "Pacific/Truk": "AQ",
|
||||
"Pacific/Wake": "UM", "Pacific/Wallis": "WF", "Pacific/Yap": "AQ",
|
||||
"Poland": "PL", "Portugal": "PT", "ROC": "TW", "ROK": "KR",
|
||||
"Singapore": "MY", "Turkey": "TR", "US/Alaska": "US", "US/Aleutian": "US",
|
||||
"US/Arizona": "CA", "US/Central": "US", "US/East-Indiana": "US",
|
||||
"US/Eastern": "US", "US/Hawaii": "US", "US/Indiana-Starke": "US",
|
||||
"US/Michigan": "US", "US/Mountain": "US", "US/Pacific": "US",
|
||||
"US/Samoa": "AS", "W-SU": "RU", "WET": "PT"
|
||||
}
|
||||
|
||||
var FALLBACK = "en"
|
||||
|
||||
// Offset-only zones such as Etc/GMT+5 have no country and get the fallback.
|
||||
function languageFor(zoneId) {
|
||||
var id = String(zoneId)
|
||||
if (OVERRIDES[id] && LANGUAGES[OVERRIDES[id]]) return OVERRIDES[id]
|
||||
// countryFor, not ZONE_COUNTRY, so the alias corrections apply.
|
||||
var key = COUNTRIES[countryFor(id)]
|
||||
return (key && LANGUAGES[key]) ? key : FALLBACK
|
||||
}
|
||||
|
||||
// { text, roman, language, key } for a zone at a local hour.
|
||||
function greeting(zoneId, hour) {
|
||||
var key = languageFor(zoneId)
|
||||
var bands = LANGUAGES[key].bands
|
||||
var h = Math.max(0, Math.min(23, Math.floor(Number(hour))))
|
||||
if (!isFinite(h)) h = 0
|
||||
var band = bands[0]
|
||||
for (var i = 1; i < bands.length; i++) {
|
||||
if (bands[i].from > h) break
|
||||
band = bands[i]
|
||||
}
|
||||
return { text: band.text, roman: band.roman, language: LANGUAGES[key].name, key: key }
|
||||
}
|
||||
|
||||
function languageKeys() {
|
||||
var out = []
|
||||
for (var key in LANGUAGES) out.push(key)
|
||||
return out
|
||||
}
|
||||
|
||||
function zoneIds() {
|
||||
var out = []
|
||||
for (var id in ZONE_COUNTRY) out.push(id)
|
||||
return out
|
||||
}
|
||||
|
||||
// "" for the offset-only zones.
|
||||
function countryFor(zoneId) {
|
||||
var id = String(zoneId)
|
||||
return ALIAS_COUNTRY[id] || ZONE_COUNTRY[id] || ""
|
||||
}
|
||||
|
||||
function countryCodes() {
|
||||
var out = []
|
||||
for (var code in COUNTRIES) out.push(code)
|
||||
return out
|
||||
}
|
||||
|
||||
function bandsOf(key) { return LANGUAGES[key].bands }
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
greeting: greeting,
|
||||
languageFor: languageFor,
|
||||
countryFor: countryFor,
|
||||
languageKeys: languageKeys,
|
||||
zoneIds: zoneIds,
|
||||
countryCodes: countryCodes,
|
||||
bandsOf: bandsOf
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "GlobeModel.js" as Solar
|
||||
|
||||
// "World [globe] Clock" under an arched caption. The little globe is the door
|
||||
// into globe mode and, once the big globe has left it, the cross back out.
|
||||
Column {
|
||||
id: hero
|
||||
|
||||
property string caption: ""
|
||||
property color captionColor: dim
|
||||
property bool captionClickable: false
|
||||
property color foreground: Color.foreground
|
||||
property color dim: Qt.darker(foreground, 1.55)
|
||||
property string fontFamily: Style.font.family
|
||||
|
||||
// 0 is the list, 1 is the globe filling the panel.
|
||||
property real zoom: 0
|
||||
property bool globeEnabled: true
|
||||
property real bigGlobeOpacity: 0
|
||||
|
||||
// Where the little globe rests, and the city it marks.
|
||||
property real restLon: 0
|
||||
property bool markerShown: false
|
||||
property real markerLat: 0
|
||||
property real markerLon: 0
|
||||
|
||||
readonly property Item icon: heroIcon
|
||||
readonly property bool iconHovered: heroMouse.containsMouse
|
||||
readonly property real spin: heroIcon.spin
|
||||
|
||||
signal captionClicked()
|
||||
signal globeClicked(bool slow)
|
||||
|
||||
// Three turns that land on `lon`; null replays the last endpoints.
|
||||
function spinOpening(lon) {
|
||||
globeSpin.stop()
|
||||
if (lon !== null) {
|
||||
globeSpin.from = lon - 1080
|
||||
globeSpin.to = lon
|
||||
}
|
||||
globeSpin.restart()
|
||||
}
|
||||
|
||||
// `from` is passed in: once the focus changes, the rest binding has already moved spin.
|
||||
function turn(from, lon) {
|
||||
globeSpin.stop()
|
||||
focusSpin.from = from
|
||||
focusSpin.to = from + Solar.shortestTurn(from, lon)
|
||||
focusSpin.restart()
|
||||
}
|
||||
|
||||
spacing: Style.space(2)
|
||||
|
||||
ArcText {
|
||||
width: parent.width
|
||||
text: hero.caption
|
||||
rise: Style.space(6)
|
||||
color: hero.captionColor
|
||||
fontFamily: hero.fontFamily
|
||||
pixelSize: Style.font.caption
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: hero.captionClickable
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: hero.captionClicked()
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Style.space(9)
|
||||
|
||||
TitleWord { text: "World" }
|
||||
|
||||
Item {
|
||||
id: heroIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: Math.round(Style.font.display * 1.3)
|
||||
implicitHeight: Math.round(Style.font.display * 1.3)
|
||||
|
||||
// Degrees of longitude facing the viewer; the globe draws the spin itself.
|
||||
property real spin: 0
|
||||
|
||||
Binding {
|
||||
target: heroIcon
|
||||
property: "spin"
|
||||
value: hero.restLon
|
||||
when: !globeSpin.running && !focusSpin.running
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
|
||||
rotation: Solar.AXIAL_TILT
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
textFormat: Text.PlainText
|
||||
text: "\u00d7"
|
||||
color: heroMouse.containsMouse ? hero.foreground : hero.dim
|
||||
font.family: hero.fontFamily
|
||||
font.pixelSize: Math.round(heroIcon.width * 0.8)
|
||||
// The globe leans with the earth; the cross stays upright.
|
||||
rotation: -Solar.AXIAL_TILT
|
||||
opacity: Util.clamp((hero.zoom - 0.2) / 0.35, 0, 1)
|
||||
visible: opacity > 0
|
||||
}
|
||||
|
||||
MiniGlobe {
|
||||
anchors.fill: parent
|
||||
opacity: 1 - hero.bigGlobeOpacity
|
||||
visible: opacity > 0
|
||||
spin: heroIcon.spin
|
||||
// Full strength: at this size a dimmed globe reads as washed out.
|
||||
color: hero.foreground
|
||||
bold: true
|
||||
showMarker: hero.markerShown
|
||||
markerLat: hero.markerLat
|
||||
markerLon: hero.markerLon
|
||||
}
|
||||
|
||||
// A MouseArea rather than a TapHandler: only mouse events carry the
|
||||
// Shift modifier that asks for slow motion.
|
||||
MouseArea {
|
||||
id: heroMouse
|
||||
anchors.fill: parent
|
||||
enabled: hero.globeEnabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: function(mouse) {
|
||||
hero.globeClicked((mouse.modifiers & Qt.ShiftModifier) !== 0)
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: globeSpin
|
||||
target: heroIcon
|
||||
property: "spin"
|
||||
duration: 1250
|
||||
easing.type: Easing.OutQuart
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: focusSpin
|
||||
target: heroIcon
|
||||
property: "spin"
|
||||
duration: 700
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
TitleWord { text: "Clock" }
|
||||
}
|
||||
|
||||
component TitleWord: Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
textFormat: Text.PlainText
|
||||
color: hero.foreground
|
||||
font.family: hero.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import "GlobeModel.js" as Solar
|
||||
import "Model.js" as Model
|
||||
|
||||
// A small drawn globe for where an icon would go. Drawn, not glyphed, so it
|
||||
// can spin: `spin` is the longitude facing the viewer; rotate the item to lean it.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real spin: 0
|
||||
property color color: Color.foreground
|
||||
// Below this size land turns to noise; draw the graticule alone.
|
||||
readonly property bool showLand: width >= 22
|
||||
|
||||
property var land: []
|
||||
|
||||
// Heavier strokes and fuller fills, for a centerpiece rather than an inline icon.
|
||||
property bool bold: false
|
||||
|
||||
// "You are here".
|
||||
property bool showMarker: false
|
||||
property real markerLat: 0
|
||||
property real markerLon: 0
|
||||
property color markerColor: Color.accent
|
||||
|
||||
readonly property real radius: Math.min(width, height) / 2 - 1
|
||||
readonly property string pluginDir: Quickshell.env("OMARCHY_PATH") + "/shell/plugins/panels/elsewhen"
|
||||
|
||||
onSpinChanged: canvas.requestPaint()
|
||||
onColorChanged: canvas.requestPaint()
|
||||
onShowMarkerChanged: canvas.requestPaint()
|
||||
onMarkerLatChanged: canvas.requestPaint()
|
||||
onMarkerLonChanged: canvas.requestPaint()
|
||||
|
||||
FileView {
|
||||
path: root.pluginDir + "/world.json"
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
try { root.land = JSON.parse(text()); canvas.requestPaint() } catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
renderStrategy: Canvas.Cooperative
|
||||
|
||||
function strokePath(ctx, pts) {
|
||||
var segs = Solar.visibleSegments(pts, root.spin, 0, root.radius)
|
||||
for (var i = 0; i < segs.length; i++) {
|
||||
ctx.moveTo(segs[i][0].x, segs[i][0].y)
|
||||
for (var j = 1; j < segs[i].length; j++) ctx.lineTo(segs[i][j].x, segs[i][j].y)
|
||||
}
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
ctx.translate(width / 2, height / 2)
|
||||
var r = root.radius
|
||||
if (r <= 0) return
|
||||
var c = root.color
|
||||
var lat, lon, pts, i
|
||||
|
||||
// The ocean, opaque like the large globe.
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = Model.mix(Color.popups.background, c, root.bold ? 0.16 : 0.13)
|
||||
ctx.fill()
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
|
||||
// Graticule first, so the land sits on top of it.
|
||||
ctx.beginPath()
|
||||
for (lon = -90; lon < 90; lon += 90) {
|
||||
pts = []
|
||||
for (lat = -90; lat <= 90; lat += 6) pts.push([lat, lon])
|
||||
strokePath(ctx, pts)
|
||||
}
|
||||
pts = []
|
||||
for (lon = -180; lon <= 180; lon += 6) pts.push([0, lon])
|
||||
strokePath(ctx, pts)
|
||||
ctx.lineWidth = Math.max(1, r * (root.bold ? 0.055 : 0.045))
|
||||
ctx.strokeStyle = Util.alpha(c, root.showLand ? (root.bold ? 0.42 : 0.30) : 0.85)
|
||||
ctx.stroke()
|
||||
|
||||
if (root.showLand && root.land.length > 0) {
|
||||
ctx.beginPath()
|
||||
for (i = 0; i < root.land.length; i++) {
|
||||
var ring = root.land[i]
|
||||
// Major landmasses only; islands read as dirt on the lens.
|
||||
if (ring.length < 40) continue
|
||||
var poly = Solar.clipRingToDisc(ring, root.spin, 0, root.radius)
|
||||
if (poly.length < 3) continue
|
||||
ctx.moveTo(poly[0].x, poly[0].y)
|
||||
for (var q = 1; q < poly.length; q++) ctx.lineTo(poly[q].x, poly[q].y)
|
||||
ctx.closePath()
|
||||
}
|
||||
ctx.fillStyle = Util.alpha(c, root.bold ? 1.0 : 0.85)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// "You are here", only while on the near side.
|
||||
if (root.showMarker) {
|
||||
var mp = Solar.project(root.markerLat, root.markerLon, root.spin, 0, r)
|
||||
if (mp.visible) {
|
||||
var mr = Math.max(1.6, r * 0.13)
|
||||
ctx.beginPath()
|
||||
ctx.arc(mp.x, mp.y, mr, 0, Math.PI * 2)
|
||||
ctx.fillStyle = root.markerColor
|
||||
ctx.fill()
|
||||
// Edged so it does not dissolve into a continent of similar lightness.
|
||||
ctx.lineWidth = Math.max(1, r * 0.04)
|
||||
ctx.strokeStyle = Util.alpha(Color.background, 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(mp.x, mp.y, mr * 1.9, 0, Math.PI * 2)
|
||||
ctx.lineWidth = Math.max(1, r * 0.045)
|
||||
ctx.strokeStyle = Util.alpha(root.markerColor, 0.65)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
|
||||
// The rim last, so nothing spills over it.
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||
ctx.lineWidth = Math.max(1, r * (root.bold ? 0.095 : 0.08))
|
||||
ctx.strokeStyle = Util.alpha(c, root.bold ? 1.0 : 0.95)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
// QML has no Intl, so each zone's UTC offset comes from a `date` probe and the
|
||||
// clocks tick locally against it.
|
||||
|
||||
var DEFAULT_ZONES = "Los Angeles|America/Los_Angeles, Paris|Europe/Paris, Tokyo|Asia/Tokyo"
|
||||
|
||||
var WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
// Keeps the list free of entries that could never name a zone.
|
||||
var ZONE_ID = /^[A-Za-z0-9_+\-\/]+$/
|
||||
|
||||
// Labels live in a "Label|Zone, Label|Zone" string, so they may not carry either delimiter.
|
||||
function cleanLabel(label) {
|
||||
return String(label || "").replace(/[,|]/g, " ").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
// "Los Angeles|America/Los_Angeles, Asia/Tokyo" -> [{label, id}, ...]. Empty means a fresh install.
|
||||
function parseZones(spec) {
|
||||
var text = String(spec === undefined || spec === null ? "" : spec)
|
||||
var out = []
|
||||
var parts = text.split(",")
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var entry = parts[i].trim()
|
||||
if (entry === "") continue
|
||||
var fields = entry.split("|")
|
||||
var label = ""
|
||||
var id = ""
|
||||
if (fields.length >= 2) {
|
||||
label = fields[0].trim()
|
||||
id = fields[1].trim()
|
||||
} else {
|
||||
id = entry
|
||||
label = entry.split("/").pop().replace(/_/g, " ")
|
||||
}
|
||||
if (id === "" || !ZONE_ID.test(id)) continue
|
||||
if (label === "") label = id.split("/").pop().replace(/_/g, " ")
|
||||
out.push({ label: label, id: id })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the date probe
|
||||
|
||||
// argv for one `date` per zone, printing "Asia/Tokyo|JST|+0900". Zones are
|
||||
// arguments, never script text. `withLocal` adds "LOCAL|<zone>" plus that zone's own line.
|
||||
function probeCommand(ids, withLocal) {
|
||||
var script = "for z in \"$@\"; do TZ=\"$z\" date \"+$z|%Z|%z\"; done"
|
||||
if (withLocal)
|
||||
script = "tz=$(timedatectl show -p Timezone --value); "
|
||||
+ "printf 'LOCAL|%s\\n' \"$tz\"; TZ=\"$tz\" date \"+$tz|%Z|%z\"; " + script
|
||||
return ["bash", "-c", script, "bash"].concat(ids || [])
|
||||
}
|
||||
|
||||
// "-0700" -> -420, or null.
|
||||
function parseOffset(text) {
|
||||
var m = /^([+-])(\d{2})(\d{2})$/.exec(String(text || "").trim())
|
||||
if (!m) return null
|
||||
var minutes = parseInt(m[2], 10) * 60 + parseInt(m[3], 10)
|
||||
return m[1] === "-" ? -minutes : minutes
|
||||
}
|
||||
|
||||
function parseProbeLine(line) {
|
||||
var fields = String(line || "").split("|")
|
||||
if (fields.length < 3) return null
|
||||
var id = fields[0].trim()
|
||||
var offset = parseOffset(fields[2])
|
||||
if (id === "" || offset === null) return null
|
||||
return { id: id, abbr: fields[1].trim(), offsetMinutes: offset }
|
||||
}
|
||||
|
||||
function localZoneFromProbe(text) {
|
||||
var lines = String(text || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var f = lines[i].split("|")
|
||||
if (f.length >= 2 && f[0].trim() === "LOCAL") return f[1].trim()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Probe stdout -> { "America/Los_Angeles": {abbr, offsetMinutes}, ... }
|
||||
function parseProbe(text) {
|
||||
var map = {}
|
||||
var lines = String(text || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var parsed = parseProbeLine(lines[i])
|
||||
if (parsed) map[parsed.id] = { abbr: parsed.abbr, offsetMinutes: parsed.offsetMinutes }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// Absolute offset: "UTC+2", "UTC-3:30", "UTC". Minutes only when they are not zero.
|
||||
function utcOffsetLabel(minutes) {
|
||||
if (minutes === undefined || minutes === null) return ""
|
||||
var total = Number(minutes)
|
||||
if (!isFinite(total)) return ""
|
||||
total = Math.round(total)
|
||||
if (total === 0) return "UTC"
|
||||
var abs = Math.abs(total)
|
||||
var hours = Math.floor(abs / 60)
|
||||
var mins = abs % 60
|
||||
return "UTC" + (total < 0 ? "-" : "+") + hours
|
||||
+ (mins === 0 ? "" : ":" + (mins < 10 ? "0" : "") + mins)
|
||||
}
|
||||
|
||||
// ---- clock faces
|
||||
|
||||
// Shifting the instant by the offset and reading UTC getters gives the zone's wall clock.
|
||||
function zoneParts(nowMs, offsetMinutes) {
|
||||
var d = new Date(Number(nowMs) + Number(offsetMinutes) * 60000)
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth(),
|
||||
day: d.getUTCDate(),
|
||||
weekday: d.getUTCDay(),
|
||||
hour: d.getUTCHours(),
|
||||
minute: d.getUTCMinutes()
|
||||
}
|
||||
}
|
||||
|
||||
function localParts(nowMs) {
|
||||
var d = new Date(Number(nowMs))
|
||||
return {
|
||||
year: d.getFullYear(),
|
||||
month: d.getMonth(),
|
||||
day: d.getDate(),
|
||||
weekday: d.getDay(),
|
||||
hour: d.getHours(),
|
||||
minute: d.getMinutes()
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar fields only, so DST cannot skew the count.
|
||||
function dayDelta(parts, reference) {
|
||||
var a = Date.UTC(parts.year, parts.month, parts.day)
|
||||
var b = Date.UTC(reference.year, reference.month, reference.day)
|
||||
return Math.round((a - b) / 86400000)
|
||||
}
|
||||
|
||||
function dayLabel(delta) {
|
||||
if (delta === 0) return ""
|
||||
if (delta === 1) return "Tomorrow"
|
||||
if (delta === -1) return "Yesterday"
|
||||
return delta > 0 ? "+" + delta + " days" : delta + " days"
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return (n < 10 ? "0" : "") + n
|
||||
}
|
||||
|
||||
function formatTime(parts, hour24) {
|
||||
if (hour24) return pad2(parts.hour) + ":" + pad2(parts.minute)
|
||||
var h = parts.hour % 12
|
||||
if (h === 0) h = 12
|
||||
return h + ":" + pad2(parts.minute)
|
||||
}
|
||||
|
||||
function meridiem(parts) {
|
||||
return parts.hour < 12 ? "AM" : "PM"
|
||||
}
|
||||
|
||||
// "14:05" or "2:05 PM" at a zone offset; "" before the offset is known.
|
||||
function formatClock(nowMs, offsetMinutes, hour24) {
|
||||
if (offsetMinutes === undefined || offsetMinutes === null) return ""
|
||||
var parts = zoneParts(nowMs, offsetMinutes)
|
||||
return formatTime(parts, hour24) + (hour24 ? "" : " " + meridiem(parts))
|
||||
}
|
||||
|
||||
function formatDate(parts) {
|
||||
return WEEKDAYS[parts.weekday].slice(0, 3) + " " + MONTHS[parts.month] + " " + parts.day
|
||||
}
|
||||
|
||||
function isDaytime(parts) {
|
||||
return parts.hour >= 6 && parts.hour < 18
|
||||
}
|
||||
|
||||
// Fixed civil hours, used until a city's real sunrise is known.
|
||||
var DAY_START = 6
|
||||
var DAY_END = 18
|
||||
var DAWN_END = 8
|
||||
var DUSK_START = 17
|
||||
|
||||
function phaseFor(parts) {
|
||||
var h = parts.hour
|
||||
if (h < DAY_START || h >= DAY_END + 2) return "night"
|
||||
if (h < DAWN_END) return "dawn"
|
||||
if (h < DUSK_START) return "day"
|
||||
return "dusk"
|
||||
}
|
||||
|
||||
function phaseLabel(phase) {
|
||||
if (phase === "dawn") return "sunrise"
|
||||
if (phase === "dusk") return "sunset"
|
||||
if (phase === "night") return "night"
|
||||
return "daytime"
|
||||
}
|
||||
|
||||
function dayProgress(parts) {
|
||||
return (parts.hour * 60 + parts.minute) / 1440
|
||||
}
|
||||
|
||||
function daylightStart() { return DAY_START / 24 }
|
||||
function daylightEnd() { return DAY_END / 24 }
|
||||
|
||||
// Geometric, not phaseFor(): "dusk" runs past the end of the lit band.
|
||||
function inDaylight(progress) {
|
||||
return progress >= daylightStart() && progress < daylightEnd()
|
||||
}
|
||||
|
||||
// "+9h" or "-3.5h" from the viewer's clock; "" on the viewer's own offset.
|
||||
function relativeOffsetLabel(zoneOffsetMinutes, localOffsetMinutes) {
|
||||
var diff = Number(zoneOffsetMinutes) - Number(localOffsetMinutes)
|
||||
if (diff === 0) return ""
|
||||
var hours = diff / 60
|
||||
var text = (Math.round(hours * 10) / 10).toString()
|
||||
return (diff > 0 ? "+" : "") + text + "h"
|
||||
}
|
||||
|
||||
// Everything a row needs; `ready` is false until the zone has been probed.
|
||||
function rowFor(zone, probe, nowMs, localOffsetMinutes, hour24) {
|
||||
var info = probe ? probe[zone.id] : null
|
||||
if (!info) return { label: zone.label, id: zone.id, ready: false }
|
||||
var parts = zoneParts(nowMs, info.offsetMinutes)
|
||||
var here = localParts(nowMs)
|
||||
var delta = dayDelta(parts, here)
|
||||
return {
|
||||
label: zone.label,
|
||||
id: zone.id,
|
||||
ready: true,
|
||||
abbr: info.abbr,
|
||||
offsetMinutes: info.offsetMinutes,
|
||||
time: formatTime(parts, hour24),
|
||||
hour: parts.hour,
|
||||
meridiem: hour24 ? "" : meridiem(parts),
|
||||
date: formatDate(parts),
|
||||
dayLabel: dayLabel(delta),
|
||||
daytime: isDaytime(parts),
|
||||
phase: phaseFor(parts),
|
||||
lit: inDaylight(dayProgress(parts)),
|
||||
phaseLabel: phaseLabel(phaseFor(parts)),
|
||||
progress: dayProgress(parts),
|
||||
relative: relativeOffsetLabel(info.offsetMinutes, localOffsetMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
function rows(zones, probe, nowMs, localOffsetMinutes, hour24) {
|
||||
var out = []
|
||||
for (var i = 0; i < zones.length; i++)
|
||||
out.push(rowFor(zones[i], probe, nowMs, localOffsetMinutes, hour24))
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- editing
|
||||
|
||||
// Rows and globe cities share no index space, so they meet on (label, zone id).
|
||||
function indexOfZone(zones, label, id) {
|
||||
var list = zones || []
|
||||
for (var i = 0; i < list.length; i++)
|
||||
if (list[i].label === label && list[i].id === id) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
// Focus is held as a key so it follows the city when `zones` is replaced.
|
||||
function indexOfZoneKey(zones, key) {
|
||||
var list = zones || []
|
||||
if (!key) return -1
|
||||
for (var i = 0; i < list.length; i++)
|
||||
if (factsKey(list[i]) === key) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
function labelForZoneId(id) {
|
||||
return String(id || "").split("/").pop().replace(/_/g, " ")
|
||||
}
|
||||
|
||||
function serializeZones(zones) {
|
||||
var parts = []
|
||||
for (var i = 0; i < zones.length; i++) {
|
||||
var z = zones[i]
|
||||
if (!z || !z.id) continue
|
||||
parts.push(z.label && z.label !== "" ? z.label + "|" + z.id : z.id)
|
||||
}
|
||||
return parts.join(", ")
|
||||
}
|
||||
|
||||
// Two cities may share a zone, so an entry is its label and zone together.
|
||||
function hasEntry(zones, id, label) {
|
||||
for (var i = 0; i < zones.length; i++)
|
||||
if (zones[i].id === id && zones[i].label === label) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns the same array when nothing changes, so callers can skip the write.
|
||||
function addZone(zones, id, label) {
|
||||
var zoneId = String(id || "").trim()
|
||||
if (zoneId === "" || !ZONE_ID.test(zoneId)) return zones
|
||||
var name = cleanLabel(label)
|
||||
if (name === "") name = labelForZoneId(zoneId)
|
||||
if (hasEntry(zones, zoneId, name)) return zones
|
||||
var out = zones.slice()
|
||||
out.push({ label: name, id: zoneId })
|
||||
return out
|
||||
}
|
||||
|
||||
// By position: with two rows on one zone, an id cannot say which was clicked.
|
||||
function removeZoneAt(zones, index) {
|
||||
if (zones.length <= 1 || index < 0 || index >= zones.length) return zones
|
||||
var out = zones.slice()
|
||||
out.splice(index, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
function moveZone(zones, from, to) {
|
||||
var n = zones.length
|
||||
if (from === to || from < 0 || from >= n || to < 0 || to >= n) return zones
|
||||
var out = zones.slice()
|
||||
out.splice(to, 0, out.splice(from, 1)[0])
|
||||
return out
|
||||
}
|
||||
|
||||
// The last city is never removed.
|
||||
function removeZone(zones, id) {
|
||||
if (zones.length <= 1) return zones
|
||||
var out = []
|
||||
for (var i = 0; i < zones.length; i++) if (zones[i].id !== id) out.push(zones[i])
|
||||
return out.length === zones.length ? zones : out
|
||||
}
|
||||
|
||||
// `timedatectl list-timezones` plus CITY_ALIASES -> picker options, minus `existing`.
|
||||
function zoneOptions(text, existing) {
|
||||
var lines = String(text || "").split("\n")
|
||||
var seen = {}
|
||||
var out = []
|
||||
|
||||
function offer(id, label) {
|
||||
var key = label + "\u0000" + id
|
||||
if (seen[key]) return
|
||||
if (existing && hasEntry(existing, id, label)) return
|
||||
seen[key] = true
|
||||
out.push({ value: id, label: label, description: id })
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var id = lines[i].trim()
|
||||
if (id === "" || id.indexOf("/") === -1) continue
|
||||
offer(id, labelForZoneId(id))
|
||||
}
|
||||
for (var j = 0; j < CITY_ALIASES.length; j++)
|
||||
offer(CITY_ALIASES[j].id, CITY_ALIASES[j].label)
|
||||
|
||||
out.sort(function(a, b) { return a.label < b.label ? -1 : (a.label > b.label ? 1 : 0) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Matches city name or IANA id; city-name prefixes sort first.
|
||||
function searchZones(options, query, limit) {
|
||||
var q = String(query || "").trim().toLowerCase()
|
||||
var max = limit === undefined ? 6 : limit
|
||||
var starts = []
|
||||
var contains = []
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
var o = options[i]
|
||||
var label = String(o.label).toLowerCase()
|
||||
var id = String(o.value).toLowerCase()
|
||||
if (q === "") { starts.push(o); }
|
||||
else if (label.indexOf(q) === 0) starts.push(o)
|
||||
else if (label.indexOf(q) !== -1 || id.indexOf(q) !== -1) contains.push(o)
|
||||
if (starts.length >= max && q === "") break
|
||||
}
|
||||
return starts.concat(contains).slice(0, max)
|
||||
}
|
||||
|
||||
// Keyboard selection in a result list, wrapping at both ends.
|
||||
function moveSelection(index, delta, count) {
|
||||
if (count === 0) return 0
|
||||
return ((index + delta) % count + count) % count
|
||||
}
|
||||
|
||||
// Globe cities as [name, zone, lat, lon, rank]: home first, then the rest,
|
||||
// first name wins. Tracked and session cities need coordinates.
|
||||
function mergeCities(home, builtIn, tracked, session) {
|
||||
var out = []
|
||||
var seen = {}
|
||||
function add(city, needsCoords) {
|
||||
if (needsCoords && (city[2] === null || city[2] === undefined)) return
|
||||
var key = String(city[0]).toLowerCase()
|
||||
if (seen[key]) return
|
||||
seen[key] = true
|
||||
out.push(city)
|
||||
}
|
||||
if (home && home.length > 0 && home[2] !== undefined) add(home, false)
|
||||
var i
|
||||
for (i = 0; i < (builtIn || []).length; i++) add(builtIn[i], false)
|
||||
for (i = 0; i < (tracked || []).length; i++) add(tracked[i], true)
|
||||
for (i = 0; i < (session || []).length; i++) add(session[i], true)
|
||||
return out
|
||||
}
|
||||
|
||||
// Places people think of that the tz database files under another city's zone.
|
||||
var CITY_ALIASES = [
|
||||
// US Eastern
|
||||
{ label: "Miami", id: "America/New_York" },
|
||||
{ label: "Boca Raton", id: "America/New_York" },
|
||||
{ label: "Boston", id: "America/New_York" },
|
||||
{ label: "Philadelphia", id: "America/New_York" },
|
||||
{ label: "Washington DC", id: "America/New_York" },
|
||||
{ label: "Atlanta", id: "America/New_York" },
|
||||
{ label: "Orlando", id: "America/New_York" },
|
||||
{ label: "Tampa", id: "America/New_York" },
|
||||
{ label: "Charlotte", id: "America/New_York" },
|
||||
{ label: "Pittsburgh", id: "America/New_York" },
|
||||
{ label: "Cleveland", id: "America/New_York" },
|
||||
// US Central
|
||||
{ label: "Chicago", id: "America/Chicago" },
|
||||
{ label: "Austin", id: "America/Chicago" },
|
||||
{ label: "Dallas", id: "America/Chicago" },
|
||||
{ label: "Houston", id: "America/Chicago" },
|
||||
{ label: "San Antonio", id: "America/Chicago" },
|
||||
{ label: "Nashville", id: "America/Chicago" },
|
||||
{ label: "New Orleans", id: "America/Chicago" },
|
||||
{ label: "Minneapolis", id: "America/Chicago" },
|
||||
{ label: "Kansas City", id: "America/Chicago" },
|
||||
{ label: "St. Louis", id: "America/Chicago" },
|
||||
{ label: "Memphis", id: "America/Chicago" },
|
||||
// US Mountain
|
||||
{ label: "Salt Lake City", id: "America/Denver" },
|
||||
{ label: "Albuquerque", id: "America/Denver" },
|
||||
{ label: "Colorado Springs", id: "America/Denver" },
|
||||
{ label: "Boulder", id: "America/Denver" },
|
||||
// Arizona does not observe DST, so it is its own zone.
|
||||
{ label: "Tucson", id: "America/Phoenix" },
|
||||
{ label: "Scottsdale", id: "America/Phoenix" },
|
||||
// US Pacific
|
||||
{ label: "San Francisco", id: "America/Los_Angeles" },
|
||||
{ label: "San Diego", id: "America/Los_Angeles" },
|
||||
{ label: "San Jose", id: "America/Los_Angeles" },
|
||||
{ label: "Oakland", id: "America/Los_Angeles" },
|
||||
{ label: "Sacramento", id: "America/Los_Angeles" },
|
||||
{ label: "Seattle", id: "America/Los_Angeles" },
|
||||
{ label: "Portland", id: "America/Los_Angeles" },
|
||||
{ label: "Las Vegas", id: "America/Los_Angeles" },
|
||||
// Canada
|
||||
{ label: "Montreal", id: "America/Toronto" },
|
||||
{ label: "Ottawa", id: "America/Toronto" },
|
||||
{ label: "Calgary", id: "America/Edmonton" },
|
||||
{ label: "Victoria", id: "America/Vancouver" },
|
||||
// Latin America
|
||||
{ label: "Rio de Janeiro", id: "America/Sao_Paulo" },
|
||||
{ label: "Brasilia", id: "America/Sao_Paulo" },
|
||||
{ label: "Guadalajara", id: "America/Mexico_City" },
|
||||
// UK and Ireland
|
||||
{ label: "Manchester", id: "Europe/London" },
|
||||
{ label: "Edinburgh", id: "Europe/London" },
|
||||
{ label: "Glasgow", id: "Europe/London" },
|
||||
{ label: "Birmingham", id: "Europe/London" },
|
||||
{ label: "Cambridge", id: "Europe/London" },
|
||||
{ label: "Oxford", id: "Europe/London" },
|
||||
// Continental Europe
|
||||
{ label: "Munich", id: "Europe/Berlin" },
|
||||
{ label: "Frankfurt", id: "Europe/Berlin" },
|
||||
{ label: "Hamburg", id: "Europe/Berlin" },
|
||||
{ label: "Cologne", id: "Europe/Berlin" },
|
||||
{ label: "Lyon", id: "Europe/Paris" },
|
||||
{ label: "Marseille", id: "Europe/Paris" },
|
||||
{ label: "Nice", id: "Europe/Paris" },
|
||||
{ label: "Barcelona", id: "Europe/Madrid" },
|
||||
{ label: "Valencia", id: "Europe/Madrid" },
|
||||
{ label: "Seville", id: "Europe/Madrid" },
|
||||
{ label: "Milan", id: "Europe/Rome" },
|
||||
{ label: "Naples", id: "Europe/Rome" },
|
||||
{ label: "Florence", id: "Europe/Rome" },
|
||||
{ label: "Venice", id: "Europe/Rome" },
|
||||
{ label: "Turin", id: "Europe/Rome" },
|
||||
{ label: "Rotterdam", id: "Europe/Amsterdam" },
|
||||
{ label: "Geneva", id: "Europe/Zurich" },
|
||||
{ label: "Basel", id: "Europe/Zurich" },
|
||||
{ label: "Gothenburg", id: "Europe/Stockholm" },
|
||||
{ label: "Porto", id: "Europe/Lisbon" },
|
||||
{ label: "Krakow", id: "Europe/Warsaw" },
|
||||
{ label: "St Petersburg", id: "Europe/Moscow" },
|
||||
// Asia
|
||||
{ label: "Beijing", id: "Asia/Shanghai" },
|
||||
{ label: "Shenzhen", id: "Asia/Shanghai" },
|
||||
{ label: "Guangzhou", id: "Asia/Shanghai" },
|
||||
{ label: "Osaka", id: "Asia/Tokyo" },
|
||||
{ label: "Kyoto", id: "Asia/Tokyo" },
|
||||
{ label: "Yokohama", id: "Asia/Tokyo" },
|
||||
{ label: "Nagoya", id: "Asia/Tokyo" },
|
||||
{ label: "Busan", id: "Asia/Seoul" },
|
||||
{ label: "Mumbai", id: "Asia/Kolkata" },
|
||||
{ label: "Delhi", id: "Asia/Kolkata" },
|
||||
{ label: "New Delhi", id: "Asia/Kolkata" },
|
||||
{ label: "Bangalore", id: "Asia/Kolkata" },
|
||||
{ label: "Bengaluru", id: "Asia/Kolkata" },
|
||||
{ label: "Chennai", id: "Asia/Kolkata" },
|
||||
{ label: "Hyderabad", id: "Asia/Kolkata" },
|
||||
{ label: "Pune", id: "Asia/Kolkata" },
|
||||
{ label: "Abu Dhabi", id: "Asia/Dubai" },
|
||||
{ label: "Tel Aviv", id: "Asia/Jerusalem" },
|
||||
// Oceania and Africa
|
||||
{ label: "Canberra", id: "Australia/Sydney" },
|
||||
{ label: "Cape Town", id: "Africa/Johannesburg" },
|
||||
{ label: "Durban", id: "Africa/Johannesburg" },
|
||||
{ label: "Alexandria", id: "Africa/Cairo" }
|
||||
]
|
||||
|
||||
// ---- weather
|
||||
|
||||
// Coordinates and weather are keyed by "label|zone".
|
||||
function factsKey(zone) {
|
||||
return String(zone.label) + "|" + String(zone.id)
|
||||
}
|
||||
|
||||
var WEATHER_TTL_MS = 20 * 60 * 1000
|
||||
|
||||
function geocodeUrl(label) {
|
||||
return "https://geocoding-api.open-meteo.com/v1/search?count=10&language=en&format=json&name="
|
||||
+ encodeURIComponent(label)
|
||||
}
|
||||
|
||||
// { place, answered }: a place in the row's own zone beats the top result, and
|
||||
// `answered` is false when the request itself failed.
|
||||
function pickGeocode(text, zone) {
|
||||
var results
|
||||
try { results = JSON.parse(text).results || [] } catch (e) { return { place: null, answered: false } }
|
||||
var best = null
|
||||
for (var i = 0; i < results.length && !best; i++) if (results[i].timezone === zone) best = results[i]
|
||||
if (!best && results.length > 0) best = results[0]
|
||||
return { place: best ? { lat: best.latitude, lon: best.longitude } : null, answered: true }
|
||||
}
|
||||
|
||||
// The zone's representative city from zone1970.tab (ISO 6709), the offline fallback.
|
||||
function zoneTabCoords(tabText, zone) {
|
||||
var lines = String(tabText || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var parts = lines[i].split("\t")
|
||||
if (lines[i].charAt(0) === "#" || parts.length < 3 || parts[2] !== zone) continue
|
||||
var m = /^([+-])(\d{2})(\d{2})(\d{2})?([+-])(\d{3})(\d{2})(\d{2})?$/.exec(parts[1])
|
||||
if (!m) return null
|
||||
function dec(sign, d, mm, ss) { return (sign === "-" ? -1 : 1) * (Number(d) + Number(mm) / 60 + Number(ss || 0) / 3600) }
|
||||
return { lat: Math.round(dec(m[1], m[2], m[3], m[4]) * 1e4) / 1e4, lon: Math.round(dec(m[5], m[6], m[7], m[8]) * 1e4) / 1e4 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function forecastUrl(points) {
|
||||
return "https://api.open-meteo.com/v1/forecast?current=temperature_2m,weather_code&temperature_unit=celsius"
|
||||
+ "&latitude=" + points.map(function(p) { return p.lat }).join(",")
|
||||
+ "&longitude=" + points.map(function(p) { return p.lon }).join(",")
|
||||
}
|
||||
|
||||
// Open-Meteo answers one point as an object and several as an array, in request order.
|
||||
function parseForecast(text, keys, nowMs) {
|
||||
var payload
|
||||
try { payload = JSON.parse(text) } catch (e) { return {} }
|
||||
if (!Array.isArray(payload)) payload = [payload]
|
||||
var out = {}
|
||||
for (var i = 0; i < keys.length && i < payload.length; i++) {
|
||||
var current = (payload[i] && payload[i].current) || {}
|
||||
if (current.temperature_2m === undefined || current.temperature_2m === null) continue
|
||||
out[keys[i]] = { c: Math.round(current.temperature_2m * 10) / 10, w: current.weather_code, at: nowMs }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function weatherStale(entry, nowMs) {
|
||||
return !entry || nowMs - entry.at >= WEATHER_TTL_MS
|
||||
}
|
||||
|
||||
// { "label|zone": { lat, lon, c, w } }, each field only when known.
|
||||
function mergeFacts(keys, coords, weather) {
|
||||
var out = {}
|
||||
keys.forEach(function(key) {
|
||||
var entry = {}
|
||||
if (coords[key]) { entry.lat = coords[key].lat; entry.lon = coords[key].lon }
|
||||
if (weather[key]) {
|
||||
entry.c = weather[key].c
|
||||
if (weather[key].w !== undefined && weather[key].w !== null) entry.w = weather[key].w
|
||||
}
|
||||
out[key] = entry
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// An explicit "C" or "F" wins; anything else follows the system.
|
||||
function resolveUnits(setting, auto) {
|
||||
var explicit = String(setting === undefined || setting === null ? "" : setting)
|
||||
.trim().toUpperCase()
|
||||
if (explicit === "C" || explicit === "F") return explicit
|
||||
return String(auto).toUpperCase() === "F" ? "F" : "C"
|
||||
}
|
||||
|
||||
// Only a 12-hour pattern has an AM/PM designator once quoted literals are stripped.
|
||||
function usesTwentyFourHour(timeFormat) {
|
||||
var pattern = String(timeFormat === undefined || timeFormat === null ? "" : timeFormat)
|
||||
.replace(/'[^']*'/g, "")
|
||||
return !/[Aa]/.test(pattern)
|
||||
}
|
||||
|
||||
// An explicit setting wins, and a stored `false` is explicit.
|
||||
function resolveHour24(setting, auto) {
|
||||
if (setting === true || setting === false) return setting
|
||||
var text = String(setting === undefined || setting === null ? "" : setting)
|
||||
.trim().toLowerCase()
|
||||
if (text === "true" || text === "24") return true
|
||||
if (text === "false" || text === "12") return false
|
||||
return auto === true
|
||||
}
|
||||
|
||||
function formatTemp(celsius, units) {
|
||||
if (celsius === undefined || celsius === null) return ""
|
||||
if (String(units).toUpperCase() === "C") return Math.round(celsius) + "°C"
|
||||
return Math.round(celsius * 9 / 5 + 32) + "°F"
|
||||
}
|
||||
|
||||
function tempLabel(facts, units) {
|
||||
return facts ? formatTemp(facts.c, units) : ""
|
||||
}
|
||||
|
||||
// WMO present-weather codes collapsed to the five glyphs a row can show.
|
||||
function weatherKind(code) {
|
||||
// Number(null) is 0, which means "clear".
|
||||
if (code === null || code === undefined || code === "") return ""
|
||||
var c = Number(code)
|
||||
if (!isFinite(c)) return ""
|
||||
if (c <= 1) return "sunny"
|
||||
if (c === 2) return "partly"
|
||||
if (c === 3 || c === 45 || c === 48) return "cloudy"
|
||||
if (c >= 71 && c <= 77) return "snow"
|
||||
if (c === 85 || c === 86) return "snow"
|
||||
if (c >= 51 && c <= 67) return "rain"
|
||||
if (c >= 80 && c <= 82) return "rain"
|
||||
if (c >= 95 && c <= 99) return "rain"
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- scrubbing
|
||||
|
||||
var DAY_MINUTES = 1440
|
||||
|
||||
// The nearest occurrence of the local time under the pointer, in (-720, 720].
|
||||
function scrubDeltaMinutes(fraction, cityLocalMinutes) {
|
||||
var target = Math.max(0, Math.min(1, fraction)) * DAY_MINUTES
|
||||
var delta = target - cityLocalMinutes
|
||||
while (delta > DAY_MINUTES / 2) delta -= DAY_MINUTES
|
||||
while (delta <= -DAY_MINUTES / 2) delta += DAY_MINUTES
|
||||
return delta
|
||||
}
|
||||
|
||||
// Round before splitting into hour and minute, and wrap after, so 419.8 is "7:00".
|
||||
function formatMinuteOfDay(minute, hour24) {
|
||||
var m = Math.round(Number(minute))
|
||||
m = ((m % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES
|
||||
var hour = Math.floor(m / 60)
|
||||
return formatTime({ hour: hour, minute: m % 60 }, hour24)
|
||||
+ (hour24 ? "" : (hour < 12 ? " AM" : " PM"))
|
||||
}
|
||||
|
||||
// "+3h", "-45m", "" for now.
|
||||
function formatScrubDelta(minutes) {
|
||||
var m = Math.round(minutes)
|
||||
if (m === 0) return ""
|
||||
var sign = m > 0 ? "+" : "-"
|
||||
var a = Math.abs(m)
|
||||
if (a < 60) return sign + a + "m"
|
||||
var h = Math.floor(a / 60), rem = a % 60
|
||||
return sign + h + "h" + (rem ? " " + rem + "m" : "")
|
||||
}
|
||||
|
||||
// ---- the strip's sunrise arrows
|
||||
|
||||
function arrowBox(fraction, barWidth, boxWidth, tuck, rising) {
|
||||
var at = barWidth * fraction
|
||||
var pos = rising ? at - boxWidth + tuck : at - tuck
|
||||
return Math.round(Math.max(0, Math.min(barWidth - boxWidth, pos)))
|
||||
}
|
||||
|
||||
function arrowCovered(boxX, boxWidth, markerCenter, markerWidth, slack) {
|
||||
return Math.abs(markerCenter - (boxX + boxWidth / 2)) < markerWidth / 2 + slack
|
||||
}
|
||||
|
||||
// One chip shows at a time. Both rules read what showed at press time, so a
|
||||
// click comes out the same whichever handler Qt runs first.
|
||||
var NO_CHIP = -1
|
||||
|
||||
function chipAfterTap(shownAtPress, slot) {
|
||||
return shownAtPress === slot ? NO_CHIP : slot
|
||||
}
|
||||
|
||||
// Clears the chip unless something else changed it during this press.
|
||||
function chipAfterRelease(shownNow, shownAtPress) {
|
||||
return shownNow === shownAtPress ? NO_CHIP : shownNow
|
||||
}
|
||||
|
||||
// ---- globe transition and row drag
|
||||
|
||||
// Rows are knocked aside one after another as `zoom` runs 0..1, alternating sides.
|
||||
var KNOCK_STAGGER = 0.09
|
||||
var KNOCK_MAX_LEAD = 0.5
|
||||
var KNOCK_TILT = 14
|
||||
var KNOCK_SHRINK = 0.18
|
||||
|
||||
function knockAt(index, zoom) {
|
||||
var lead = Math.min(KNOCK_MAX_LEAD, index * KNOCK_STAGGER)
|
||||
return Math.max(0, Math.min(1, (zoom - lead) / (1 - lead)))
|
||||
}
|
||||
|
||||
function knockSide(index) { return index % 2 === 0 ? -1 : 1 }
|
||||
|
||||
function knockX(index, zoom, throwX) { return knockAt(index, zoom) * knockSide(index) * throwX }
|
||||
|
||||
// Squared, so rows accelerate as they fall.
|
||||
function knockY(index, zoom, fall) {
|
||||
var p = knockAt(index, zoom)
|
||||
return p * p * fall
|
||||
}
|
||||
|
||||
function knockTilt(index, zoom) { return knockAt(index, zoom) * knockSide(index) * KNOCK_TILT }
|
||||
function knockShrink(index, zoom) { return 1 - KNOCK_SHRINK * knockAt(index, zoom) }
|
||||
|
||||
// Rows the pointer must travel past the held slot before the target moves.
|
||||
var DRAG_HYSTERESIS = 0.6
|
||||
|
||||
function dragTargetFor(dragIndex, dragOffset, rowPitch, heldTarget, count) {
|
||||
if (dragIndex < 0 || rowPitch <= 0) return -1
|
||||
var raw = dragOffset / rowPitch
|
||||
var held = heldTarget < 0 ? 0 : heldTarget - dragIndex
|
||||
var next = Math.abs(raw - held) >= DRAG_HYSTERESIS ? Math.round(raw) : held
|
||||
return Math.max(0, Math.min(count - 1, dragIndex + next))
|
||||
}
|
||||
|
||||
// The dragged row follows the pointer; rows it has passed step aside by one pitch.
|
||||
function rowShift(index, dragIndex, dragTarget, dragOffset, rowPitch) {
|
||||
if (dragIndex < 0) return 0
|
||||
if (index === dragIndex) return dragOffset
|
||||
if (dragIndex < dragTarget && index > dragIndex && index <= dragTarget) return -rowPitch
|
||||
if (dragIndex > dragTarget && index >= dragTarget && index < dragIndex) return rowPitch
|
||||
return 0
|
||||
}
|
||||
|
||||
// Opaque blend of two colors: a Qt color inside QML, {r, g, b, a} under node.
|
||||
function mix(from, to, t) {
|
||||
var r = from.r + (to.r - from.r) * t
|
||||
var g = from.g + (to.g - from.g) * t
|
||||
var b = from.b + (to.b - from.b) * t
|
||||
return typeof Qt !== "undefined" ? Qt.rgba(r, g, b, 1) : { r: r, g: g, b: b, a: 1 }
|
||||
}
|
||||
|
||||
// ---- first run
|
||||
|
||||
// Home plus four well-known cities spread round the clock from it. Rank breaks ties.
|
||||
var SEED_CANDIDATES = [
|
||||
{ label: "Honolulu", id: "Pacific/Honolulu", rank: 3 },
|
||||
{ label: "Anchorage", id: "America/Anchorage", rank: 3 },
|
||||
{ label: "Los Angeles", id: "America/Los_Angeles", rank: 2 },
|
||||
{ label: "Vancouver", id: "America/Vancouver", rank: 3 },
|
||||
{ label: "Mexico City", id: "America/Mexico_City", rank: 3 },
|
||||
{ label: "Chicago", id: "America/Chicago", rank: 3 },
|
||||
{ label: "New York", id: "America/New_York", rank: 1 },
|
||||
{ label: "Sao Paulo", id: "America/Sao_Paulo", rank: 2 },
|
||||
{ label: "Buenos Aires", id: "America/Argentina/Buenos_Aires", rank: 3 },
|
||||
{ label: "Reykjavik", id: "Atlantic/Reykjavik", rank: 3 },
|
||||
{ label: "London", id: "Europe/London", rank: 1 },
|
||||
{ label: "Lisbon", id: "Europe/Lisbon", rank: 3 },
|
||||
{ label: "Paris", id: "Europe/Paris", rank: 1 },
|
||||
{ label: "Berlin", id: "Europe/Berlin", rank: 3 },
|
||||
{ label: "Madrid", id: "Europe/Madrid", rank: 3 },
|
||||
{ label: "Rome", id: "Europe/Rome", rank: 3 },
|
||||
{ label: "Cairo", id: "Africa/Cairo", rank: 3 },
|
||||
{ label: "Johannesburg", id: "Africa/Johannesburg", rank: 3 },
|
||||
{ label: "Istanbul", id: "Europe/Istanbul", rank: 3 },
|
||||
{ label: "Moscow", id: "Europe/Moscow", rank: 3 },
|
||||
{ label: "Nairobi", id: "Africa/Nairobi", rank: 3 },
|
||||
{ label: "Dubai", id: "Asia/Dubai", rank: 2 },
|
||||
{ label: "Karachi", id: "Asia/Karachi", rank: 3 },
|
||||
{ label: "Delhi", id: "Asia/Kolkata", rank: 2 },
|
||||
{ label: "Bangkok", id: "Asia/Bangkok", rank: 3 },
|
||||
{ label: "Jakarta", id: "Asia/Jakarta", rank: 3 },
|
||||
{ label: "Singapore", id: "Asia/Singapore", rank: 2 },
|
||||
{ label: "Hong Kong", id: "Asia/Hong_Kong", rank: 2 },
|
||||
{ label: "Shanghai", id: "Asia/Shanghai", rank: 2 },
|
||||
{ label: "Perth", id: "Australia/Perth", rank: 3 },
|
||||
{ label: "Seoul", id: "Asia/Seoul", rank: 3 },
|
||||
{ label: "Tokyo", id: "Asia/Tokyo", rank: 1 },
|
||||
{ label: "Sydney", id: "Australia/Sydney", rank: 2 },
|
||||
{ label: "Auckland", id: "Pacific/Auckland", rank: 3 }
|
||||
]
|
||||
|
||||
function seedCandidateZones() {
|
||||
var out = []
|
||||
for (var i = 0; i < SEED_CANDIDATES.length; i++) out.push(SEED_CANDIDATES[i].id)
|
||||
return out
|
||||
}
|
||||
|
||||
// Minutes east of home, wrapped to one turn of the dial.
|
||||
function eastOf(offsetMinutes, homeMinutes) {
|
||||
var d = (offsetMinutes - homeMinutes) % DAY_MINUTES
|
||||
return d < 0 ? d + DAY_MINUTES : d
|
||||
}
|
||||
|
||||
function dialDistance(a, b) {
|
||||
var d = Math.abs(a - b) % DAY_MINUTES
|
||||
return Math.min(d, DAY_MINUTES - d)
|
||||
}
|
||||
|
||||
// Best-ranked candidates first, each at least `gap` minutes from home and from every pick.
|
||||
function pickSeedAt(home, offsets, count, gap) {
|
||||
var homeOff = offsets[home.id]
|
||||
var homeLabel = String(home.label || "").toLowerCase()
|
||||
var picked = []
|
||||
for (var r = 1; r <= 3; r++) {
|
||||
for (var j = 0; j < SEED_CANDIDATES.length && picked.length < count; j++) {
|
||||
var c = SEED_CANDIDATES[j]
|
||||
if (c.rank !== r) continue
|
||||
var off = offsets[c.id]
|
||||
if (off === undefined || off === null) continue
|
||||
if (c.id === home.id) continue
|
||||
if (String(c.label).toLowerCase() === homeLabel) continue
|
||||
var east = eastOf(off, homeOff)
|
||||
if (Math.min(east, DAY_MINUTES - east) < gap) continue
|
||||
var clash = false
|
||||
for (var k = 0; k < picked.length; k++)
|
||||
if (dialDistance(picked[k].east, east) < gap) { clash = true; break }
|
||||
if (clash) continue
|
||||
picked.push({ label: c.label, id: c.id, east: east })
|
||||
}
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
// `offsets` maps zone id to minutes east of UTC and must include home's.
|
||||
function pickSeedZones(home, offsets, count) {
|
||||
var n = count === undefined ? 4 : count
|
||||
if (!home || !home.id || !offsets) return []
|
||||
if (offsets[home.id] === undefined || offsets[home.id] === null) return []
|
||||
|
||||
// Relax the gap where half the world sits within a couple of hours of home.
|
||||
var picked = []
|
||||
var gaps = [180, 120, 60]
|
||||
for (var g = 0; g < gaps.length; g++) {
|
||||
picked = pickSeedAt(home, offsets, n, gaps[g])
|
||||
if (picked.length >= n) break
|
||||
}
|
||||
|
||||
picked.sort(function (a, b) { return a.east - b.east })
|
||||
|
||||
var out = []
|
||||
for (var m = 0; m < picked.length; m++)
|
||||
out.push({ label: picked[m].label, id: picked[m].id })
|
||||
return out
|
||||
}
|
||||
|
||||
function seedZones(home, offsets, count) {
|
||||
if (!home || !home.id) return []
|
||||
var out = [{ label: home.label, id: home.id }]
|
||||
var rest = pickSeedZones(home, offsets, count)
|
||||
for (var i = 0; i < rest.length; i++) out.push(rest[i])
|
||||
return out
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
DEFAULT_ZONES: DEFAULT_ZONES,
|
||||
NO_CHIP: NO_CHIP,
|
||||
SEED_CANDIDATES: SEED_CANDIDATES,
|
||||
parseZones: parseZones,
|
||||
probeCommand: probeCommand,
|
||||
parseOffset: parseOffset,
|
||||
parseProbe: parseProbe,
|
||||
localZoneFromProbe: localZoneFromProbe,
|
||||
utcOffsetLabel: utcOffsetLabel,
|
||||
relativeOffsetLabel: relativeOffsetLabel,
|
||||
zoneParts: zoneParts,
|
||||
localParts: localParts,
|
||||
formatTime: formatTime,
|
||||
meridiem: meridiem,
|
||||
formatClock: formatClock,
|
||||
daylightStart: daylightStart,
|
||||
daylightEnd: daylightEnd,
|
||||
rows: rows,
|
||||
indexOfZone: indexOfZone,
|
||||
indexOfZoneKey: indexOfZoneKey,
|
||||
labelForZoneId: labelForZoneId,
|
||||
serializeZones: serializeZones,
|
||||
addZone: addZone,
|
||||
removeZoneAt: removeZoneAt,
|
||||
moveZone: moveZone,
|
||||
removeZone: removeZone,
|
||||
zoneOptions: zoneOptions,
|
||||
searchZones: searchZones,
|
||||
moveSelection: moveSelection,
|
||||
mergeCities: mergeCities,
|
||||
factsKey: factsKey,
|
||||
geocodeUrl: geocodeUrl,
|
||||
pickGeocode: pickGeocode,
|
||||
zoneTabCoords: zoneTabCoords,
|
||||
forecastUrl: forecastUrl,
|
||||
parseForecast: parseForecast,
|
||||
weatherStale: weatherStale,
|
||||
mergeFacts: mergeFacts,
|
||||
resolveUnits: resolveUnits,
|
||||
usesTwentyFourHour: usesTwentyFourHour,
|
||||
resolveHour24: resolveHour24,
|
||||
formatTemp: formatTemp,
|
||||
tempLabel: tempLabel,
|
||||
weatherKind: weatherKind,
|
||||
scrubDeltaMinutes: scrubDeltaMinutes,
|
||||
formatMinuteOfDay: formatMinuteOfDay,
|
||||
formatScrubDelta: formatScrubDelta,
|
||||
arrowBox: arrowBox,
|
||||
arrowCovered: arrowCovered,
|
||||
chipAfterTap: chipAfterTap,
|
||||
chipAfterRelease: chipAfterRelease,
|
||||
knockAt: knockAt,
|
||||
knockX: knockX,
|
||||
knockY: knockY,
|
||||
knockTilt: knockTilt,
|
||||
knockShrink: knockShrink,
|
||||
dragTargetFor: dragTargetFor,
|
||||
rowShift: rowShift,
|
||||
mix: mix,
|
||||
seedCandidateZones: seedCandidateZones,
|
||||
pickSeedZones: pickSeedZones,
|
||||
seedZones: seedZones
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import "GlobeModel.js" as Solar
|
||||
|
||||
// The night marker drawn as the moon's current phase. The faint full disc
|
||||
// keeps it findable at new moon; the lit part is filled solid on top.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real phase: 0 // 0 new, 0.25 first quarter, 0.5 full
|
||||
property color color: Color.foreground
|
||||
|
||||
onPhaseChanged: canvas.requestPaint()
|
||||
onColorChanged: canvas.requestPaint()
|
||||
|
||||
Canvas {
|
||||
id: canvas
|
||||
anchors.fill: parent
|
||||
renderStrategy: Canvas.Cooperative
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var r = Math.min(width, height) / 2
|
||||
if (r <= 0) return
|
||||
ctx.translate(width / 2, height / 2)
|
||||
var c = root.color
|
||||
|
||||
// The whole disc, faint.
|
||||
ctx.beginPath()
|
||||
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = Util.alpha(c, 0.22)
|
||||
ctx.fill()
|
||||
|
||||
// The lit part, solid.
|
||||
var lit = Solar.moonLitOutline(root.phase, r, 28)
|
||||
if (lit.length > 2) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lit[0].x, lit[0].y)
|
||||
for (var i = 1; i < lit.length; i++) ctx.lineTo(lit[i].x, lit[i].y)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = c
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,888 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Model.js" as Model
|
||||
import "GlobeModel.js" as Solar
|
||||
|
||||
// A bar button that opens a panel of clocks, one row per city, and zooms into
|
||||
// a globe. QML has no Intl, so a `date` probe supplies each zone's offset and
|
||||
// the rows tick locally against it; the probe re-runs on open and every five
|
||||
// minutes to catch DST.
|
||||
Panel {
|
||||
id: root
|
||||
moduleName: "omarchy.elsewhen"
|
||||
ipcTarget: "omarchy.elsewhen"
|
||||
manageIpc: false
|
||||
|
||||
|
||||
// ---- settings
|
||||
readonly property var zones: Model.parseZones(setting("zones", ""))
|
||||
readonly property var zoneIds: zones.map(function(z) { return z.id })
|
||||
// Never written: the first probe seeds the list around the local zone.
|
||||
readonly property bool needsSeed: String(setting("zones", "")).trim() === ""
|
||||
readonly property var probeIds: needsSeed ? zoneIds.concat(Model.seedCandidateZones()) : zoneIds
|
||||
|
||||
readonly property bool autoHour24: Model.usesTwentyFourHour(Qt.locale().timeFormat(Locale.ShortFormat))
|
||||
readonly property bool hour24: Model.resolveHour24(setting("hour24", ""), autoHour24)
|
||||
// "home" shows the distance from here ("+2h"), "utc" the zone itself ("UTC-5").
|
||||
readonly property string offsetMode: setting("offsetMode", "home") === "utc" ? "utc" : "home"
|
||||
// Only the US measurement system means Fahrenheit.
|
||||
readonly property string autoUnits: Qt.locale().measurementSystem === Locale.ImperialUSSystem ? "F" : "C"
|
||||
readonly property string units: Model.resolveUnits(setting("units", ""), autoUnits)
|
||||
readonly property bool globeEnabled: setting("globeEnabled", true) !== false
|
||||
// Draw the globe with less in it while it moves.
|
||||
readonly property bool smoothMotion: setting("smoothMotion", true) !== false
|
||||
|
||||
function toggleHour24() { persistSettings({ hour24: !hour24 }) }
|
||||
function toggleOffsetMode() { persistSettings({ offsetMode: offsetMode === "utc" ? "home" : "utc" }) }
|
||||
function toggleUnits() { persistSettings({ units: units === "C" ? "F" : "C" }) }
|
||||
|
||||
function offsetTextFor(rowData) {
|
||||
if (!rowData || !rowData.ready) return ""
|
||||
return offsetMode === "utc" ? Model.utcOffsetLabel(rowData.offsetMinutes) : rowData.relative
|
||||
}
|
||||
|
||||
// ---- state
|
||||
property var probe: ({})
|
||||
property var searchProbe: ({})
|
||||
property string zoneCatalogText: ""
|
||||
property var facts: ({})
|
||||
// Geocoder answers are final for the session; fallbacks are retried.
|
||||
property var geo: ({})
|
||||
property var fallbackGeo: ({})
|
||||
property var weather: ({})
|
||||
// Cities jumped to on the globe; never persisted.
|
||||
property var sessionCities: []
|
||||
property int localOffsetMinutes: -(new Date().getTimezoneOffset())
|
||||
property string localZone: ""
|
||||
property double nowMs: Date.now()
|
||||
property bool probeQueued: false
|
||||
property bool searchProbeQueued: false
|
||||
property bool factsQueued: false
|
||||
|
||||
readonly property bool adding: citySearch.active
|
||||
onAddingChanged: if (!adding) scroller.scrollToTop()
|
||||
readonly property var zoneOptions: Model.zoneOptions(zoneCatalogText, zones)
|
||||
// The globe's jump box may go to a city that is already tracked.
|
||||
readonly property var allZoneOptions: Model.zoneOptions(zoneCatalogText, [])
|
||||
readonly property var searchZoneIds: {
|
||||
var out = []
|
||||
for (var i = 0; i < citySearch.matches.length; i++) {
|
||||
var id = citySearch.matches[i].value
|
||||
if (out.indexOf(id) < 0) out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
onSearchZoneIdsChanged: probeSearchZones()
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||
readonly property color dim: Qt.darker(foreground, 1.55)
|
||||
readonly property color fainter: Qt.darker(foreground, 2.1)
|
||||
// A literal gold: several themes' "yellow" is not yellow.
|
||||
readonly property color daylightMarker: "#E5C736"
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
|
||||
// ---- the zoom
|
||||
// One number drives the list-to-globe transition: 0 is the list, 1 the globe.
|
||||
property bool globeMode: false
|
||||
property real zoom: globeMode ? 1 : 0
|
||||
readonly property bool zoomIdle: zoom === 0 || zoom === 1
|
||||
readonly property real bigGlobeOpacity: Util.clamp(zoom / 0.12, 0, 1)
|
||||
readonly property real globeStageHeight: Style.space(378)
|
||||
// Far enough for the top row to fall past the whole list.
|
||||
readonly property real knockFall: listWrap.implicitHeight + Style.space(220)
|
||||
|
||||
// Shift-click the globe to run the transition at a third of the speed.
|
||||
readonly property int slowMotionFactor: 3
|
||||
// Set before globeMode flips, so the animation never reads a stale duration.
|
||||
property int zoomDuration: 800
|
||||
property int zoomEasing: Easing.OutQuart
|
||||
|
||||
function setGlobeMode(on, slow) {
|
||||
zoomDuration = (on ? 800 : 500) * (slow === true ? slowMotionFactor : 1)
|
||||
zoomEasing = on ? Easing.OutQuart : Easing.InOutCubic
|
||||
globeMode = on
|
||||
if (on && globeLoader.item) showFocusOnGlobe()
|
||||
}
|
||||
|
||||
// The globe opens on the list's focus, when its coordinates are known.
|
||||
function showFocusOnGlobe() {
|
||||
var g = globeLoader.item
|
||||
if (!g) return
|
||||
if (focusIndex >= 0 && focusKnown) g.goTo(focusZone.label, focusZone.id)
|
||||
else g.showHome()
|
||||
}
|
||||
|
||||
function focusFromGlobe(label, zone) {
|
||||
var i = Model.indexOfZone(zones, label, zone)
|
||||
if (i >= 0) focusOn(i)
|
||||
}
|
||||
|
||||
// The little globe's center in stage coordinates, where the big one grows
|
||||
// from. The unused sum makes it recompute when the layout above moves.
|
||||
readonly property var heroCenter: {
|
||||
var _ = hero.icon.width + hero.icon.height + stage.y + stage.width
|
||||
var pt = hero.icon.mapToItem(stage, hero.icon.width / 2, hero.icon.height / 2)
|
||||
return (pt && pt.x !== undefined) ? pt : null
|
||||
}
|
||||
readonly property real heroCenterX: heroCenter ? heroCenter.x : stage.width / 2
|
||||
readonly property real heroCenterY: heroCenter ? heroCenter.y : 0
|
||||
readonly property real heroDiscRadius: Math.max(1, hero.icon.width / 2 - 1)
|
||||
|
||||
// ---- drag to reorder
|
||||
// Rows move by transform while dragging; the order is committed on drop.
|
||||
property int dragIndex: -1
|
||||
property real dragOffset: 0
|
||||
property real rowPitch: 0
|
||||
property int dragTarget: -1
|
||||
|
||||
function beginRowDrag(index, pitch) {
|
||||
dropAnimation.stop()
|
||||
dragIndex = index
|
||||
dragTarget = index
|
||||
rowPitch = pitch
|
||||
}
|
||||
|
||||
function moveRowDrag(offset) {
|
||||
dragOffset = offset
|
||||
dragTarget = Model.dragTargetFor(dragIndex, dragOffset, rowPitch, dragTarget, zones.length)
|
||||
}
|
||||
|
||||
// Glide into the slot, then commit.
|
||||
function releaseRowDrag() {
|
||||
if (dragIndex < 0) { cancelRowDrag(); return }
|
||||
dropAnimation.to = (dragTarget - dragIndex) * rowPitch
|
||||
dropAnimation.restart()
|
||||
}
|
||||
|
||||
function commitRowDrag() {
|
||||
if (dragIndex >= 0 && dragTarget >= 0 && dragTarget !== dragIndex)
|
||||
persistSettings({ zones: Model.serializeZones(Model.moveZone(zones, dragIndex, dragTarget)) })
|
||||
cancelRowDrag()
|
||||
}
|
||||
|
||||
function cancelRowDrag() {
|
||||
dropAnimation.stop()
|
||||
dragIndex = -1
|
||||
dragTarget = -1
|
||||
dragOffset = 0
|
||||
}
|
||||
|
||||
// ---- scrubbing
|
||||
// Set absolutely from the pointer, held briefly after release, then back to now.
|
||||
property real scrubMinutes: 0
|
||||
readonly property double effectiveMs: nowMs + scrubMinutes * 60000
|
||||
readonly property string scrubLabel: Model.formatScrubDelta(scrubMinutes)
|
||||
|
||||
// Measured against the unscrubbed present, so a drag cannot drift.
|
||||
function scrubTo(rowData, fraction) {
|
||||
if (!rowData || !rowData.ready) return
|
||||
var parts = Model.zoneParts(nowMs, rowData.offsetMinutes)
|
||||
scrubMinutes = Model.scrubDeltaMinutes(fraction, parts.hour * 60 + parts.minute)
|
||||
}
|
||||
|
||||
function beginScrub(rowData, fraction) {
|
||||
scrubHold.stop()
|
||||
scrubTo(rowData, fraction)
|
||||
}
|
||||
|
||||
function endScrub() { scrubHold.restart() }
|
||||
|
||||
// ---- the moon
|
||||
// Shift-click a moon marker to walk the phase through a lunation.
|
||||
property bool moonShowing: false
|
||||
property real moonDemo: -1
|
||||
readonly property var moonShowStops: [0, 0.12, 0.25, 0.38, 0.5, 0.62, 0.75, 0.88, 1]
|
||||
property int moonShowStep: 0
|
||||
readonly property real moonPhase: moonShowing && moonDemo >= 0 ? moonDemo : Solar.moonPhase(effectiveMs)
|
||||
|
||||
function startMoonShow() {
|
||||
moonShowStep = 0
|
||||
moonShowing = true
|
||||
moonDemo = moonShowStops[0]
|
||||
moonShowTimer.restart()
|
||||
}
|
||||
|
||||
function stopMoonShow() {
|
||||
moonShowTimer.stop()
|
||||
// Before the value, so the tween does not run the phase back to -1.
|
||||
moonShowing = false
|
||||
moonDemo = -1
|
||||
}
|
||||
|
||||
// ---- places
|
||||
readonly property var clockRows: Model.rows(zones, probe, effectiveMs, localOffsetMinutes, hour24)
|
||||
|
||||
// The system zone names a representative city; `homeCity` overrides it.
|
||||
readonly property string homeCity: {
|
||||
var override = String(setting("homeCity", "")).trim()
|
||||
if (override !== "") return override
|
||||
return localZone === "" ? "" : Model.labelForZoneId(localZone)
|
||||
}
|
||||
readonly property var homeZone: ({ label: homeCity, id: localZone })
|
||||
|
||||
function placeOf(zone) {
|
||||
var f = facts[Model.factsKey(zone)]
|
||||
return (f && f.lat !== undefined && f.lon !== undefined) ? f : null
|
||||
}
|
||||
|
||||
readonly property var homePlace: homeCity === "" || localZone === "" ? null : placeOf(homeZone)
|
||||
|
||||
// Matched by name: Miami should not light up New York.
|
||||
readonly property var trackedNames: zones.map(function(z) { return String(z.label).toLowerCase() })
|
||||
|
||||
function globeRows(list, rank) {
|
||||
var out = []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var f = placeOf(list[i])
|
||||
if (f) out.push([list[i].label, list[i].id, f.lat, f.lon, rank])
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property var trackedCities: globeRows(zones, 0)
|
||||
readonly property var sessionPlaces: globeRows(sessionCities, 1)
|
||||
|
||||
// Held as the city's key, not a row index, so a reorder carries the focus
|
||||
// with the city and a removal drops it back to home.
|
||||
property string focusKey: ""
|
||||
readonly property int focusIndex: Model.indexOfZoneKey(zones, focusKey)
|
||||
readonly property var focusZone: focusIndex >= 0 ? zones[focusIndex] : homeZone
|
||||
readonly property var focusPlace: placeOf(focusZone)
|
||||
readonly property bool focusKnown: focusPlace !== null
|
||||
readonly property real focusLat: focusKnown ? focusPlace.lat : 0
|
||||
readonly property real focusLon: focusKnown ? focusPlace.lon : 0
|
||||
|
||||
function focusOn(index) {
|
||||
var from = hero.spin
|
||||
focusKey = index >= 0 && index < zones.length ? Model.factsKey(zones[index]) : ""
|
||||
if (focusKnown) hero.turn(from, focusLon)
|
||||
}
|
||||
|
||||
// Surrogate pairs rather than literal glyphs, which re-encoding can break.
|
||||
function weatherGlyph(zone) {
|
||||
var f = facts[Model.factsKey(zone)]
|
||||
if (!f || f.w === undefined) return ""
|
||||
var kind = Model.weatherKind(f.w)
|
||||
if (kind === "sunny") return "\udb81\udda8" // white-balance-sunny: a solid disc
|
||||
if (kind === "partly") return "\udb81\udd95"
|
||||
if (kind === "cloudy") return "\udb81\udd90"
|
||||
if (kind === "rain") return "\udb81\udd97"
|
||||
if (kind === "snow") return "\udb81\udd98"
|
||||
return ""
|
||||
}
|
||||
|
||||
readonly property string localTime: {
|
||||
var here = Model.localParts(effectiveMs)
|
||||
var text = Model.formatTime(here, hour24)
|
||||
return hour24 ? text : text + " " + Model.meridiem(here)
|
||||
}
|
||||
|
||||
// No full stop: a period hanging off the arc reads as a speck.
|
||||
readonly property string hereLine: "It's " + localTime + " here"
|
||||
+ (homeCity !== "" ? " in " + homeCity : "")
|
||||
+ (scrubLabel !== "" ? " " + scrubLabel : "")
|
||||
|
||||
// A capital's line box has empty space above it; the row's top pad drops by it.
|
||||
FontMetrics {
|
||||
id: nameFontMetrics
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: nameCapMetrics
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.subtitle
|
||||
text: "M"
|
||||
}
|
||||
|
||||
readonly property real capGap: Math.max(0,
|
||||
nameFontMetrics.height - nameFontMetrics.descent + nameCapMetrics.tightBoundingRect.y)
|
||||
|
||||
function tick() {
|
||||
nowMs = Date.now()
|
||||
localOffsetMinutes = -(new Date().getTimezoneOffset())
|
||||
}
|
||||
|
||||
// Written straight back to this widget's shell.json entry, like the clock's.
|
||||
function persistSettings(values) {
|
||||
var entry = { id: root.moduleName }
|
||||
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
|
||||
for (var key in values) entry[key] = values[key]
|
||||
|
||||
root.settings = entry
|
||||
if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function")
|
||||
root.bar.shell.updateEntryInline(root.moduleName, entry)
|
||||
}
|
||||
|
||||
function setZones(next) {
|
||||
if (next === zones) return
|
||||
persistSettings({ zones: Model.serializeZones(next) })
|
||||
refresh()
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// The label matters as much as the zone: many cities share one.
|
||||
function addCity(id, label) { setZones(Model.addZone(zones, id, label || "")) }
|
||||
function removeCity(id) { setZones(Model.removeZone(zones, id)) }
|
||||
function removeCityAt(index) { setZones(Model.removeZoneAt(zones, index)) }
|
||||
|
||||
function startAdding() { citySearch.start() }
|
||||
|
||||
function addSessionCity(label, id) {
|
||||
for (var i = 0; i < sessionCities.length; i++)
|
||||
if (sessionCities[i].label === label && sessionCities[i].id === id) return
|
||||
sessionCities = sessionCities.concat([{ label: label, id: id }])
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// The local city plus four places spread round the clock from it.
|
||||
function seedFirstRun() {
|
||||
var offs = ({})
|
||||
for (var id in probe) offs[id] = probe[id].offsetMinutes
|
||||
|
||||
var seeded = localZone === "" ? []
|
||||
: Model.seedZones({ label: Model.labelForZoneId(localZone), id: localZone }, offs, 4)
|
||||
if (seeded.length === 0) seeded = Model.parseZones(Model.DEFAULT_ZONES)
|
||||
if (seeded.length === 0) return
|
||||
|
||||
persistSettings({ zones: Model.serializeZones(seeded) })
|
||||
refresh()
|
||||
refreshFacts()
|
||||
}
|
||||
|
||||
// ---- processes
|
||||
// Each refresh queues behind one in flight rather than being dropped.
|
||||
function refresh() {
|
||||
if (probeIds.length === 0) return
|
||||
if (probeProc.running) { probeQueued = true; return }
|
||||
probeProc.running = true
|
||||
}
|
||||
|
||||
function probeSearchZones() {
|
||||
if (searchZoneIds.length === 0) return
|
||||
if (searchProc.running) { searchProbeQueued = true; return }
|
||||
searchProc.running = true
|
||||
}
|
||||
|
||||
function refreshFacts() {
|
||||
if (zones.length === 0) return
|
||||
if (geoProc.running || weatherProc.running) { factsQueued = true; return }
|
||||
var missing = factsRequest.filter(function(row) { return !geo[Model.factsKey(row)] })
|
||||
if (missing.length === 0) { fetchWeather(); return }
|
||||
geoProc.rows = missing
|
||||
geoProc.command = ["bash", "-c", 'for url; do curl -fsS --max-time 8 "$url"; printf "\\n\\036\\n"; done', "bash"]
|
||||
.concat(missing.map(function(row) { return Model.geocodeUrl(row.label) }))
|
||||
geoProc.running = true
|
||||
}
|
||||
|
||||
function coordsFor(key) {
|
||||
return geo[key] || fallbackGeo[key] || null
|
||||
}
|
||||
|
||||
function fetchWeather() {
|
||||
var now = Date.now()
|
||||
var stale = factsRequest.map(Model.factsKey).filter(function(key) {
|
||||
return coordsFor(key) && Model.weatherStale(weather[key], now)
|
||||
})
|
||||
if (stale.length === 0) { publishFacts(); return }
|
||||
weatherProc.keys = stale
|
||||
weatherProc.command = ["curl", "-fsS", "--max-time", "8", Model.forecastUrl(stale.map(coordsFor))]
|
||||
weatherProc.running = true
|
||||
}
|
||||
|
||||
function publishFacts() {
|
||||
var coords = {}
|
||||
var keys = factsRequest.map(Model.factsKey)
|
||||
keys.forEach(function(key) { coords[key] = coordsFor(key) })
|
||||
facts = Model.mergeFacts(keys, coords, weather)
|
||||
if (!factsQueued) return
|
||||
factsQueued = false
|
||||
Qt.callLater(refreshFacts)
|
||||
}
|
||||
|
||||
function loadCatalog() {
|
||||
if (zoneCatalogText !== "" || catalogProc.running) return
|
||||
catalogProc.running = true
|
||||
}
|
||||
|
||||
function utcLabelFor(zoneId) {
|
||||
var known = searchProbe[zoneId]
|
||||
return known === undefined ? "" : Model.utcOffsetLabel(known.offsetMinutes)
|
||||
}
|
||||
|
||||
// Tracked cities, the city you are in (for the globe's home) and session cities.
|
||||
readonly property var factsRequest: {
|
||||
var out = zones.map(function(z) { return { label: z.label, id: z.id } })
|
||||
if (localZone !== "" && homeCity !== "") out.push({ label: homeCity, id: localZone })
|
||||
return out.concat(sessionCities)
|
||||
}
|
||||
|
||||
// ---- opening spin
|
||||
// It lands on the home meridian, so it waits for home's coordinates, read
|
||||
// straight from the facts: focusKnown can flip a beat before focusLon lands.
|
||||
property bool spinPending: false
|
||||
|
||||
function homeMeridian() {
|
||||
var f = facts[Model.factsKey(focusZone)]
|
||||
return (f && f.lon !== undefined && f.lon !== null) ? f.lon : null
|
||||
}
|
||||
|
||||
function startOpeningSpin() {
|
||||
var lon = homeMeridian()
|
||||
if (lon === null) { spinPending = true; spinFallback.restart(); return }
|
||||
spinPending = false
|
||||
spinFallback.stop()
|
||||
hero.spinOpening(lon)
|
||||
}
|
||||
|
||||
onFactsChanged: if (spinPending) startOpeningSpin()
|
||||
|
||||
// No coordinates on a cold cache without network: spin anyway.
|
||||
Timer {
|
||||
id: spinFallback
|
||||
interval: 1500
|
||||
onTriggered: if (root.spinPending) { root.spinPending = false; hero.spinOpening(null) }
|
||||
}
|
||||
|
||||
onOpenedChanged: {
|
||||
if (opened) {
|
||||
focusKey = ""
|
||||
tick(); refresh(); refreshFacts(); loadCatalog()
|
||||
startOpeningSpin()
|
||||
} else {
|
||||
citySearch.stop(); setGlobeMode(false, false); scrubMinutes = 0
|
||||
spinPending = false; spinFallback.stop()
|
||||
}
|
||||
}
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Process {
|
||||
id: probeProc
|
||||
command: Model.probeCommand(root.probeIds, true)
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.probe = Model.parseProbe(text)
|
||||
var lz = Model.localZoneFromProbe(text)
|
||||
if (lz !== "") root.localZone = lz
|
||||
if (root.needsSeed) root.seedFirstRun()
|
||||
root.tick()
|
||||
Qt.callLater(function() {
|
||||
if (!root.probeQueued) return
|
||||
root.probeQueued = false
|
||||
root.refresh()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merged, so a known offset stays on screen while the next probe runs.
|
||||
Process {
|
||||
id: searchProc
|
||||
command: Model.probeCommand(root.searchZoneIds, false)
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var merged = {}
|
||||
for (var known in root.searchProbe) merged[known] = root.searchProbe[known]
|
||||
var fresh = Model.parseProbe(text)
|
||||
for (var id in fresh) merged[id] = fresh[id]
|
||||
root.searchProbe = merged
|
||||
Qt.callLater(function() {
|
||||
if (!root.searchProbeQueued) return
|
||||
root.searchProbeQueued = false
|
||||
root.probeSearchZones()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: catalogProc
|
||||
command: ["timedatectl", "list-timezones"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.zoneCatalogText = text
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: zoneTab
|
||||
path: "/usr/share/zoneinfo/zone1970.tab"
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: geoProc
|
||||
property var rows: []
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var answers = text.split("\n\u001e\n")
|
||||
var nextGeo = Object.assign({}, root.geo)
|
||||
var nextFallback = {}
|
||||
geoProc.rows.forEach(function(row, i) {
|
||||
var key = Model.factsKey(row)
|
||||
var found = Model.pickGeocode(answers[i] || "", row.id)
|
||||
var place = found.place || Model.zoneTabCoords(zoneTab.text(), row.id)
|
||||
if (!place) return
|
||||
if (found.answered) nextGeo[key] = place
|
||||
else nextFallback[key] = place
|
||||
})
|
||||
root.geo = nextGeo
|
||||
root.fallbackGeo = nextFallback
|
||||
root.fetchWeather()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: weatherProc
|
||||
property var keys: []
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.weather = Object.assign({}, root.weather, Model.parseForecast(text, weatherProc.keys, Date.now()))
|
||||
root.publishFacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 900000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.refreshFacts()
|
||||
}
|
||||
|
||||
// Heavy on the way out, brisk on the way back.
|
||||
Behavior on zoom {
|
||||
NumberAnimation {
|
||||
duration: root.zoomDuration
|
||||
easing.type: root.zoomEasing
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: moonShowTimer
|
||||
interval: 620
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.moonShowStep++
|
||||
if (root.moonShowStep >= root.moonShowStops.length) root.stopMoonShow()
|
||||
else root.moonDemo = root.moonShowStops[root.moonShowStep]
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on moonDemo {
|
||||
enabled: root.moonShowing
|
||||
NumberAnimation { duration: 460; easing.type: Easing.InOutSine }
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: dropAnimation
|
||||
target: root
|
||||
property: "dragOffset"
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
onFinished: root.commitRowDrag()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: scrubHold
|
||||
interval: 2500
|
||||
onTriggered: root.scrubMinutes = 0
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.tick()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 300000
|
||||
running: root.opened
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: root.ipcTarget
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function refresh(): string { root.refresh(); return "ok" }
|
||||
|
||||
function globe(): string {
|
||||
if (!root.globeEnabled) return "disabled"
|
||||
root.setGlobeMode(!root.globeMode, false)
|
||||
return root.globeMode ? "globe" : "list"
|
||||
}
|
||||
|
||||
// Ticks first: the clock only runs while the panel is open.
|
||||
function times(): string {
|
||||
root.tick()
|
||||
return JSON.stringify(root.clockRows)
|
||||
}
|
||||
function add(zone: string, label: string): string {
|
||||
root.setZones(Model.addZone(root.zones, zone, label))
|
||||
return Model.serializeZones(root.zones)
|
||||
}
|
||||
function remove(zone: string): string { root.removeCity(zone); return Model.serializeZones(root.zones) }
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: ""
|
||||
tooltipText: "World clock"
|
||||
onPressed: function(buttonCode) {
|
||||
if (buttonCode === Qt.RightButton || buttonCode === Qt.MiddleButton) root.refresh()
|
||||
else root.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
focusTarget: keyCatcher
|
||||
contentWidth: panel.fittedContentWidth(Style.space(340))
|
||||
contentHeight: panel.fittedContentHeight(content.implicitHeight, Style.space(680))
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
blocked: root.adding
|
||||
// Escape unwinds one layer: search (its own field), globe, then panel.
|
||||
onCloseRequested: {
|
||||
if (root.globeMode) root.setGlobeMode(false, false)
|
||||
else root.close()
|
||||
}
|
||||
// Space toggles the globe. Return also arrives as an activate, which it skips.
|
||||
property bool returnHandled: false
|
||||
onReturnRequested: returnHandled = true
|
||||
onActivateRequested: {
|
||||
if (returnHandled) { returnHandled = false; return }
|
||||
if (root.globeEnabled) root.setGlobeMode(!root.globeMode, false)
|
||||
}
|
||||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||||
// "+" searches in either view; "j" jumps on the globe, "a" adds on the list.
|
||||
onTextKey: function(text) {
|
||||
var key = text.toLowerCase()
|
||||
if (key === "r") root.refresh()
|
||||
else if (root.globeMode && (key === "+" || key === "j")) {
|
||||
if (globeLoader.item) globeLoader.item.startJump()
|
||||
} else if (!root.globeMode && (key === "+" || key === "a")) {
|
||||
root.startAdding()
|
||||
}
|
||||
}
|
||||
|
||||
// Clipped to the card and scrolled by the wheel only: the rows own drags.
|
||||
Flickable {
|
||||
id: scroller
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
interactive: false
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
readonly property real maxScroll: Math.max(0, contentHeight - height)
|
||||
|
||||
function clamp() { contentY = Util.clamp(contentY, 0, maxScroll) }
|
||||
onHeightChanged: clamp()
|
||||
onContentHeightChanged: clamp()
|
||||
|
||||
function scrollTo(y) {
|
||||
scrollAnim.stop()
|
||||
scrollAnim.from = contentY
|
||||
scrollAnim.to = y
|
||||
scrollAnim.start()
|
||||
}
|
||||
function scrollToTop() { scrollTo(0) }
|
||||
|
||||
// Follows maxScroll: the results arrive over several frames after the
|
||||
// search opens, so a one-shot scroll lands short.
|
||||
onMaxScrollChanged: if (root.adding) scrollTo(maxScroll)
|
||||
|
||||
// Not a Behavior: the wheel writes contentY too and must track directly.
|
||||
NumberAnimation {
|
||||
id: scrollAnim
|
||||
target: scroller
|
||||
property: "contentY"
|
||||
duration: 160
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(event) {
|
||||
var d = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 120 * Style.space(40)
|
||||
scroller.contentY -= d
|
||||
scroller.clamp()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: scroller.width
|
||||
spacing: Style.spacing.panelGap
|
||||
|
||||
HeroTitle {
|
||||
id: hero
|
||||
width: parent.width
|
||||
caption: root.hereLine
|
||||
// Accent while scrubbed, so a shifted time is never taken for now.
|
||||
captionColor: root.scrubMinutes !== 0 ? Color.accent : root.dim
|
||||
captionClickable: root.focusIndex >= 0
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fontFamily: root.fontFamily
|
||||
zoom: root.zoom
|
||||
globeEnabled: root.globeEnabled
|
||||
bigGlobeOpacity: root.bigGlobeOpacity
|
||||
restLon: root.focusLon
|
||||
markerShown: root.focusKnown
|
||||
markerLat: root.focusLat
|
||||
markerLon: root.focusLon
|
||||
onCaptionClicked: root.focusOn(-1)
|
||||
onGlobeClicked: function(slow) { root.setGlobeMode(!root.globeMode, slow) }
|
||||
}
|
||||
|
||||
// ---- stage
|
||||
// The list and the globe share it; it grows with the zoom. Not
|
||||
// clipped, so the globe can start from the header above it.
|
||||
Item {
|
||||
id: stage
|
||||
width: parent.width
|
||||
height: Math.max(1, listWrap.implicitHeight
|
||||
+ (root.globeStageHeight - listWrap.implicitHeight) * root.zoom)
|
||||
|
||||
// Clipped, so knocked rows fall out of the panel.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: listWrap
|
||||
width: parent.width
|
||||
spacing: Style.spacing.panelGap
|
||||
|
||||
Column {
|
||||
visible: root.zoom < 1
|
||||
width: parent.width
|
||||
spacing: Style.spacing.md
|
||||
|
||||
// The zone list, not clockRows: that ticks, and would
|
||||
// rebuild every delegate mid-drag.
|
||||
Repeater {
|
||||
model: root.zones
|
||||
|
||||
CityRow { panel: root }
|
||||
}
|
||||
}
|
||||
|
||||
CitySearch {
|
||||
id: citySearch
|
||||
visible: root.zoom < 1
|
||||
width: parent.width
|
||||
options: root.zoneOptions
|
||||
loading: root.zoneCatalogText === ""
|
||||
offsetLabel: function(zoneId) { return root.utcLabelFor(zoneId) }
|
||||
foreground: root.foreground
|
||||
dim: root.dim
|
||||
fainter: root.fainter
|
||||
fontFamily: root.fontFamily
|
||||
onActiveChanged: if (active) root.loadCatalog()
|
||||
onPicked: function(label, id) { root.addCity(id, label) }
|
||||
onDismissed: Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
|
||||
// Knocked aside last, after every row.
|
||||
transform: Translate {
|
||||
x: Model.knockX(root.zones.length, root.zoom, Style.space(95))
|
||||
y: Model.knockY(root.zones.length, root.zoom, root.knockFall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- globe
|
||||
// Loaded early on hover and kept for the whole flight, so reading
|
||||
// its data never lands mid-animation.
|
||||
Loader {
|
||||
id: globeLoader
|
||||
active: root.globeEnabled && (root.globeMode || root.zoom > 0 || hero.iconHovered)
|
||||
visible: root.zoom > 0
|
||||
opacity: root.bigGlobeOpacity
|
||||
width: parent.width
|
||||
height: root.globeStageHeight
|
||||
source: "Globe.qml"
|
||||
|
||||
// The disc's center, above the globe's footer and jump bar.
|
||||
readonly property real discCX: width / 2
|
||||
readonly property real discCY: item ? (height - item.footerHeight - item.jumpHeight) / 2 : height / 2
|
||||
readonly property real discR: item && item.radius > 0 ? item.radius : 1
|
||||
readonly property real startScale: root.heroDiscRadius / discR
|
||||
readonly property real zoomScale: startScale + (1 - startScale) * root.zoom
|
||||
|
||||
// Scaled about the disc, then carried from the little globe.
|
||||
transform: [
|
||||
Scale {
|
||||
origin.x: globeLoader.discCX
|
||||
origin.y: globeLoader.discCY
|
||||
xScale: globeLoader.zoomScale
|
||||
yScale: globeLoader.zoomScale
|
||||
},
|
||||
Translate {
|
||||
x: (root.heroCenterX - globeLoader.discCX) * (1 - root.zoom)
|
||||
y: (root.heroCenterY - globeLoader.discCY) * (1 - root.zoom)
|
||||
}
|
||||
]
|
||||
|
||||
onLoaded: {
|
||||
item.foreground = Qt.binding(function() { return root.foreground })
|
||||
item.dim = Qt.binding(function() { return root.dim })
|
||||
item.fainter = Qt.binding(function() { return root.fainter })
|
||||
item.daylightMarker = Qt.binding(function() { return root.daylightMarker })
|
||||
item.moonPhase = Qt.binding(function() { return root.moonPhase })
|
||||
item.fontFamily = Qt.binding(function() { return root.fontFamily })
|
||||
item.hour24 = Qt.binding(function() { return root.hour24 })
|
||||
item.trackedNames = Qt.binding(function() { return root.trackedNames })
|
||||
item.trackedCities = Qt.binding(function() { return root.trackedCities })
|
||||
item.sessionCities = Qt.binding(function() { return root.sessionPlaces })
|
||||
item.homeRow = Qt.binding(function() {
|
||||
var home = root.homePlace
|
||||
return home ? [root.homeCity, root.localZone, home.lat, home.lon, 0] : []
|
||||
})
|
||||
item.offsetMode = Qt.binding(function() { return root.offsetMode })
|
||||
item.homeOffsetMinutes = Qt.binding(function() { return root.localOffsetMinutes })
|
||||
item.smoothMotion = Qt.binding(function() { return root.smoothMotion })
|
||||
item.transitioning = Qt.binding(function() { return !root.zoomIdle })
|
||||
item.zoomLevel = Qt.binding(function() { return root.zoom })
|
||||
// The footer and jump bar arrive once the globe has landed.
|
||||
item.chromeOpacity = Qt.binding(function() { return Util.clamp((root.zoom - 0.74) / 0.26, 0, 1) })
|
||||
item.jumpOptions = Qt.binding(function() { return root.allZoneOptions })
|
||||
item.offsetModeToggleRequested.connect(function() { root.toggleOffsetMode() })
|
||||
item.jumpRequested.connect(function(label, zone) { root.addSessionCity(label, zone) })
|
||||
item.exitRequested.connect(function() { root.setGlobeMode(false, false) })
|
||||
item.jumpDismissed.connect(function() { Qt.callLater(function() { keyCatcher.forceActiveFocus() }) })
|
||||
item.citySelected.connect(function(label, zone) { root.focusFromGlobe(label, zone) })
|
||||
// A cold start can finish loading after the mode switched on.
|
||||
if (root.globeMode) root.showFocusOnGlobe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
.pragma library
|
||||
|
||||
.import "GlobeModel.js" as Solar
|
||||
|
||||
// Sunrise and sunset for a place on its own day, from the globe's own
|
||||
// subsolarPoint so the strip and the terminator cannot disagree.
|
||||
|
||||
var DEG = Math.PI / 180
|
||||
|
||||
// Refraction plus the sun's radius, the figure published sunrise tables use.
|
||||
var HORIZON = -0.833
|
||||
|
||||
function wrap180(deg) {
|
||||
return ((deg + 540) % 360) - 180
|
||||
}
|
||||
|
||||
// Local solar noon. The subsolar meridian moves 15 degrees an hour, so three
|
||||
// corrections take the residual below a second.
|
||||
function solarNoonMs(lon, nearMs) {
|
||||
var t = nearMs
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var sub = Solar.subsolarPoint(t)
|
||||
t += wrap180(sub.lon - lon) / 15 * 3600000
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// The zone's own local midnight, as a UTC instant.
|
||||
function localMidnightMs(ms, offsetMinutes) {
|
||||
var local = ms + offsetMinutes * 60000
|
||||
return Math.floor(local / 86400000) * 86400000 - offsetMinutes * 60000
|
||||
}
|
||||
|
||||
// Sunrise and sunset for the local day containing `ms`, as instants and as
|
||||
// minutes from local midnight. The minutes may fall outside 0..1440 for a zone
|
||||
// far from its own sun. `kind` is "normal", "midnightSun" or "polarNight".
|
||||
function sunTimes(lat, lon, ms, offsetMinutes) {
|
||||
var midnight = localMidnightMs(ms, offsetMinutes)
|
||||
var noon = solarNoonMs(lon, midnight + 43200000)
|
||||
var dec = Solar.subsolarPoint(noon).lat
|
||||
|
||||
var cosH = (Math.sin(HORIZON * DEG) - Math.sin(lat * DEG) * Math.sin(dec * DEG))
|
||||
/ (Math.cos(lat * DEG) * Math.cos(dec * DEG))
|
||||
|
||||
if (cosH <= -1)
|
||||
return { kind: "midnightSun", noonMs: noon, riseMs: null, setMs: null,
|
||||
riseMinutes: null, setMinutes: null, dayMinutes: 1440 }
|
||||
if (cosH >= 1)
|
||||
return { kind: "polarNight", noonMs: noon, riseMs: null, setMs: null,
|
||||
riseMinutes: null, setMinutes: null, dayMinutes: 0 }
|
||||
|
||||
var halfDayMs = Math.acos(cosH) / DEG / 15 * 3600000
|
||||
var riseMs = noon - halfDayMs
|
||||
var setMs = noon + halfDayMs
|
||||
|
||||
return {
|
||||
kind: "normal",
|
||||
noonMs: noon,
|
||||
riseMs: riseMs,
|
||||
setMs: setMs,
|
||||
riseMinutes: (riseMs - midnight) / 60000,
|
||||
setMinutes: (setMs - midnight) / 60000,
|
||||
dayMinutes: (setMs - riseMs) / 60000
|
||||
}
|
||||
}
|
||||
|
||||
// The lit parts of a 24-hour strip as 0..1 spans. The day is also drawn a day
|
||||
// either side, so a sunset past midnight lights the start of the bar too.
|
||||
function litSpans(times) {
|
||||
if (!times) return []
|
||||
if (times.kind === "polarNight") return []
|
||||
if (times.kind === "midnightSun") return [{ x0: 0, x1: 1 }]
|
||||
|
||||
var out = []
|
||||
for (var shift = -1440; shift <= 1440; shift += 1440) {
|
||||
var a = Math.max(0, Math.min(1440, times.riseMinutes + shift))
|
||||
var b = Math.max(0, Math.min(1440, times.setMinutes + shift))
|
||||
if (b > a) out.push({ x0: a / 1440, x1: b / 1440 })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Where an event's tick goes, or null when it falls off this bar.
|
||||
function eventMark(minutes) {
|
||||
if (minutes === null || minutes === undefined) return null
|
||||
if (minutes < 0 || minutes > 1440) return null
|
||||
return minutes / 1440
|
||||
}
|
||||
|
||||
// Daylight at `minutes`, read from the same three days litSpans draws.
|
||||
function litAt(times, minutes) {
|
||||
if (!times) return false
|
||||
if (times.kind === "midnightSun") return true
|
||||
if (times.kind === "polarNight") return false
|
||||
for (var shift = -1440; shift <= 1440; shift += 1440)
|
||||
if (minutes >= times.riseMinutes + shift && minutes < times.setMinutes + shift)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
solarNoonMs: solarNoonMs,
|
||||
localMidnightMs: localMidnightMs,
|
||||
sunTimes: sunTimes,
|
||||
litSpans: litSpans,
|
||||
eventMark: eventMark,
|
||||
litAt: litAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[["Pago Pago","Pacific/Pago_Pago",-14.28,-170.7,3],["Honolulu","Pacific/Honolulu",21.31,-157.86,2],["Anchorage","America/Anchorage",61.22,-149.9,2],["Los Angeles","America/Los_Angeles",34.05,-118.24,1],["Vancouver","America/Vancouver",49.25,-123.12,2],["Denver","America/Denver",39.74,-104.98,2],["Phoenix","America/Phoenix",33.45,-112.07,3],["Mexico City","America/Mexico_City",19.43,-99.13,1],["Chicago","America/Chicago",41.85,-87.65,1],["New York","America/New_York",40.71,-74.01,1],["Toronto","America/Toronto",43.71,-79.4,2],["Bogota","America/Bogota",4.61,-74.08,2],["Lima","America/Lima",-12.04,-77.03,2],["Halifax","America/Halifax",44.64,-63.58,3],["Caracas","America/Caracas",10.49,-66.88,3],["Santiago","America/Santiago",-33.46,-70.65,2],["Sao Paulo","America/Sao_Paulo",-23.55,-46.64,1],["Buenos Aires","America/Argentina/Buenos_Aires",-34.61,-58.38,1],["Praia","Atlantic/Cape_Verde",14.93,-23.51,3],["Reykjavik","Atlantic/Reykjavik",64.14,-21.9,3],["London","Europe/London",51.51,-0.13,1],["Accra","Africa/Accra",5.56,-0.2,3],["Lisbon","Europe/Lisbon",38.73,-9.15,2],["Paris","Europe/Paris",48.85,2.35,1],["Berlin","Europe/Berlin",52.52,13.41,1],["Madrid","Europe/Madrid",40.42,-3.7,2],["Lagos","Africa/Lagos",6.45,3.39,2],["Rome","Europe/Rome",41.89,12.51,2],["Cairo","Africa/Cairo",30.06,31.25,1],["Athens","Europe/Athens",37.98,23.73,2],["Johannesburg","Africa/Johannesburg",-26.2,28.04,1],["Kyiv","Europe/Kyiv",50.45,30.52,2],["Moscow","Europe/Moscow",55.75,37.62,1],["Istanbul","Europe/Istanbul",41.01,28.95,1],["Nairobi","Africa/Nairobi",-1.28,36.82,2],["Riyadh","Asia/Riyadh",24.69,46.72,2],["Tehran","Asia/Tehran",35.69,51.42,2],["Dubai","Asia/Dubai",25.08,55.31,1],["Kabul","Asia/Kabul",34.53,69.17,3],["Karachi","Asia/Karachi",24.86,67.01,2],["Delhi","Asia/Kolkata",28.65,77.23,1],["Kathmandu","Asia/Kathmandu",27.7,85.32,3],["Dhaka","Asia/Dhaka",23.71,90.41,2],["Almaty","Asia/Almaty",43.25,76.91,3],["Yangon","Asia/Yangon",16.81,96.16,3],["Bangkok","Asia/Bangkok",13.75,100.5,1],["Jakarta","Asia/Jakarta",-6.21,106.85,2],["Singapore","Asia/Singapore",1.29,103.85,1],["Shanghai","Asia/Shanghai",31.22,121.46,1],["Hong Kong","Asia/Hong_Kong",22.28,114.17,2],["Perth","Australia/Perth",-31.95,115.86,2],["Seoul","Asia/Seoul",37.57,126.98,1],["Tokyo","Asia/Tokyo",35.69,139.69,1],["Darwin","Australia/Darwin",-12.46,130.84,3],["Adelaide","Australia/Adelaide",-34.93,138.6,3],["Sydney","Australia/Sydney",-33.87,151.21,1],["Brisbane","Australia/Brisbane",-27.47,153.03,3],["Noumea","Pacific/Noumea",-22.27,166.45,3],["Auckland","Pacific/Auckland",-36.85,174.76,1],["Suva","Pacific/Fiji",-18.14,178.43,3],["Apia","Pacific/Apia",-13.83,-171.77,3],["Kiritimati","Pacific/Kiritimati",1.87,-157.43,3],["St John's","America/St_Johns",47.56,-52.71,3],["Copenhagen","Europe/Copenhagen",55.68,12.57,2],["Stockholm","Europe/Stockholm",59.33,18.07,2],["Oslo","Europe/Oslo",59.91,10.75,2],["Amsterdam","Europe/Amsterdam",52.37,4.89,2],["Brussels","Europe/Brussels",50.85,4.35,3],["Zurich","Europe/Zurich",47.37,8.55,2],["Vienna","Europe/Vienna",48.21,16.37,2],["Warsaw","Europe/Warsaw",52.23,21.01,2],["Prague","Europe/Prague",50.09,14.42,2],["Dublin","Europe/Dublin",53.33,-6.25,2],["Helsinki","Europe/Helsinki",60.17,24.94,3]]
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.elsewhen",
|
||||
"name": "Elsewhen",
|
||||
"version": "1.0.0",
|
||||
"author": "Jason Fried",
|
||||
"license": "MIT",
|
||||
"description": "A world clock for the Omarchy shell: the time in several cities at once, with a spinnable globe.",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Elsewhen",
|
||||
"description": "A world clock: one row per city, and a globe.",
|
||||
"category": "Time",
|
||||
"aliases": [
|
||||
"worldclock",
|
||||
"world clock",
|
||||
"timezones"
|
||||
],
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "right",
|
||||
"defaults": {
|
||||
"zones": "",
|
||||
"offsetMode": "home",
|
||||
"units": "",
|
||||
"globeEnabled": true,
|
||||
"homeCity": "",
|
||||
"smoothMotion": true
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"key": "zones",
|
||||
"type": "string",
|
||||
"label": "Zones (Label|IANA name, comma separated; blank seeds on first run)",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"key": "hour24",
|
||||
"type": "boolean",
|
||||
"label": "24-hour time (unset follows the system)"
|
||||
},
|
||||
{
|
||||
"key": "offsetMode",
|
||||
"type": "string",
|
||||
"label": "Row offsets: home (distance from you) or utc",
|
||||
"defaultValue": "home"
|
||||
},
|
||||
{
|
||||
"key": "units",
|
||||
"type": "string",
|
||||
"label": "Temperature units (F or C; blank follows the system)",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"key": "globeEnabled",
|
||||
"type": "boolean",
|
||||
"label": "Globe mode (tap the hero globe)",
|
||||
"defaultValue": true
|
||||
},
|
||||
{
|
||||
"key": "homeCity",
|
||||
"type": "string",
|
||||
"label": "Your city (blank = from the system time zone)",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"key": "smoothMotion",
|
||||
"type": "boolean",
|
||||
"label": "Drop labels and detail while the globe moves",
|
||||
"defaultValue": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[[-59.6,-80.0,-60.2,-81.0,-66.3,-80.3,-59.6,-80.0],[-159.2,-79.5,-163.7,-78.6,-161.2,-78.4,-159.2,-79.5],[-45.2,-78.0,-43.9,-78.5,-43.3,-80.0,-50.5,-81.0,-54.2,-80.6,-48.7,-78.0,-45.2,-78.0],[-121.2,-73.5,-118.7,-73.5,-122.6,-73.7,-121.2,-73.5],[-125.6,-73.5,-124.0,-73.9,-127.3,-73.5,-125.6,-73.5],[-99.0,-71.9,-96.2,-72.5,-102.3,-71.9,-99.0,-71.9],[-68.5,-71.0,-68.8,-72.2,-71.1,-72.5,-75.0,-71.7,-72.1,-71.2,-71.7,-69.5,-70.3,-68.9,-68.5,-71.0],[-58.6,-64.2,-62.0,-64.8,-62.6,-65.5,-62.1,-66.2,-65.7,-68.0,-61.8,-70.7,-60.8,-73.7,-70.6,-76.6,-77.2,-76.7,-73.7,-77.9,-77.9,-78.4,-78.0,-79.2,-58.2,-83.2,-49.8,-81.7,-42.8,-82.1,-28.5,-80.3,-29.7,-79.3,-35.6,-79.5,-35.8,-78.3,-17.5,-75.1,-15.7,-74.5,-16.5,-73.9,-15.4,-73.1,-10.3,-71.3,-7.4,-71.7,-6.9,-70.9,-0.2,-71.6,7.7,-69.9,10.8,-70.8,13.4,-70.0,27.1,-70.5,32.0,-69.7,33.9,-68.5,38.6,-69.8,54.5,-65.8,61.4,-68.0,68.9,-67.9,69.7,-69.2,67.8,-70.3,69.1,-70.7,67.9,-71.9,69.9,-72.3,73.9,-69.9,77.6,-69.5,82.8,-67.2,86.8,-67.2,88.0,-66.2,89.7,-67.2,95.8,-67.4,99.7,-67.2,102.8,-65.6,106.2,-66.9,113.6,-65.9,119.8,-67.3,134.8,-66.2,135.1,-65.3,137.5,-67.0,145.5,-66.9,148.8,-68.4,154.3,-68.6,161.6,-70.6,171.2,-71.7,169.3,-73.7,166.1,-74.4,163.6,-76.2,164.7,-78.2,167.0,-78.8,161.8,-79.2,159.8,-80.9,169.4,-83.8,180,-84.7,180,-90,-180,-90,-180,-84.7,-179.1,-84.1,-170.0,-83.9,-158.1,-85.4,-143.1,-85.0,-153.6,-83.7,-152.9,-82.0,-156.8,-81.1,-150.6,-81.3,-146.4,-80.3,-155.3,-79.1,-158.1,-78.0,-158.4,-76.9,-151.3,-77.4,-146.1,-76.5,-146.2,-75.4,-135.2,-74.3,-121.1,-74.5,-113.9,-73.7,-112.3,-74.7,-107.6,-75.2,-100.1,-74.9,-102.5,-74.1,-103.7,-72.6,-96.3,-73.6,-90.1,-73.3,-89.2,-72.6,-81.5,-73.9,-80.3,-73.1,-74.9,-73.9,-67.4,-72.5,-68.5,-69.7,-67.4,-68.1,-67.7,-67.3,-63.0,-64.6,-57.2,-63.5,-58.6,-64.2],[-67.8,-53.9,-65.0,-54.7,-69.2,-55.5,-74.7,-52.8,-71.1,-54.1,-69.3,-52.5,-67.8,-53.9],[-58.5,-51.1,-58.0,-51.9,-61.2,-51.9,-58.5,-51.1],[145.4,-40.8,148.3,-40.9,147.9,-43.2,146.0,-43.5,144.7,-41.2,145.4,-40.8],[173.0,-40.9,174.2,-41.3,173.1,-43.9,171.5,-44.2,169.3,-46.6,166.7,-46.2,167.0,-45.1,173.0,-40.9],[174.6,-36.2,176.8,-37.9,178.5,-37.7,175.2,-41.7,174.9,-39.9,173.8,-39.5,174.7,-37.4,172.6,-34.5,174.6,-36.2],[50.1,-13.6,50.4,-15.7,49.7,-15.7,47.1,-24.9,45.4,-25.6,44.0,-25.0,43.3,-22.1,44.4,-20.1,44.4,-16.2,47.7,-14.6,49.2,-12.0,50.1,-13.6],[143.6,-13.8,145.4,-15.0,146.4,-19.0,148.8,-20.4,153.1,-26.1,152.9,-31.6,150.0,-37.4,146.3,-39.0,145.0,-37.9,143.6,-38.8,140.6,-38.0,139.6,-36.1,138.1,-35.6,138.2,-34.4,136.8,-35.3,137.8,-32.9,136.0,-34.9,134.3,-32.6,131.3,-31.5,126.1,-32.2,123.7,-33.9,119.9,-34.0,118.0,-35.1,115.0,-34.2,115.7,-31.6,113.3,-26.1,114.2,-26.3,113.4,-24.4,114.1,-21.8,114.2,-22.5,116.7,-20.7,120.9,-19.7,123.0,-16.4,123.9,-17.1,123.5,-16.6,125.7,-14.2,127.1,-13.8,129.6,-15.0,130.6,-12.5,132.6,-12.1,131.8,-11.3,132.4,-11.1,135.3,-12.2,136.5,-11.9,137.0,-12.4,135.5,-15.0,140.2,-17.7,141.3,-16.4,142.5,-10.7,143.6,-13.8],[124.4,-10.1,123.5,-10.2,125.1,-8.7,127.3,-8.4,124.4,-10.1],[108.6,-6.8,110.8,-6.5,115.7,-8.4,105.4,-6.9,106.1,-5.9,108.6,-6.8],[152.0,-5.5,150.2,-6.3,148.3,-5.7,150.8,-5.5,151.5,-4.2,152.3,-4.3,152.0,-5.5],[134.1,-1.2,134.4,-2.8,135.5,-3.4,138.3,-1.7,144.6,-3.9,147.6,-6.1,147.2,-7.4,150.7,-10.6,147.9,-10.1,144.7,-7.6,142.6,-9.3,139.1,-8.1,137.6,-8.4,138.7,-7.3,137.9,-5.4,133.7,-3.5,133.0,-4.1,132.0,-2.8,133.7,-2.2,130.5,-0.9,132.4,-0.4,134.1,-1.2],[125.2,1.4,123.7,0.2,120.2,0.2,120.9,-1.4,123.3,-0.6,121.5,-1.9,123.2,-5.3,122.2,-5.3,122.7,-4.5,121.5,-4.6,121.0,-2.6,120.3,-2.9,120.4,-5.5,119.4,-5.4,119.5,-3.5,118.8,-2.8,120.0,0.6,120.9,1.3,125.2,1.4],[128.7,1.1,128.1,-0.9,127.4,1.0,127.9,2.2,128.7,1.1],[105.8,-5.9,102.6,-4.2,95.3,5.5,97.5,5.2,103.8,0.1,103.4,-0.7,106.1,-3.1,105.8,-5.9],[117.9,1.8,119.0,0.9,117.8,0.8,116.1,-4.0,110.2,-2.9,109.1,-0.5,109.7,2.0,111.2,1.9,111.4,2.7,113.0,3.1,116.7,6.9,119.2,5.4,117.3,3.2,117.9,1.8],[126.4,8.4,126.5,7.2,126.2,6.3,125.8,7.3,125.4,6.8,125.4,5.6,124.2,6.2,123.6,7.8,121.9,7.2,123.5,8.7,125.5,9.0,125.4,9.8,126.4,8.4],[81.2,6.2,79.9,6.8,80.1,9.8,81.8,7.5,81.2,6.2],[118.5,9.3,117.2,8.4,119.5,11.4,118.5,9.3],[121.3,18.5,122.2,18.5,122.5,17.1,121.7,14.3,124.0,13.8,124.1,12.5,120.6,13.9,121.0,14.5,120.1,15.0,119.9,16.4,121.3,18.5],[-72.6,19.9,-68.3,18.6,-70.7,18.4,-71.4,17.6,-74.5,18.3,-72.3,18.7,-73.4,19.6,-72.6,19.9],[-79.7,22.8,-74.2,20.3,-77.8,19.9,-77.1,20.4,-78.7,21.6,-81.8,22.6,-85.0,21.9,-82.3,23.2,-79.7,22.8],[121.2,22.8,120.7,22.0,120.1,23.6,121.5,25.3,121.2,22.8],[15.5,38.2,15.1,36.6,12.4,37.6,15.5,38.2],[141.0,37.1,140.3,35.1,137.2,34.6,135.8,33.5,135.1,34.6,131.0,33.9,132.0,33.1,131.3,31.5,130.2,31.4,130.4,32.3,129.4,33.3,132.6,35.4,135.7,35.5,136.7,37.3,137.4,36.8,139.4,38.2,139.9,40.6,141.4,41.4,141.9,39.2,141.0,37.1],[143.9,44.2,145.3,44.4,145.5,43.3,143.2,42.0,141.6,42.7,141.1,41.6,140.0,41.6,139.8,42.6,141.4,43.4,142.0,45.6,143.9,44.2],[-123.5,48.5,-125.7,48.8,-128.4,50.8,-125.8,50.3,-123.5,48.5],[-56.1,50.7,-56.8,49.8,-53.5,49.2,-53.8,48.5,-53.1,48.7,-52.6,47.5,-53.1,46.7,-54.2,46.8,-54.2,47.8,-55.4,Line truncated
|
||||
@@ -36,7 +36,7 @@ pass "default center anchor exists in center layout"
|
||||
jq -e '
|
||||
def ids: map(.id // .);
|
||||
(.bar.layout.center | ids) as $ids |
|
||||
($ids | index("omacom.elsewhen")) as $elsewhen |
|
||||
($ids | index("omarchy.elsewhen")) as $elsewhen |
|
||||
($ids | index("omarchy.clock")) as $clock |
|
||||
$elsewhen != null and $clock == $elsewhen + 1
|
||||
' "$ROOT/config/omarchy/shell.json" >/dev/null
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const { layout } = requireFromRoot('shell/plugins/panels/elsewhen/Arc.js')
|
||||
|
||||
const near = (a, b, eps) => Math.abs(a - b) <= (eps === undefined ? 0.01 : eps)
|
||||
const mono = (count, width) => new Array(count).fill(width === undefined ? 6 : width)
|
||||
const RISE = 6
|
||||
|
||||
// ---- a flat line
|
||||
|
||||
const flat = layout(mono(10), 0, true)
|
||||
assert(flat.height === 0 && flat.width === 60, 'elsewhen lays a flat line out full width with no height')
|
||||
assertEqual(flat.chars.map(c => c.x).join(), '0,6,12,18,24,30,36,42,48,54', 'elsewhen lays a flat line out end to end')
|
||||
assert(flat.chars.every(c => c.rotation === 0), 'elsewhen turns nothing on a flat line')
|
||||
assertEqual(layout([], 6, true).chars.length, 0, 'elsewhen lays out empty text')
|
||||
assertEqual(layout(mono(4), -3, true).height, 0, 'elsewhen treats a negative rise as flat')
|
||||
|
||||
// ---- the rise
|
||||
|
||||
for (const count of [20, 30, 50, 90]) {
|
||||
const r = 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
|
||||
assert(near(r.height, RISE, 0.05), `elsewhen bends ${count} characters by the rise`, r.height)
|
||||
// The end glyphs sit half a character in from the ends of the arc.
|
||||
assert(dip > RISE * 0.85 && dip <= RISE, `elsewhen dips ${count} glyphs by about the rise`, dip)
|
||||
}
|
||||
|
||||
// ---- shape
|
||||
|
||||
const smile = layout(mono(30), RISE, true)
|
||||
const frown = layout(mono(30), RISE, false)
|
||||
const last = smile.chars.length - 1
|
||||
assert(smile.chars[15].y > smile.chars[0].y && frown.chars[15].y < frown.chars[0].y, 'elsewhen dips a smile and raises a frown')
|
||||
assert(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)), 'elsewhen mirrors a smile and a frown')
|
||||
assert(smile.chars.every((c, i) => near(c.y, smile.chars[last - i].y) && near(c.rotation, -smile.chars[last - i].rotation)), 'elsewhen bends symmetrically')
|
||||
assert(smile.chars.every((c, i) => i === 0 || c.x > smile.chars[i - 1].x) && near(smile.chars[0].x, 0), 'elsewhen advances from the left edge')
|
||||
// Qt turns clockwise for a positive angle.
|
||||
assert(smile.chars[0].rotation > 0 && smile.chars[last].rotation < 0, 'elsewhen leans into each end')
|
||||
assert(near(smile.chars[14].rotation, -smile.chars[15].rotation), 'elsewhen levels the middle')
|
||||
|
||||
const offTangent = []
|
||||
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
|
||||
if (!near(Math.atan2(dy, dx) * 180 / Math.PI, turn, 0.1)) offTangent.push(i)
|
||||
}
|
||||
assertDeepEqual(offTangent, [], 'elsewhen turns each character along the arc')
|
||||
|
||||
// ---- proportional text and reserved width
|
||||
|
||||
const prop = layout([4, 4, 4, 16, 16, 16].concat(mono(24)), RISE, true)
|
||||
assert(near(prop.chars[1].x - prop.chars[0].x, 4, 0.1) && near(prop.chars[4].x - prop.chars[3].x, 16, 0.1), 'elsewhen keeps proportional advances')
|
||||
|
||||
const wide = layout(mono(40), 20, true)
|
||||
assert(wide.chars.every(c => c.x >= -0.001 && c.x + 6 <= wide.width + 0.001), 'elsewhen reserves width for every box')
|
||||
assert(wide.width < 240 && wide.width > 230, 'elsewhen narrows the arc only a little', wide.width)
|
||||
|
||||
const absurd = layout(mono(10), 1000, true)
|
||||
assert(isFinite(absurd.height) && absurd.height <= 15.01, 'elsewhen clamps an absurd rise', absurd.height)
|
||||
assert(absurd.chars.length === 10 && absurd.chars.every(c => isFinite(c.x) && isFinite(c.y) && isFinite(c.rotation)), 'elsewhen still lays out every character under an absurd rise')
|
||||
JS
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
require_command jq
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
mkdir -p "$test_dir/bin" "$test_dir/home"
|
||||
export CALL_LOG="$test_dir/calls"
|
||||
|
||||
cat >"$test_dir/bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'package %s\n' "$*" >>"$CALL_LOG"
|
||||
exit "${PACKAGE_STATUS:-0}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CALL_LOG"
|
||||
quiet=0
|
||||
if [[ $1 == "-q" ]]; then
|
||||
quiet=1
|
||||
shift
|
||||
fi
|
||||
if [[ ${SHELL_ABSENT:-0} == 1 ]]; then
|
||||
(( quiet )) && exit 0
|
||||
echo "omarchy-shell is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $2 != "rescanPlugins" ]] || exit 0
|
||||
printf '%s\n' "${TEST_PUT_RESULT:-ok}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-restart-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
echo 'migration must leave the restart to omarchy update' >&2
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
mkdir -p "$test_dir/packaged/shell/plugins/omacom.elsewhen"
|
||||
migration="$ROOT/migrations/1790042972.sh"
|
||||
sed "s|/usr/share/omarchy|$test_dir/packaged|g" "$migration" >"$test_dir/migration.sh"
|
||||
|
||||
plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen"
|
||||
run_migration() {
|
||||
: >"$CALL_LOG"
|
||||
HOME="$test_dir/home" OMARCHY_PATH="${1:-$test_dir/packaged}" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1
|
||||
}
|
||||
|
||||
if PACKAGE_STATUS=1 run_migration; then
|
||||
fail "package failure stops the migration"
|
||||
fi
|
||||
[[ ! -e $plugin && ! -L $plugin && $(cat "$CALL_LOG") == "package elsewhen" ]] ||
|
||||
fail "package failure leaves the plugin and shell untouched"
|
||||
pass "package failure stops before changing the shell"
|
||||
|
||||
run_migration
|
||||
[[ ! -e $plugin && ! -L $plugin ]] || fail "migration does not create a user plugin link"
|
||||
[[ ! -e $ROOT/config/omarchy/plugins/omacom.elsewhen && ! -L $ROOT/config/omarchy/plugins/omacom.elsewhen ]] || fail "fresh installs do not ship a user plugin link"
|
||||
pass "migration and fresh installs rely on the packaged plugin directory"
|
||||
|
||||
expected=$'package elsewhen\n-q shell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}'
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan and placement run in order" "$(cat "$CALL_LOG")"
|
||||
pass "real bar helper enables and places before the clock without restarting during reload"
|
||||
|
||||
run_migration
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun"
|
||||
[[ ! -e $plugin && ! -L $plugin ]] || fail "rerunning the migration does not create a user plugin link"
|
||||
pass "migration can be rerun without a user plugin link"
|
||||
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev checkout links the packaged plugin"
|
||||
pass "dev checkout discovers Elsewhen through a user plugin link"
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev link survives a rerun"
|
||||
pass "dev link is idempotent"
|
||||
rm "$plugin"
|
||||
|
||||
mkdir -p "$plugin"
|
||||
printf 'local work\n' >"$plugin/notes"
|
||||
run_migration "$ROOT"
|
||||
[[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved"
|
||||
pass "existing checkout and local files are preserved"
|
||||
|
||||
rm "$plugin/notes"
|
||||
rmdir "$plugin"
|
||||
ln -s "$test_dir/custom-plugin" "$plugin"
|
||||
run_migration "$ROOT"
|
||||
[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "existing symlink is preserved"
|
||||
pass "existing symlink is preserved, including a missing target"
|
||||
|
||||
# An earlier revision, and a symlink once shipped under config/, pointed every
|
||||
# install at the package's old path.
|
||||
for tree in "$ROOT" "$test_dir/packaged"; do
|
||||
ln -sfn "$test_dir/packaged/plugins/omacom.elsewhen" "$plugin"
|
||||
run_migration "$tree"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] ||
|
||||
fail "a stranded link to the package's old path is re-pointed (OMARCHY_PATH=$tree)" "$(readlink "$plugin")"
|
||||
done
|
||||
pass "a stranded link to the package's old path is re-pointed on every install"
|
||||
rm "$plugin"
|
||||
|
||||
if env TEST_PUT_RESULT=unknown HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
|
||||
fail "an unknown widget must leave the migration pending"
|
||||
fi
|
||||
pass "an unknown widget leaves the migration pending"
|
||||
|
||||
# An update with no shell to ask, from a TTY or with the shell down, still
|
||||
# finishes: the package and link land, the placement is skipped, and the update
|
||||
# restarts the shell afterwards.
|
||||
rm "$plugin"
|
||||
: >"$CALL_LOG"
|
||||
if ! env SHELL_ABSENT=1 OMARCHY_SHELL_ABSENT_ATTEMPTS=1 HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then
|
||||
fail "an absent shell must not fail the migration" "$(cat "$test_dir/output")"
|
||||
fi
|
||||
grep -q "omacom.elsewhen was not put on the bar" "$test_dir/output" || fail "an absent shell is reported" "$(cat "$test_dir/output")"
|
||||
[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "the package and plugin link land without a shell"
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "the rescan is best-effort and the put is still asked" "$(cat "$CALL_LOG")"
|
||||
pass "an absent shell leaves the update running with the package and link in place"
|
||||
|
||||
# The first run of this migration was under 1789581661.sh, before the re-point
|
||||
# existed; that marker must not stop the renamed file from running there.
|
||||
state="$test_dir/state"
|
||||
mkdir -p "$state"
|
||||
touch "$state/1789581661.sh"
|
||||
OMARCHY_MIGRATION_STATE="$state" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-migrate" --pending >"$test_dir/pending" || true
|
||||
grep -qx "$(basename "$migration")" "$test_dir/pending" || fail "the old marker must not satisfy the renamed migration" "$(cat "$test_dir/pending")"
|
||||
pass "a machine that applied the migration under its old name runs it again"
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ELSEWHEN_ONLINE=1 also checks day and night against Open-Meteo's is_day.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const https = require('https')
|
||||
const { Solar: G } = requireFromRoot('test/shell.d/elsewhen/solar.js')
|
||||
|
||||
const dataDir = path.join(root, 'shell/plugins/panels/elsewhen')
|
||||
const cities = JSON.parse(fs.readFileSync(path.join(dataDir, 'cities.json'), 'utf8'))
|
||||
const land = JSON.parse(fs.readFileSync(path.join(dataDir, 'world.json'), 'utf8')).filter(r => r.length >= 40)
|
||||
const DEG = Math.PI / 180
|
||||
|
||||
// ---- projection
|
||||
|
||||
const center = G.project(0, 0, 0, 0, 100)
|
||||
assert(Math.abs(center.x) < 1e-9 && Math.abs(center.y) < 1e-9 && center.visible, 'elsewhen projects the view center to the origin')
|
||||
assertEqual(G.project(0, 180, 0, 0, 100).visible, false, 'elsewhen hides the antipode')
|
||||
assert(G.project(90, 0, 0, 0, 100).y < -99, 'elsewhen puts north up at zero tilt')
|
||||
assert(G.project(0, 45, 0, 0, 100).x > 0, 'elsewhen puts east to the right')
|
||||
assert(Math.abs(G.project(0, 45, 45, 0, 100).x) < 1e-9, 'elsewhen spin follows the point')
|
||||
assert([[12, 34], [-56, 78], [80, -170]].every(([lat, lon]) => {
|
||||
const q = G.project(lat, lon, 20, 15, 100)
|
||||
return Math.hypot(q.x, q.y) <= 100.0001
|
||||
}), 'elsewhen projects inside the disc')
|
||||
|
||||
// ---- the sun
|
||||
|
||||
const jun = G.subsolarPoint(Date.UTC(2026, 5, 21, 12, 0, 0))
|
||||
assert(Math.abs(jun.lat - 23.44) < 0.5, 'elsewhen puts the June solstice sun near +23.4', jun.lat)
|
||||
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 11, 21, 12, 0, 0)).lat + 23.44) < 0.5, 'elsewhen puts the December solstice sun near -23.4')
|
||||
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 2, 20, 12, 0, 0)).lat) < 1, 'elsewhen puts the equinox sun near the equator')
|
||||
assert(Math.abs(jun.lon) < 5, 'elsewhen puts the noon UTC sun near Greenwich', jun.lon)
|
||||
assert(Math.abs(G.subsolarPoint(Date.UTC(2026, 5, 21, 18, 0, 0)).lon + 90) < 5, 'elsewhen puts the 18:00 UTC sun near 90W')
|
||||
|
||||
assert(Math.abs(G.solarElevation(jun.lat, jun.lon, jun) - 90) < 0.01, 'elsewhen has the sun overhead at the subsolar point')
|
||||
assert(G.solarElevation(-jun.lat, jun.lon + 180, jun) < -89, 'elsewhen has the antipode in deepest night')
|
||||
assertEqual(G.isDaylight(jun.lat, jun.lon, jun), true, 'elsewhen has daylight at the subsolar point')
|
||||
assertEqual(G.isDaylight(-jun.lat, jun.lon + 180, jun), false, 'elsewhen has night at the antipode')
|
||||
assert([[0, 0], [51, 0], [-33, 151], [78, -68]].every(([lat, lon]) => Math.abs(G.solarElevation(lat, lon, jun)) <= 90.01), 'elsewhen keeps elevation in range')
|
||||
|
||||
const sub = G.subsolarPoint(Date.now())
|
||||
const term = G.terminator(sub, 60)
|
||||
assertEqual(term.length, 61, 'elsewhen closes the terminator')
|
||||
assert(term.every(([lat, lon]) => {
|
||||
const 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.abs(cosz) < 1e-9
|
||||
}), 'elsewhen draws the terminator 90 degrees from the sun')
|
||||
|
||||
// ---- labels and scale
|
||||
|
||||
const placed = G.layoutLabels([
|
||||
{ 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 },
|
||||
{ index: 2, name: 'CCCC', x: 0, y: 80, rank: 1, cosc: 0.8 }
|
||||
], 7, 12)
|
||||
assertDeepEqual(placed.map(p => p.index), [0, 2], 'elsewhen drops an overlapping label')
|
||||
|
||||
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
|
||||
assert(free.x + free.w > 150, 'elsewhen lets an unbounded label overflow')
|
||||
assert(bounded.x + bounded.w <= 150 && bounded.x < 120 && bounded.x + bounded.w > 60, 'elsewhen flips a bounded label to the left of its dot')
|
||||
const lagos = [{ index: 0, name: 'Lagos', x: -40, y: 0, rank: 1, cosc: 1 }]
|
||||
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150)[0].box.x, -34, 'elsewhen leaves a label that fits with the default gap')
|
||||
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150, 10)[0].box.x, -30, 'elsewhen uses a scaled gap')
|
||||
assertEqual(G.layoutLabels(lagos, 7, 12, 10, 150, 0)[0].box.x, -34, 'elsewhen falls back to the default gap')
|
||||
|
||||
assertDeepEqual([G.scalePx(1.4, 1), G.scalePx(1.4, 2), G.scalePx(7.5, 1.5)], [1.4, 2.8, 11.25], 'elsewhen scales drawn sizes')
|
||||
const ratio = s => G.scalePx(7.5, s) / G.scalePx(2.2, s)
|
||||
assert(Math.abs(ratio(1) - ratio(1.67)) < 1e-9, 'elsewhen keeps drawn proportions at any scale')
|
||||
assertDeepEqual([G.scalePx(1, 0.5), G.scalePx(1, 0.5, 0.25)], [1, 0.5], 'elsewhen floors hairlines at an overridable pixel')
|
||||
assertDeepEqual([G.scalePx(2.5, 0), G.scalePx(2.5, undefined), G.scalePx(2.5, NaN)], [2.5, 2.5, 2.5], 'elsewhen treats a nonsense scale as 1')
|
||||
|
||||
assertDeepEqual([[0, 90], [0, -90], [170, -170], [-170, 170], [0, 180], [0, -180], [10, 190], [0, 540]].map(([a, b]) => G.shortestTurn(a, b)), [90, -90, 20, -20, 180, 180, 180, 180], 'elsewhen turns the short way round')
|
||||
assert(Math.abs(G.shortestTurn(1080 + 5, 10) - 5) < 1e-9, 'elsewhen turns the short way from a wound-up spin')
|
||||
|
||||
// ---- clipping to the disc
|
||||
|
||||
const R = 28
|
||||
const DISC = Math.PI * R * R
|
||||
const area = poly => {
|
||||
let a = 0
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const j = (i + 1) % poly.length
|
||||
a += poly[i].x * poly[j].y - poly[j].x * poly[i].y
|
||||
}
|
||||
return Math.abs(a) / 2
|
||||
}
|
||||
let worst = 0
|
||||
let previous = null
|
||||
for (let spin = 0; spin < 360; spin += 0.25) {
|
||||
const total = land.reduce((sum, ring) => sum + area(G.clipRingToDisc(ring, spin, 0, R)), 0)
|
||||
if (previous !== null) worst = Math.max(worst, Math.abs(total - previous))
|
||||
previous = total
|
||||
}
|
||||
assert(worst < DISC * 0.01, 'elsewhen clipped land area changes smoothly as the globe turns', `${worst.toFixed(2)} px2 per 0.25 degrees`)
|
||||
assert(land.every(ring => G.clipRingToDisc(ring, 137, 0, R).every(p => Math.hypot(p.x, p.y) <= R + 0.01)), 'elsewhen keeps clipped polygons inside the disc')
|
||||
assertEqual(G.clipRingToDisc([0, 0, 1, 0, 1, 1], 180, 0, R).length, 0, 'elsewhen clips a far-side ring to nothing')
|
||||
assert(G.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)), 'elsewhen breaks polylines at the horizon')
|
||||
|
||||
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 = G.decimateRing(ring, 2, 8)
|
||||
assert(half.length / 2 <= ring.length / 4 + 1 && half.length % 2 === 0, 'elsewhen halves a ring into lon,lat pairs')
|
||||
assert(half[0] === ring[0] && half[1] === ring[1], 'elsewhen starts a decimated ring on the same vertex')
|
||||
assert(half[half.length - 2] === ring[ring.length - 2] && half[half.length - 1] === ring[ring.length - 1], 'elsewhen ends a decimated ring on its own last vertex')
|
||||
const originals = new Set()
|
||||
for (let i = 0; i < ring.length; i += 2) originals.add(ring[i] + ',' + ring[i + 1])
|
||||
let kept = true
|
||||
for (let i = 0; i < half.length; i += 2) if (!originals.has(half[i] + ',' + half[i + 1])) kept = false
|
||||
assert(kept, 'elsewhen keeps only original vertices')
|
||||
const tiny = [0, 0, 1, 0, 1, 1, 0, 1]
|
||||
assert(G.decimateRing(tiny, 2, 8) === tiny && G.decimateRing(ring, 1, 8) === ring, 'elsewhen leaves small rings and step 1 alone')
|
||||
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 coarse = G.clipRingToDisc(half, 10, 0, R)
|
||||
const full = bbox(G.clipRingToDisc(ring, 10, 0, R))
|
||||
assert(coarse.length >= 3 && bbox(coarse).every((v, i) => Math.abs(v - full[i]) < R * 0.03), 'elsewhen clips a decimated ring to the same ground')
|
||||
assert(land.every(r => {
|
||||
const d = G.decimateRing(r, 2, 8)
|
||||
return d.length % 2 === 0 && d.length >= 6 && d.length <= r.length
|
||||
}), 'elsewhen decimates every world.json ring')
|
||||
|
||||
// ---- the moon
|
||||
|
||||
const eclipses = [
|
||||
['2017-08-21T18:26Z', 0.0],
|
||||
['2024-04-08T18:17Z', 0.0],
|
||||
['2019-01-21T05:12Z', 0.5],
|
||||
['2022-11-08T10:59Z', 0.5],
|
||||
['2021-05-26T11:19Z', 0.5]
|
||||
]
|
||||
const offByDays = eclipses.map(([iso, want]) => {
|
||||
let d = Math.abs(G.moonPhase(Date.parse(iso)) - want)
|
||||
if (d > 0.5) d = 1 - d
|
||||
return d * G.SYNODIC_MONTH
|
||||
})
|
||||
assert(offByDays.every(d => d <= 1), 'elsewhen puts eclipses within a day of new and full moon', offByDays.join(', '))
|
||||
assert([0, 1e12, Date.now(), Date.UTC(1970, 0, 1)].every(ms => { const p = G.moonPhase(ms); return p >= 0 && p < 1 }), 'elsewhen keeps the moon phase in [0, 1)')
|
||||
const now = Date.now()
|
||||
const drift = Math.abs(G.moonPhase(now) - G.moonPhase(now + G.SYNODIC_MONTH * 86400000))
|
||||
assert(drift < 1e-6 || drift > 1 - 1e-6, 'elsewhen repeats the phase after a synodic month')
|
||||
|
||||
const MOON_R = 40
|
||||
const illumination = phase => (1 - Math.cos(2 * Math.PI * phase)) / 2
|
||||
const inside = (poly, x, y) => {
|
||||
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
|
||||
}
|
||||
const measure = phase => {
|
||||
const poly = G.moonLitOutline(phase, MOON_R, 96)
|
||||
let lit = 0, disc = 0, left = 0, right = 0
|
||||
for (let y = -MOON_R; y <= MOON_R; y += 0.5)
|
||||
for (let x = -MOON_R; x <= MOON_R; x += 0.5) {
|
||||
if (x * x + y * y > MOON_R * MOON_R) continue
|
||||
disc++
|
||||
if (!inside(poly, x, y)) continue
|
||||
lit++
|
||||
if (x < 0) left++; else right++
|
||||
}
|
||||
return { frac: lit / disc, left, right }
|
||||
}
|
||||
const phases = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]
|
||||
const measured = phases.map(measure)
|
||||
assert(phases.every((p, i) => Math.abs(measured[i].frac - illumination(p)) < 0.03), 'elsewhen draws the lit area the phase calls for')
|
||||
assert(measured[1].right > measured[1].left * 20 && measured[2].left < measured[2].right * 0.05, 'elsewhen lights the right limb while waxing')
|
||||
assert(measured[7].left > measured[7].right * 20 && measured[6].right < measured[6].left * 0.05, 'elsewhen lights the left limb while waning')
|
||||
assert(measured[0].frac < 0.01 && measured[4].frac > 0.99, 'elsewhen draws new moon dark and full moon lit')
|
||||
assert([0, 0.2, 0.4, 0.6, 0.8].every(p => G.moonLitOutline(p, MOON_R, 48).every(q => Math.hypot(q.x, q.y) <= MOON_R + 1e-6)), 'elsewhen keeps the moon outline inside its disc')
|
||||
|
||||
// ---- day and night against Open-Meteo
|
||||
|
||||
if (process.env.ELSEWHEN_ONLINE) {
|
||||
const lats = cities.map(c => c[2]).join(',')
|
||||
const lons = cities.map(c => c[3]).join(',')
|
||||
https.get(`https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=is_day`, res => {
|
||||
let body = ''
|
||||
res.on('data', d => body += d)
|
||||
res.on('end', () => {
|
||||
const feed = JSON.parse(body)
|
||||
const sun = G.subsolarPoint(Date.now())
|
||||
// Open-Meteo's flag lags up to 15 minutes, so only disagreements away from the horizon count.
|
||||
const wrong = []
|
||||
feed.forEach((entry, i) => {
|
||||
const theirs = (entry.current || {}).is_day
|
||||
const mine = G.isDaylight(cities[i][2], cities[i][3], sun) ? 1 : 0
|
||||
if (theirs !== undefined && mine !== theirs && Math.abs(G.solarElevation(cities[i][2], cities[i][3], sun)) > 4) wrong.push(cities[i][0])
|
||||
})
|
||||
assertDeepEqual(wrong, [], 'elsewhen agrees with Open-Meteo about day and night')
|
||||
})
|
||||
}).on('error', error => fail('elsewhen reaches Open-Meteo', error.message))
|
||||
}
|
||||
JS
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const { execFileSync } = require('child_process')
|
||||
const g = requireFromRoot('shell/plugins/panels/elsewhen/Greetings.js')
|
||||
|
||||
const at = (zone, hour) => g.greeting(zone, hour).text
|
||||
const language = (zone, hour) => g.greeting(zone, hour === undefined ? 9 : hour).language
|
||||
|
||||
// ---- coverage of every zone the picker offers
|
||||
|
||||
const offered = execFileSync('timedatectl', ['list-timezones'], { encoding: 'utf8' }).trim().split('\n')
|
||||
const placeless = zone => /^(Etc\/|GMT|UCT$|UTC$|Universal$|Zulu$|Greenwich$|Factory$)/.test(zone)
|
||||
const places = offered.filter(zone => !placeless(zone))
|
||||
const zoneIds = new Set(g.zoneIds())
|
||||
const countries = new Set(g.countryCodes())
|
||||
|
||||
assert(places.length > 300, 'elsewhen reads the system zone list', places.length)
|
||||
assertDeepEqual(places.filter(z => !zoneIds.has(z)), [], 'elsewhen has every offered zone in its table')
|
||||
assertDeepEqual(places.filter(z => g.countryFor(z) === ''), [], 'elsewhen puts every place in a country')
|
||||
assertDeepEqual(places.filter(z => !countries.has(g.countryFor(z))), [], 'elsewhen greets every place by its country, never by accident')
|
||||
assert(at('Etc/GMT+5', 9) === 'Good morning' && g.countryFor('Etc/GMT+5') === '', 'elsewhen still greets offset-only zones')
|
||||
|
||||
const cities = JSON.parse(fs.readFileSync(path.join(root, 'shell/plugins/panels/elsewhen/cities.json'), 'utf8'))
|
||||
assertDeepEqual([...new Set(cities.map(c => c[1]))].filter(z => !zoneIds.has(z)), [], 'elsewhen maps every catalogue city')
|
||||
|
||||
// ---- languages, overrides and aliases
|
||||
|
||||
assertDeepEqual(
|
||||
[language('Asia/Jerusalem', 7), g.greeting('Asia/Jerusalem', 7).roman, language('Asia/Singapore', 14), at('Asia/Singapore', 14)],
|
||||
['Hebrew', 'boker tov', 'Malay', 'Selamat tengah hari'],
|
||||
'elsewhen greets Tel Aviv and Singapore in their own languages'
|
||||
)
|
||||
assertDeepEqual(
|
||||
['Pacific/Honolulu', 'America/Denver', 'America/Montreal', 'America/Toronto'].map(z => language(z)),
|
||||
['Hawaiian', 'English', 'French', 'English'],
|
||||
'elsewhen applies city overrides only to their cities'
|
||||
)
|
||||
assertDeepEqual(
|
||||
['Asia/Calcutta', 'US/Pacific', 'Europe/Kiev'].map(z => language(z)),
|
||||
['Hindi', 'English', 'Ukrainian'],
|
||||
'elsewhen resolves aliases like their targets'
|
||||
)
|
||||
assert(g.countryCodes().length > 240, 'elsewhen speaks for every country code')
|
||||
|
||||
assertDeepEqual(
|
||||
[at('Mars/Olympus_Mons', 9), language('', 9), at(undefined, 9).length > 0],
|
||||
['Good morning', 'English', true],
|
||||
'elsewhen falls back to English for unknown zones'
|
||||
)
|
||||
|
||||
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']
|
||||
]
|
||||
assertDeepEqual(
|
||||
samePlace.filter(([alias, canonical]) => g.countryFor(alias) !== g.countryFor(canonical) || at(alias, 9) !== at(canonical, 9)).map(([alias]) => alias),
|
||||
[],
|
||||
'elsewhen puts legacy aliases in the country of the place they name'
|
||||
)
|
||||
assertDeepEqual([g.countryFor('Antarctica/South_Pole'), g.countryFor('Pacific/Ponape')], ['AQ', 'FM'], 'elsewhen keeps the South Pole and Pohnpei where they are')
|
||||
assertEqual(language('Europe/Simferopol'), 'Ukrainian', 'elsewhen greets Simferopol in Ukrainian')
|
||||
assert(g.countryFor('America/Montreal') === g.countryFor('America/Toronto') && language('America/Montreal') === 'French', 'elsewhen greets Montreal in French inside Canada')
|
||||
|
||||
// ---- every table
|
||||
|
||||
const LATIN = /^[ -~ -ɏɐ-˿̀-ͯḀ-ỿ‘’]+$/
|
||||
const problems = []
|
||||
for (const key of g.languageKeys()) {
|
||||
const bands = g.bandsOf(key)
|
||||
if (bands[0].from !== 0) problems.push(`${key}: does not start at midnight`)
|
||||
if (!bands.every((b, i) => i === 0 || b.from > bands[i - 1].from)) problems.push(`${key}: bands do not ascend`)
|
||||
if (!bands.every(b => b.from >= 0 && b.from <= 23)) problems.push(`${key}: band outside the day`)
|
||||
bands.forEach((b, i) => {
|
||||
if (b.text.length === 0 || b.text.trim() !== b.text) problems.push(`${key}: blank or padded greeting`)
|
||||
const wrap = i === bands.length - 1 && b.text === bands[0].text
|
||||
if (i > 0 && !wrap && b.text === bands[i - 1].text) problems.push(`${key}: band ${i} repeats the one before`)
|
||||
if (LATIN.test(b.text) ? b.roman !== '' : b.roman === '') problems.push(`${key}: roman where the script is ${LATIN.test(b.text) ? '' : 'not '}Latin`)
|
||||
if (b.roman !== '' && !/^[a-z' -]+$/.test(b.roman)) problems.push(`${key}: unsayable roman ${b.roman}`)
|
||||
})
|
||||
const zone = g.zoneIds().find(z => g.languageFor(z) === key)
|
||||
const texts = new Set(bands.map(b => b.text))
|
||||
if (zone) for (let h = 0; h < 24; h++) if (!texts.has(at(zone, h))) problems.push(`${key}: hour ${h} has no greeting`)
|
||||
}
|
||||
assertDeepEqual(problems, [], 'elsewhen language tables cover the day with sayable greetings')
|
||||
|
||||
assertEqual(at('Asia/Tokyo', 24), at('Asia/Tokyo', 23), 'elsewhen clamps hour 24 into the day')
|
||||
assertEqual(at('Asia/Tokyo', -3), at('Asia/Tokyo', 0), 'elsewhen clamps a negative hour to midnight')
|
||||
assertEqual(at('Asia/Tokyo', 11.9), at('Asia/Tokyo', 11), 'elsewhen truncates a fractional hour')
|
||||
|
||||
// ---- the boundaries the tables exist for
|
||||
|
||||
assert(at('Asia/Tokyo', 7) !== at('Asia/Tokyo', 13) && at('Asia/Tokyo', 13) !== at('Asia/Tokyo', 20), 'elsewhen walks Tokyo through its day')
|
||||
assertDeepEqual([at('Europe/Madrid', 20), at('America/Lima', 20)], ['Buenas tardes', 'Buenas noches'], 'elsewhen keeps Madrid in the afternoon after Lima')
|
||||
assert(language('Europe/Madrid', 20).startsWith('Spanish') && language('America/Lima', 20) === 'Spanish', 'elsewhen greets Madrid and Lima in Spanish')
|
||||
assert(at('Europe/Vienna', 13) !== at('Europe/Berlin', 13) && at('Europe/Zurich', 13) !== at('Europe/Berlin', 13), 'elsewhen tells Vienna and Zurich from Berlin at midday')
|
||||
assert(at('Asia/Jakarta', 12) !== at('Asia/Jakarta', 16), 'elsewhen gives Jakarta an afternoon and a late afternoon')
|
||||
assertEqual(new Set([0, 6, 12, 18, 23].map(h => at('Asia/Yangon', h))).size, 1, 'elsewhen greets Yangon the same all day')
|
||||
assert(at('Asia/Hong_Kong', 8) !== at('Asia/Shanghai', 8), 'elsewhen does not greet Hong Kong in Mandarin')
|
||||
JS
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
require_command jq
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
mkdir -p "$test_dir/bin" "$test_dir/home"
|
||||
export CALL_LOG="$test_dir/calls"
|
||||
|
||||
cat >"$test_dir/bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'drop %s\n' "$*" >>"$CALL_LOG"
|
||||
exit "${PACKAGE_STATUS:-0}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CALL_LOG"
|
||||
quiet=0
|
||||
if [[ $1 == "-q" ]]; then
|
||||
quiet=1
|
||||
shift
|
||||
fi
|
||||
if [[ ${SHELL_ABSENT:-0} == 1 ]]; then
|
||||
(( quiet )) && exit 0
|
||||
echo "omarchy-shell is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $2 != "rescanPlugins" ]] || exit 0
|
||||
printf '%s\n' "${TEST_PUT_RESULT:-ok}"
|
||||
SH
|
||||
cat >"$test_dir/bin/omarchy-restart-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
echo 'migration must leave the restart to omarchy update' >&2
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$test_dir/bin/"*
|
||||
|
||||
run() {
|
||||
: >"$CALL_LOG"
|
||||
env HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" "$@" \
|
||||
bash -euo pipefail "$migration" >"$test_dir/output" 2>&1
|
||||
}
|
||||
|
||||
[[ -f $ROOT/shell/plugins/panels/elsewhen/manifest.json ]] || fail "Elsewhen ships in the shell tree"
|
||||
[[ $(jq -r .id "$ROOT/shell/plugins/panels/elsewhen/manifest.json") == "omarchy.elsewhen" ]] ||
|
||||
fail "Elsewhen uses the first-party namespace"
|
||||
! grep -qx elsewhen "$ROOT/install/omarchy-base.packages" || fail "fresh installs do not install the retired package"
|
||||
pass "Elsewhen ships in the shell tree as omarchy.elsewhen"
|
||||
|
||||
# Placement
|
||||
migration="$ROOT/migrations/1790042972.sh"
|
||||
expected=$'-q shell rescanPlugins\nshell putBarWidget omarchy.elsewhen {"before":"omarchy.clock"}'
|
||||
|
||||
run
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "scan and placement run in order" "$(cat "$CALL_LOG")"
|
||||
pass "real bar helper places Elsewhen before the clock without restarting the shell"
|
||||
|
||||
run
|
||||
[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "placement can be rerun"
|
||||
pass "placement can be rerun"
|
||||
|
||||
if run TEST_PUT_RESULT=unknown; then
|
||||
fail "an unknown widget must leave the migration pending"
|
||||
fi
|
||||
pass "an unknown widget leaves the migration pending"
|
||||
|
||||
# An update with no shell to ask, from a TTY or with the shell down, still
|
||||
# finishes; the update restarts the shell afterwards.
|
||||
run SHELL_ABSENT=1 OMARCHY_SHELL_ABSENT_ATTEMPTS=1 || fail "an absent shell must not fail the migration" "$(cat "$test_dir/output")"
|
||||
grep -q "omarchy.elsewhen was not put on the bar" "$test_dir/output" || fail "an absent shell is reported" "$(cat "$test_dir/output")"
|
||||
pass "an absent shell leaves the update running"
|
||||
|
||||
# An entry under the legacy id is left for the rename, not joined by a second widget.
|
||||
mkdir -p "$test_dir/home/.config/omarchy"
|
||||
printf '{"bar":{"layout":{"center":[{"id":"omacom.elsewhen","zones":"Tokyo|Asia/Tokyo"},"omarchy.clock"]}}}\n' \
|
||||
>"$test_dir/home/.config/omarchy/shell.json"
|
||||
run
|
||||
[[ $(cat "$CALL_LOG") == "-q shell rescanPlugins" ]] || fail "a legacy entry skips placement" "$(cat "$CALL_LOG")"
|
||||
migration="$ROOT/migrations/1790528634.sh"
|
||||
run
|
||||
[[ $(jq -c '[.bar.layout.center[] | if type == "object" then .id else . end]' "$test_dir/home/.config/omarchy/shell.json") == '["omarchy.elsewhen","omarchy.clock"]' ]] ||
|
||||
fail "the legacy entry becomes the only Elsewhen" "$(cat "$test_dir/home/.config/omarchy/shell.json")"
|
||||
rm "$test_dir/home/.config/omarchy/shell.json"
|
||||
migration="$ROOT/migrations/1790042972.sh"
|
||||
pass "a legacy entry is renamed rather than duplicated"
|
||||
|
||||
# The first run of this migration was under 1789581661.sh, before a later
|
||||
# repair existed; that marker must not stop the renamed file from running there.
|
||||
state="$test_dir/state"
|
||||
mkdir -p "$state"
|
||||
touch "$state/1789581661.sh"
|
||||
OMARCHY_MIGRATION_STATE="$state" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-migrate" --pending >"$test_dir/pending" || true
|
||||
grep -qx "$(basename "$migration")" "$test_dir/pending" || fail "the old marker must not satisfy the renamed migration" "$(cat "$test_dir/pending")"
|
||||
pass "a machine that applied the placement under its old name runs it again"
|
||||
|
||||
# Package retirement
|
||||
migration="$ROOT/migrations/1790528634.sh"
|
||||
plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen"
|
||||
mkdir -p "${plugin%/*}"
|
||||
|
||||
mkdir -p "$test_dir/home/.cache/omacom-elsewhen"
|
||||
touch "$test_dir/home/.cache/omacom-elsewhen/data.json"
|
||||
run
|
||||
[[ $(cat "$CALL_LOG") == "drop elsewhen" ]] || fail "the package is dropped" "$(cat "$CALL_LOG")"
|
||||
[[ ! -e $test_dir/home/.config/omarchy/shell.json ]] || fail "a missing config is not created"
|
||||
[[ ! -e $test_dir/home/.cache/omacom-elsewhen ]] || fail "the old cache is removed"
|
||||
pass "the retired package and its old cache are removed"
|
||||
|
||||
config="$test_dir/home/.config/omarchy/shell.json"
|
||||
cat >"$config" <<'JSON'
|
||||
{
|
||||
"bar": {
|
||||
"centerAnchor": "omacom.elsewhen",
|
||||
"layout": {
|
||||
"left": ["omarchy.menu"],
|
||||
"center": [{ "id": "omacom.elsewhen", "zones": "Tokyo|Asia/Tokyo" }, { "id": "omarchy.clock" }],
|
||||
"right": ["omacom.elsewhen"]
|
||||
}
|
||||
},
|
||||
"plugins": [{ "id": "someone.else" }],
|
||||
"disabledPlugins": ["omacom.elsewhen", "omarchy.battery"]
|
||||
}
|
||||
JSON
|
||||
run
|
||||
expected_config='{"bar":{"centerAnchor":"omarchy.elsewhen","layout":{"left":["omarchy.menu"],"center":[{"id":"omarchy.elsewhen","zones":"Tokyo|Asia/Tokyo"},{"id":"omarchy.clock"}],"right":["omarchy.elsewhen"]}},"plugins":[{"id":"someone.else"}],"disabledPlugins":["omarchy.elsewhen","omarchy.battery"]}'
|
||||
[[ $(jq -c . "$config") == "$expected_config" ]] || fail "bar entries are renamed with their settings" "$(jq -c . "$config")"
|
||||
run
|
||||
[[ $(jq -c . "$config") == "$expected_config" ]] || fail "the rename can be rerun"
|
||||
pass "bar entries, the center anchor and plugin lists move to omarchy.elsewhen with their settings"
|
||||
|
||||
printf 'not json' >"$config"
|
||||
if run; then
|
||||
fail "an unreadable config must leave the migration pending"
|
||||
fi
|
||||
[[ $(cat "$config") == "not json" ]] || fail "an unreadable config is left untouched"
|
||||
pass "an unreadable config leaves the migration pending and the file untouched"
|
||||
rm "$config"
|
||||
|
||||
for target in /usr/share/omarchy/shell/plugins/omacom.elsewhen /usr/share/omarchy/plugins/omacom.elsewhen; do
|
||||
ln -sfn "$target" "$plugin"
|
||||
run
|
||||
[[ ! -e $plugin && ! -L $plugin ]] || fail "a link to the packaged plugin is removed ($target)"
|
||||
done
|
||||
pass "dev links to the packaged plugin are removed, including the stranded old path"
|
||||
|
||||
ln -s "$test_dir/custom-plugin" "$plugin"
|
||||
run
|
||||
[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "a link the user made is preserved"
|
||||
rm "$plugin"
|
||||
mkdir "$plugin"
|
||||
printf 'local work\n' >"$plugin/notes"
|
||||
run
|
||||
[[ $(cat "$plugin/notes") == "local work" ]] || fail "a local checkout is preserved"
|
||||
pass "user links and checkouts are preserved"
|
||||
|
||||
if run PACKAGE_STATUS=1; then
|
||||
fail "a failed removal must leave the migration pending"
|
||||
fi
|
||||
pass "a failed removal leaves the migration pending"
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const { execFileSync } = require('child_process')
|
||||
const M = requireFromRoot('shell/plugins/panels/elsewhen/Model.js')
|
||||
|
||||
const probe = (ids, withLocal) => {
|
||||
const command = M.probeCommand(ids, withLocal)
|
||||
return execFileSync(command[0], command.slice(1), { encoding: 'utf8' })
|
||||
}
|
||||
const near = (a, b, eps) => Math.abs(a - b) <= (eps === undefined ? 1e-9 : eps)
|
||||
|
||||
// ---- the date probe
|
||||
|
||||
const zoneCommand = M.probeCommand(['Asia/Tokyo', 'Europe/Paris'])
|
||||
assertDeepEqual(zoneCommand.slice(0, 2), ['bash', '-c'], 'elsewhen probe runs one bash')
|
||||
assertDeepEqual(zoneCommand.slice(3), ['bash', 'Asia/Tokyo', 'Europe/Paris'], 'elsewhen probe passes zones as arguments, not script text')
|
||||
assert(zoneCommand[2].indexOf('Asia/Tokyo') === -1, 'elsewhen probe script never contains a zone id')
|
||||
|
||||
const probed = M.parseProbe(probe(['Asia/Tokyo', 'Asia/Kathmandu', 'UTC']))
|
||||
assertDeepEqual(probed['Asia/Tokyo'], { abbr: 'JST', offsetMinutes: 540 }, 'elsewhen probe reads Tokyo')
|
||||
assertEqual(probed['Asia/Kathmandu'].offsetMinutes, 345, 'elsewhen probe reads a three-quarter-hour zone')
|
||||
assertEqual(probed.UTC.offsetMinutes, 0, 'elsewhen probe reads UTC')
|
||||
|
||||
const local = probe(['Asia/Tokyo'], true)
|
||||
const localZone = M.localZoneFromProbe(local)
|
||||
assert(localZone !== '', 'elsewhen probe names the local zone')
|
||||
assert(M.parseProbe(local)[localZone] !== undefined, 'elsewhen probe also gives the local zone an offset line')
|
||||
assert(M.parseProbe(local)['Asia/Tokyo'] !== undefined, 'elsewhen local probe still probes the listed zones')
|
||||
assertEqual(M.parseProbe(local).LOCAL, undefined, 'elsewhen LOCAL marker is not a zone')
|
||||
|
||||
assertEqual(M.parseOffset('-0700'), -420, 'elsewhen parses a negative offset')
|
||||
assertEqual(M.parseOffset('+0545'), 345, 'elsewhen parses a positive offset')
|
||||
assertEqual(M.parseOffset('0700'), null, 'elsewhen rejects an unsigned offset')
|
||||
assertDeepEqual(M.parseProbe('Bad|X\nEurope/Paris|CET|nope\n\n'), {}, 'elsewhen ignores unparseable probe lines')
|
||||
|
||||
// ---- clocks
|
||||
|
||||
const at = Date.UTC(2026, 5, 21, 13, 5)
|
||||
assertEqual(M.formatClock(at, 0, true), '13:05', 'elsewhen formats a 24-hour clock')
|
||||
assertEqual(M.formatClock(at, 0, false), '1:05 PM', 'elsewhen formats a 12-hour clock')
|
||||
assertEqual(M.formatClock(at, 540, false), '10:05 PM', 'elsewhen applies the zone offset')
|
||||
assertEqual(M.formatClock(at, -13 * 60 - 5, true), '00:00', 'elsewhen pads midnight on a 24-hour clock')
|
||||
assertEqual(M.formatClock(at, -13 * 60 - 5, false), '12:00 AM', 'elsewhen shows midnight as 12 AM')
|
||||
assertEqual(M.formatClock(at, -60 - 5, false), '12:00 PM', 'elsewhen shows noon as 12 PM')
|
||||
assertEqual(M.formatClock(at, -5 * 60, true), '08:05', 'elsewhen pads a morning hour')
|
||||
assertEqual(M.formatClock(at, undefined, true), '', 'elsewhen shows nothing before the offset is known')
|
||||
|
||||
// ---- scrubbing
|
||||
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(0.5, 600)), 120, 'elsewhen scrubs forward')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(0.5, 840)), -120, 'elsewhen scrubs backward')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(0.0, 1380)), 60, 'elsewhen scrubs the short way round midnight')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(2, 720)), 720, 'elsewhen clamps a scrub past the right edge')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(-1, 720)), 720, 'elsewhen resolves a half-day scrub forward')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(1, 720)), 720, 'elsewhen resolves a half-day scrub forward from the other edge')
|
||||
assertEqual(Math.round(M.scrubDeltaMinutes(600 / 1440, 600)), 0, 'elsewhen scrub at the current time is zero')
|
||||
assertDeepEqual([M.formatScrubDelta(0), M.formatScrubDelta(45), M.formatScrubDelta(-90), M.formatScrubDelta(180)], ['', '+45m', '-1h 30m', '+3h'], 'elsewhen formats scrub deltas')
|
||||
|
||||
assertDeepEqual([M.formatMinuteOfDay(0, false), M.formatMinuteOfDay(13 * 60 + 5, false), M.formatMinuteOfDay(13 * 60, true)], ['12:00 AM', '1:05 PM', '13:00'], 'elsewhen formats minutes of the day')
|
||||
assertEqual(M.formatMinuteOfDay(419.81, false), '7:00 AM', 'elsewhen rolls the hour when rounding seconds')
|
||||
assertEqual(M.formatMinuteOfDay(419.81, true), '07:00', 'elsewhen rolls the hour on a 24-hour clock')
|
||||
assertEqual(M.formatMinuteOfDay(419.5, false), '7:00 AM', 'elsewhen rounds half a minute up')
|
||||
assertEqual(M.formatMinuteOfDay(419.49, false), '6:59 AM', 'elsewhen rounds under half a minute down')
|
||||
assertEqual(M.formatMinuteOfDay(719.7, false), '12:00 PM', 'elsewhen rounds into noon')
|
||||
assertEqual(M.formatMinuteOfDay(1439.7, false), '12:00 AM', 'elsewhen wraps the last minute of the day')
|
||||
assertEqual(M.formatMinuteOfDay(1439.7, true), '00:00', 'elsewhen wraps the last minute on a 24-hour clock')
|
||||
assertEqual(M.formatMinuteOfDay(375, false), '6:15 AM', 'elsewhen leaves whole minutes alone')
|
||||
|
||||
// ---- offset labels
|
||||
|
||||
assertDeepEqual(
|
||||
[0, 120, -300, 345, -210, 570, 840, -720, 65].map(M.utcOffsetLabel),
|
||||
['UTC', 'UTC+2', 'UTC-5', 'UTC+5:45', 'UTC-3:30', 'UTC+9:30', 'UTC+14', 'UTC-12', 'UTC+1:05'],
|
||||
'elsewhen labels absolute offsets'
|
||||
)
|
||||
assertDeepEqual([undefined, null, NaN].map(M.utcOffsetLabel), ['', '', ''], 'elsewhen labels unknown offsets as nothing')
|
||||
|
||||
assertEqual(M.relativeOffsetLabel(540, 0), '+9h', 'elsewhen labels a zone ahead')
|
||||
assertEqual(M.relativeOffsetLabel(-300, -120), '-3h', 'elsewhen labels a zone behind')
|
||||
assertEqual(M.relativeOffsetLabel(345, 0), '+5.8h', 'elsewhen rounds quarter zones to a tenth')
|
||||
assertEqual(M.relativeOffsetLabel(330, 0), '+5.5h', 'elsewhen keeps half-hour zones')
|
||||
assertEqual(M.relativeOffsetLabel(570, 600), '-0.5h', 'elsewhen labels Adelaide from Sydney')
|
||||
assertEqual(M.relativeOffsetLabel(-480, -480), '', 'elsewhen says nothing on your own offset')
|
||||
|
||||
// ---- editing and selection
|
||||
|
||||
const zones = M.parseZones('Paris|Europe/Paris, Tokyo|Asia/Tokyo, New York|America/New_York')
|
||||
const labels = list => list.map(z => z.label).join(',')
|
||||
|
||||
assertEqual(M.parseZones('').length, 0, 'elsewhen treats a blank setting as a fresh install')
|
||||
assertEqual(M.parseZones(undefined).length, 0, 'elsewhen treats an unset setting as a fresh install')
|
||||
assertDeepEqual(M.parseZones('Europe/Rome'), [{ label: 'Rome', id: 'Europe/Rome' }], 'elsewhen labels a bare zone from its id')
|
||||
assertEqual(M.labelForZoneId('America/Los_Angeles'), 'Los Angeles', 'elsewhen labels a zone id')
|
||||
|
||||
assertDeepEqual([0, 1, 2].map(i => M.indexOfZone(zones, zones[i].label, zones[i].id)), [0, 1, 2], 'elsewhen finds every row by label and zone')
|
||||
assertEqual(M.indexOfZone(zones, 'Lagos', 'Africa/Lagos'), -1, 'elsewhen finds no row for an untracked globe city')
|
||||
assertEqual(M.indexOfZone(zones, 'Tokyo', 'Asia/Osaka'), -1, 'elsewhen needs the zone to match')
|
||||
assertEqual(M.indexOfZone(zones, 'Tokyo City', 'Asia/Tokyo'), -1, 'elsewhen needs the label to match')
|
||||
assertEqual(M.indexOfZone(undefined, 'Paris', 'Europe/Paris'), -1, 'elsewhen tolerates a missing list')
|
||||
|
||||
const tokyoKey = M.factsKey(zones[1])
|
||||
assertEqual(M.indexOfZoneKey(zones, tokyoKey), 1, 'elsewhen finds a focused city by key')
|
||||
assertEqual(M.indexOfZoneKey(zones, ''), -1, 'elsewhen treats an empty key as no focus')
|
||||
assertEqual(M.indexOfZoneKey(null, tokyoKey), -1, 'elsewhen tolerates a missing list for a key')
|
||||
const moved = M.moveZone(zones, 0, 2)
|
||||
assertEqual(moved[M.indexOfZoneKey(moved, tokyoKey)].label, 'Tokyo', 'elsewhen focus follows the city through a reorder')
|
||||
assertEqual(M.indexOfZoneKey(M.removeZoneAt(zones, 0), tokyoKey), 0, 'elsewhen focus follows the city through a removal')
|
||||
assertEqual(M.indexOfZoneKey(M.removeZoneAt(zones, 1), tokyoKey), -1, 'elsewhen drops focus when the city is removed')
|
||||
|
||||
const four = M.parseZones('A|X/a, B|X/b, C|X/c, D|X/d')
|
||||
assertEqual(labels(M.moveZone(four, 0, 1)), 'B,A,C,D', 'elsewhen moves a row down one')
|
||||
assertEqual(labels(M.moveZone(four, 0, 3)), 'B,C,D,A', 'elsewhen moves a row to the end')
|
||||
assertEqual(labels(M.moveZone(four, 3, 0)), 'D,A,B,C', 'elsewhen moves a row to the front')
|
||||
assert(M.moveZone(four, 2, 2) === four && M.moveZone(four, -1, 2) === four && M.moveZone(four, 0, 9) === four, 'elsewhen ignores no-op and out-of-range moves')
|
||||
assertEqual(labels(four), 'A,B,C,D', 'elsewhen leaves the original list untouched')
|
||||
assertEqual(M.removeZoneAt([zones[0]], 0).length, 1, 'elsewhen never removes the last city')
|
||||
assertEqual(labels(M.removeZone(zones, 'Asia/Tokyo')), 'Paris,New York', 'elsewhen removes a city by zone')
|
||||
|
||||
const catalog = M.zoneOptions('America/Los_Angeles\nAsia/Tokyo', [])
|
||||
const sharing = catalog.filter(o => o.value === 'America/Los_Angeles')
|
||||
assert(sharing.length > 1 && new Set(sharing.map(o => o.label)).size === sharing.length, 'elsewhen offers several named cities for one zone')
|
||||
assertEqual(M.addZone([], 'America/Los_Angeles', 'Oakland')[0].label, 'Oakland', 'elsewhen keeps the chosen city name')
|
||||
assertEqual(M.addZone([], 'America/Los_Angeles', '')[0].label, 'Los Angeles', 'elsewhen falls back to the zone name')
|
||||
assertEqual(M.addZone(M.addZone([], 'America/Los_Angeles', 'Oakland'), 'America/Los_Angeles', 'Las Vegas').length, 2, 'elsewhen tracks two cities in one zone')
|
||||
|
||||
const roundTrip = list => M.parseZones(M.serializeZones(list)).map(z => z.label + '@' + z.id)
|
||||
assertDeepEqual(roundTrip(M.addZone([], 'Asia/Tokyo', 'Tokyo, Japan')), ['Tokyo Japan@Asia/Tokyo'], 'elsewhen keeps a comma out of the setting')
|
||||
assertDeepEqual(roundTrip(M.addZone([], 'Asia/Tokyo', 'Tokyo|HQ')), ['Tokyo HQ@Asia/Tokyo'], 'elsewhen keeps a pipe out of the setting')
|
||||
assertEqual(M.addZone([], 'Asia/Tokyo', ',|,')[0].label, 'Tokyo', 'elsewhen falls back when a label is only delimiters')
|
||||
assertEqual(M.addZone([], 'evil id, with|pipe', 'x').length, 0, 'elsewhen refuses an id that cannot name a zone')
|
||||
const unchanged = []
|
||||
assert(M.addZone(unchanged, 'bad id', 'x') === unchanged, 'elsewhen returns the same list when refusing')
|
||||
assertEqual(M.addZone([], 'Asia/Tokyo', '<img src=x>')[0].label, '<img src=x>', 'elsewhen keeps markup as plain text')
|
||||
const remaining = M.zoneOptions('America/Los_Angeles', M.addZone([], 'America/Los_Angeles', 'Oakland'))
|
||||
assert(!remaining.some(o => o.label === 'Oakland') && remaining.some(o => o.label === 'Las Vegas'), 'elsewhen drops only the tracked city from the picker')
|
||||
|
||||
const options = M.zoneOptions('Europe/Paris\nAmerica/Santiago\nAmerica/Argentina/Buenos_Aires', [])
|
||||
assertEqual(M.searchZones(options, 'par', 6)[0].label, 'Paris', 'elsewhen ranks city-name prefixes first')
|
||||
assert(M.searchZones(options, 'argentina', 6).some(o => o.label === 'Buenos Aires'), 'elsewhen searches zone ids too')
|
||||
|
||||
assertEqual(M.moveSelection(2, 1, 5), 3, 'elsewhen moves the selection down')
|
||||
assertEqual(M.moveSelection(4, 1, 5), 0, 'elsewhen wraps the selection past the end')
|
||||
assertEqual(M.moveSelection(0, -1, 5), 4, 'elsewhen wraps the selection past the start')
|
||||
assertEqual(M.moveSelection(3, 1, 0), 0, 'elsewhen resets the selection in an empty list')
|
||||
|
||||
const home = ['Copenhagen', 'Europe/Copenhagen', 55.68, 12.57, 0]
|
||||
const merged = M.mergeCities(
|
||||
home,
|
||||
[['copenhagen', 'Europe/Copenhagen', 55, 12, 1], ['Paris', 'Europe/Paris', 48.86, 2.35, 1], ['Nowhere', 'X/y', null, null, 2]],
|
||||
[['Paris', 'Europe/Paris', 1, 1, 0], ['Oakland', 'America/Los_Angeles', 37.8, -122.3, 0], ['Pending', 'Asia/Tokyo', null, null, 0]],
|
||||
[['Lagos', 'Africa/Lagos', 6.5, 3.4, 0], ['Unplaced', 'Asia/Dubai', undefined, undefined, 0]]
|
||||
)
|
||||
assertDeepEqual(merged.map(c => c[0]), ['Copenhagen', 'Paris', 'Nowhere', 'Oakland', 'Lagos'], 'elsewhen merges globe cities with home first and names deduplicated')
|
||||
assertEqual(merged[1][2], 48.86, 'elsewhen keeps the first city of a name')
|
||||
assertDeepEqual(M.mergeCities(['Home', 'X/y', undefined, undefined, 0], [['Paris', 'Europe/Paris', 1, 2, 1]], [], []).map(c => c[0]), ['Paris'], 'elsewhen leaves out a home without coordinates')
|
||||
assertDeepEqual(M.mergeCities([], [], [], []), [], 'elsewhen merges nothing into nothing')
|
||||
|
||||
// ---- globe transition and row drag
|
||||
|
||||
assertEqual(M.knockAt(0, 0), 0, 'elsewhen rows start in place')
|
||||
assertEqual(M.knockAt(0, 1), 1, 'elsewhen rows end fully knocked aside')
|
||||
assertEqual(M.knockAt(2, 0.18), 0, 'elsewhen later rows wait their turn')
|
||||
assertEqual(M.knockAt(10, 0.75), 0.5, 'elsewhen caps the stagger at half the zoom')
|
||||
assertDeepEqual([M.knockX(0, 1, 95), M.knockX(1, 1, 95)], [-95, 95], 'elsewhen throws rows to alternating sides')
|
||||
assertEqual(M.knockY(0, 0.5, 100), 25, 'elsewhen rows accelerate as they fall')
|
||||
assertDeepEqual([M.knockTilt(0, 1), M.knockTilt(1, 1)], [-14, 14], 'elsewhen tilts rows to alternating sides')
|
||||
assert(near(M.knockShrink(0, 1), 0.82), 'elsewhen shrinks knocked rows')
|
||||
|
||||
assertEqual(M.dragTargetFor(-1, 50, 40, -1, 4), -1, 'elsewhen has no drag target without a drag')
|
||||
assertEqual(M.dragTargetFor(1, 50, 0, -1, 4), -1, 'elsewhen has no drag target before rows are measured')
|
||||
assertEqual(M.dragTargetFor(1, 10, 40, -1, 4), 1, 'elsewhen holds the slot for a small drag')
|
||||
assertEqual(M.dragTargetFor(1, 50, 40, -1, 4), 2, 'elsewhen moves the target past the threshold')
|
||||
assertEqual(M.dragTargetFor(1, 60, 40, 3, 4), 3, 'elsewhen holds the target near a boundary')
|
||||
assertEqual(M.dragTargetFor(1, 55, 40, 3, 4), 2, 'elsewhen moves the target back once clearly past')
|
||||
assertEqual(M.dragTargetFor(0, 400, 40, -1, 4), 3, 'elsewhen clamps the target to the last row')
|
||||
assertEqual(M.dragTargetFor(2, -400, 40, -1, 4), 0, 'elsewhen clamps the target to the first row')
|
||||
|
||||
assertDeepEqual([0, 1, 2, 3].map(i => M.rowShift(i, 1, 3, 70, 40)), [0, 70, -40, -40], 'elsewhen steps rows up under a downward drag')
|
||||
assertDeepEqual([0, 1, 2, 3].map(i => M.rowShift(i, 3, 1, -70, 40)), [0, 40, 40, -70], 'elsewhen steps rows down under an upward drag')
|
||||
assertDeepEqual([0, 1].map(i => M.rowShift(i, -1, -1, 0, 40)), [0, 0], 'elsewhen shifts nothing without a drag')
|
||||
|
||||
assertDeepEqual(M.mix({ r: 0, g: 0, b: 0 }, { r: 1, g: 0.5, b: 0.2 }, 0.5), { r: 0.5, g: 0.25, b: 0.1, a: 1 }, 'elsewhen mixes two colors opaquely')
|
||||
assertDeepEqual(M.mix({ r: 0.2, g: 0.4, b: 0.6, a: 0.3 }, { r: 1, g: 1, b: 1 }, 0), { r: 0.2, g: 0.4, b: 0.6, a: 1 }, 'elsewhen mix at zero is the base color, opaque')
|
||||
|
||||
// ---- the strip's arrows and chips
|
||||
|
||||
const BAR = 567, BOX = 14, TUCK = 3, MARK = 10, SLACK = 4
|
||||
assertEqual(M.arrowBox(0.26, BAR, BOX, TUCK, true), 136, 'elsewhen puts the sunrise arrow before the band')
|
||||
assertEqual(M.arrowBox(0.81, BAR, BOX, TUCK, false), 456, 'elsewhen puts the sunset arrow after the band')
|
||||
assert(M.arrowBox(0.26, BAR, BOX, TUCK, true) + BOX / 2 < 0.26 * BAR, 'elsewhen keeps the sunrise glyph before its crossing')
|
||||
assert(M.arrowBox(0.81, BAR, BOX, TUCK, false) + BOX / 2 > 0.81 * BAR, 'elsewhen keeps the sunset glyph after its crossing')
|
||||
assertEqual(M.arrowBox(0, BAR, BOX, TUCK, true), 0, 'elsewhen holds an arrow on the start of the bar')
|
||||
assertEqual(M.arrowBox(1, BAR, BOX, TUCK, false), BAR - BOX, 'elsewhen holds an arrow on the end of the bar')
|
||||
assertEqual(M.arrowCovered(136, BOX, 143, MARK, SLACK), true, 'elsewhen hides an arrow under the marker')
|
||||
assertEqual(M.arrowCovered(136, BOX, 160, MARK, SLACK), false, 'elsewhen shows an arrow the marker has passed')
|
||||
|
||||
const SUNRISE = 0, SUNSET = 1
|
||||
const click = (atPress, slot, tapFirst) => tapFirst
|
||||
? M.chipAfterRelease(M.chipAfterTap(atPress, slot), atPress)
|
||||
: (M.chipAfterRelease(atPress, atPress), M.chipAfterTap(atPress, slot))
|
||||
for (const tapFirst of [true, false]) {
|
||||
const order = tapFirst ? 'tap first' : 'release first'
|
||||
assertDeepEqual(
|
||||
[click(M.NO_CHIP, SUNRISE, tapFirst), click(SUNRISE, SUNRISE, tapFirst), click(SUNRISE, SUNSET, tapFirst), click(SUNSET, SUNRISE, tapFirst)],
|
||||
[SUNRISE, M.NO_CHIP, SUNSET, SUNRISE],
|
||||
`elsewhen chips open, close and swap (${order})`
|
||||
)
|
||||
}
|
||||
assertEqual(M.chipAfterRelease(SUNSET, SUNSET), M.NO_CHIP, 'elsewhen closes a chip on a click elsewhere')
|
||||
assertEqual(M.chipAfterRelease(SUNRISE, M.NO_CHIP), SUNRISE, 'elsewhen keeps a chip opened during the same press')
|
||||
|
||||
// ---- weather and units
|
||||
|
||||
assertDeepEqual(
|
||||
[0, 1, 2, 3, 45, 48, 51, 57, 61, 67, 80, 82, 95, 99, 71, 77, 85, 86, '61'].map(M.weatherKind),
|
||||
['sunny', 'sunny', 'partly', 'cloudy', 'cloudy', 'cloudy', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'rain', 'snow', 'snow', 'snow', 'snow', 'rain'],
|
||||
'elsewhen collapses WMO codes to five kinds'
|
||||
)
|
||||
assertDeepEqual([null, undefined, '', 'rain', 7].map(M.weatherKind), ['', '', '', '', ''], 'elsewhen never turns a missing reading into a sun')
|
||||
const kinds = ['', 'sunny', 'partly', 'cloudy', 'rain', 'snow']
|
||||
assert([...Array(100).keys()].every(c => kinds.includes(M.weatherKind(c))), 'elsewhen maps every code 0..99 to a known kind')
|
||||
|
||||
assertDeepEqual(
|
||||
[M.resolveUnits('C', 'F'), M.resolveUnits('F', 'C'), M.resolveUnits('c', 'F'), M.resolveUnits(' f ', 'C')],
|
||||
['C', 'F', 'C', 'F'],
|
||||
'elsewhen honors an explicit temperature unit'
|
||||
)
|
||||
assertDeepEqual(
|
||||
[M.resolveUnits('', 'C'), M.resolveUnits('', 'F'), M.resolveUnits(undefined, 'C'), M.resolveUnits(null, 'F'), M.resolveUnits('kelvin', 'C'), M.resolveUnits('', 'wat')],
|
||||
['C', 'F', 'C', 'F', 'C', 'C'],
|
||||
'elsewhen follows the system unit otherwise'
|
||||
)
|
||||
assertDeepEqual(
|
||||
[M.formatTemp(0, 'C'), M.formatTemp(0, 'F'), M.formatTemp(100, 'F'), M.formatTemp(37, 'F'), M.formatTemp(-17.8, 'F'), M.formatTemp(21.5, 'C'), M.formatTemp(null, 'C')],
|
||||
['0°C', '32°F', '212°F', '99°F', '0°F', '22°C', ''],
|
||||
'elsewhen formats temperatures'
|
||||
)
|
||||
assertEqual(M.tempLabel({ c: 20 }, 'C'), '20°C', 'elsewhen labels a city temperature')
|
||||
assertEqual(M.tempLabel(undefined, 'C'), '', 'elsewhen labels a missing temperature as nothing')
|
||||
|
||||
assertDeepEqual(
|
||||
['h:mm AP', 'h:mm ap', "h'h'mm AP", 'AP h:mm', 'h:mm Ap'].map(M.usesTwentyFourHour),
|
||||
[false, false, false, false, false],
|
||||
'elsewhen reads a designator as a twelve-hour clock'
|
||||
)
|
||||
assertDeepEqual(
|
||||
['HH:mm', 'HH:mm:ss', "H'h'mm", 'H:mm', 'H.mm', ''].map(M.usesTwentyFourHour),
|
||||
[true, true, true, true, true, true],
|
||||
'elsewhen reads no designator as a twenty-four-hour clock'
|
||||
)
|
||||
assertDeepEqual(
|
||||
[M.resolveHour24(true, false), M.resolveHour24(false, true), M.resolveHour24('true', false), M.resolveHour24('false', true), M.resolveHour24('24', false), M.resolveHour24('12', true)],
|
||||
[true, false, true, false, true, false],
|
||||
'elsewhen honors an explicit clock setting'
|
||||
)
|
||||
assertDeepEqual(
|
||||
[M.resolveHour24('', true), M.resolveHour24('', false), M.resolveHour24(undefined, true), M.resolveHour24(null, true), M.resolveHour24('maybe', true)],
|
||||
[true, false, true, true, true],
|
||||
'elsewhen follows the system clock otherwise'
|
||||
)
|
||||
|
||||
// ---- first run
|
||||
|
||||
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'
|
||||
]
|
||||
const probedOffsets = M.parseProbe(probe([...new Set(M.seedCandidateZones().concat(HOMES))]))
|
||||
const offsets = {}
|
||||
for (const id in probedOffsets) offsets[id] = probedOffsets[id].offsetMinutes
|
||||
const dial = (a, b) => { const d = Math.abs(a - b) % 1440; return Math.min(d, 1440 - d) }
|
||||
const known = new Set(M.SEED_CANDIDATES.map(c => c.label))
|
||||
|
||||
const seedProblems = []
|
||||
for (const home of HOMES) {
|
||||
const list = M.seedZones({ label: M.labelForZoneId(home), id: home }, offsets, 4)
|
||||
const ids = list.map(z => z.id)
|
||||
const offs = ids.map(id => offsets[id])
|
||||
const east = offs.map(o => ((o - offs[0]) % 1440 + 1440) % 1440)
|
||||
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]))
|
||||
const round = M.parseZones(M.serializeZones(list))
|
||||
|
||||
const checks = {
|
||||
'five cities': list.length === 5,
|
||||
'home first': ids[0] === home,
|
||||
'no repeated zone': new Set(ids).size === ids.length,
|
||||
'no repeated name': new Set(list.map(z => z.label.toLowerCase())).size === list.length,
|
||||
'three hours from home': offs.slice(1).every(o => dial(o, offs[0]) >= 180),
|
||||
'three hours apart': closest >= 180,
|
||||
'sorted eastward': east.slice(1).every((e, i) => i === 0 || e >= east[i]),
|
||||
'from the curated list': list.slice(1).every(z => known.has(z.label)),
|
||||
'round trips': round.length === 5 && round.every((z, i) => z.id === ids[i])
|
||||
}
|
||||
for (const name in checks) if (!checks[name]) seedProblems.push(`${home}: ${name}`)
|
||||
}
|
||||
assertDeepEqual(seedProblems, [], 'elsewhen seeds four well-spread cities around every home')
|
||||
assertEqual(M.seedZones({ label: 'Nowhere', id: 'Not/AZone' }, offsets, 4).length, 1, 'elsewhen seeds only home when its offset is unknown')
|
||||
assertEqual(M.pickSeedZones({ label: 'X', id: 'UTC' }, {}, 4).length, 0, 'elsewhen seeds nothing without offsets')
|
||||
|
||||
// ---- coordinates and weather
|
||||
const tab = '#comment\nUS\t+404251-0740023\tAmerica/New_York\nIN\t+2232+08822\tAsia/Kolkata\n'
|
||||
assertDeepEqual(M.zoneTabCoords(tab, 'America/New_York'), { lat: 40.7142, lon: -74.0064 }, 'elsewhen reads seconds-precision zone1970 coordinates')
|
||||
assertDeepEqual(M.zoneTabCoords(tab, 'Asia/Kolkata'), { lat: 22.5333, lon: 88.3667 }, 'elsewhen reads minutes-precision zone1970 coordinates')
|
||||
assertEqual(M.zoneTabCoords(tab, 'Nowhere/Else'), null, 'elsewhen has no fallback for an unknown zone')
|
||||
|
||||
const geocoded = JSON.stringify({ results: [
|
||||
{ latitude: 34.0, longitude: -98.4, timezone: 'America/Chicago' },
|
||||
{ latitude: 26.4, longitude: -80.1, timezone: 'America/New_York' }
|
||||
] })
|
||||
assertDeepEqual(M.pickGeocode(geocoded, 'America/New_York'), { place: { lat: 26.4, lon: -80.1 }, answered: true }, 'elsewhen prefers a geocode in the row\'s own zone')
|
||||
assertDeepEqual(M.pickGeocode(geocoded, 'Asia/Tokyo'), { place: { lat: 34.0, lon: -98.4 }, answered: true }, 'elsewhen falls back to the top geocode')
|
||||
assertDeepEqual(M.pickGeocode('{}', 'Asia/Tokyo'), { place: null, answered: true }, 'elsewhen treats no results as an answer')
|
||||
assertDeepEqual(M.pickGeocode('', 'Asia/Tokyo'), { place: null, answered: false }, 'elsewhen treats a failed request as unanswered')
|
||||
assert(M.geocodeUrl('São Paulo').endsWith('name=S%C3%A3o%20Paulo'), 'elsewhen URL-encodes the geocoded label')
|
||||
|
||||
assertEqual(M.forecastUrl([{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }]).split('&').filter(p => /^l(at|ong)itude=/.test(p)).join('&'), 'latitude=1,3&longitude=2,4', 'elsewhen batches every point into one forecast')
|
||||
assertDeepEqual(M.parseForecast('{"current":{"temperature_2m":14.33,"weather_code":3}}', ['a'], 5), { a: { c: 14.3, w: 3, at: 5 } }, 'elsewhen reads a single-point forecast')
|
||||
assertDeepEqual(M.parseForecast('[{"current":{"temperature_2m":1,"weather_code":0}},{"current":{}}]', ['a', 'b'], 5), { a: { c: 1, w: 0, at: 5 } }, 'elsewhen skips points without a reading')
|
||||
assertDeepEqual(M.parseForecast('', ['a'], 5), {}, 'elsewhen keeps nothing from a failed forecast')
|
||||
assert(M.weatherStale(undefined, 0) && M.weatherStale({ at: 0 }, 20 * 60 * 1000) && !M.weatherStale({ at: 0 }, 60 * 1000), 'elsewhen refetches weather after twenty minutes')
|
||||
|
||||
assertDeepEqual(
|
||||
M.mergeFacts(['a', 'b', 'c'], { a: { lat: 1, lon: 2 }, b: null }, { a: { c: 3, w: 0, at: 9 }, b: { c: 4, w: null } }),
|
||||
{ a: { lat: 1, lon: 2, c: 3, w: 0 }, b: { c: 4 }, c: {} },
|
||||
'elsewhen merges coordinates and weather, leaving out unknown fields'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# /usr/bin/qmltestrunner is the Qt 5 binary, which exits 0 having run nothing.
|
||||
qml_test_runner=""
|
||||
for candidate in /usr/lib/qt6/bin/qmltestrunner /usr/lib/qt6/qmltestrunner; do
|
||||
if [[ -x $candidate ]]; then
|
||||
qml_test_runner=$candidate
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $qml_test_runner ]]; then
|
||||
skip "qt6 qmltestrunner not found; skipping elsewhen pointer tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
output=$(QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software "$qml_test_runner" -input "$SHELL_TEST_DIR/elsewhen/qml" -o -,txt 2>&1) ||
|
||||
fail "elsewhen pointer tests" "$output"
|
||||
pass "elsewhen pointer tests"
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const { Sun } = requireFromRoot('test/shell.d/elsewhen/solar.js')
|
||||
|
||||
const noonAt = ([y, mo, d], offset) => Date.UTC(y, mo - 1, d, 12) - offset * 60000
|
||||
const within = (got, want, slack) => Math.abs(got - want) <= slack
|
||||
|
||||
// Open-Meteo's published sunrise and sunset in UTC:
|
||||
// name, lat, lon, local date, UTC offset in minutes, rise, set, minutes of slack.
|
||||
// Past 64 north the sun meets the horizon at a shallow angle and needs more slack.
|
||||
const REFERENCE = [
|
||||
['Chicago', 41.85, -87.65, [2026, 8, 31], -300, '2026-08-31T11:15', '2026-09-01T00:26', 2],
|
||||
['Auckland', -36.85, 174.76, [2026, 8, 31], 720, '2026-08-30T18:43', '2026-08-31T05:59', 2],
|
||||
['Copenhagen', 55.68, 12.57, [2026, 8, 31], 120, '2026-08-31T04:13', '2026-08-31T18:07', 2],
|
||||
['Tokyo', 35.69, 139.69, [2026, 8, 31], 540, '2026-08-30T20:12', '2026-08-31T09:11', 2],
|
||||
['Quito', -0.22, -78.51, [2026, 8, 31], -300, '2026-08-31T11:10', '2026-08-31T23:17', 2],
|
||||
['Kashgar', 39.47, 75.99, [2026, 8, 31], 480, '2026-08-31T00:23', '2026-08-31T13:29', 2],
|
||||
['Reykjavik', 64.15, -21.94, [2025, 12, 21], 0, '2025-12-21T11:21', '2025-12-21T15:30', 2],
|
||||
['Sydney', -33.87, 151.21, [2025, 12, 21], 660, '2025-12-20T18:40', '2025-12-21T09:05', 2],
|
||||
['Nairobi', -1.29, 36.82, [2026, 3, 20], 180, '2026-03-20T03:36', '2026-03-20T15:43', 2],
|
||||
['Anchorage', 61.22, -149.90, [2026, 6, 21], -480, '2026-06-21T12:19', '2026-06-22T07:43', 2],
|
||||
['Ushuaia', -54.80, -68.30, [2026, 6, 21], -180, '2026-06-21T12:58', '2026-06-21T20:11', 2],
|
||||
['Nuuk', 64.18, -51.72, [2026, 8, 31], -120, '2026-08-31T08:06', '2026-08-31T22:47', 5],
|
||||
['Anadyr', 64.73, 177.51, [2026, 8, 31], 720, '2026-08-30T16:47', '2026-08-31T07:35', 5]
|
||||
]
|
||||
const misses = []
|
||||
for (const [name, lat, lon, date, offset, rise, set, slack] of REFERENCE) {
|
||||
const times = Sun.sunTimes(lat, lon, noonAt(date, offset), offset)
|
||||
if (times.kind !== 'normal') { misses.push(`${name} has no sunrise`); continue }
|
||||
const riseOff = Math.abs(times.riseMs - Date.parse(rise + 'Z')) / 60000
|
||||
const setOff = Math.abs(times.setMs - Date.parse(set + 'Z')) / 60000
|
||||
if (riseOff > slack) misses.push(`${name} sunrise out by ${riseOff.toFixed(1)} min`)
|
||||
if (setOff > slack) misses.push(`${name} sunset out by ${setOff.toFixed(1)} min`)
|
||||
}
|
||||
assertDeepEqual(misses, [], 'elsewhen matches published sunrise and sunset times')
|
||||
|
||||
// ---- the poles
|
||||
|
||||
const longyear = (date, offset) => Sun.sunTimes(78.22, 15.63, noonAt(date, offset), offset)
|
||||
const midnightSun = longyear([2026, 6, 21], 120)
|
||||
const polarNight = longyear([2025, 12, 21], 60)
|
||||
assertEqual(midnightSun.kind, 'midnightSun', 'elsewhen knows the midnight sun')
|
||||
assertEqual(polarNight.kind, 'polarNight', 'elsewhen knows the polar night')
|
||||
assertDeepEqual(Sun.litSpans(midnightSun), [{ x0: 0, x1: 1 }], 'elsewhen lights the whole bar under the midnight sun')
|
||||
assertDeepEqual(Sun.litSpans(polarNight), [], 'elsewhen lights nothing in the polar night')
|
||||
assert(Sun.litAt(midnightSun, 3 * 60) && !Sun.litAt(polarNight, 12 * 60), 'elsewhen reads daylight at the poles')
|
||||
assertEqual(Sun.eventMark(midnightSun.riseMinutes), null, 'elsewhen marks no sunrise that does not happen')
|
||||
|
||||
// ---- the strip
|
||||
|
||||
const chicago = Sun.sunTimes(41.85, -87.65, noonAt([2026, 8, 31], -300), -300)
|
||||
const span = Sun.litSpans(chicago)
|
||||
assertEqual(span.length, 1, 'elsewhen draws one band for an ordinary city')
|
||||
assert(within(span[0].x0 * 1440, 375, 2) && within(span[0].x1 * 1440, 1166, 2), 'elsewhen draws the band from sunrise to sunset on the local clock')
|
||||
assert(within(chicago.dayMinutes, 791, 2), 'elsewhen measures the length of the day')
|
||||
assertDeepEqual([5, 12, 22, 0].map(h => Sun.litAt(chicago, h * 60)), [false, true, false, false], 'elsewhen reads daylight through the day')
|
||||
|
||||
const kashgar = Sun.sunTimes(39.47, 75.99, noonAt([2026, 8, 31], 480), 480)
|
||||
assertEqual(Sun.litSpans(kashgar).length, 1, 'elsewhen draws one band for a zone far from its sun')
|
||||
assert(within(kashgar.riseMinutes, 503, 2) && within(kashgar.setMinutes, 1289, 2), 'elsewhen keeps a far-off sun on its own clock')
|
||||
|
||||
const solstice = Sun.sunTimes(64.15, -21.94, noonAt([2026, 6, 21], 0), 0)
|
||||
const ends = Sun.litSpans(solstice)
|
||||
assert(solstice.setMinutes > 1440, 'elsewhen sets the Reykjavik solstice sun after midnight')
|
||||
assertEqual(ends.length, 2, 'elsewhen lights a bar at both ends')
|
||||
assert(within(ends[0].x0 * 1440, 0, 0.01) && within(ends[0].x1 * 1440, 4, 1), 'elsewhen lights the minutes after midnight')
|
||||
assert(within(ends[1].x0 * 1440, 175, 1) && within(ends[1].x1 * 1440, 1440, 0.01), 'elsewhen lights from sunrise to the end of the bar')
|
||||
assertDeepEqual([0, 60, 180].map(m => Sun.litAt(solstice, m)), [true, false, true], 'elsewhen reads daylight on a bar lit at both ends')
|
||||
|
||||
assertEqual(Sun.eventMark(solstice.setMinutes), null, 'elsewhen marks no sunset past midnight')
|
||||
assertDeepEqual([-30, 1500, 0, 1440].map(Sun.eventMark), [null, null, 0, 1], 'elsewhen marks only events on the bar')
|
||||
JS
|
||||
@@ -0,0 +1,79 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
// The daylight strip's arrows over its scrub MouseArea, with synthetic mouse
|
||||
// events: a click on an arrow must not scrub the clocks.
|
||||
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
|
||||
|
||||
// A TapHandler's passive grab does not let the press through to the bar.
|
||||
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")
|
||||
}
|
||||
|
||||
// Arrows hide off-row and under the now-marker; the bar must still work.
|
||||
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")
|
||||
}
|
||||
|
||||
// Past the drag threshold the handler gives up, so a drag can still 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// GlobeModel.js and Sun.js keep QML's .pragma/.import headers, so node
|
||||
// strips those lines and evaluates the rest.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const dir = path.join(__dirname, '../../../shell/plugins/panels/elsewhen')
|
||||
|
||||
function load(file, scope) {
|
||||
const source = fs.readFileSync(path.join(dir, file), 'utf8').replace(/^\.(pragma|import) .*$/gm, '')
|
||||
const module = { exports: {} }
|
||||
new Function('module', ...Object.keys(scope), source)(module, ...Object.values(scope))
|
||||
return module.exports
|
||||
}
|
||||
|
||||
const Solar = load('GlobeModel.js', {})
|
||||
const Sun = load('Sun.js', { Solar })
|
||||
|
||||
module.exports = { Solar, Sun }
|
||||
Reference in new issue
Block a user