36
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Java でも時の流れを感じる

Last updated at Posted at 2018-02-18

Emacs で時の流れを感じる - Qiita のアイディアがとても素晴らしいと感じていたのですが、残念ながら自分は Emacs ユーザではなかったので時の流れを感じられずにいました。

そんな自分でも時の流れを感じるために、使い慣れた Java でデスクトップアクセサリ風のアプリケーションを作ってみました。
本家作者の @zk_phi さんから公開の許可をいただけたので、こちらでご紹介したいと思います。

littlesky.gif

インストール

こちら からビルド済みの 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回目以降の起動では、カレントディレクトリの設定ファイルを参照します。起動するときの場所を変更する場合は、設定ファイルも移動させてください。

初回起動時のセットアップ

初回起動時は、位置情報を入力するためのダイアログが表示されます。

littlesky.jpg

緯度(Latitude)と経度(Longitude)を入力します。
(日の出・日の入の時刻と、天気情報の入手に使用します)

画面説明

littlesky.jpg

オリジナルと同じように空の状態を表すアイコン(空アイコン)、時刻、気温情報が表示されます。
空アイコンは、晴れの場合は月相が、雨または雪の場合はそれらの天気を表すアイコンが表示されます。

背景色は時刻に合わせて変化します。

コンテキストメニュー

littlesky.jpg

時刻を右クリックすると、コンテキストメニューが表示されます。
アプリケーションの終了や、設定の変更が可能です。

天候機能を利用する

オリジナル同様、OpenWeatherMap を利用した天候機能を利用できます。

OpenWeatherMap の API キーを取得する

OpenWeatherMap で API キーを入手してください。

API キーは、 OpenWeatherMap でアカウントを登録すると入手できます。
「OpenWeatherMap API キー」とかで検索したら、いろいろ情報が出てくると思います。

API キーを設定する

コンテキストメニューの Options を選択すると、 API キーを入力するフォームが表示されます。

littlesky.jpg

[API Key] に、入手した API キーを入力して [Save] ボタンで設定を保存してください。

天候機能を有効にする

littlesky.jpg

API キーを入力すると、コンテキストメニューの [Weather service] > [Start] が選択できるようになります。
これを選択すると、天候機能が起動します。

15 分に 1 回、設定された位置情報をもとに天気・雲の量・気温の情報を取得して表示に反映されるようになります。

一度 API キーを設定すると、次回以降はアプリケーション起動時に天候機能も自動的に起動するようになります。

天候機能を停止したい場合は、コンテキストメニューの [Weather service] > [Stop] を選択するか、 API キーを空にして保存してください。

プロキシの設定

インターネットへのアクセスにプロキシが必要となるような環境では、プロキシの設定が必要になります。

コンテキストメニューの [Options] で設定ダイアログを開きます。

littlesky.jpg

[HTTP Proxy] のところに、プロキシの設定を入力します。

ポートが未入力の場合は、デフォルトで 80 が使用されます。
ユーザー名とパスワードは、プロキシが認証を必要としている場合だけ入力します。
※パスワードは設定ファイル(littkesky.xml)に平文のまま保存されるのでご注意ください。

表示設定

表示に関する設定は、コンテキストメニューの [View] から変更できます。

littlesky.jpg

常に全面に表示する

[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();
    }
}

実行結果

javafx.jpg

説明

  • 窓枠のないウィンドウを作成するには、まず StagestyleStageStyle.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);
        });

        ...
    }
}

実行結果

javafx.gif

二色間の色の補間

空の背景色は、いくつかキーとなる時刻だけ背景色を決めています(オリジナルの実装方法を参考にしました)。
あるキー時刻Aとキー時刻Bの間の色は、A,Bの二色間で色を補間しています。

二色間の色の補間は、 JavaFX の色クラスである Color クラスに、そのままズバリinterpolate(Color, double) というメソッドがあり、それを利用しました。

interpolate() は、レシーバとなる色から第一引数で指定した色までのうち、第二引数で指定した割合(0.01.0 までの値)の位置の色を計算して返します。
例えば color1.interporate(color2, 0.5) とすると、 color1color2 のちょうど中間(0.5)の値を計算して返してくれます。

割合は、各キーに設定した時刻と現在時刻から Date & Time API の Duration を利用して算出しました。
具体的な実装は SkyColorGradation クラスにあります。

JavaFX プロパティを活用した実装

最近 JavaFX をガッツリ勉強した こともあり、今回の実装ではそのとき学んだ JavaFX プロパティを積極的に利用してみました。

JavaFX プロパティを使えばオブザーバーパターンをシンプルに実装できることを利用し、全体の構成は↓のような感じにしました。

javafx.png

例えば、時刻の表示を例にすると次のような感じです。

model

ClockBase.java
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();
    }
}
ReadTimeClock.java
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 を利用して外部に公開しています。

ClockBase.java
    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

TimeLabelViewModel.java
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() メソッドの中で、 ClockViewOptions に依存する形で時刻ラベルに表示するテキスト(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 プロパティ)が Clocktime プロパティと ViewOptionsshowSeconds プロパティに依存していることが定義されています
  • そして、 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

MainController.java
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 がインジェクションされています
  • この timeLabeltext プロパティと、先ほどの TimeLabelViewModel が公開している text プロパティを bind() で連動させています
  • この結果、
    1. RealTimeClock で時刻が更新される
    2. TimeLabelViewModel がそれを検知して、その時の表示設定も加味したうえで表示するテキストを更新する
    3. TimeLavelViewModeltext プロパティと連動させていた timeLabeltext プロパティが更新され、画面の表示が変わる
  • といった具合にモデルの更新がビューに反映されるようになっています

これは実装をしながら試行錯誤して辿り着いた方法なので、これが唯一の正解とか、そういうわけではありません。
しかし、モデルの変更をビューまで伝播させる方法として JavaFX プロパティの監視の仕組みを利用するこの方法は、方向性としては間違っていない気はしています。

良いと思った点

  • 各クラスの役割が明確に分かれている気がする
    • モデルはメインのロジックと値の更新
    • ビューはモデルの値をもとに表示する内容を決定
    • コントロールはビューとモデルの橋渡し
  • これらのクラスの連携が、 JavaFX プロパティの監視の仕組みでうまいことシンプルに保てている気がする
  • コントローラクラスのコード量が減った
    • JavaFX ではコントローラクラスに画面上のノードオブジェクトをインジェクションする
    • そのため、コントローラクラスにビューについての処理を書きがちになる
    • しかし、項目数が増えてくるとビューについての実装が増大していき、コントローラクラスがすぐに肥大化するという経験があった
    • 今回ビュークラスを作成して、コントローラはその橋渡しにさせたおかげで、ビューについての処理をコントローラから取り除くことができた

もうちょっと改善したいと思ってる点

  • モデルが JavaFX の API に依存している
    • DDD のドメインモデル的な発想でモデル層を見ると、 JavaFX の API への依存はちょっとアレな気もする
  • インフラ層との切り分け
    • 今回はインフラ層的な役割も全部ロジック扱いで「モデル」の中に突っ込んだが、理想は分けれたほうが良い気はしている
    • でも、さっさと動くものが欲しかったので、とりあえず「モデル」でまとめてしまった
    • もうちょっときれいに分けられないか考えてはみたい
  • コントローラクラスのごちゃごちゃ感
    • ビューの処理を切り出せたといっても、なんかビュークラスの初期化等の処理がごちゃごちゃしている気がする
    • なんか、もうちょっとスッキリさせたい気はする(アイディアはない)

日の出、日の入時刻の算出

日の出、日の入時刻の算出について、何か Java でライブラリがないかなぁと探したら以下の2つが見つかりました。

前者は日の出、日の入の時刻を計算し、後者はさらに太陽の位置や月の位置、月相なども計算してくれるっぽい感じです。

前者は数年間メンテナンスがされていないようですし、今回の用途を考えると後者のライブラリのほうが良さそうです。

しかし、実際に使ってみたところ日の出、日の入の時刻はどちらもそこそこの精度(誤差数分)で求められるのですが、後者のライブラリによる月相の計算が実際のモノとかなりずれていました。

ということで、最終的には日の出・日の入計算は前者のライブラリで行うことにしました。

SunriseSunsetTime
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 にあります。

36
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?