mirror of
https://github.com/csharpee/Map-Projections.git
synced 2025-12-21 00:00:16 -05:00
I made a program to automatically generate HTML snippets to fill the projection page in my GH-Pages branch. It also automatically produces nice pictures to go with it. I also threw in a nifty new transverse feature, primarily for azimuthal maps. And I now represent properties and types as enums. And I fixed a bug in 2 point equidistant. And fixed some unicode issues.
534 lines
17 KiB
Java
534 lines
17 KiB
Java
/**
|
|
*
|
|
*/
|
|
package apps;
|
|
|
|
import java.io.File;
|
|
import java.lang.reflect.Array;
|
|
import java.util.function.BiConsumer;
|
|
import java.util.function.BooleanSupplier;
|
|
import java.util.function.Consumer;
|
|
import java.util.function.DoubleUnaryOperator;
|
|
|
|
import dialogs.ProgressBarDialog;
|
|
import javafx.application.Application;
|
|
import javafx.application.Platform;
|
|
import javafx.collections.FXCollections;
|
|
import javafx.geometry.Pos;
|
|
import javafx.scene.Node;
|
|
import javafx.scene.Scene;
|
|
import javafx.scene.control.Alert;
|
|
import javafx.scene.control.Button;
|
|
import javafx.scene.control.ComboBox;
|
|
import javafx.scene.control.Control;
|
|
import javafx.scene.control.Label;
|
|
import javafx.scene.control.MenuButton;
|
|
import javafx.scene.control.MenuItem;
|
|
import javafx.scene.control.Slider;
|
|
import javafx.scene.control.Spinner;
|
|
import javafx.scene.control.SpinnerValueFactory;
|
|
import javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory;
|
|
import javafx.scene.control.Tooltip;
|
|
import javafx.scene.input.KeyCode;
|
|
import javafx.scene.input.KeyCodeCombination;
|
|
import javafx.scene.input.KeyCombination;
|
|
import javafx.scene.input.KeyEvent;
|
|
import javafx.scene.layout.ColumnConstraints;
|
|
import javafx.scene.layout.GridPane;
|
|
import javafx.scene.layout.HBox;
|
|
import javafx.scene.layout.Priority;
|
|
import javafx.scene.layout.Region;
|
|
import javafx.scene.layout.StackPane;
|
|
import javafx.scene.layout.VBox;
|
|
import javafx.scene.text.Text;
|
|
import javafx.stage.FileChooser;
|
|
import javafx.stage.Stage;
|
|
import maps.Cylindrical;
|
|
import maps.Projection;
|
|
import utils.Procedure;
|
|
|
|
|
|
/**
|
|
* A base class for all GUI applications that deal with maps
|
|
*
|
|
* @author jkunimune
|
|
*/
|
|
public abstract class MapApplication extends Application {
|
|
|
|
protected static final int GUI_WIDTH = 350;
|
|
protected static final int IMG_WIDTH = 500;
|
|
|
|
private static final KeyCombination ctrlO = new KeyCodeCombination(KeyCode.O, KeyCodeCombination.CONTROL_DOWN);
|
|
private static final KeyCombination ctrlS = new KeyCodeCombination(KeyCode.S, KeyCodeCombination.CONTROL_DOWN);
|
|
private static final KeyCombination ctrlEnter = new KeyCodeCombination(KeyCode.ENTER, KeyCodeCombination.CONTROL_DOWN);
|
|
|
|
private static final String[] ASPECT_NAMES = { "Standard", "Transverse", "Cassini",
|
|
"Center of Mass", "Jerusalem", "Point Nemo", "Longest Line", "Longest Line Transverse",
|
|
"Cylindrical", "Conic", "Tetrahedral", "Quincuncial", "Antipode", "Random" };
|
|
private static final double[][] ASPECT_VALS = { //the aspect presets (in degrees)
|
|
{ 90., 0., 0., 29.98, 31.78, 48.88, -28.52,-46.4883,-35., -17., 47., 60. },
|
|
{ 0., 0., 90., 31.13, 35.22, 56.61, 141.45, 16.5305,-13.61, -7.,-173., -6. },
|
|
{ 0., 0.,-90.,-32., -35., -45., 161.5, 137., 145., 151., 138.,-100. } };
|
|
|
|
|
|
final private String name;
|
|
private Stage root;
|
|
private ComboBox<Projection> projectionChooser;
|
|
private GridPane paramGrid;
|
|
private Label[] paramLabels;
|
|
private Slider[] paramSliders;
|
|
private Spinner<Double>[] paramSpinners;
|
|
private double[] currentParams;
|
|
|
|
private Flag isChanging = new Flag();
|
|
private Flag suppressListeners = new Flag();
|
|
|
|
|
|
|
|
public MapApplication(String name) {
|
|
super();
|
|
this.name = name;
|
|
}
|
|
|
|
|
|
@Override
|
|
public void start(Stage root) {
|
|
this.root = root;
|
|
this.root.setTitle(this.name);
|
|
this.root.setScene(new Scene(new StackPane(makeWidgets())));
|
|
this.root.show();
|
|
|
|
this.suppressListeners.set();
|
|
this.projectionChooser.setValue(Cylindrical.MERCATOR);
|
|
this.suppressListeners.clear();
|
|
}
|
|
|
|
|
|
protected abstract Node makeWidgets();
|
|
|
|
|
|
/**
|
|
* Create a set of widgets to select an input image
|
|
* @param allowedExtensions The List of possible file types for the input
|
|
* @param setInput The method to be called when an input file is loaded.
|
|
* This method will be called from a nongui thread.
|
|
* @return the full formatted Region
|
|
*/
|
|
protected Region buildInputSelector(
|
|
FileChooser.ExtensionFilter[] allowedExtensions,
|
|
FileChooser.ExtensionFilter defaultExtension,
|
|
Consumer<File> inputSetter) {
|
|
final Label label = new Label("Current input:");
|
|
final Text inputLabel = new Text("None");
|
|
|
|
final FileChooser inputChooser = new FileChooser(); //TODO: remember last directory
|
|
inputChooser.setInitialDirectory(new File("input"));
|
|
inputChooser.setTitle("Choose an input map");
|
|
inputChooser.getExtensionFilters().addAll(allowedExtensions);
|
|
inputChooser.setSelectedExtensionFilter(defaultExtension);
|
|
|
|
final Button loadButton = new Button("Choose input...");
|
|
loadButton.setTooltip(new Tooltip(
|
|
"Choose the image to determine the style of your map"));
|
|
loadButton.setOnAction((event) -> {
|
|
File file;
|
|
try {
|
|
file = inputChooser.showOpenDialog(root);
|
|
} catch (IllegalArgumentException e) {
|
|
inputChooser.setInitialDirectory(new File("."));
|
|
file = inputChooser.showOpenDialog(root);
|
|
}
|
|
if (file != null) {
|
|
final File f = file;
|
|
trigger(loadButton, () -> inputSetter.accept(f));
|
|
inputLabel.setText(f.getName());
|
|
}
|
|
});
|
|
loadButton.setTooltip(new Tooltip(
|
|
"Change the input image"));
|
|
root.addEventHandler(KeyEvent.KEY_PRESSED, (event) -> { // ctrl-O opens
|
|
if (ctrlO.match(event)) {
|
|
loadButton.requestFocus();
|
|
loadButton.fire();
|
|
}
|
|
});
|
|
|
|
VBox output = new VBox(5, new HBox(3, label, inputLabel), loadButton);
|
|
output.setAlignment(Pos.CENTER);
|
|
return output;
|
|
}
|
|
|
|
|
|
/**
|
|
* Create a set of widgets to choose a Projection
|
|
* @param projections The Projections from which the user may choose
|
|
* @param defProj The default projection, before the user chooses anything
|
|
* @return the full formatted Region
|
|
*/
|
|
protected Region buildProjectionSelector(Projection[] projections, Procedure projectionSetter) {
|
|
final Label label = new Label("Projection:");
|
|
projectionChooser =
|
|
new ComboBox<Projection>(FXCollections.observableArrayList(projections));
|
|
projectionChooser.setPrefWidth(210);
|
|
|
|
final Text description = new Text();
|
|
description.setWrappingWidth(GUI_WIDTH);
|
|
|
|
projectionChooser.setOnAction((event) -> {
|
|
final boolean suppressedListeners = suppressListeners.isSet(); //save this value, because
|
|
description.setText(projectionChooser.getValue().getDescription()); //revealParameters
|
|
revealParameters(projectionChooser.getValue()); //clears suppressListeners. That's fine,
|
|
if (!suppressedListeners) //because suppressListeners is only needed here for that one case.
|
|
projectionSetter.execute();
|
|
});
|
|
|
|
return new VBox(5, new HBox(3, label, projectionChooser), description);
|
|
}
|
|
|
|
|
|
/**
|
|
* Create a set of widgets to choose an aspect either from a preset or numbers
|
|
* Also bind aspectArr to the sliders
|
|
* @return the full formatted Region
|
|
*/
|
|
protected Region buildAspectSelector(double[] aspectArr, Procedure aspectSetter) {
|
|
final MenuButton presetChooser = new MenuButton("Aspect Presets");
|
|
presetChooser.setTooltip(new Tooltip(
|
|
"Set the aspect sliders based on a preset"));
|
|
|
|
final String[] labels = { "Latitude:", "Longitude:", "Ctr. Meridian:" };
|
|
final Slider[] sliders = new Slider[] {
|
|
new Slider(-90, 90, 0.), //TODO: can we call setAspectByPreset("Standard") instead of this?
|
|
new Slider(-180, 180, 0.),
|
|
new Slider(-180, 180, 0.) };
|
|
final Spinner<Double> spin0 = new Spinner<Double>(-90, 90, 0.); //yes, this is awkward. Java gets weird about arrays with generic types
|
|
@SuppressWarnings("unchecked")
|
|
final Spinner<Double>[] spinners = (Spinner<Double>[]) Array.newInstance(spin0.getClass(), 3);
|
|
spinners[0] = spin0;
|
|
spinners[1] = new Spinner<Double>(-180, 180, 0.);
|
|
spinners[2] = new Spinner<Double>(-180, 180, 0.);
|
|
|
|
for (int i = 0; i < 3; i ++) {
|
|
aspectArr[i] = Math.toRadians(sliders[i].getValue());
|
|
link(sliders[i], spinners[i], i, aspectArr, Math::toRadians,
|
|
aspectSetter, isChanging, suppressListeners);
|
|
}
|
|
setAspectByPreset("Standard", sliders, spinners);
|
|
|
|
for (String preset: ASPECT_NAMES) {
|
|
MenuItem m = new MenuItem(preset);
|
|
m.setOnAction((event) -> {
|
|
setAspectByPreset(((MenuItem) event.getSource()).getText(),
|
|
sliders, spinners);
|
|
for (int i = 0; i < 3; i ++)
|
|
aspectArr[i] = Math.toRadians(sliders[i].getValue());
|
|
if (!suppressListeners.isSet())
|
|
aspectSetter.execute();
|
|
});
|
|
presetChooser.getItems().add(m);
|
|
}
|
|
|
|
final GridPane grid = new GridPane();
|
|
grid.setVgap(5);
|
|
grid.setHgap(3);
|
|
grid.getColumnConstraints().addAll(
|
|
new ColumnConstraints(92,Control.USE_COMPUTED_SIZE,Control.USE_COMPUTED_SIZE),
|
|
new ColumnConstraints(), new ColumnConstraints(92));
|
|
for (int i = 0; i < 3; i ++) {
|
|
GridPane.setHgrow(sliders[i], Priority.ALWAYS);
|
|
sliders[i].setTooltip(new Tooltip("Change the aspect of the map"));
|
|
spinners[i].setTooltip(new Tooltip("Change the aspect of the map"));
|
|
spinners[i].setEditable(true);
|
|
grid.addRow(i, new Label(labels[i]), sliders[i], spinners[i]);
|
|
}
|
|
|
|
VBox all = new VBox(5, presetChooser, grid);
|
|
all.setAlignment(Pos.CENTER);
|
|
return all;
|
|
}
|
|
|
|
|
|
/**
|
|
* Create a grid of sliders and spinners not unlike the aspectSelector
|
|
* @param parameterSetter The function to execute when the parameters change
|
|
* @return the full formatted Region
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
protected Node buildParameterSelector(Procedure parameterSetter) {
|
|
currentParams = new double[4];
|
|
paramLabels = new Label[4];
|
|
paramSliders = new Slider[4]; // I don't think any projection has more than four parameters
|
|
final Spinner<Double> spin0 = new Spinner<Double>(0.,0.,0.); //yes, this is awkward. Java gets weird about arrays with generic types
|
|
paramSpinners = (Spinner<Double>[]) Array.newInstance(spin0.getClass(), 4);
|
|
paramSpinners[0] = spin0;
|
|
|
|
for (int i = 0; i < 4; i ++) {
|
|
paramLabels[i] = new Label();
|
|
paramSliders[i] = new Slider();
|
|
if (i != 0)
|
|
paramSpinners[i] = new Spinner<Double>(0.,0.,0.);
|
|
link(paramSliders[i], paramSpinners[i], i, currentParams, (d)->d, parameterSetter, isChanging, suppressListeners);
|
|
}
|
|
|
|
for (int i = 0; i < 4; i ++) {
|
|
GridPane.setHgrow(paramSliders[i], Priority.ALWAYS);
|
|
paramSpinners[i].setEditable(true);
|
|
}
|
|
|
|
paramGrid = new GridPane();
|
|
paramGrid.setVgap(5);
|
|
paramGrid.setHgap(3);
|
|
paramGrid.getColumnConstraints().addAll(
|
|
new ColumnConstraints(92,Control.USE_COMPUTED_SIZE,Control.USE_COMPUTED_SIZE),
|
|
new ColumnConstraints(), new ColumnConstraints(92));
|
|
return paramGrid;
|
|
}
|
|
|
|
|
|
/**
|
|
* Create a default button that will update the map
|
|
* @return the button
|
|
*/
|
|
protected Button buildUpdateButton(Runnable mapUpdater) {
|
|
final Button updateButton = new Button("Update map");
|
|
updateButton.setOnAction((event) -> {
|
|
trigger(updateButton, mapUpdater);
|
|
});
|
|
updateButton.setTooltip(new Tooltip(
|
|
"Update the current map with your parameters"));
|
|
|
|
updateButton.setDefaultButton(true);
|
|
root.addEventHandler(KeyEvent.KEY_PRESSED, (event) -> {
|
|
if (ctrlEnter.match(event)) {
|
|
updateButton.requestFocus();
|
|
updateButton.fire();
|
|
}
|
|
});
|
|
|
|
return updateButton;
|
|
}
|
|
|
|
|
|
/**
|
|
* Build a button that will save something
|
|
* @param bindCtrlS Should ctrl+S trigger this button?
|
|
* @param savee The name of the thing being saved
|
|
* @param allowedExtensions The allowed file formats that can be saved
|
|
* @param defaultExtension The default file format to be saved
|
|
* @param settingCollector A callback to run just before the saving happens that returns true if it should commence
|
|
* @param calculateAndSaver The callback that saves the thing
|
|
* @return the button, ready to be pressed
|
|
*/
|
|
protected Button buildSaveButton(boolean bindCtrlS, String savee,
|
|
FileChooser.ExtensionFilter[] allowedExtensions,
|
|
FileChooser.ExtensionFilter defaultExtension,
|
|
BooleanSupplier settingCollector,
|
|
BiConsumer<File, ProgressBarDialog> calculateAndSaver) {
|
|
final FileChooser saver = new FileChooser();
|
|
saver.setInitialDirectory(new File("output"));
|
|
saver.setInitialFileName("my"+savee+defaultExtension.getExtensions().get(0).substring(1));
|
|
saver.setTitle("Save "+savee);
|
|
saver.getExtensionFilters().addAll(allowedExtensions);
|
|
saver.setSelectedExtensionFilter(defaultExtension);
|
|
try {
|
|
if (!saver.getInitialDirectory().exists())
|
|
saver.getInitialDirectory().mkdirs();
|
|
} catch (SecurityException e) {}
|
|
|
|
final Button saveButton = new Button("Save "+savee+"...");
|
|
saveButton.setOnAction((event) -> {
|
|
File file;
|
|
try {
|
|
file = saver.showSaveDialog(root);
|
|
} catch(IllegalArgumentException e) {
|
|
saver.setInitialDirectory(new File("."));
|
|
file = saver.showSaveDialog(root);
|
|
}
|
|
if (file != null) {
|
|
final File f = file;
|
|
if (settingCollector.getAsBoolean()) {
|
|
final ProgressBarDialog pBar = new ProgressBarDialog();
|
|
pBar.setContentText("Finalizing "+savee+"...");
|
|
pBar.show();
|
|
trigger(saveButton, () -> {
|
|
calculateAndSaver.accept(f, pBar);
|
|
Platform.runLater(pBar::close);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
saveButton.setTooltip(new Tooltip("Save the "+savee+" with current settings"));
|
|
|
|
if (bindCtrlS) // ctrl+S saves
|
|
root.addEventHandler(KeyEvent.KEY_PRESSED, (event) -> {
|
|
if (ctrlS.match(event)) {
|
|
saveButton.requestFocus();
|
|
saveButton.fire();
|
|
}
|
|
});
|
|
|
|
return saveButton;
|
|
}
|
|
|
|
|
|
protected Projection getProjection() {
|
|
return projectionChooser.getValue();
|
|
}
|
|
|
|
|
|
protected boolean getParamsChanging() { //are the aspect or parameters actively changing?
|
|
return isChanging.isSet();
|
|
}
|
|
|
|
|
|
protected void loadParameters() {
|
|
getProjection().setParameters(currentParams);
|
|
}
|
|
|
|
|
|
private void setAspectByPreset(String presetName,
|
|
Slider[] sliders, Spinner<Double>[] spinners) {
|
|
this.suppressListeners.set();
|
|
if (presetName.equals("Antipode")) {
|
|
sliders[0].setValue(-sliders[0].getValue());
|
|
sliders[1].setValue((sliders[1].getValue()+360)%360-180);
|
|
sliders[2].setValue(-sliders[2].getValue());
|
|
}
|
|
else if (presetName.equals("Random")) {
|
|
sliders[0].setValue(Math.toDegrees(Math.asin(Math.random()*2-1)));
|
|
sliders[1].setValue(Math.random()*360-180);
|
|
sliders[2].setValue(Math.random()*360-180);
|
|
}
|
|
else {
|
|
for (int i = 0; i < ASPECT_NAMES.length; i ++) {
|
|
if (ASPECT_NAMES[i].equals(presetName)) {
|
|
for (int j = 0; j < 3; j ++)
|
|
sliders[j].setValue(ASPECT_VALS[j][i]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < 3; i ++)
|
|
spinners[i].getEditor().textProperty().set(
|
|
Double.toString(sliders[i].getValue()));
|
|
this.suppressListeners.clear();
|
|
}
|
|
|
|
|
|
private void revealParameters(Projection proj) {
|
|
this.suppressListeners.set();
|
|
final String[] paramNames = proj.getParameterNames();
|
|
final double[][] paramValues = proj.getParameterValues();
|
|
paramGrid.getChildren().clear();
|
|
for (int i = 0; i < proj.getNumParameters(); i ++) {
|
|
paramLabels[i].setText(paramNames[i]+":");
|
|
paramSliders[i].setMin(paramValues[i][0]);
|
|
paramSliders[i].setMax(paramValues[i][1]);
|
|
paramSliders[i].setValue(paramValues[i][2]);
|
|
final SpinnerValueFactory.DoubleSpinnerValueFactory paramSpinVF =
|
|
(DoubleSpinnerValueFactory) paramSpinners[i].getValueFactory();
|
|
paramSpinVF.setMin(paramValues[i][0]);
|
|
paramSpinVF.setMax(paramValues[i][1]);
|
|
paramSpinVF.setValue(paramValues[i][2]);
|
|
final Tooltip tt = new Tooltip(
|
|
"Change the "+paramNames[i]+" of the map (default is "+
|
|
paramValues[i][2]+")");
|
|
paramSliders[i].setTooltip(tt);
|
|
paramSpinners[i].setTooltip(tt);
|
|
paramGrid.addRow(i, paramLabels[i], paramSliders[i], paramSpinners[i]);
|
|
}
|
|
this.suppressListeners.clear();
|
|
}
|
|
|
|
|
|
private static void trigger(Button btn, Runnable task) {
|
|
btn.setDisable(true);
|
|
new Thread(() -> {
|
|
try {
|
|
task.run();
|
|
} catch (Exception e) {
|
|
e.printStackTrace();
|
|
} finally {
|
|
btn.setDisable(false);
|
|
}
|
|
}).start();
|
|
}
|
|
|
|
|
|
private static void link(Slider sld, Spinner<Double> spn, int i, double[] doubles,
|
|
DoubleUnaryOperator converter, Procedure callback, Flag isChanging, Flag suppressListeners) {
|
|
sld.valueChangingProperty().addListener((observable, prev, now) -> { //link spinner to slider
|
|
isChanging.set(now);
|
|
if (!now) {
|
|
if (spn.getValue() != sld.getValue())
|
|
spn.getValueFactory().setValue(sld.getValue());
|
|
doubles[i] = converter.applyAsDouble(sld.getValue());
|
|
if (!suppressListeners.isSet())
|
|
callback.execute();
|
|
}
|
|
});
|
|
sld.valueProperty().addListener((observable, prev, now) -> {
|
|
if (spn.getValue() != sld.getValue())
|
|
spn.getValueFactory().setValue(sld.getValue());
|
|
doubles[i] = converter.applyAsDouble(now.doubleValue());
|
|
if (!suppressListeners.isSet())
|
|
callback.execute();
|
|
});
|
|
|
|
spn.valueProperty().addListener((observable, prev, now) -> { //link slider to spinner
|
|
if (spn.getValue() != sld.getValue())
|
|
sld.setValue(spn.getValue());
|
|
});
|
|
|
|
spn.focusedProperty().addListener((observable, prev, now) -> { //make spinner act rationally
|
|
if (!now) spn.increment(0);
|
|
});
|
|
}
|
|
|
|
|
|
protected static void showError(String header, String message) { //a simple thread-safe error handling thing
|
|
Platform.runLater(() -> {
|
|
final Alert alert = new Alert(Alert.AlertType.ERROR);
|
|
alert.setHeaderText(header);
|
|
alert.setContentText(message);
|
|
alert.showAndWait();
|
|
});
|
|
}
|
|
|
|
|
|
|
|
private class Flag { //because Java apparently doesn't already have a mutable Bullion
|
|
private boolean set;
|
|
|
|
public Flag() {
|
|
this(false);
|
|
}
|
|
|
|
public Flag(boolean set) {
|
|
this.set = set;
|
|
}
|
|
|
|
public boolean isSet() {
|
|
return this.set;
|
|
}
|
|
|
|
public void set(boolean set) {
|
|
this.set = set;
|
|
}
|
|
|
|
public void set() {
|
|
this.set = true;
|
|
}
|
|
|
|
public void clear() {
|
|
this.set = false;
|
|
}
|
|
|
|
public String toString() {
|
|
return "Flag("+this.set+")";
|
|
}
|
|
}
|
|
|
|
}
|