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 claimHome(label, zone) { var stored = Model.homeCityAfterTap(label, zone, setting("homeCity", ""), localZone) if (stored === null) return persistSettings({ homeCity: stored }) refreshFacts() } 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) }) item.cityTapped.connect(function(label, zone) { root.claimHome(label, zone) }) // A cold start can finish loading after the mode switched on. if (root.globeMode) root.showFocusOnGlobe() } } } } } } } }