前言
欢迎来到这篇 Vala 和 GTK4 的实战教程!1
许多入门教程止步于“Hello, World!”或简单控件演示,这样一方面各部分间缺乏组织逻辑,不便进行复杂演示,另一方面也难以指导读者开发一个真正实用、现代、体验良好的应用。本教程将通过一个完整的项目案例,带你从需求分析、界面设计、核心算法、异步网络、数据导出等多个维度,系统梳理依托 GLib/GObject 世界生态的 Vala 开发流程。
本教程适合有一定编程基础,希望深入学习 Vala 语言和最新的 GTK4、LibAdwaita 等开发框架,并渴望开发高质量应用的开发者。
本教程将以笔者实现的“太阳高度角计算器”的完整应用为例,深入剖析其从概念设计到功能实现的每一个环节。这个应用不仅能进行科学计算,还拥有一个使用 LibAdwaita 构建的、支持深色模式的现代化用户界面,并具备异步网络请求、JSON 解析、自定义绘图和文件导出等高级功能。
![]() |
![]() |
---|---|
太阳高度角计算器(浅色模式) | 太阳高度角计算器(深色模式) |
![]() |
![]() |
获取地理位置时的加载动画 | 提示与选择 |
通过本教程,你将学到:
- Vala 语言的核心特性:面向对象、信号与槽、异步编程等。
- GTK4 和 LibAdwaita:如何构建响应式、符合现代 GNOME 设计规范的 UI。
- Cairo 图形库:如何在
Gtk.DrawingArea
上绘制自定义图表。 - 异步网络编程:如何在 Vala 中使用
async
和yield
从网络 API 获取数据而不会冻结 UI。 - JSON-GLib:如何轻松解析网络返回的 JSON 数据。
- 文件 I/O:如何使用
Gtk.FileDialog
和 Cairo 将图表导出为图片(PNG/SVG/PDF),以及如何将数据保存为 CSV 文件。
让我们开始这段激动人心的旅程吧!
第一部分:应用概念与设计
在编写任何代码之前,清晰地构思应用的功能和设计是至关重要的。
应用目标
我们的目标是创建一个工具,能够:
- 接收用户输入的地理位置(经纬度)、时区和日期。
- 计算出该地点在指定日期内,一天中每一分钟的太阳高度角。
- 将计算结果以直观的图表形式可视化展示出来。
- 提供交互功能,用户可以点击图表上的任意点,查看具体时间的太阳高度角。
- 支持自动定位功能,通过网络服务获取用户当前位置。
- 允许用户将图表导出为图片,或将原始数据导出为 CSV 文件。
- 对于特殊情况,应当为用户提供友好的提示。
UI/UX 设计哲学
为了让应用看起来现代化且用户友好,我们选择使用 LibAdwaita。它是 GTK4 的一个辅助库,旨在帮助开发者构建遵循 GNOME 人机界面指南 (HIG) 的应用。
我们的界面布局采用常见的双栏结构:
- 左侧面板:作为控制中心,包含所有输入控件(经纬度、时区、日期选择器)和操作按钮(导出、自动定位)。我们使用
Adw.PreferencesGroup
来组织这些设置,使其清晰明了。 - 右侧主区域:用于展示核心内容——太阳高度角图表。这部分将使用
Gtk.DrawingArea
进行完全的自定义绘制。
这种设计不仅结构清晰,也为未来可能的响应式布局(例如在小屏幕上将左侧面板收起到侧边栏)打下了基础。
现代桌面应用越来越重视主题一致性和深色模式支持。LibAdwaita 天生支持系统主题切换,能够自动适配浅色/深色模式,为用户带来舒适的视觉体验。在本项目中,我们不仅让应用自动响应系统主题,还在标题栏右上角提供了深色模式切换按钮,用户可以随时手动切换浅色/深色界面。
主题切换的实现思路如下:
- 通过
Adw.StyleManager
检测当前系统主题(dark
属性)。- 如果用户没有手动切换深浅色主题,本应用的深浅色将跟随系统主题,并随系统主题改变而改变。
- 在
HeaderBar
添加一个Gtk.ToggleButton
,点击时切换color_scheme
属性,实现强制浅色或深色。- 用户手动指定主题后,程序深浅色主题将不再跟随系统变化。
- 所有 Cairo 自定义绘图都根据当前主题动态选择配色方案,确保视觉风格统一。
- 主题切换时,主动触发重绘,保证界面即时更新。
这种做法不仅让应用外观与系统保持一致,也为自定义控件和绘图区域带来了良好的主题适配体验。
第二部分:项目设置与编译
Vala 代码需要被编译成 C 代码,然后再编译成可执行文件。Vala 编译器 valac
会为我们处理好这一切。
读者不妨先编译并试用该应用,再继续阅读。这样可以更好地理解每个功能的意义。
依赖项
完成本教程涉及的代码编译需要确保系统已安装 Vala、GTK4、LibAdwaita,用于 JSON 解析的 json-glib
库,以及 C 编译工具(如 gcc
);在 Linux 下,还需要额外安装在 GLib/GIO 中实现网络访问的 gvfs
库(Windows则不需要)。笔者在此列举了在 Arch Linux 和 Windows MSYS2 环境下的安装命令:
- Arch Linux:
sudo pacman -S --needed vala gtk4 libadwaita json-glib gvfs
- Windows MSYS2:
在 MSYS2 UCRT64 环境中:
pacman -S --needed mingw-w64-ucrt-x86_64-vala mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gtk4 mingw-w64-ucrt-x86_64-libadwaita mingw-w64-ucrt-x86_64-json-glib
我们的应用的编译参数还可以指定到文件的 Shebang 中,这样在 Linux 操作系统上授予可执行权限后直接执行源代码时,Vala 编译器会自动帮你编译并运行程序,一条龙完成。(Windows不支持 Shebang)
对于不熟悉 Shebang 的读者:Shebang 是 Unix/Linux 系统中脚本文件的第一行,用于指定可执行文件的解释器。例如,#!/usr/bin/env -S vala
表示使用 vala
编译器来执行该脚本。需要注意的是,虽然看起来像是直接运行 Vala 代码,但实际上它会在后台调用 valac
编译器来处理代码,并不涉及解释执行。
#!/usr/bin/env -S vala --pkg=gtk4 --pkg=libadwaita-1 --pkg=json-glib-1.0 -X -lm -X -O2 -X -march=native -X -pipe
--pkg=gtk4
: GTK4 核心库,用于构建 UI。--pkg=libadwaita-1
: LibAdwaita 库,用于现代化窗口和控件。--pkg=json-glib-1.0
: GLib 的 JSON 支持库,用于解析 JSON 数据。-X -lm
: 传递-lm
链接选项给 C 编译器,因为我们的计算代码使用了数学库 (math.h
)。-X -O2 -X -march=native -X -pipe
: 这些是传递给 C 编译器的参数。在脚本中应用编译优化存在一些独特考量:-O2
提供了良好的优化级别,既明显提升运行时性能,又避免激进优化可能导致的编译时间显著延长 —— 这点尤为重要,因为每次通过 Shebang 直接执行脚本都会重新编译,编译耗时直接影响用户体验;-march=native
利用当前 CPU 的全部指令集特性进行优化。由于编译产物仅在当前机器上运行且不被保留,不存在跨设备兼容性风险,这有望带来本地性能提升。
编译与运行
读者可以将教程最后的完整代码保存为 solarangleadw.vala
。你可以直接运行这个脚本文件(如果它有执行权限 chmod +x solarangleadw.vala
),或者使用以下命令手动编译,避免每次运行前都自动编译带来的启动延迟:
valac --pkg=gtk4 --pkg=libadwaita-1 --pkg=json-glib-1.0 -X -lm -X -O2 -X -pipe solarangleadw.vala
需要注意的是,与“执行”用的 vala
不同,编译需要使用 valac
命令。
第三部分:构建用户界面
现在,让我们深入 activate
方法,看看 UI 是如何一步步构建起来的。
应用与主窗口
基于 LibAdwaita/GTK4 的应用的入口点是一个继承自 Adw.Application
的类。在 activate
方法中,我们创建了主窗口 Adw.ApplicationWindow
。
public class SolarAngleApp : Adw.Application {
private Adw.ApplicationWindow window;
// ...
protected override void activate () {
window = new Adw.ApplicationWindow (this) {
title = "Solar Angle Calculator",
};
// ...
window.present ();
}
}
布局结构:Adw.ToolbarView
我们使用 Adw.ToolbarView
作为顶级布局容器。它天生支持顶部栏(HeaderBar
)和主内容区域,是构建现代 LibAdwaita 应用的理想选择。Adw.HeaderBar
作为应用的顶部栏,不仅承载了窗口标题,还提供了放置按钮等交互元素的区域。在本应用中,我们利用 Adw.HeaderBar
来显示应用标题,并在其右侧集成了深色模式切换按钮,为用户提供便捷的主题控制。
var header_bar = new Adw.HeaderBar ();
// Add dark mode toggle button
var dark_mode_button = new Gtk.ToggleButton () {
icon_name = "weather-clear-night-symbolic",
tooltip_text = "Toggle dark mode",
active = style_manager.dark,
};
// ...
header_bar.pack_end (dark_mode_button);
var toolbar_view = new Adw.ToolbarView ();
toolbar_view.add_top_bar (header_bar);
// ... main content ...
window.content = toolbar_view;
左侧输入面板
左侧面板是一个垂直的 Gtk.Box
,里面包含了几个 Adw.PreferencesGroup
,用于对设置项进行逻辑分组。这些控件负责收集用户输入,并提供操作入口。
处理加载状态:Gtk.Stack
与 Gtk.Spinner
在现代应用中,为耗时操作(如网络请求)提供即时反馈至关重要。当用户点击“自动获取位置”时,我们不希望界面冻结无响应,也不希望界面无反应。一个常见的模式是在操作期间用一个加载指示器(如提供旋转加载动画的 Gtk.Spinner
)替换原始控件(如 Gtk.Button
)。
然而,一个朴素的实现——简单地移除按钮并添加 Gtk.Spinner
——可能会导致界面布局发生变化,因为两个控件的大小可能不同。这会造成视觉上的“抖动”,影响用户体验。
为了解决这个问题,我们采用了一种更优雅的方案:Gtk.Stack
。Gtk.Stack
是一个容器,它一次只显示其众多子控件中的一个,就像一叠卡片。将按钮和 Gtk.Spinner
都放入同一个 Gtk.Stack
中,并设置其 hhomogeneous
和 vhomogeneous
属性为 true
时,Gtk.Stack
会确保其分配的空间足以容纳其最大的子控件,并让所有子控件都占用同样大小的空间。此外,设置 transition_type
为 Gtk.StackTransitionType.CROSSFADE
可以实现平滑的淡入淡出过渡效果。
// Use a stack to keep consistent allocation and avoid layout jitter
location_stack = new Gtk.Stack () {
hhomogeneous = true,
vhomogeneous = true,
transition_type = Gtk.StackTransitionType.CROSSFADE,
};
location_spinner = new Gtk.Spinner ();
location_button = new Gtk.Button () { /* ... */ };
location_stack.add_child (location_button);
location_stack.add_child (location_spinner);
location_stack.visible_child = location_button;
这样,当我们通过 location_stack.visible_child = location_spinner;
在按钮和加载器之间切换时,容器的尺寸保持不变,从而彻底消除了界面抖动。再配合 transition_type
设置一个淡入淡出的过渡效果,用户体验就非常平滑了。
输入与交互控件
- 输入控件
Adw.SpinRow
:对于经纬度和时区这类数值输入,Adw.SpinRow
是一个非常合适的控件。它将一个标签、一个描述和一个数值调节器组合在一起,美观且便于使用。latitude_row = new Adw.SpinRow.with_range (-90, 90, 0.1) { title = "Latitude", subtitle = "Degrees", value = latitude, digits = 2, };
我们通过监听其
notify::value
信号来响应用户的输入变化。当值改变时,我们更新内部变量并重绘图表。Vala 语言支持信号与槽机制,这使得响应用户交互变得非常简单。此外,Vala 还可以很方便地使用 lambda 匿名函数来简化代码。latitude_row.notify["value"].connect (() => { latitude = latitude_row.value; update_plot_data (); drawing_area.queue_draw (); });
- 日期选择
Gtk.Calendar
:Gtk.Calendar
控件提供了直观的日期选择功能,用户可以直接在日历界面中选择所需要的日期。我们将其放入一个Adw.ActionRow
中,以保持与其他设置项风格的统一。
右侧绘图区域
右侧区域的核心是 Gtk.DrawingArea
。它本质上是一块空白画布,我们可以通过 set_draw_func
注册一个绘图函数,来控制其显示内容。
drawing_area = new Gtk.DrawingArea () { /* ... */ };
drawing_area.set_draw_func (draw_sun_angle_chart);
每当需要重绘时(例如窗口大小改变、数据更新),这个函数就会被调用。此外,在需要的时候,我们也可以通过 drawing_area.queue_draw()
主动发起一次重绘。
第四部分:核心功能实现
现在,我们来实现应用的核心功能:计算与表达。
太阳高度角计算
generate_sun_angles
函数是应用计算的核心函数。它基于 NOAA 赤纬公式来计算太阳高度角。这个公式保留了较多傅里叶级数项(三阶正余弦),计算精度较高。
- 日行轨迹组分与年角计算:
fractional_day_component = day_of_year - 1 + ((double) i) / RESOLUTION_PER_MIN
:计算一年中的具体时刻(以天为单位,包含小数部分)。gamma_rad = (2.0 * Math.PI / days_in_year) * fractional_day_component
:计算年角(弧度),表示地球在轨道上的精确位置。
-
太阳赤纬
\[\begin{aligned} \delta &= 0.006918 \\ &\quad {}- 0.399912 \cos(\gamma) \\ &\quad {}+ 0.070257 \sin(\gamma) \\ &\quad {}- 0.006758 \cos(2 \times \gamma) \\ &\quad {}+ 0.000907 \sin(2 \times \gamma) \\ &\quad {}- 0.002697 \cos(3 \times \gamma) \\ &\quad {}+ 0.001480 \sin(3 \times \gamma) \end{aligned}\]δ
计算(使用 NOAA 傅里叶级数近似公式): 将上述gamma_rad
代入经验公式: - 均时差 (Equation of Time, EoT) 计算:
eqtime_minutes = 229.18 * (0.000075 + 0.001868 * cos(gamma_rad) ...)
:计算均时差(分钟),真太阳时(True Solar Time,基于太阳真实位置)与均太阳时(Mean Solar Time,假设太阳匀速运行)之差,主要由地球轨道偏心率和黄赤交角引起,反映钟表时间和日晷时间的偏差。将本地平时(分钟i
)修正为真太阳时(分钟),以保证后续时角、太阳高度角计算的天文精度。- 真太阳时:基于太阳在天空中的实际位置计算。
- 平太阳时:虚构一个匀速运动的“平太阳”作为参考,将一天固定为24小时(86,400秒),消除季节性波动。这是日常钟表时间的基准。
- 真太阳日的长度(太阳连续两次过中天的时间间隔)会因地球轨道离心率和黄赤交角的影响而变化,可达±30秒。
- 这些微小日变化会逐日累积,导致真太阳时与平太阳时的偏差可达 -14分15秒至+16分25秒 (公元2000年),因此有必要均时差修正。
- 真太阳时 (True Solar Time, TST) 计算:
tst_minutes = i + eqtime_minutes + 4.0 * longitude_deg - 60.0 * timezone_offset_hrs
将本地钟表时间(分钟i
)先加上均时差修正(eqtime_minutes
),再加上因经度(每向东 1 度 +4 分钟)带来的分钟偏移,最后减去因时区带来的分钟差,得到真太阳时(分钟)。longitude_deg
:经度(度),正值为东经、负值为西经;timezone_offset_hrs
:时区偏移(小时),正值为东区、负值为西区;4.0 * longitude_deg
:将经度转换为分钟偏移;60.0 * timezone_offset_hrs
:将时区小时数转换为分钟偏移;- 时角 (Hour Angle, HA) 计算:
ha_deg = (tst_minutes / 4.0) - 180.0
:根据真太阳时计算时角(度),表示太阳相对于本地子午线的角距离。
- 太阳高度角计算:
使用球面三角公式,结合纬度
latitude_rad
、太阳赤纬decl_rad
和时角ha_rad
计算太阳天顶角phi_rad
,进而得到太阳高度角(90° - phi_rad)
。- 结果填充到
sun_angles
数组(单位:°),每分钟一个采样点。
- 结果填充到
自定义绘图与 Cairo
draw_sun_angle_chart
函数是应用的绘制核心。它接收一个 Cairo.Context
对象(通常简写为 cr
),你可以把它想象成 Cairo 的画笔,拥有颜色、粗细、字体等属性。
绘图过程遵循一个清晰的层次结构:
- 主题颜色:我们定义了一个
ThemeColors
结构体,并准备了LIGHT_THEME
和DARK_THEME
两套颜色。在绘图开始时,根据style_manager.dark
的状态选择合适的颜色方案,实现了对系统深色/浅色模式的自适应。 - 绘制背景和坐标系:首先填充背景色,然后绘制网格线、坐标轴和标签。这里大量使用了
cr.move_to()
,cr.line_to()
,cr.stroke()
等基本绘图命令。对于文本,cr.show_text()
用于绘制,cr.text_extents()
用于测量文本尺寸以实现精确定位。 - 绘制数据曲线:这是最关键的一步。我们遍历
sun_angles
数组,将每个数据点(时间 -> x, 角度 -> y)转换为画布上的像素坐标,然后用cr.line_to()
将这些点连接起来,形成平滑的曲线。 - 绘制交互点:如果用户点击了图表 (
has_click_point
为true
),我们会在对应位置绘制一个圆点和十字参考线,为用户提供即时反馈。 - 绘制标题:在图表顶部添加动态标题,显示当前的日期和位置信息。
交互式图表:Gtk.GestureClick
为了响应用户的点击,我们给 drawing_area
添加了一个 Gtk.GestureClick
控制器。
var click_controller = new Gtk.GestureClick ();
click_controller.pressed.connect (on_chart_clicked);
drawing_area.add_controller (click_controller);
在 on_chart_clicked
回调函数中:
- 获取点击的
x
,y
坐标。 - 将
x
坐标从像素值转换回一天中的时间(小时)。 - 根据时间从
sun_angles
数组中查找到对应的太阳高度角。 - 更新
click_info_label
的文本,显示选定点的信息。 - 设置
has_click_point = true
并调用drawing_area.queue_draw()
,请求重绘以显示点击标记。
异步网络请求与 JSON 解析
自动定位功能是本应用的一个亮点,它完美地展示了 Vala 强大的异步处理能力:网络请求是耗时的 I/O 操作,如果我们在主线程中直接请求,在收到网络响应前整个应用的 UI 会被冻结,这会带来极差的用户体验;因此,我们使用 Vala 的异步编程特性来处理这一问题。
- Vala 的
async
/yield
Vala 借鉴了 C# 的async/await
语法,使得异步编程像写同步代码一样直观。- 我们将网络请求逻辑放在一个
async
方法get_location_async
中。 - 当遇到耗时操作时(如
file.read_async
),我们使用yield
关键字。这会“暂停”当前方法的执行,将控制权交还给主事件循环(让 UI 保持响应),当 I/O 操作完成后,方法会自动从yield
的地方继续执行。 - 为了避免网络请求长时间无响应,我们还引入了超时机制。通过
GLib.Cancellable
和GLib.Timeout.add_seconds
,我们可以在指定时间(例如 5 秒)后取消网络请求,并向用户显示错误信息,提升应用的健壮性。private async void get_location_async () throws IOError { var file = File.new_for_uri ("https://ipapi.co/json/"); var parser = new Json.Parser (); // 设置 5 秒超时 var cancellable = new Cancellable (); var timeout_id = Timeout.add_seconds_once (5, () => { cancellable.cancel (); }); try { var stream = yield file.read_async (Priority.DEFAULT, cancellable); // ... } catch (Error e) { // ... } finally { // ... } }
- 我们将网络请求逻辑放在一个
- JSON-GLib 解析
JSON-GLib 提供了一套健壮的 API 来遍历和提取 JSON 结构中的数据,并能很好地处理潜在的错误。获取到网络响应后,我们使用
Json.Parser
来解析它。得益于 JSON-GLib 与 GLib/GObject/GIO 生态的强大集成,我们可以直接方便地使用parser.load_from_stream_async
从网络流中异步加载和解析 JSON 数据,无需手动处理字节流。var parser = new Json.Parser (); yield parser.load_from_stream_async (stream, cancellable); var root_object = parser.get_root ().get_object (); if (root_object.has_member ("latitude") && root_object.has_member ("longitude")) { latitude = root_object.get_double_member ("latitude"); longitude = root_object.get_double_member ("longitude"); } else { throw new IOError.FAILED ("No coordinates found in the response"); } // ...
- 时区冲突处理
在自动定位时,网络服务返回的时区信息可能与用户系统当前设置的时区不同。为了提供更好的用户体验,应用会检测这种差异。如果发现网络时区与系统时区不一致,应用会弹出一个
Adw.AlertDialog
(详见后文)对话框,询问用户希望使用哪个时区。用户选择后,应用会根据用户的决定更新时区设置。这种交互通过yield dialog.choose(window, null)
实现,它会异步等待用户的选择,并在用户做出选择后继续执行代码。
错误处理与用户交互:Adw.AlertDialog
在涉及网络请求等可能失败的操作时,提供明确的错误反馈至关重要。我们使用 Adw.AlertDialog
来创建符合 GNOME HIG 规范的现代化错误对话框。此外,Adw.AlertDialog
不仅可以用于显示简单的错误信息,还可以通过 add_response
和 choose
方法实现更复杂的异步用户选择,在本示例中是本地与 IP 时区冲突时让用户选择使用哪个时区:
private void show_error_dialog (string title, string error_message) {
var dialog = new Adw.AlertDialog (
title, // 主标题
error_message // 详细描述
);
// 添加确认按钮(自动遵循当前主题)
dialog.add_response ("ok", "OK");
// 显示对话框并关联到主窗口
dialog.present (window);
// 同时输出到终端
message ("%s: %s", title, error_message);
}
// 利用 Adw.AlertDialog 给用户提供选择
private async void handle_timezone_mismatch (double network_tz_offset, double local_tz_offset) {
var dialog = new Adw.AlertDialog (
"Timezone Mismatch",
"The timezone from the network (UTC%+.2f) differs from your system's timezone (UTC%+.2f).\n\nWhich one would you like to use?".printf (
network_tz_offset,
local_tz_offset
)
);
dialog.add_response ("network", "Use Network Timezone");
dialog.add_response ("local", "Use System Timezone");
dialog.default_response = "network"; // 默认选择网络时区
// 异步等待用户的选择
string choice = yield dialog.choose (window, null);
if (choice == "network") {
timezone_offset_hours = network_tz_offset;
} else {
timezone_offset_hours = local_tz_offset;
}
// ... 更新 UI ...
}
Adw.AlertDialog
自动适应深浅色模式,符合 GNOME 人机界面指南。而且无需复杂布局,标题+描述+交互按钮三步完成创建,十分方便。对于文件保存对话框,当用户取消操作时,我们现在会更优雅地处理,避免弹出不必要的错误提示框,只在终端输出日志。
文件导出
Gtk.FileDialog
为了提供现代化的文件保存体验,我们使用Gtk.FileDialog
。它取代了旧的Gtk.FileChooserDialog
,通过异步回调函数处理用户的选择。var file_dialog = new Gtk.FileDialog () { /* ... */ }; file_dialog.save.begin (window, null, (obj, res) => { try { var file = file_dialog.save.end (res); if (file != null) { export_chart (file); } } catch (Error e) { // 用户取消操作时,不显示警告对话框,仅在终端输出日志 message ("Image file has not been saved: %s", e.message); } });
- 导出为图片:
Cairo 的一个强大之处在于其“设备无关性”。我们的
draw_sun_angle_chart
函数不仅可以向屏幕绘图,也可以向不同的表面 (Surface) 绘图。通过创建Cairo.SvgSurface
、Cairo.PdfSurface
或Cairo.ImageSurface
,我们可以将完全相同的绘图代码重定向到文件,从而轻松实现 SVG、PDF 和 PNG 格式的导出。 - 导出为 CSV:
CSV 导出则是一个标准的文本文件写入过程。我们使用
DataOutputStream
来高效地将格式化的字符串写入文件。在数据之前,我们还写入了以#
开头的注释行,作为元数据,这是一种良好的实践。
使用
- 启动应用:运行编译后的程序。
- 设置位置:
- 自动:点击“Auto-detect Location”按钮。应用会尝试通过网络获取你当前的位置和时区,自动填充这些值并更新图表。
- 手动:在左侧面板中,拖动或输入你的纬度(Latitude)、经度(Longitude)和时区(Timezone)。图表会实时更新。
- 选择日期:点击左侧的日历,选择你感兴趣的任何日期。
- 分析图表:
- 右侧的图表显示了从 0 点到 24 点的太阳高度角变化。
- 水平的黑线代表地平线(0°)。曲线在地平线上方表示白天,下方表示夜晚。
- 点击图表上的任意位置,左下角的“Selected Point”区域会显示该精确时间的太阳高度角,同时图表上会出现一个蓝色的标记点和参考线。
- 导出结果:
- 图片:点击“Export Image”,在弹出的对话框中选择保存位置、文件名和格式(PNG, SVG, PDF)。
- 数据:点击“Export CSV”,可以将当天每分钟的太阳高度角数据保存为 CSV 文件,以便在电子表格软件(如 LibreOffice Calc, Excel)中进行进一步分析。
总结
这个太阳高度角计算器功能集中,“麻雀虽小,五脏俱全”。它综合运用了 Vala 语言的现代特性、GTK4/LibAdwaita 的 UI 构建能力、Cairo 的强大绘图功能,以及 GLib 提供的异步处理和数据解析工具。
在这个实例中,读者不仅可以学会如何使用这些独立的工具,更重要的是,读者还可以了看到如何将它们有机地结合起来,构建一个功能完整、体验良好、代码结构清晰的现代桌面应用程序。希望这个教程能为你未来的 Vala/GTK 开发之旅提供坚实的垫脚石。
完整源代码
#!/usr/bin/env -S vala --pkg=gtk4 --pkg=libadwaita-1 --pkg=json-glib-1.0 -X -lm -X -O2 -X -march=native -X -pipe
/* SPDX-License-Identifier: LGPL-2.1-or-later */
/**
* Solar Angle Calculator Application.
* Copyright (C) 2025 wszqkzqk <wszqkzqk@qq.com>
* A libadwaita application that calculates and visualizes solar elevation angles
* throughout the day for a given location and date. The application provides
* an interactive interface for setting latitude, longitude, timezone, and date,
* and displays a real-time chart of solar elevation angles with export capabilities.
*/
public class SolarAngleApp : Adw.Application {
// Constants for solar angle calculations
private const double DEG2RAD = Math.PI / 180.0;
private const double RAD2DEG = 180.0 / Math.PI;
private const int RESOLUTION_PER_MIN = 1440; // 1 sample per minute
// Constants for drawing area
private const int MARGIN_LEFT = 70;
private const int MARGIN_RIGHT = 20;
private const int MARGIN_TOP = 50;
private const int MARGIN_BOTTOM = 70;
// Model / persistent state
private DateTime selected_date;
private double sun_angles[RESOLUTION_PER_MIN];
private double latitude = 0.0;
private double longitude = 0.0;
private double timezone_offset_hours = 0.0;
// Interaction / transient UI state
private double clicked_time_hours = 0.0;
private double corresponding_angle = 0.0;
private bool has_click_point = false;
// UI widgets
private Adw.ApplicationWindow window;
private Gtk.DrawingArea drawing_area;
private Gtk.Label click_info_label;
private Gtk.Stack location_stack;
private Gtk.Spinner location_spinner;
private Gtk.Button location_button;
private Adw.SpinRow latitude_row;
private Adw.SpinRow longitude_row;
private Adw.SpinRow timezone_row;
// Color theme struct for chart drawing
private struct ThemeColors {
double bg_r; double bg_g; double bg_b; // Background
double grid_r; double grid_g; double grid_b; double grid_a; // Grid with alpha
double axis_r; double axis_g; double axis_b; // Axes
double text_r; double text_g; double text_b; // Text
double curve_r; double curve_g; double curve_b; // Curve
double shade_r; double shade_g; double shade_b; double shade_a; // Shaded area with alpha
double point_r; double point_g; double point_b; // Click point
double line_r; double line_g; double line_b; double line_a; // Guide line with alpha
}
// Light theme
private static ThemeColors LIGHT_THEME = {
bg_r: 1.0, bg_g: 1.0, bg_b: 1.0, // White background
grid_r: 0.5, grid_g: 0.5, grid_b: 0.5, grid_a: 0.5, // Gray grid
axis_r: 0.0, axis_g: 0.0, axis_b: 0.0, // Black axes
text_r: 0.0, text_g: 0.0, text_b: 0.0, // Black text
curve_r: 1.0, curve_g: 0.5, curve_b: 0.0, // Orange curve
shade_r: 0.7, shade_g: 0.7, shade_b: 0.7, shade_a: 0.3, // Light gray shade
point_r: 0.0, point_g: 0.0, point_b: 1.0, // Blue point
line_r: 0.0, line_g: 0.0, line_b: 1.0, line_a: 0.5 // Blue guide lines
};
// Dark theme
private static ThemeColors DARK_THEME = {
bg_r: 0.0, bg_g: 0.0, bg_b: 0.0, // Black background
grid_r: 0.5, grid_g: 0.5, grid_b: 0.5, grid_a: 0.5, // Gray grid
axis_r: 1.0, axis_g: 1.0, axis_b: 1.0, // White axes
text_r: 1.0, text_g: 1.0, text_b: 1.0, // White text
curve_r: 1.0, curve_g: 0.5, curve_b: 0.0, // Orange curve
shade_r: 0.3, shade_g: 0.3, shade_b: 0.3, shade_a: 0.7, // Dark gray shade
point_r: 0.3, point_g: 0.7, point_b: 1.0, // Light blue point
line_r: 0.3, line_g: 0.7, line_b: 1.0, line_a: 0.7 // Light blue guide lines
};
/**
* Creates a new SolarAngleApp instance.
*
* Initializes the application with a unique application ID and sets
* the selected date to the current local date.
*/
public SolarAngleApp () {
Object (application_id: "com.github.wszqkzqk.SolarAngleAdw");
selected_date = new DateTime.now_local ();
}
/**
* Activates the application and creates the main window.
*
* Sets up the user interface including input controls, drawing area,
* and initializes the plot data with current settings.
*/
protected override void activate () {
window = new Adw.ApplicationWindow (this) {
title = "Solar Angle Calculator",
};
// Create header bar
var header_bar = new Adw.HeaderBar () {
title_widget = new Adw.WindowTitle ("Solar Angle Calculator", ""),
};
// Add dark mode toggle button
var dark_mode_button = new Gtk.ToggleButton () {
icon_name = "weather-clear-night-symbolic",
tooltip_text = "Toggle dark mode",
active = style_manager.dark,
};
dark_mode_button.toggled.connect (() => {
style_manager.color_scheme = (dark_mode_button.active) ? Adw.ColorScheme.FORCE_DARK : Adw.ColorScheme.FORCE_LIGHT;
drawing_area.queue_draw ();
});
// Listen for system theme changes
style_manager.notify["dark"].connect (() => {
drawing_area.queue_draw ();
});
header_bar.pack_end (dark_mode_button);
// Create toolbar view to hold header bar and content
var toolbar_view = new Adw.ToolbarView ();
toolbar_view.add_top_bar (header_bar);
var main_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0);
var left_panel = new Gtk.Box (Gtk.Orientation.VERTICAL, 12) {
hexpand = false,
vexpand = true,
width_request = 320,
margin_start = 12,
margin_end = 12,
margin_top = 12,
margin_bottom = 12,
};
// Location and Time Settings Group
var location_time_group = new Adw.PreferencesGroup () {
title = "Location and Time Settings",
};
// Auto-detect location button
var location_detect_row = new Adw.ActionRow () {
title = "Auto-detect Location",
subtitle = "Get current location and timezone",
activatable = true,
};
var location_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6);
// Use a stack to keep consistent allocation and avoid layout jitter
location_stack = new Gtk.Stack () {
hhomogeneous = true,
vhomogeneous = true,
transition_type = Gtk.StackTransitionType.CROSSFADE,
};
location_spinner = new Gtk.Spinner ();
location_button = new Gtk.Button () {
icon_name = "find-location-symbolic",
valign = Gtk.Align.CENTER,
css_classes = { "flat" },
tooltip_text = "Auto-detect current location",
};
location_button.clicked.connect (on_auto_detect_location);
// Add to stack as separate pages
location_stack.add_child (location_button);
location_stack.add_child (location_spinner);
location_stack.visible_child = location_button;
// Place stack into the suffix box
location_box.append (location_stack);
location_detect_row.add_suffix (location_box);
location_detect_row.activated.connect (on_auto_detect_location);
latitude_row = new Adw.SpinRow.with_range (-90, 90, 0.1) {
title = "Latitude",
subtitle = "Degrees",
value = latitude,
digits = 2,
};
latitude_row.notify["value"].connect (() => {
latitude = latitude_row.value;
update_plot_data ();
drawing_area.queue_draw ();
});
longitude_row = new Adw.SpinRow.with_range (-180.0, 180.0, 0.1) {
title = "Longitude",
subtitle = "Degrees",
value = longitude,
digits = 2,
};
longitude_row.notify["value"].connect (() => {
longitude = longitude_row.value;
update_plot_data ();
drawing_area.queue_draw ();
});
timezone_row = new Adw.SpinRow.with_range (-12.0, 14.0, 0.5) {
title = "Timezone",
subtitle = "Hours from UTC",
value = timezone_offset_hours,
digits = 2,
};
timezone_row.notify["value"].connect (() => {
timezone_offset_hours = timezone_row.value;
update_plot_data ();
drawing_area.queue_draw ();
});
location_time_group.add (location_detect_row);
location_time_group.add (latitude_row);
location_time_group.add (longitude_row);
location_time_group.add (timezone_row);
// Date Selection Group
var date_group = new Adw.PreferencesGroup () {
title = "Date Selection",
};
var calendar = new Gtk.Calendar () {
margin_start = 12,
margin_end = 12,
margin_top = 6,
margin_bottom = 6,
};
calendar.day_selected.connect (() => {
selected_date = calendar.get_date ();
update_plot_data ();
drawing_area.queue_draw ();
});
var calendar_row = new Adw.ActionRow ();
calendar_row.child = calendar;
date_group.add (calendar_row);
// Export Group
var export_group = new Adw.PreferencesGroup () {
title = "Export",
};
var export_image_row = new Adw.ActionRow () {
title = "Export Image",
subtitle = "Save chart as PNG, SVG, or PDF",
activatable = true,
};
var export_image_button = new Gtk.Button () {
icon_name = "document-save-symbolic",
valign = Gtk.Align.CENTER,
css_classes = { "flat" },
};
export_image_button.clicked.connect (on_export_clicked);
export_image_row.add_suffix (export_image_button);
export_image_row.activated.connect (on_export_clicked);
var export_csv_row = new Adw.ActionRow () {
title = "Export CSV",
subtitle = "Save data as CSV file",
activatable = true,
};
var export_csv_button = new Gtk.Button () {
icon_name = "x-office-spreadsheet-symbolic",
valign = Gtk.Align.CENTER,
css_classes = { "flat" },
};
export_csv_button.clicked.connect (on_export_csv_clicked);
export_csv_row.add_suffix (export_csv_button);
export_csv_row.activated.connect (on_export_csv_clicked);
export_group.add (export_image_row);
export_group.add (export_csv_row);
// Click Info Group
var click_info_group = new Adw.PreferencesGroup () {
title = "Selected Point",
};
click_info_label = new Gtk.Label ("Click on chart to view data\n") {
halign = Gtk.Align.START,
margin_start = 12,
margin_end = 12,
margin_top = 6,
margin_bottom = 6,
wrap = true,
};
var click_info_row = new Adw.ActionRow ();
click_info_row.child = click_info_label;
click_info_group.add (click_info_row);
left_panel.append (location_time_group);
left_panel.append (date_group);
left_panel.append (export_group);
left_panel.append (click_info_group);
drawing_area = new Gtk.DrawingArea () {
hexpand = true,
vexpand = true,
width_request = 600,
height_request = 500,
};
drawing_area.set_draw_func (draw_sun_angle_chart);
// Add click event controller
var click_controller = new Gtk.GestureClick ();
click_controller.pressed.connect (on_chart_clicked);
drawing_area.add_controller (click_controller);
main_box.append (left_panel);
main_box.append (drawing_area);
toolbar_view.content = main_box;
update_plot_data ();
window.content = toolbar_view;
window.present ();
}
/**
* Handles auto-detect location button click.
*
* Uses a free IP geolocation service to get current location and timezone.
*/
private void on_auto_detect_location () {
location_button.sensitive = false;
// Switch to spinner page without changing allocation size
location_stack.visible_child = location_spinner;
location_spinner.start ();
// Run async to avoid blocking the UI
get_location_async.begin ((obj, res) => {
try {
get_location_async.end (res);
} catch (Error e) {
show_error_dialog ("Location Detection Failed", e.message);
}
location_button.sensitive = true;
location_spinner.stop ();
// Switch back to button page
location_stack.visible_child = location_button;
});
}
/**
* Asynchronously gets current location using IP geolocation service with timeout.
*/
private async void get_location_async () throws IOError {
var file = File.new_for_uri ("https://ipapi.co/json/");
var parser = new Json.Parser ();
var cancellable = new Cancellable ();
var timeout_id = Timeout.add_seconds_once (5, () => {
cancellable.cancel ();
});
try {
var stream = yield file.read_async (Priority.DEFAULT, cancellable);
yield parser.load_from_stream_async (stream, cancellable);
} catch (Error e) {
throw new IOError.FAILED ("Failed to get location: %s", e.message);
} finally {
// MUST free the timeout here (local variable `cancellable` is NOT owned by Timeout)
if (!cancellable.is_cancelled ()) {
Source.remove (timeout_id);
}
}
var root_object = parser.get_root ().get_object ();
if (root_object.get_boolean_member_with_default ("error", false)) {
throw new IOError.FAILED ("Location service error: %s", root_object.get_string_member_with_default ("reason", "Unknown error"));
}
if (root_object.has_member ("latitude") && root_object.has_member ("longitude")) {
latitude = root_object.get_double_member ("latitude");
longitude = root_object.get_double_member ("longitude");
} else {
throw new IOError.FAILED ("No coordinates found in the response");
}
double network_tz_offset = 0.0;
bool has_network_tz = false;
if (root_object.has_member ("utc_offset")) {
var offset_str = root_object.get_string_member ("utc_offset");
network_tz_offset = double.parse (offset_str) / 100.0;
has_network_tz = true;
}
// Get local system's current timezone offset
var timezone = new TimeZone.local ();
var time_interval = timezone.find_interval (GLib.TimeType.UNIVERSAL, selected_date.to_unix ());
var local_tz_offset = timezone.get_offset (time_interval) / 3600.0;
const double TZ_EPSILON = 0.01; // Epsilon for floating point comparison
if (has_network_tz && (!(-TZ_EPSILON < (network_tz_offset - local_tz_offset) < TZ_EPSILON))) {
const string RESPONSE_NETWORK = "network"; // ID for network timezone
const string RESPONSE_LOCAL = "local"; // ID for local timezone
// Timezones differ, prompt user for a choice
var dialog = new Adw.AlertDialog (
"Timezone Mismatch",
"The timezone from the network (UTC%+.2f) differs from your system's timezone (UTC%+.2f).\n\nWhich one would you like to use?".printf (
network_tz_offset,
local_tz_offset
)
);
dialog.add_response (RESPONSE_NETWORK, "Use Network Timezone");
dialog.add_response (RESPONSE_LOCAL, "Use System Timezone");
dialog.default_response = RESPONSE_NETWORK;
// Asynchronously wait for the user's choice
unowned var choice = yield dialog.choose (window, null);
timezone_offset_hours = (choice == RESPONSE_NETWORK) ? network_tz_offset : local_tz_offset;
} else {
// Network's timezone is the same as local's or unavailable
timezone_offset_hours = local_tz_offset;
}
latitude_row.value = latitude;
longitude_row.value = longitude;
timezone_row.value = timezone_offset_hours;
update_plot_data ();
drawing_area.queue_draw ();
}
/**
* Shows a generic error dialog and logs the error message.
*
* @param title The title of the error dialog.
* @param error_message The error message to display.
*/
private void show_error_dialog (string title, string error_message) {
var dialog = new Adw.AlertDialog (title, error_message);
dialog.add_response ("ok", "OK");
dialog.present (window);
message ("%s: %s", title, error_message);
}
/**
* Calculates solar elevation angles for each minute of the day.
*
* @param latitude_rad Latitude in radians.
* @param day_of_year Day of the year (1-365/366).
* @param year The year.
* @param longitude_deg Longitude in degrees.
* @param timezone_offset_hrs Timezone offset from UTC in hours.
*/
private void generate_sun_angles (double latitude_rad, int day_of_year, int year, double longitude_deg, double timezone_offset_hrs) {
double sin_lat = Math.sin (latitude_rad);
double cos_lat = Math.cos (latitude_rad);
double days_in_year = ((year % 4 == 0) && ((year % 100 != 0) || (year % 400 == 0))) ? 366.0 : 365.0;
for (int i = 0; i < RESOLUTION_PER_MIN; i += 1) {
// fractional_day_component: day of year plus fraction of the day
double fractional_day_component = day_of_year - 1 + ((double) i) / RESOLUTION_PER_MIN;
// gamma: fractional year angle in radians
double gamma_rad = (2.0 * Math.PI / days_in_year) * fractional_day_component;
// Solar declination delta (rad) via Fourier series approximation
double decl_rad = 0.006918
- 0.399912 * Math.cos (gamma_rad)
+ 0.070257 * Math.sin (gamma_rad)
- 0.006758 * Math.cos (2.0 * gamma_rad)
+ 0.000907 * Math.sin (2.0 * gamma_rad)
- 0.002697 * Math.cos (3.0 * gamma_rad)
+ 0.001480 * Math.sin (3.0 * gamma_rad);
// Equation of Time (EoT) in minutes
double eqtime_minutes = 229.18 * (0.000075
+ 0.001868 * Math.cos (gamma_rad)
- 0.032077 * Math.sin (gamma_rad)
- 0.014615 * Math.cos (2.0 * gamma_rad)
- 0.040849 * Math.sin (2.0 * gamma_rad));
// True Solar Time (TST) in minutes, correcting local clock by EoT and longitude
double tst_minutes = i + eqtime_minutes + 4.0 * longitude_deg - 60.0 * timezone_offset_hrs;
// Hour angle H (°) relative to solar noon
double ha_deg = tst_minutes / 4.0 - 180.0;
double ha_rad = ha_deg * DEG2RAD;
// cos(phi): cosine of zenith angle via spherical trig
double cos_phi = sin_lat * Math.sin (decl_rad) + cos_lat * Math.cos (decl_rad) * Math.cos (ha_rad);
// clamp to valid range
if (cos_phi > 1.0) {
cos_phi = 1.0;
} else if (cos_phi < -1.0) {
cos_phi = -1.0;
}
// Zenith angle phi (rad)
double phi_rad = Math.acos (cos_phi);
// Solar elevation alpha = 90° - phi, convert to degrees
double solar_elevation_rad = Math.PI / 2.0 - phi_rad;
sun_angles[i] = solar_elevation_rad * RAD2DEG;
}
}
/**
* Updates solar angle data for current settings.
*/
private void update_plot_data () {
int day_of_year = selected_date.get_day_of_year ();
double latitude_rad = latitude * DEG2RAD;
int year = selected_date.get_year ();
generate_sun_angles (latitude_rad, day_of_year, year, longitude, timezone_offset_hours);
// Clear click point when data updates
has_click_point = false;
click_info_label.label = "Click on chart to view data\n";
}
/**
* Handles mouse click events on the chart.
*
* @param n_press Number of button presses.
* @param x X coordinate of the click.
* @param y Y coordinate of the click.
*/
private void on_chart_clicked (int n_press, double x, double y) {
int width = drawing_area.get_width ();
int height = drawing_area.get_height ();
int chart_width = width - MARGIN_LEFT - MARGIN_RIGHT;
// Check if click is within plot area and single click
if (x >= MARGIN_LEFT && x <= width - MARGIN_RIGHT && y >= MARGIN_TOP && y <= height - MARGIN_BOTTOM && n_press == 1) {
// Convert coordinates to time and get corresponding angle
clicked_time_hours = (x - MARGIN_LEFT) / chart_width * 24.0;
int time_minutes = (int) (clicked_time_hours * 60) % RESOLUTION_PER_MIN;
corresponding_angle = sun_angles[time_minutes];
has_click_point = true;
// Format time display
int hours = (int) clicked_time_hours;
int minutes = (int) ((clicked_time_hours - hours) * 60);
// Update info label
string info_text = "Time: %02d:%02d\nSolar Elevation: %.1f°".printf (
hours, minutes, corresponding_angle
);
click_info_label.label = info_text;
drawing_area.queue_draw ();
} else {
// Double click or outside plot area - clear point
has_click_point = false;
click_info_label.label = "Click on chart to view data\n";
drawing_area.queue_draw ();
}
}
/**
* Draws the solar elevation chart.
*
* @param area The drawing area widget.
* @param cr The Cairo context for drawing.
* @param width The width of the drawing area.
* @param height The height of the drawing area.
*/
private void draw_sun_angle_chart (Gtk.DrawingArea area, Cairo.Context cr, int width, int height) {
ThemeColors colors = style_manager.dark ? DARK_THEME : LIGHT_THEME;
// Fill background
cr.set_source_rgb (colors.bg_r, colors.bg_g, colors.bg_b);
cr.paint ();
int chart_width = width - MARGIN_LEFT - MARGIN_RIGHT;
int chart_height = height - MARGIN_TOP - MARGIN_BOTTOM;
double horizon_y = MARGIN_TOP + chart_height * 0.5; // 0° is at middle of -90° to +90° range
// Shade area below horizon
cr.set_source_rgba (colors.shade_r, colors.shade_g, colors.shade_b, colors.shade_a);
cr.rectangle (MARGIN_LEFT, horizon_y, chart_width, height - MARGIN_BOTTOM - horizon_y);
cr.fill ();
// Draw horizontal grid every 15°
cr.set_source_rgba (colors.grid_r, colors.grid_g, colors.grid_b, colors.grid_a);
cr.set_line_width (1);
for (int angle = -90; angle <= 90; angle += 15) {
double tick_y = MARGIN_TOP + chart_height * (90 - angle) / 180.0;
cr.move_to (MARGIN_LEFT, tick_y);
cr.line_to (width - MARGIN_RIGHT, tick_y);
cr.stroke ();
}
// Draw vertical grid every 2 hours
for (int h = 0; h <= 24; h += 2) {
double tick_x = MARGIN_LEFT + chart_width * (h / 24.0);
cr.move_to (tick_x, MARGIN_TOP);
cr.line_to (tick_x, height - MARGIN_BOTTOM);
cr.stroke ();
}
// Draw axes and horizon
cr.set_source_rgb (colors.axis_r, colors.axis_g, colors.axis_b);
cr.set_line_width (2);
cr.move_to (MARGIN_LEFT, height - MARGIN_BOTTOM);
cr.line_to (width - MARGIN_RIGHT, height - MARGIN_BOTTOM);
cr.stroke ();
cr.move_to (MARGIN_LEFT, MARGIN_TOP);
cr.line_to (MARGIN_LEFT, height - MARGIN_BOTTOM);
cr.stroke ();
// Horizon line
cr.move_to (MARGIN_LEFT, horizon_y);
cr.line_to (width - MARGIN_RIGHT, horizon_y);
cr.stroke ();
// Draw axis ticks and labels
cr.set_source_rgb (colors.text_r, colors.text_g, colors.text_b);
cr.set_line_width (1);
cr.set_font_size (20);
for (int angle = -90; angle <= 90; angle += 15) {
double tick_y = MARGIN_TOP + chart_height * (90 - angle) / 180.0;
cr.move_to (MARGIN_LEFT - 5, tick_y);
cr.line_to (MARGIN_LEFT, tick_y);
cr.stroke ();
var te = Cairo.TextExtents ();
var txt = angle.to_string ();
cr.text_extents (txt, out te);
cr.move_to (MARGIN_LEFT - 10 - te.width, tick_y + te.height / 2);
cr.show_text (txt);
}
for (int h = 0; h <= 24; h += 2) {
double tick_x = MARGIN_LEFT + chart_width * (h / 24.0);
cr.move_to (tick_x, height - MARGIN_BOTTOM);
cr.line_to (tick_x, height - MARGIN_BOTTOM + 5);
cr.stroke ();
var te = Cairo.TextExtents ();
var txt = h.to_string ();
cr.text_extents (txt, out te);
cr.move_to (tick_x - te.width / 2, height - MARGIN_BOTTOM + 25);
cr.show_text (txt);
}
// Plot solar elevation curve
cr.set_source_rgb (colors.curve_r, colors.curve_g, colors.curve_b);
cr.set_line_width (2);
for (int i = 0; i < RESOLUTION_PER_MIN; i += 1) {
double x = MARGIN_LEFT + chart_width * (i / (double) (RESOLUTION_PER_MIN - 1));
double y = MARGIN_TOP + chart_height * (90.0 - sun_angles[i]) / 180.0;
if (i == 0) {
cr.move_to (x, y);
} else {
cr.line_to (x, y);
}
}
cr.stroke ();
// Draw click point if exists
if (has_click_point) {
// Calculate current coordinates from stored time and angle
double clicked_x = MARGIN_LEFT + chart_width * (clicked_time_hours / 24.0);
double corresponding_y = MARGIN_TOP + chart_height * (90.0 - corresponding_angle) / 180.0;
cr.set_source_rgba (colors.point_r, colors.point_g, colors.point_b, 0.8);
cr.arc (clicked_x, corresponding_y, 5, 0, 2 * Math.PI);
cr.fill ();
// Draw vertical line to show time
cr.set_source_rgba (colors.line_r, colors.line_g, colors.line_b, colors.line_a);
cr.set_line_width (1);
cr.move_to (clicked_x, MARGIN_TOP);
cr.line_to (clicked_x, height - MARGIN_BOTTOM);
cr.stroke ();
// Draw horizontal line to show angle
cr.move_to (MARGIN_LEFT, corresponding_y);
cr.line_to (width - MARGIN_RIGHT, corresponding_y);
cr.stroke ();
}
// Draw axis titles
cr.set_source_rgb (colors.text_r, colors.text_g, colors.text_b);
cr.set_font_size (20);
string x_title = "Time (hours)";
Cairo.TextExtents x_ext;
cr.text_extents (x_title, out x_ext);
cr.move_to ((double) width / 2 - x_ext.width / 2, height - MARGIN_BOTTOM + 55);
cr.show_text (x_title);
string y_title = "Solar Elevation (°)";
Cairo.TextExtents y_ext;
cr.text_extents (y_title, out y_ext);
cr.save ();
cr.translate (MARGIN_LEFT - 45, (double)height / 2);
cr.rotate (-Math.PI / 2);
cr.move_to (-y_ext.width / 2, 0);
cr.show_text (y_title);
cr.restore ();
// Draw chart captions
string caption_line1 = "Solar Elevation Angle - Date: %s".printf (selected_date.format ("%Y-%m-%d"));
string caption_line2 = "Lat: %.2f°, Lon: %.2f°, TZ: UTC%+.2f".printf (latitude, longitude, timezone_offset_hours);
cr.set_font_size (18);
Cairo.TextExtents cap_ext1, cap_ext2;
cr.text_extents (caption_line1, out cap_ext1);
cr.text_extents (caption_line2, out cap_ext2);
double total_caption_height = cap_ext1.height + cap_ext2.height + 5;
cr.move_to ((width - cap_ext1.width) / 2, (MARGIN_TOP - total_caption_height) / 2 + cap_ext1.height);
cr.show_text (caption_line1);
cr.move_to ((width - cap_ext2.width) / 2, (MARGIN_TOP - total_caption_height) / 2 + cap_ext1.height + 5 + cap_ext2.height);
cr.show_text (caption_line2);
}
/**
* Handles export button click event.
*
* Shows a file save dialog with filters for PNG, SVG, and PDF formats.
*/
private void on_export_clicked () {
// Show save dialog with PNG, SVG, PDF filters
var png_filter = new Gtk.FileFilter ();
png_filter.name = "PNG Images";
png_filter.add_mime_type ("image/png");
var svg_filter = new Gtk.FileFilter ();
svg_filter.name = "SVG Images";
svg_filter.add_mime_type ("image/svg+xml");
var pdf_filter = new Gtk.FileFilter ();
pdf_filter.name = "PDF Documents";
pdf_filter.add_mime_type ("application/pdf");
var filter_list = new ListStore (typeof (Gtk.FileFilter));
filter_list.append (png_filter);
filter_list.append (svg_filter);
filter_list.append (pdf_filter);
var file_dialog = new Gtk.FileDialog () {
modal = true,
initial_name = "solar_elevation_chart.png",
filters = filter_list,
};
file_dialog.save.begin (window, null, (obj, res) => {
try {
var file = file_dialog.save.end (res);
if (file != null) {
export_chart (file);
}
} catch (Error e) {
// Dismissed by user, so do not show alert dialog
message ("Image file has not been saved: %s", e.message);
}
});
}
/**
* Exports the current chart to a file.
*
* Supports PNG, SVG, and PDF formats based on file extension.
* Defaults to PNG if extension is not recognized.
*
* @param file The file to export the chart to.
*/
private void export_chart (File file) {
int width = drawing_area.get_width ();
int height = drawing_area.get_height ();
if (width <= 0 || height <= 0) {
width = 800;
height = 600;
}
string filepath = file.get_path ();
string? extension = null;
var last_dot = filepath.last_index_of_char ('.');
if (last_dot != -1) {
extension = filepath[last_dot:].down ();
}
if (extension == ".svg") {
Cairo.SvgSurface surface = new Cairo.SvgSurface (filepath, width, height);
Cairo.Context cr = new Cairo.Context (surface);
draw_sun_angle_chart (drawing_area, cr, width, height);
} else if (extension == ".pdf") {
Cairo.PdfSurface surface = new Cairo.PdfSurface (filepath, width, height);
Cairo.Context cr = new Cairo.Context (surface);
draw_sun_angle_chart (drawing_area, cr, width, height);
} else {
Cairo.ImageSurface surface = new Cairo.ImageSurface (Cairo.Format.RGB24, width, height);
Cairo.Context cr = new Cairo.Context (surface);
draw_sun_angle_chart (drawing_area, cr, width, height);
surface.write_to_png (filepath);
}
}
/**
* Handles CSV export button click event.
*
* Shows a file save dialog for CSV format.
*/
private void on_export_csv_clicked () {
var csv_filter = new Gtk.FileFilter ();
csv_filter.name = "CSV Files";
csv_filter.add_mime_type ("text/csv");
var filter_list = new ListStore (typeof (Gtk.FileFilter));
filter_list.append (csv_filter);
var file_dialog = new Gtk.FileDialog () {
modal = true,
initial_name = "solar_elevation_data.csv",
filters = filter_list,
};
file_dialog.save.begin (window, null, (obj, res) => {
try {
var file = file_dialog.save.end (res);
if (file != null) {
export_csv_data (file);
}
} catch (Error e) {
// Dismissed by user, so do not show alert dialog
message ("CSV file has not been saved: %s", e.message);
}
});
}
/**
* Exports the solar elevation data to a CSV file.
*
* @param file The file to export the data to.
*/
private void export_csv_data (File file) {
try {
var stream = file.replace (null, false, FileCreateFlags.REPLACE_DESTINATION);
var data_stream = new DataOutputStream (stream);
// Write CSV metadata as comments
data_stream.put_string ("# Solar Elevation Data\n");
data_stream.put_string ("# Date: %s\n".printf (selected_date.format ("%Y-%m-%d")));
data_stream.put_string ("# Latitude: %.2f degrees\n".printf (latitude));
data_stream.put_string ("# Longitude: %.2f degrees\n".printf (longitude));
data_stream.put_string ("# Timezone: UTC%+.2f\n".printf (timezone_offset_hours));
data_stream.put_string ("#\n");
// Write CSV header
data_stream.put_string ("Time,Solar Elevation (degrees)\n");
// Write data points
for (int i = 0; i < RESOLUTION_PER_MIN; i += 1) {
int hours = i / 60;
int minutes = i % 60;
data_stream.put_string (
"%02d:%02d,%.3f\n".printf (hours, minutes, sun_angles[i])
);
}
data_stream.close ();
} catch (Error e) {
show_error_dialog ("CSV export failed", e.message);
}
}
/**
* Application entry point.
*
* Creates and runs the SolarAngleApp instance.
*
* @param args Command line arguments.
* @return Exit code.
*/
public static int main (string[] args) {
var app = new SolarAngleApp ();
return app.run (args);
}
}
-
本文采用CC-BY-SA-4.0协议发布,但本文代码采用LGPL v2.1+协议公开 ↩