JavaFX ChartFx3d

A Proof of Concept

This is a very first version of something I need and fancied playing around with.

The current version just shows a BarChart in 3D, allowing the user to rotate the chart around all axes by dragging the mouse over the graphic.

A screenshot of the barchart when it is first shown A screenshot of the barchart rotated about the y-axis A screenshot of the barchart rotated about the x-axis

You supply the data to be shown using the same API as JavaFX 8 BarChart.

My purpose in posting this code is to hear from anyone who

Send your feedback to info (at) i2Brain (dot) com

My todo-list: (pretty much in the order I think they need doing)

  1. Allow Strings, not just Numbers. (Currently, only List<XYChart.Series<Number, Number>> is allowed, not List<XYChart.Series<String, Number>>)
  2. Get the axes (length etc) to depend on the actual data
  3. Put the code into Git or similar
  4. Add a control (keystroke? button? both?) to return the graphic to its original orientation (animated)
  5. Make each plane which contains 2 axes a coloured rectangle, which has to be transparent and is therefore possible in 8u60, I hope
  6. Make the bars prettier (unsure how to do that)
  7. Make many of the constants, e.g. in the Axes class into public properties so the caller can change and/or bind them
  8. Animate the chart when it is first shown (cute, but nice)
  9. Clean the code more
  10. Implement other types of chart (bar, bubble, scatter...)

The code

You can download the 5 zipped .java files here.

Here's the file which contains main and start. It gets the data from any old class, in this case ChartDemoData, but yours could read from a DB or whatever.

The main point is that its method getData() returns a List<Series<Number, Number>>.

This list is then passed into BarChart3D's createBarChart3DScene(data) and then BarChart3D does all the work for you.

package application;

import java.util.List;

import com.i2brain.chart3d.BarChart3D;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.chart.XYChart.Series;
import javafx.stage.Stage;

/**
 */
public class Chart3DDemoMain extends Application {

	@Override
	public void start(Stage primaryStage) {

		List<Series<Number, Number>> data = ChartDemoData.getData();
		
		Scene scene = new BarChart3D().createBarChart3DScene(data);

		primaryStage.setTitle("Chart3D Demo");
		primaryStage.setScene(scene);
		primaryStage.show();
	}

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

As explained above, this is the class you replace. It has one method which should return your data in the List.

package application;

import java.util.ArrayList;
import java.util.List;

import javafx.scene.chart.XYChart;

/** A simple class whose only method fills a List with data.
 *  So the method could read a DB or whatever.
 *  It's separated from main to fulfil SRP.
 *  
 * @author JohnDev
 *
 */
public class ChartDemoData {

	static List<XYChart.Series<Number, Number>> getData() {
		
		List<XYChart.Series<Number, Number>> dataList = new ArrayList<>();

		XYChart.Series<Number, Number> series1 = new XYChart.Series<>();
		series1.setName("2003");	// Z-Axis
		series1.getData().add(new XYChart.Data<>(1, 200));
		series1.getData().add(new XYChart.Data<>(2, 100));
		series1.getData().add(new XYChart.Data<>(3, 150));
		series1.getData().add(new XYChart.Data<>(4, 70));
		dataList.add(series1);

		XYChart.Series<Number, Number> series2 = new XYChart.Series<>();
		series2.setName("2004");	// Z-Axis
		series2.getData().add(new XYChart.Data<>(1, 20));
		series2.getData().add(new XYChart.Data<>(2, 130));
		series2.getData().add(new XYChart.Data<>(3, 50));
		series2.getData().add(new XYChart.Data<>(4, 150));
		dataList.add(series2);

		XYChart.Series<Number, Number> series3 = new XYChart.Series<>();
		series3.setName("2005");	// Z-Axis
		series3.getData().add(new XYChart.Data<>(1, 70));
		series3.getData().add(new XYChart.Data<>(2, 30));
		series3.getData().add(new XYChart.Data<>(3, 50));
		series3.getData().add(new XYChart.Data<>(4, 150));
		dataList.add(series3);
		
		return dataList;
	}

}

This is your gateway to BarChart3D. You simply call its single public method, passing in the data in a List. That's it.

Later versions will alow you to influence further aspects of the chart.



package com.i2brain.chart3d;

import java.util.Collection;
import java.util.List;

import javafx.geometry.Point3D;
import javafx.scene.Camera;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.SceneAntialiasing;
import javafx.scene.chart.XYChart;
import javafx.scene.chart.XYChart.Data;
import javafx.scene.chart.XYChart.Series;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Box;
import javafx.scene.transform.Rotate;
import application.ChartDemoData;

/** What the user calls in order to create a BarChart3D.
 * 
 * @author JohnDev
 */
public class BarChart3D {

	// TODO make these into properties. Use API of ChartFX.
	private static final int xBarSeparation = 50;
	private static final int zBarSeparation = 50;
	private static final int barDepth = 10;
	private static final int barWidth = 10;
	
	private final double sceneWidth = 600;
	private final double sceneHeight = 600;
	private static double mouseXold = 0;
	private static double mouseYold = 0;
	private static final double rotateModifier = 10;
	
	private final Color[] barColors = { Color.ORANGERED, Color.YELLOW, Color.BLUE, Color.GREEN };	// TODO use CSS:

	/** Sets up a Scene, the Camera and Children for a BarChar3D.
	 * 
	 * @param data	TODO Allow List<String, Number> to match ChartXY's API
	 * @return
	 */
	public Scene createBarChart3DScene(List<Series<Number,Number>> data) {
		Group group = new Group();
		Scene scene = new Scene(group, sceneWidth, sceneHeight, true, SceneAntialiasing.BALANCED);
		scene.setFill(Color.WHITE);
		Camera camera = createCamera();
		scene.setCamera(camera);

		group.getChildren().add(createChildren(data));

		scene.addEventHandler(MouseEvent.ANY, event -> {
			mouseHandler(group, event);
		});
		addMouseWheelControl(scene, group);
		return scene;
	}

	public Group createChildren(List<Series<Number,Number>> data) {
		final Group bars = createValueBars(data);
		// TODO pass the names of the Axes in as a parameter
		Group primitiveGroup = new Group(new Axes().createAxes("Month", "Income", "Year"), bars);
		return primitiveGroup;
	}

	private Group createValueBars(Collection<Series<Number, Number>> collection) {
		Group bars = new Group();
		int z = 0;
		for (XYChart.Series<Number, Number> series : collection) {
			// TODO use the name in legend
			String zName = series.getName();
			for (Data<Number, Number> data : series.getData()) {
				double x = data.XValueProperty().get().doubleValue();
				double val = data.YValueProperty().get().doubleValue();

				addValueBar(bars, x, z, val);
			}
			z++;
		}
		return bars;
	}

	private void addValueBar(Group bars, double x, int z, double val) {
		Color color = barColors[z];
		bars.getChildren().add(createBar(new Point3D(x * xBarSeparation, 0, -z * zBarSeparation), val, color));
	}

	private static Box createBar(Point3D pos, double height, Color color) {
		Box box = new Box(barWidth, height, barDepth);
		final PhongMaterial material = new PhongMaterial();
		material.setDiffuseColor(color);
		material.setSpecularColor(color.brighter());
		box.setMaterial(material);

//		bar.getStyleClass().addAll("chart-bar", "series" + seriesIndex, "data" + itemIndex,series.defaultColorStyleClass); From class BarChart

		box.setTranslateX(pos.getX());
		box.setTranslateY(pos.getY() - height / 2);
		box.setTranslateZ(pos.getZ());
		Tooltip.install(box, new Tooltip("x = " + pos.getX() + ", height = " + height + ", z = " + -pos.getZ()));
		return box;
	}

	private static void mouseHandler(Group sceneRoot, MouseEvent event) {
		if (event.getEventType() == MouseEvent.MOUSE_PRESSED || event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
			mousePressedOrMoved(sceneRoot, event);
		}
	}

	private static void mousePressedOrMoved(Group sceneRoot, MouseEvent event) {
		Rotate xRotate = new Rotate(0, 0, 0, 0, Rotate.X_AXIS);
		Rotate yRotate = new Rotate(0, 0, 0, 0, Rotate.Y_AXIS);
		sceneRoot.getTransforms().addAll(xRotate, yRotate);
		double mouseXnew = event.getSceneX();
		double mouseYnew = event.getSceneY();
		if (event.getEventType() == MouseEvent.MOUSE_DRAGGED) {
			double pitchRotate = xRotate.getAngle() + (mouseYnew - mouseYold) / rotateModifier;
			xRotate.setAngle(pitchRotate);
			double yawRotate = yRotate.getAngle() - (mouseXnew - mouseXold) / rotateModifier;
			yRotate.setAngle(yawRotate);
		}
		mouseXold = mouseXnew;
		mouseYold = mouseYnew;
	}

	public static void addMouseWheelControl(Scene scene, Group group) {
		scene.setOnScroll(event -> {
			if (event.isShiftDown()) {
				group.setTranslateX(group.getTranslateX() + event.getDeltaX());
			} else {
				if (event.isControlDown()) {
					group.setTranslateZ(group.getTranslateZ() - event.getDeltaY());
				} else {
					group.setTranslateY(group.getTranslateY() + event.getDeltaY());
				}
			}
		});

	}

	private static Camera createCamera() {
		Camera camera = new PerspectiveCamera(true);
		camera.setNearClip(0.1);
		camera.setFarClip(10000.0);
		camera.setTranslateX(400);
		camera.setTranslateY(-200);
		camera.setRotationAxis(Rotate.X_AXIS);
		camera.setRotate(20);
		camera.setRotationAxis(Rotate.Y_AXIS);
		camera.setRotate(-20);
		camera.setTranslateZ(-1200);

		return camera;
	}

}

And here's a class which is currently solely used by BarChart3D.

package com.i2brain.chart3d;

import static java.lang.Math.sqrt;
import javafx.geometry.Point3D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Box;
import javafx.scene.shape.Rectangle;

import com.i2brain.fx3d.LineFactory;

public class Axes {
	final int barWidth = 2;

	/** The length of the axes (number of pixels). TODO make these into properties. They are
	 * temporarily public to allow users to change them. Cludge!*/
	public int maxX = 500, maxY = 500, maxZ = 500;

	final PhongMaterial axisMaterial = new PhongMaterial();

	/** Gap between the lines on the plane created by 2 axes. */
	private int lineSpacing = 100;
	private LineFactory lineFactory = new LineFactory();

	Node createAxes(String xLabel, String yLabel, String zLabel) {
		Group axes = new Group();
		axisMaterial.setDiffuseColor(Color.BLACK);
		axisMaterial.setSpecularColor(Color.BLACK.brighter());

		axes.getChildren().addAll(createXAxis(xLabel), createYAxis(yLabel), createZAxis(zLabel), createXYPlane(), createYZPlane(), createXZPlane());

		return axes;
	}

	private Node createXYPlane() {
		Group group = new Group();
		for (int x = 0; x <= maxX; x += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(x, 0, 0), new Point3D(x, -maxY, 0)));
		}
		for (int y = 0; y <= maxY; y += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(0, -y, 0), new Point3D(maxX, -y, 0)));
		}
		
//		group.getChildren().add(makeAxesPlane(maxX, maxY, 2, Color.LIGHTYELLOW, maxX / 2, -maxY / 2, barWidth*2));

		return group;
	}

	private static Box makeAxesPlane(int maxX, int maxY, int maxZ, Color col, double xTrans, double yTrans, double zTrans) {
		Box plane = new Box(maxX, maxY, maxZ);
		plane.setTranslateX(xTrans);
		plane.setTranslateY(yTrans);
		plane.setTranslateZ(zTrans);
		plane.setMaterial(new PhongMaterial(col));
		// TODO use transparency in 8u60: https://stackoverflow.com/questions/29308397/javafx-3d-transparency
		return plane;
	}

	private Node createYZPlane() {
		Group group = new Group();
		for (int z = 0; z <= maxZ; z += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(0, 0, -z), new Point3D(0, -maxY, -z)));
		}
		for (int y = 0; y <= maxY; y += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(0, -y, 0), new Point3D(0, -y, -maxZ)));
		}
//		group.getChildren().add(makeAxesPlane(2, maxY, maxZ, Color.ORANGE, -barWidth*2, -maxY / 2, -maxZ / 2));
		return group;
	}

	private Node createXZPlane() {
		Group group = new Group();
		for (int z = 0; z <= maxZ; z += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(0, 0, -z), new Point3D(maxX, 0, -z)));
		}
		for (int x = 0; x <= maxX; x += lineSpacing) {
			group.getChildren().add(createLine(new Point3D(x, 0, 0), new Point3D(x, 0, -maxZ)));
		}
//		group.getChildren().add(makeAxesPlane(maxX, 2, maxZ, Color.LIGHTBLUE, maxX / 2, barWidth*2, -maxZ / 2));
		return group;
	}

	private Node createLine(Point3D origin, Point3D target) {
		return lineFactory.createLine(origin, target);
	}

	/** TODO Ideally, we could expect the Chart3D to be based on the CSS for 2D Charts...
	 * @param label
	 */
	@SuppressWarnings({ "rawtypes", "unchecked" })
	private static void setStyle(Node label) {
//		label.getStyleClass().add("axis-label");	// TODO get it working: https://docs.oracle.com/javase/8/javafx/user-interface-tutorial/css-styles.htm#CIHGIAGE
//		List styleList = BarChart.getClassCssMetaData();
//		for (Object style : styleList) {
//			CssMetaData data = (CssMetaData)style;
//			System.out.println(data);
//		}
//		for (String style : new BarChart(new CategoryAxis(), new NumberAxis()).getStyleClass()) {	// chart, bar-chart
//		for (String style : new CategoryAxis().getStyleClass()) {	// axis
//			System.out.println(style);
//		}
//		String textFill = Label.getClassCssMetaData();
//		getStyle("-fx-text-fill: aqua;");
	}

	private Group createXAxis(String xLabel) {
		Group group = new Group();
		Box xAxis = new Box(maxX, barWidth, barWidth);
		xAxis.setMaterial(axisMaterial);
		xAxis.setTranslateX(maxX / 2);
		Label label = new Label(xLabel);
		label.setTranslateX(maxX / 2);
		label.setTranslateY(0);
		setStyle(label);
		label.setStyle("-fx-text-fill: aqua;");
		group.getChildren().addAll(xAxis, label, createTicksOnXAxis());
		return group;
	}

	/** TODO Very rough. Needs list of Texts, really. */
	private Node createTicksOnXAxis() {
		Group group = new Group();
		int x = 0;
		int cntTicks = 5;
		int deltaX = maxX / cntTicks ;
		// TODO breaks when deltaX < 1!
		for (int i = 0 ; i <= cntTicks ; i++) {
			Label label = new Label(String.valueOf(x));
			label.setTranslateX(x - 10); // guessed	// should be deltaX - length / 2 !
			label.setTranslateY(15); // guessed	// should be deltaX - length / 2 !
			group.getChildren().addAll(label, lineFactory.createLine(new Point3D(x, 0, 0), new Point3D(x, 10, 0)));
			System.out.println(label.getBoundsInParent());	// I need the width!
			x += deltaX;
		}
		return group;
	}

	private Group createYAxis(String yLabel) {
		Group group = new Group();
		Box yAxis = new Box(barWidth, maxY, barWidth);
		yAxis.setMaterial(axisMaterial);
		yAxis.setTranslateY(-maxY / 2);
		Label label = new Label(yLabel);
//		System.out.println("Width = " + label.getWidth());	// 0!
		label.setTranslateX(-50);	// Guessed! TODO how to do it properly?
		label.setTranslateY(-maxY / 2);
		
		label.setStyle("-fx-text-fill: aqua;");
		group.getChildren().addAll(yAxis, label, createTicksOnYAxis());
		return group;
	}

	private Node createTicksOnYAxis() {
		Group group = new Group();
		int y = 0;
		int cntTicks = 5;
		int deltaY = maxY / cntTicks ;
		// TODO breaks when deltaY < 1!
		for (int i = 0 ; i <= cntTicks ; i++) {
			Label label = new Label(String.valueOf(y));
			label.setTranslateX(-40); // guessed
			label.setTranslateY(-y - 10); // guessed
			group.getChildren().addAll(label, lineFactory.createLine(new Point3D(0, -y, 0), new Point3D(-10, -y, 0)));
			y += deltaY;
		}
		return group;
	}

	private Group createZAxis(String zLabel) {
		Group group = new Group();
		Box zAxis = new Box(barWidth, barWidth, maxZ);
		zAxis.setMaterial(axisMaterial);
		zAxis.setTranslateZ(-maxZ / 2);
		Label label = new Label(zLabel);
		label.setTranslateX(-50);	// Guessed! TODO how to do it properly?
		label.setTranslateZ(-maxZ / 2);
		label.setStyle("-fx-text-fill: aqua;");
		group.getChildren().addAll(zAxis, label, createTicksOnZAxis());
		return group;
	}

	private Node createTicksOnZAxis() {
		Group group = new Group();
		int z = 0;
		int cntTicks = 5;
		int deltaZ = maxZ / cntTicks ;
		// TODO breaks when deltaZ < 1!
		for (int i = 0 ; i <= cntTicks ; i++) {
			Label label = new Label(String.valueOf(z));
			label.setTranslateX(-40); // guessed
			label.setTranslateZ(-z - 10); // guessed
			group.getChildren().addAll(label, lineFactory.createLine(new Point3D(0, 0, -z), new Point3D(-10, 0, -z)));
			z += deltaZ;
		}
		return group;
	}
}

This last file is the first which presented itself as a useful utility class. (So it's in a separate package.) It creates a line between the coordinates which are passed to the method createLine(Point3D origin, Point3D target).



package com.i2brain.fx3d;

import javafx.geometry.Point3D;
import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Box;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Translate;

/** Creates a line between the coordinates which are passed to the method createLine.
*/
public class LineFactory {

	/** The material for the lines parallel to the axes. */
	private PhongMaterial lineMaterial = new PhongMaterial();

	/** Sets a default grey material for the line.
	 */
	public LineFactory() {
		lineMaterial.setDiffuseColor(Color.GREY);
		lineMaterial.setSpecularColor(Color.GREY.brighter());
	}
	
	/** The caller may change the material. */
	public void setMaterial(PhongMaterial lineMaterial) {
		this.lineMaterial = lineMaterial;
	}
	
	/** Based on https://netzwerg.ch/blog/2015/03/22/javafx-3d-line/
	 */
	public Node createLine(Point3D origin, Point3D target) {
	    Point3D yAxis = new Point3D(0, 1, 0);
	    Point3D diff = target.subtract(origin);
	    double height = diff.magnitude();

	    Point3D mid = target.midpoint(origin);
	    Translate moveToMidpoint = new Translate(mid.getX(), mid.getY(), mid.getZ());

	    Point3D axisOfRotation = diff.crossProduct(yAxis);
	    double angle = Math.acos(diff.normalize().dotProduct(yAxis));
	    Rotate rotateAroundCenter = new Rotate(-Math.toDegrees(angle), axisOfRotation);

	    Box line = new Box(1, height, 1);
	    line.setMaterial(lineMaterial);

	    line.getTransforms().addAll(moveToMidpoint, rotateAroundCenter);

	    return line;
	}


}