Emacs で時の流れを感じる - Qiita のアイディアがとても素晴らしいと感じていたのですが、残念ながら自分は Emacs ユーザではなかったので時の流れを感じられずにいました。
そんな自分でも時の流れを感じるために、使い慣れた Java でデスクトップアクセサリ風のアプリケーションを作ってみました。
本家作者の @zk_phi さんから公開の許可をいただけたので、こちらでご紹介したいと思います。
インストール
こちら からビルド済みの jar ファイル(littlesky.jar
)を取得できます。
ソースコードは GitHub に上げています > https://github.com/opengl-8080/little-sky
動作環境
OS
Windows 10 では動くことを確認しています。
手元に Mac の環境がないので、 Mac で動くかどうかは確認できていません。
一応 OS に依存する処理は書いていないつもりですが、もし Mac で動かない場合はプルリクエストください。
JRE
Java 8u40 以上で動作するはずです(Dialog を使用しているので)。
一応 Java 9 でも動くのは見ました。
起動
$ java -jar littlesky.jar
設定ファイルが実行時のカレントディレクトリに保存されます(littlesky.xml
)。
2回目以降の起動では、カレントディレクトリの設定ファイルを参照します。起動するときの場所を変更する場合は、設定ファイルも移動させてください。
初回起動時のセットアップ
初回起動時は、位置情報を入力するためのダイアログが表示されます。
緯度(Latitude
)と経度(Longitude
)を入力します。
(日の出・日の入の時刻と、天気情報の入手に使用します)
画面説明
オリジナルと同じように空の状態を表すアイコン(空アイコン)、時刻、気温情報が表示されます。
空アイコンは、晴れの場合は月相が、雨または雪の場合はそれらの天気を表すアイコンが表示されます。
背景色は時刻に合わせて変化します。
コンテキストメニュー
時刻を右クリックすると、コンテキストメニューが表示されます。
アプリケーションの終了や、設定の変更が可能です。
天候機能を利用する
オリジナル同様、OpenWeatherMap を利用した天候機能を利用できます。
OpenWeatherMap の API キーを取得する
OpenWeatherMap で API キーを入手してください。
API キーは、 OpenWeatherMap でアカウントを登録すると入手できます。
「OpenWeatherMap API キー」とかで検索したら、いろいろ情報が出てくると思います。
API キーを設定する
コンテキストメニューの Options を選択すると、 API キーを入力するフォームが表示されます。
[API Key] に、入手した API キーを入力して [Save] ボタンで設定を保存してください。
天候機能を有効にする
API キーを入力すると、コンテキストメニューの [Weather service] > [Start] が選択できるようになります。
これを選択すると、天候機能が起動します。
15 分に 1 回、設定された位置情報をもとに天気・雲の量・気温の情報を取得して表示に反映されるようになります。
一度 API キーを設定すると、次回以降はアプリケーション起動時に天候機能も自動的に起動するようになります。
天候機能を停止したい場合は、コンテキストメニューの [Weather service] > [Stop] を選択するか、 API キーを空にして保存してください。
プロキシの設定
インターネットへのアクセスにプロキシが必要となるような環境では、プロキシの設定が必要になります。
コンテキストメニューの [Options] で設定ダイアログを開きます。
[HTTP Proxy] のところに、プロキシの設定を入力します。
ポートが未入力の場合は、デフォルトで 80
が使用されます。
ユーザー名とパスワードは、プロキシが認証を必要としている場合だけ入力します。
※パスワードは設定ファイル(littkesky.xml
)に平文のまま保存されるのでご注意ください。
表示設定
表示に関する設定は、コンテキストメニューの [View] から変更できます。
常に全面に表示する
[Always on top] にチェックを入れると、ウィンドウが常にデスクトップの最前面に表示されるようになります。
デフォルトはオフです。
秒の表示・非表示
[Show seconds] のチェックを切り替えることで、時刻の秒の表示・非表示を切り替えられます。
デフォルトはオンです。
気温の表示・非表示
[Show temperature] のチェックを切り替えることで、気温の表示・非表示を切り替えられます。
デフォルトはオンです。
空アイコンの表示・非表示
[Show sky status icon] のチェックを切り替えることで、空アイコン(月相・天気アイコン)の表示・非表示を切り替えられます。
デフォルトはオンです。
学んだこととか
ブコメで指摘いただき、たしかにちょっと良くないかなと思ったので、後付けで恐縮ですが、このアプリケーションを作る中で学んだことをまとめたいと思います。
窓枠のないウィンドウを作成する
package sample.javafx;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
public class NoFrameWindow extends Application {
public static void main(String[] args) {
launch(NoFrameWindow.class, args);
}
@Override
public void start(Stage primaryStage) throws Exception {
Pane pane = new Pane();
pane.setPrefWidth(200);
pane.setPrefHeight(100);
pane.setStyle("-fx-background-radius: 50; -fx-background-color: yellow;");
Scene scene = new Scene(pane);
scene.setFill(Color.TRANSPARENT);
primaryStage.initStyle(StageStyle.TRANSPARENT);
primaryStage.setScene(scene);
primaryStage.show();
}
}
実行結果
説明
- 窓枠のないウィンドウを作成するには、まず
Stage
のstyle
にStageStyle.TRANSPARENT
を指定する- これでウィンドウの窓枠が消える
- ただし、これだけだと
Scene
の背景が透過されない-
Scene
が透過されないと、ノードの角を丸めても見た目上は四角いままになってしまう -
Scene
を透過させるために、setFill()
でColor.TRANSPARENT
を設定する
-
- すると、
Scene
に登録したノードの角を丸めたりすれば、↑のように任意の形の窓枠のないウィンドウが作れるようになる - 背景の角の丸みは、 CSS なら
-fx-background-radius
で指定できる - 実装で設定する場合は、たぶん↓のような感じになる
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.CornerRadii;
import javafx.scene.paint.Color;
...
BackgroundFill backgroundFill = new BackgroundFill(Color.YELLOW, new CornerRadii(50), null);
Background background = new Background(backgroundFill);
pane.setBackground(background);
ウィンドウをドラッグできるようにする
窓枠を無くすと、そのままだとウィンドウをドラッグできない。
この実装は、自力で頑張る必要がある。
Stackoverflow とかを探すと同様の質問はいくつかって、実装方法も紹介されているので、それをそのまま利用させてもらう。
Moving an undecorated stage in javafx 2 - Stack Overflow
package sample.javafx;
...
public class NoFrameWindow extends Application {
...
private double mouseX;
private double mouseY;
@Override
public void start(Stage primaryStage) throws Exception {
Pane pane = new Pane();
...
pane.setOnMousePressed(e -> {
this.mouseX = primaryStage.getX() - e.getScreenX();
this.mouseY = primaryStage.getY() - e.getScreenY();
});
pane.setOnMouseDragged(e -> {
primaryStage.setX(e.getScreenX() + this.mouseX);
primaryStage.setY(e.getScreenY() + this.mouseY);
});
...
}
}
実行結果
二色間の色の補間
空の背景色は、いくつかキーとなる時刻だけ背景色を決めています(オリジナルの実装方法を参考にしました)。
あるキー時刻Aとキー時刻Bの間の色は、A,Bの二色間で色を補間しています。
二色間の色の補間は、 JavaFX の色クラスである Color
クラスに、そのままズバリinterpolate(Color, double) というメソッドがあり、それを利用しました。
interpolate()
は、レシーバとなる色から第一引数で指定した色までのうち、第二引数で指定した割合(0.0
~1.0
までの値)の位置の色を計算して返します。
例えば color1.interporate(color2, 0.5)
とすると、 color1
と color2
のちょうど中間(0.5
)の値を計算して返してくれます。
割合は、各キーに設定した時刻と現在時刻から Date & Time API の Duration を利用して算出しました。
具体的な実装は SkyColorGradation クラスにあります。
JavaFX プロパティを活用した実装
最近 JavaFX をガッツリ勉強した こともあり、今回の実装ではそのとき学んだ JavaFX プロパティを積極的に利用してみました。
JavaFX プロパティを使えばオブザーバーパターンをシンプルに実装できることを利用し、全体の構成は↓のような感じにしました。
例えば、時刻の表示を例にすると次のような感じです。
model
package littlesky.model.clock;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import java.time.LocalDate;
import java.time.LocalTime;
public class ClockBase implements Clock {
protected final ReadOnlyObjectWrapper<LocalDate> date = new ReadOnlyObjectWrapper<>();
protected final ReadOnlyObjectWrapper<LocalTime> time = new ReadOnlyObjectWrapper<>();
@Override
public LocalDate getDate() {
return this.date.get();
}
@Override
public LocalTime getTime() {
return this.time.get();
}
@Override
public ReadOnlyObjectProperty<LocalDate> dateProperty() {
return this.date.getReadOnlyProperty();
}
@Override
public ReadOnlyObjectProperty<LocalTime> timeProperty() {
return this.time.getReadOnlyProperty();
}
}
package littlesky.model.clock;
import javafx.application.Platform;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class RealTimeClock extends ClockBase {
public RealTimeClock() {
this.updateDateTime();
}
public void start() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor((runnable) -> {
Thread thread = new Thread(runnable);
thread.setDaemon(true);
return thread;
});
executor.scheduleAtFixedRate(() -> {
Platform.runLater(this::updateDateTime);
}, 0, 500, TimeUnit.MILLISECONDS);
}
private void updateDateTime() {
LocalDateTime now = LocalDateTime.now();
if (this.getDate() == null || !this.getDate().equals(now.toLocalDate())) {
this.date.set(now.toLocalDate());
}
this.time.set(now.toLocalTime());
}
}
現在時刻をカウントするクラスです。
UIスレッドとは別のスレッドを起動し、 500 ミリ秒間隔で現在時刻をカウントしています。
現在時刻は日付(date
)と時刻(time
) を、それぞれ ReadOnlyObjectProperty
を利用して外部に公開しています。
protected final ReadOnlyObjectWrapper<LocalDate> date = new ReadOnlyObjectWrapper<>();
protected final ReadOnlyObjectWrapper<LocalTime> time = new ReadOnlyObjectWrapper<>();
...
@Override
public ReadOnlyObjectProperty<LocalDate> dateProperty() {
return this.date.getReadOnlyProperty();
}
@Override
public ReadOnlyObjectProperty<LocalTime> timeProperty() {
return this.time.getReadOnlyProperty();
}
ReadOnlyObjectWrapper
は、読み取り専用のプロパティを外部に公開するときに利用できるクラスです。
これを利用すれば、モデルの外から値を勝手に書き換えられないようにできます。
一点注意なのが、値の更新を UIスレッドとは別のスレッドで行っているので、プロパティの値を更新するときは必ず Platform.runLater()
を使って UI スレッドから実行しないといけない点です。
モデルの中で runLater()
を使って値を更新しておけば、ビュー側は安心してそのプロパティを利用できるようになります。
view
package littlesky.view.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.ReadOnlyStringWrapper;
...
import littlesky.model.clock.Clock;
import littlesky.model.option.ViewOptions;
...
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import static littlesky.util.BindingBuilder.*;
public class TimeLabelViewModel {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final DateTimeFormatter formatterWithoutSeconds = DateTimeFormatter.ofPattern("HH:mm");
private final ReadOnlyStringWrapper text = new ReadOnlyStringWrapper("00:00:00");
...
public void bind(Clock clock, SkyColor skyColor, ViewOptions viewOptions) {
this.text.bind(
binding(clock.timeProperty(), viewOptions.showSecondsProperty())
.computeValue(() -> this.formatClockTime(clock, viewOptions))
);
...
}
private String formatClockTime(Clock clock, ViewOptions viewOptions) {
LocalTime time = clock.getTime();
return time.format(viewOptions.isShowSeconds() ? formatter : formatterWithoutSeconds);
}
...
public ReadOnlyStringProperty textProperty() {
return this.text.getReadOnlyProperty();
}
...
}
TimeLabelViewModel
は、時刻表示を制御するクラスです。
bind()
メソッドの中で、 Clock
と ViewOptions
に依存する形で時刻ラベルに表示するテキスト(text
プロパティ)の値を定義しています。
public void bind(Clock clock, SkyColor skyColor, ViewOptions viewOptions) {
this.text.bind(
binding(clock.timeProperty(), viewOptions.showSecondsProperty())
.computeValue(() -> this.formatClockTime(clock, viewOptions))
);
...
}
-
binding(...).computeValue(...)
は、Binding
インスタンスを少ない記述で書けるようにしたビルダーで、やっていることはObjectBinding
の匿名クラスを作成しているだけです。- 実装は こちら
- ここでは、時刻のテキスト(
text
プロパティ)がClock
のtime
プロパティとViewOptions
のshowSeconds
プロパティに依存していることが定義されています - そして、
time
,showSeconds
のいずれかの値が変化した場合はformatClockTime()
メソッドが実行されてtext
プロパティの値が更新されます
private String formatClockTime(Clock clock, ViewOptions viewOptions) {
LocalTime time = clock.getTime();
return time.format(viewOptions.isShowSeconds() ? formatter : formatterWithoutSeconds);
}
-
formatClockTime()
は、更新後のtime
,showSeconds
を参照して、画面に表示する時刻テキストを生成します - 最終的に、
text
プロパティはモデルと同じように読み取り専用のプロパティとして外部に公開しています
private final ReadOnlyStringWrapper text = new ReadOnlyStringWrapper("00:00:00");
...
public ReadOnlyStringProperty textProperty() {
return this.text.getReadOnlyProperty();
}
- 公開された
text
プロパティは、コントローラで画面の項目に紐づけられます
controller
package littlesky.controller.main;
...
public class MainController implements Initializable {
...
@FXML
private Label timeLabel;
...
@Override
public void initialize(URL url, ResourceBundle resources) {
...
this.replaceClockAndWeather(this.realTimeClock, this.openWeatherMap);
}
...
private void replaceClockAndWeather(Clock newClock, Weather weather) {
...
TimeLabelViewModel timeLabelViewModel = new TimeLabelViewModel();
timeLabelViewModel.bind(newClock, skyColor, this.options.getViewOptions());
this.timeLabel.textProperty().bind(timeLabelViewModel.textProperty());
...
}
...
}
-
MainController
には画面に時刻を表示しているtimeLabel
がインジェクションされています - この
timeLabel
のtext
プロパティと、先ほどのTimeLabelViewModel
が公開しているtext
プロパティをbind()
で連動させています - この結果、
-
RealTimeClock
で時刻が更新される -
TimeLabelViewModel
がそれを検知して、その時の表示設定も加味したうえで表示するテキストを更新する -
TimeLavelViewModel
のtext
プロパティと連動させていたtimeLabel
のtext
プロパティが更新され、画面の表示が変わる
-
- といった具合にモデルの更新がビューに反映されるようになっています
これは実装をしながら試行錯誤して辿り着いた方法なので、これが唯一の正解とか、そういうわけではありません。
しかし、モデルの変更をビューまで伝播させる方法として JavaFX プロパティの監視の仕組みを利用するこの方法は、方向性としては間違っていない気はしています。
良いと思った点
- 各クラスの役割が明確に分かれている気がする
- モデルはメインのロジックと値の更新
- ビューはモデルの値をもとに表示する内容を決定
- コントロールはビューとモデルの橋渡し
- これらのクラスの連携が、 JavaFX プロパティの監視の仕組みでうまいことシンプルに保てている気がする
- コントローラクラスのコード量が減った
- JavaFX ではコントローラクラスに画面上のノードオブジェクトをインジェクションする
- そのため、コントローラクラスにビューについての処理を書きがちになる
- しかし、項目数が増えてくるとビューについての実装が増大していき、コントローラクラスがすぐに肥大化するという経験があった
- 今回ビュークラスを作成して、コントローラはその橋渡しにさせたおかげで、ビューについての処理をコントローラから取り除くことができた
もうちょっと改善したいと思ってる点
- モデルが JavaFX の API に依存している
- DDD のドメインモデル的な発想でモデル層を見ると、 JavaFX の API への依存はちょっとアレな気もする
- インフラ層との切り分け
- 今回はインフラ層的な役割も全部ロジック扱いで「モデル」の中に突っ込んだが、理想は分けれたほうが良い気はしている
- でも、さっさと動くものが欲しかったので、とりあえず「モデル」でまとめてしまった
- もうちょっときれいに分けられないか考えてはみたい
- コントローラクラスのごちゃごちゃ感
- ビューの処理を切り出せたといっても、なんかビュークラスの初期化等の処理がごちゃごちゃしている気がする
- なんか、もうちょっとスッキリさせたい気はする(アイディアはない)
日の出、日の入時刻の算出
日の出、日の入時刻の算出について、何か Java でライブラリがないかなぁと探したら以下の2つが見つかりました。
- mikereedell/sunrisesunsetlib-java: Library for computing the sunrise/sunset from GPS coordinates and a date, in Java.
- shred/commons-suncalc: Java port of SunCalc library
前者は日の出、日の入の時刻を計算し、後者はさらに太陽の位置や月の位置、月相なども計算してくれるっぽい感じです。
前者は数年間メンテナンスがされていないようですし、今回の用途を考えると後者のライブラリのほうが良さそうです。
しかし、実際に使ってみたところ日の出、日の入の時刻はどちらもそこそこの精度(誤差数分)で求められるのですが、後者のライブラリによる月相の計算が実際のモノとかなりずれていました。
ということで、最終的には日の出・日の入計算は前者のライブラリで行うことにしました。
package littlesky.model.sun;
import com.luckycatlabs.sunrisesunset.SunriseSunsetCalculator;
import com.luckycatlabs.sunrisesunset.dto.Location;
import littlesky.model.location.UserLocation;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
public class SunriseSunsetTime {
private final LocalDate localDate;
private final SunriseSunsetCalculator calculator;
private final TimeZone timeZone = TimeZone.getDefault();
public SunriseSunsetTime(UserLocation userLocation, LocalDate localDate) {
this.localDate = localDate;
Location location = new Location(userLocation.getLatitude(), userLocation.getLongitude());
this.calculator = new SunriseSunsetCalculator(location, this.timeZone);
}
public LocalTime sunriseTime() {
Calendar today = this.toCalendar(this.localDate);
Calendar sunriseTime = this.calculator.getOfficialSunriseCalendarForDate(today);
return this.toLocalTime(sunriseTime);
}
public LocalTime sunsetTime() {
Calendar today = this.toCalendar(this.localDate);
Calendar sunsetTime = this.calculator.getOfficialSunsetCalendarForDate(today);
return this.toLocalTime(sunsetTime);
}
private Calendar toCalendar(LocalDate localDate) {
Date date = Date.from(localDate.atStartOfDay(this.timeZone.toZoneId()).toInstant());
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return calendar;
}
private LocalTime toLocalTime(Calendar calendar) {
return calendar.getTime().toInstant().atZone(this.timeZone.toZoneId()).toLocalTime();
}
}
数年間メンテされてないこともあり、ライブラリが求める日付オブジェクトは Calendar
型です。
こちとら Java 8 なので、 Date & Time API と相互変換して利用する必要がありました。
月相の算出
では月相の算出はどうしたかというと、ライブラリは見つけられなかったので自力で計算することにしました。
とはいっても軌道計算まではさすがにできないので、
この辺を参考にして、そこそこの精度の月齢を求めるプログラムを書きました。
具体的には、 1999年1月1日の月齢(13.17)を起点にして、現在日付までの日数を求め、その間に進む月齢をメトン周期などを考慮して計算する感じです。
実装は MoonPhase にあります。