プログラムdeタマゴ

nodamushiの著作物は、文章、画像、プログラムにかかわらず全てUnlicenseです

ToggleButtonの集合を簡単に扱えるようにしてみた

 はてなにお布施の時期が過ぎていたので、ついでにはてなブログに移行してみました。プロの値段…高いよ………。

 さて、そんなこんなで、今日もJavaFXネタ。
 RadioButtonって複数のRadioButtonを並べて表示するって使い方が普通の場面だと思う。でも、これをするには

  1. 複数のRadioButtonを用意して
  2. ToggleGroupを用意して
  3. ToggleGroupにRadioButtonを追加して
  4. レイアウト用PaneにRadioButtonを代入して
  5. ToggleGroupのselectedToggleプロパティを監視して必要な値に変換させる

 ってな感じで面倒くさい

 というわけで、もうどっかにあるんだろうけど、この手順を簡略化するクラスを作ってみました。(長いので続きに。もしくはToggleButtons.java)

 で、それを使うと以下のように短く書けます。

//importは略
public class Test extends Application{

  public static void main(final String[] args){launch(args);}

  @Override
  public void start(final Stage primaryStage) throws Exception{
    final String[] fileList={"None Selected",
        "Lenna","Mandrill","Aerial","Earth","Girl","Parrots","Pepper","Siboat"};
    final ToggleButtons buttons = new ToggleButtons();
    //画像付きRadioButtonを追加
    for(int i=1;i<fileList.length;i++){
      final String fname = fileList[i];
      buttons.createItem(fname,Test.class.getResourceAsStream(fname+".jpg"));
    }
    //選択されているToggleButtonは数値になっている
    buttons.selectedProperty().addListener(new InvalidationListener(){
      @Override
      public void invalidated(final Observable observable){
        System.out.println(fileList[buttons.getSelected()+1]);
      }
    });

    primaryStage.setScene(new Scene(buttons.asFlowPane()));
    primaryStage.show();
  }
}

 実行するとこんな感じ
 f:id:nodamushi:20150301174913p:plain

続きを読む

JavaFXのLineChartがわりと高性能になった件

 ども、一ヶ月ぐらい前に作った自作のLineChartをちまちま更新していたら、何か結構高性能になったんじゃね?ってことで、動画にとって見ましたのでご紹介。
 機能としては、「データを拡大して、スライドさせることが出来る」「マウスポイントに追従してデータの位置を取得できる」「複数のLineChartの端を一致させたまま配置できる」「複数のLineChartでスクロールバーやマウスポイントを同期させられる」「大量のデータを入れてもとりあえず固まらない」あたりです。
NodamushiChart(Github)


 とりあえず、動画とか初めてキャプチャして、それをそのままYoutubeにぶち込んだだけだから画質とか酷いけど、気にしないで。てか、相当昔に取ったnodamushiのアカウントに付けてた名前が田中太郎って宇宙人かよ。(ネタ分かる人居るのかな)


JavaFXで対数グラフを表示する

 と、いうわけで、前回の自作LineChartに対数グラフの機能を実装しますた。
 NodamushiChart
 
 拘ったのはラベルのとこ。102みたいな感じに表示されていますが、これ、JavaFXの標準NumberAxisじゃまず無理ですよね。うぇへへへ。

 LogarithmicAxisの実行サンプルはtest.Test2で見ることが出来ます。

 

JavaFXでLineChartをスクロールしたり高性能にする

やろーども。滅入り苦しみます。

 と、いうわけで、全国約80人のJavaFXユーザーにnodamushiサンタがナイスノンケなプレゼントをもってやってきたぜぃ。


 さて、妙なノリもここまでにして、JavaFXのLineChartって使いにくい、というのが私の結論です。グラフ描画領域内のマウスイベントは取れない、double[]配列でデータあるのをいちいちDataに変換しないといけない、Axisをスクロールさせて描画領域を変化させることも出来ない。複数のグラフを並べて表示させることが出来ない。その上妙なアニメーションやらまじうぜぇ。



 で、これらを解決すべく色々模索した結果………


 LineChart自作してまいました( ゚д゚)メリークリスマス



ソースコード:NodamushiChart


 あ〜、車輪の再発明糞面倒くさいぜー

 まだバグバグしてるし、欲しい機能(マウスイベント、表示範囲の変更など)全てを実装し終わったわけじゃないけど、こんな感じで動きます。(クリスマス前に記事を投稿したかったんじゃー(>_<) )

 図はsinc関数を表示した物です。sinc(0)=1ですが、テストでsinc(0)=∞にしています。このグラフは∞やNaNを自動的に判断して、無限だったら図のような垂直な線を描画、NaNだったら線を切断して表示してくれます。
 また、JavaFXのXYChartの様にグラフの表示エリアをオブジェクトの持つGroupインスタンスの中で構築し、外に出さない様な仕様と違い、グラフ表示領域は一つのクラスとしてつくってあるので、複数のグラフを整頓して配置することも容易に出来るように作ってあります。

 数値解析のような大量のデータをグラフに描画して、観察したい、という用途なら既に結構十分動きます。ソースコードは上記Githubにおいておくのでご自由に使って遊んでみてください。とりあえず、test.Testを実行すれば上の図が出てきます。ライセンスはCC0で著作権とその行使は放棄しているので、好きなように改変、利用してかまいません。
 ドキュメントとかはまぁ、そのうち(気が向いたら)正月の間にやるから待ってくだしぁ。

relocateはsetLayoutX,setLayoutYの代わりではない

 朝から叫んでおりました内容が解決したので、記事にしておきます。
 まずは、以下のようなクラスを用意します。一辺100の正方形の中心に直径60の円と長さ100の横棒を重ねたような図を表示します。

import javafx.scene.Group;
import javafx.scene.shape.Circle;
import javafx.scene.shape.Line;
public class ShapeGroup extends Group{
  private Circle circle;
  private Line line;
  
  @Override
  protected void layoutChildren(){
    if(circle == null){
      circle = new Circle(50,50,30);
      line = new Line(0,50,100,50);
      getChildren().addAll(circle,line);
    }
  }
  @Override
  public double prefHeight(double width){
    return 100d;
  }
  @Override
  public double prefWidth(double height){
    return 100d;
  }
}



 ちなみに、Groupを継承する場合はcomputePrefWidthとかじゃなくて、prefWidthをオーバーライドします。今日初めて知りました。
 で、下図のようにこいつの棒の端っこに文字をくっつけたいと思います。

import javafx.geometry.Insets;
import javafx.scene.layout.Region;
import javafx.scene.text.Text;


public class ClassA extends Region{
  private String str;
  private Text text;
  private ShapeGroup shape;
  
  public ClassA(String str){
    this.str=str;
  }
  
  private void initInstance(){
    if(text==null){
      text = new Text(str);
      shape = new ShapeGroup();
      getChildren().addAll(text,shape);
    }
  }
  
  @Override
  protected void layoutChildren(){
    initInstance();
    Insets inset = getInsets();
    double il = inset.getLeft();
    double it = inset.getTop();
    double textWidth = text.prefWidth(-1);
    double textHeight = text.prefHeight(textWidth);

    double shapeHeight = shape.prefHeight(-1);
    
    //ラベルのy座標はちょうど
    //shapeの中央に来るようにする
    text.resize(textWidth, textHeight);
    text.relocate(il, it + shapeHeight*0.5-textHeight*0.5);
    
    shape.relocate(textWidth+il, it);
  }
  
  
  @Override
  protected double computePrefHeight(double width){
    initInstance();
    Insets inset = getInsets();
    double shapeHeight = shape.prefHeight(-1);
    return shapeHeight+inset.getTop()+inset.getBottom();
  }
  
  @Override
  protected double computePrefWidth(double height){
    initInstance();
    Insets inset = getInsets();
    double textWidth = text.prefWidth(-1);
    double shapeWidth = shape.prefWidth(-1);
    return textWidth+shapeWidth
        +inset.getLeft()+inset.getRight();
  }
}

実行結果



 あるぇええええええ?




 これの原因が全然分からなくって、Textの高さが指定したものと異なる、もしくは、描画位置が異なるのかとか勝手に思って、勝手にドツボにハマっていました。

 原因はTextではなく、こいつです。

shape.relocate(textWidth+il, it);
ShapeGroup extends Group



 javafx.scene.Node のrelocateには

Sets the node's layoutX and layoutY translation properties in order to relocate this node to the x,y location in the parent.

と書いてある。というわけで、私はsetLayoutXやsetLayoutYではなく、もっぱらrelocateを配置する際に使っていた。しかし、このrelocateは単にsetLayoutXやsetLayoutYの代用メソッドではなくて、割といらないことしてくれる。

//javafx.scene.Nodeのrelocate
    public void relocate(double x, double y) {
        setLayoutX(x - getLayoutBounds().getMinX());
        setLayoutY(y - getLayoutBounds().getMinY());

        PlatformLogger logger = Logging.getLayoutLogger();
        if (logger.isLoggable(PlatformLogger.FINER)) {
            logger.finer(this.toString()+" moved to ("+x+","+y+")");
        }
    }

 というように、単にsetLayoutY(y)とするのではなく、getLayoutBounds().getMinY()という値を引いてしまっている。
 そして、今回、ShapeGroupは高さ100の正方形の中心に直径60の円を描くという前提でプログラムを書いた。すなわち、ShapeGroupの上下にはそれぞれ長さ20の空白がある。getLayoutBounds().getMinY()はこの20を返す。従って、高さ100の正方形をy座標が0の位置に表示したつもりでも、高さ100の正方形をy座標が-20の位置に表示することになる。これが表示しているテキストの位置と横棒の位置がずれた原因である。
 relocateをsetLayoutX,setLayoutYに置き換える、もしくはGroupを継承するのではなく、RegionやPaneなどを継承するように変更すると、私が意図したような正しい表示がされる。




 というわけで、Groupを使うときは要注意と言うことと、relocate(またはresizeRelocate)はsetLayoutXで設定するのと結果が異なる場合があると知っていないといけない、というのが今日の教訓でした。

JavaFXのInvalidationListenerやChangeListenerやObservableListやBindingについて

 いい加減、何となく動作してるからいいやじゃなくって、中で何やってるのか理解しておくかと、ソースコード読んできたのでまとめておくよ。


InvalidationListenerとChangeListener

 ついさっきまで挙動の差とか、全く理解していなかったほど、違いがよく分からんこの二つ。ソースコードを読んでも差が分からなかったので櫻庭さんに解説していただいた。





 というわけで、どうやらInvalidationListenerとChangeListenerの明確な違いは以下の所にある模様。

//DoublePropertyBaseのmakrInvalidとgetメソッド
    private void markInvalid() {
        if (valid) {//←ここと
            valid = false;//←ここと
            invalidated();
            fireValueChangedEvent();
        }
    }

    @Override
    public double get() {
        valid = true;//←これ
        return observable == null ? value : observable.get();
    }




 つまり、getを呼び出さない限り、2回目以降のfireValueChangedEventは呼び出されない


 で、私がInvalidationListenerとChangeListenerの違いが分からなかった理由は次のコード。


//ExpressionHelper.GenericのfireValueChangedEventメソッドの一部抜粋

//InvalidationListenerの呼び出し
for (int i = 0; i < curInvalidationSize; i++) {
  curInvalidationList[i].invalidated(observable);
}

//ChangeListenerの呼び出し
if (curChangeSize > 0) {
  final T oldValue = currentValue;//currentValueはフィールド変数

  //***↓ここでvalidがTrueになる**
  currentValue = observable.getValue();

  final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
  if (changed) {//値が変更されたときだけ呼び出す
    for (int i = 0; i < curChangeSize; i++) {
      curChangeList[i].changed(observable, oldValue, currentValue);
    }
  }
}

 櫻庭さんの言うChangeListenerを登録しているからInvalidationListenerも呼び出されるというのは、ChangeListenerを呼び出す前にgetを呼び出すからということみたい。

 違いが細けぇっ( ゚д゚)

 ということは、InvalidationListenerのみの場合でも、毎回getをしてしまえば二つの呼び出しに差がなくなる。テストするときなんかにSystem.out.println(get)なんてやっていると私のように何の違いがあるのかさっぱり分からん!となる。「変化した」というフラグだけ残しておいて、実際に処理するのは描画直前とか、そういう使い方をしたい場合はInvalidListenerの方が効率的という事の模様。
 ちなみに、InvalidationListenerもしくはChangeListenerのどちらか一つしかないような場合は、GenericじゃなくてSingle〜っていう名前のクラスで特別に場合分けされている。一つのリスナしか登録しないなんて場面は多々あるから、合理的だね。


ObservableListとInvalidationListenerとListChangeListener

 JavaFXで使う機会の多いのがFXCollections.observable(Array)Listかな。observableListでは、渡されたListをラップするOvservableListを返す。従って、渡したリストの中身を変更すると、ObservableListの中身も変わる。作られるObservableListの種類は、Listがランダムアクセスに対応しているかどうかで場合分けされている。
 さて、ObservableListの変更がリスナに通知されるのはやっぱり、fireValueChangedEventメソッドだ。ただし、今回はListListenerHelperというクラスのfireValueChangedEventメソッドだが。あと、追加削除等の変更から、実際に通知を行うところまでソースコードを読もうとすると結構長い道のりを辿る。割と面倒くさかったけど、ざっくりというと、配列のいったいどこからどこまでが変更されたのかを記録するクラス(ListChangeBuilder)があって、そいつにここ変更したよとか、終わったよと通知することで、今までの変更を勝手に処理してくれてChangeを作成し、fireValueChangedEventを呼び出してくれる。で、そのfireValueChangedEventの中身を以下に示す。

for (int i = 0; i < curInvalidationSize; i++) {
   curInvalidationList[i].invalidated(change.getList());
}
for (int i = 0; i < curChangeSize; i++) {
    change.reset();
    curChangeList[i].onChanged(change);
}

 

 結論としてはListChangeListener使っとけって話ですね。change.reset()は何もしないか、単に内部のカウンタを-1にセットするだけなので重たい処理ではありません。なので、色々情報取得できるListChangeListenerを使っておけば何の問題もないと言うことですね。普通のObservableとどっちも同じリスナで監視したいときなんかはInvalidationListenerの方が良いのかな?
 あと、注意点としてはfireValueChangedEventはaddやaddAllを呼び出すたびに実行される。中身のArrayListは十分な容量取ってるから一個一個追加していってもメモリ的に無駄がないはずと思っていても、結構無駄が多い。基本的には、他のArrayListや配列にいったん保存しておいてから、addAllで一発で全部追加するのが効率が良さそうだ。(個人的には、ObjectListenerにbeginChangeとendChageを追加してくれれば良いと思ってる。)



Binding

 Bindingって何となく使ってるけど、いったい何で変化を検出してるのか、いつ計算するのか、GCの動きとかどうなってるのかよく理解してないよね。
 まず、イベントの検出の仕方はInvalidationListenerを使っている。つまり、値が変化したかどうかは見ていない。さて、気になるメモリ管理だが、このInvalidationListenerではWeakReferenceを用いてインスタンスの保持をしている。つまり、Bindしている状態であっても、GCの動作を阻害しない

//BindingHelperObserverの一部抜粋
private final WeakReference<Binding<?>> ref;
public void invalidated(Observable observable) {
  final Binding<?> binding = ref.get();
  if (binding == null) {
     observable.removeListener(this);
  } else {
     binding.invalidate();
  }
}




 refの中にバインドされている対象が入っている。(=refの内容がobservableの値によって変化する。) バインドされている対象がGCで消されてしまったら、自動的にリスナの登録を解除してくれる。つまり、Bindしたものに関しては基本的に自分でunbindしなくても大丈夫と言うことだね。

 で、observableの内容が変化した(可能性がある)場合には、上のソースコードで見て分かるように、invalidateメソッドを呼び出す。これが何をするかというと、変更されたというフラグを立てるだけで、値の再計算は実行されない。実際に値が再計算されるのはgetメソッドを呼び出したときになっている。

//DoubleBindingのgetメソッド
    @Override
    public final double get() {
        if (!valid) {//変化フラグが立っているかどうか
            value = computeValue();//値の再計算及び保持
            valid = true;//フラグを消す
        }
        return value;
    }

 実際にgetメソッドを呼び出さない限りはcomputeValueは呼び出されないので、無駄な計算コストを支払うことはない。つまり、いつgetを呼び出すのかということが重要になってくる。変更されるたびに必要もないのにとりあえずgetしていると、無駄な計算コストを支払うことになる。描画直前とか、本当に必要なときだけ呼び出すようにすると良さそう。


 JavaFXの何かよく分からんな〜と放置していたブラックボックスがだいぶん見えてきました。やっぱり、中の挙動が分かってる方が、プログラム書いてるときも安心できるよね。

JavaFXのChartでMinorTickでも線を描く

 JavaFXのChartって使いにくいなこんちきしょう。グラフ内部のマウス座標とか、全然取れないし 。(グラフ内部というのは、本当にグラフの表示領域内部だけでの座標ね。あと、根性で取れるようにしたよ、ちきしょう。Javaのバージョンアップとかで、もしかしたら駄目になるかも。)


 そんななか、私を丸1日悩ませたのは、MinorTickにおける背景の線を描けないこと。ちょっと特殊な目的でMinorTickでも線が欲しい。(下図参照。通常では線が描画されるのはMajorTickだけ。しかし、MajorTickの数は増やしたくない。)
 

 


 まず、前提条件として、AxisはValueAxisを拡張したクラス(NumberAxis)を利用するとします。また、たぶんどのChartでも同じように出来ると思いますが、今回はLineChartを対象とします。

 最初に、MinorTickの線を引くには、MinorTickがどこに表示されているのか知る必要がある。それはValueAxisのcalculateMinorTickMarksで取得が可能だ。
 しかし、calculateMinorTickMarksは可視性がprotectedで見えない。さらに困ったことに、NumberAxisはfinal宣言されてて、可視性の上書きも出来ない。
 というわけで、こうしました↓。

package javafx.scene.chart;//←これ
import java.util.List;

public class CallProtectedMethod{
  
  public static <T extends Number> List<T> calculateMinorTickMarks(ValueAxis<T> axis)
  {
    return axis.calculateMinorTickMarks();
  }
}

 javafx.scene.chartパッケージ下で新たにクラスを作成して、それを介して無理矢理呼び出します。
 今回はわかりやすい名前にしたけど、絶対に衝突しないような名前にしとくと良いでしょう。



 で、次に背景に線を引く処理ですが、困ったことにXYChartで実際に線を引いている背景の領域というのは取得できない。一応、内部の構造を理解した上で、子ノードを辿り、取得は出来るが、その座標系は表内部だけの座標系にされていないので、細かい調整がよく分からん。ていうか、本来ブラックボックスのはずの内部処理を理解した上での実装だから、バージョンアップでちょっと処理を書き換えられたらすぐに使えなくなる。それはしたくない。
 そこで、妥協としてグラフの値を描画する領域(Group)において、グラフの線を描く前に描画をします。線の更新処理はlayoutPlotChildrenの処理に追加することで実装します。layoutChildrenだと、Axisの大きさがまだ設定されていないので上手くいかない。いや〜、layoutPlotChildrenがfinal宣言されてなくて良かった。
 グラフの表示領域の大きさと、Axisの長さが同じに設定されているので、線の長さはAxisの長さと同じにすれば良い。次に、線の位置はAxisのgetDisplayPositionから取得できる。以上をまとめると次のようになる。

package nodamushi.jfx.chart;

import java.util.*;

import javafx.collections.ObservableList;
import javafx.scene.chart.*;
import javafx.scene.shape.*;

public class LineChart2<X extends Number,Y extends Number> extends LineChart<X, Y>{
  private Path xMinorPath,yMinorPath;
  
  public LineChart2(ValueAxis<X> xAxis,ValueAxis<Y> yAxis){
    super(xAxis,yAxis);
    init();
  }
  
  public LineChart2(ValueAxis<X> xAxis, ValueAxis<Y> yAxis,
      ObservableList<javafx.scene.chart.XYChart.Series<X, Y>> data){
    super(xAxis, yAxis, data);
    init();
  }
  
  private void init(){
    Path xMinorPath = new Path();
    Path yMinorPath = new Path();
    this.xMinorPath=xMinorPath;
    this.yMinorPath=yMinorPath;
    //グラフよりも先に描画されるように
    //0に挿入します
    getPlotChildren().addAll(0, Arrays.asList(xMinorPath,yMinorPath));
    //CSSの設定
    xMinorPath.getStyleClass().add("chart-vertical-grid-lines");
    yMinorPath.getStyleClass().add("chart-horizontal-grid-lines");

  }
  
  @SuppressWarnings({ "unchecked", "rawtypes" })
  @Override
  protected void layoutPlotChildren(){
    super.layoutPlotChildren();
    final ValueAxis 
    xa = (ValueAxis)getXAxis(),
    ya = (ValueAxis)getYAxis();
    final Path 
    xpath = this.xMinorPath,
    ypath = this.yMinorPath;
    final List<Number> 
    xminor = 
    CallProtectedMethod.calculateMinorTickMarks(xa),
    yminor = 
    CallProtectedMethod.calculateMinorTickMarks(ya);
    
    xpath.getElements().clear();
    double h = ya.getHeight();
    for(Number o :xminor){
      double display = xa.getDisplayPosition(o);
      xpath.getElements().addAll(
          new MoveTo(display,0),
          new LineTo(display,h)
          );
    }
    
    ypath.getElements().clear();
    double w = xa.getWidth();
    for(Number o :yminor){
      double display = ya.getDisplayPosition(o);
      ypath.getElements().addAll(
          new MoveTo(0,display),
          new LineTo(w,display)
          );
    }
  }
}



 で、実際に使ってみると以下のように描画されました。問題なさそうですね。

 もっと良い方法があるという場合は是非教えてください。