diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..924124d --- /dev/null +++ b/.classpath @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore index b5ecc69..2c8697e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ # DrawIO backup files -**/*.bkp \ No newline at end of file +**/*.bkp +/bin/ +/target/ +/data/ +*.kml \ No newline at end of file diff --git a/.project b/.project new file mode 100644 index 0000000..9e96a03 --- /dev/null +++ b/.project @@ -0,0 +1,23 @@ + + + PLP + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.jdt.core.javanature + + diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..623e652 --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,8 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 +org.eclipse.jdt.core.compiler.compliance=21 +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore +org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.source=21 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..f897a7f --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6fec576 --- /dev/null +++ b/pom.xml @@ -0,0 +1,71 @@ + + 4.0.0 + PLP + PLP + 1.0.0 + + + 2.0.16 + + + + + JOSM + JOSM + https://josm.openstreetmap.de/nexus/content/repositories/releases/ + + + + + + com.uber + h3 + 4.1.1 + + + uk.m0nom + javaapiforkml + 3.0.10 + + + org.reflections + reflections + 0.10.2 + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + org.locationtech.jts + jts-core + 1.20.0 + + + org.openstreetmap.jmapviewer + jmapviewer + 2.22 + + + + + src + + + maven-compiler-plugin + 3.13.0 + + 21 + + + + + \ No newline at end of file diff --git a/src/plp/Config.java b/src/plp/Config.java new file mode 100644 index 0000000..081b2d6 --- /dev/null +++ b/src/plp/Config.java @@ -0,0 +1,5 @@ +package plp; + +public class Config { + public static final int H3_RESOLUTION = 9; // Shared configuration variable for resolution +} diff --git a/src/plp/SystemRunner.java b/src/plp/SystemRunner.java new file mode 100644 index 0000000..773ce4f --- /dev/null +++ b/src/plp/SystemRunner.java @@ -0,0 +1,51 @@ +package plp; + +import java.util.List; + +import plp.filter.DataFilter; +import plp.filters.*; +import plp.location.LocationCell; +import plp.operator.LogicalOperator; +import plp.output.KMLGenerator; + +public class SystemRunner { + public static void main(String[] args) { + + // Create Filters + LightPollutionFilter lightPollutionFilter = new LightPollutionFilter(); + lightPollutionFilter.setRequirements(21.65); // Minimum SQM value + + LightPollutionFilter lightPollutionFilterNot = new LightPollutionFilter(); + lightPollutionFilterNot.setRequirements(21.6); // Minimum SQM value + + BoundingBoxFilter boundingBoxFilter = new BoundingBoxFilter(); + boundingBoxFilter.setRequirements(new double[]{33.5, 34.2, -116.5, -115.0}); // Desert bounding box + + // Use DataFilter + DataFilter dataFilter = new DataFilter(boundingBoxFilter); + + // SQM less than 17.9 + OperatorFilter notFilter = new OperatorFilter(); + notFilter.setRequirements(LogicalOperator.NOT); + notFilter.addFilter(lightPollutionFilterNot); + + // SQM less than 17.9 or greater than 18.1 + OperatorFilter orFilter = new OperatorFilter(); + orFilter.setRequirements(LogicalOperator.OR); + orFilter.addFilter(notFilter); + orFilter.addFilter(lightPollutionFilter); + dataFilter.addFilter(orFilter); + + + // Apply Filters + List filteredLocations = dataFilter.filterLocations(); + + // Print Results + System.out.println("Filtered Locations: " + filteredLocations); + + // Generate KML file with hexagon boundaries + String kmlFileName = "filtered_hexagons.kml"; + KMLGenerator.generateKML(filteredLocations, kmlFileName); + + } +} diff --git a/src/plp/filter/DataFilter.java b/src/plp/filter/DataFilter.java new file mode 100644 index 0000000..416c825 --- /dev/null +++ b/src/plp/filter/DataFilter.java @@ -0,0 +1,28 @@ +package plp.filter; + +import java.util.List; + + +import plp.filters.BoundingBoxFilter; +import plp.location.LocationCell; + +public class DataFilter { + private final FilterManager filterManager = new FilterManager(); + private final List allCells; + + public DataFilter(InitialFilter initialFilter) { + allCells = initialFilter.getValidCells(); + if (allCells.isEmpty()) { + throw new IllegalArgumentException("Zero cells in the initialFilter: " + initialFilter.getClass().getSimpleName()); + } + } + + + public void addFilter(Filter filter) { + filterManager.addFilter(filter); + } + + public List filterLocations() { + return filterManager.applyFilters(allCells); + } +} \ No newline at end of file diff --git a/src/plp/filter/Filter.java b/src/plp/filter/Filter.java new file mode 100644 index 0000000..8fddcec --- /dev/null +++ b/src/plp/filter/Filter.java @@ -0,0 +1,73 @@ +package plp.filter; + +import java.util.Arrays; +import java.util.List; + +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import plp.location.LocationCell; + +public interface Filter { + + /** + * Set the requirements from the CLI or other source. + * Must detect the wanted Object type and handle accordingly. + * @param requirements The filter's requirements in it's wanted type. + * @throws IllegalArgumentException If object is of wrong type or arguments are invalid + */ + void setRequirements(Object requirements) throws IllegalArgumentException; // Main Handling + + /** + * A String representation of the Filter's currently inputed requirements + * @return A user-friendly string + */ + String getRequirements(); + + /** + * Set the initial locations to filter upon + * @param Previous {@link plp.location.LocationCell LocationCells} to work with + */ + void setLocations(List locations); + + /** + * The action of filtering the locations. + * Should handle loading data in and caching, if necessary. + * @return All matching {@link plp.location.LocationCell LocationCells} + */ + List process(); // Action of filtering the locations + + /** + * Accept requirements from the Parameter Panel that this filter provides + * @param modifiedParameterPanel The panel from {@link #getParameterPanel(int, int) getParameterPanel}, modified with the user's input. + * @throws IllegalArgumentException If arguments are invalid. + */ + default void setRequirements(JPanel modifiedParameterPanel) throws IllegalArgumentException { // Handle from UI + JTextField[] fields = (JTextField[]) modifiedParameterPanel.getClientProperty("fields"); // Get the first component (the filter's parameter panel), Extract input fields + + try { + String[] inputValues = new String[fields.length]; + for (int i = 0; i < fields.length; i++) { + // Explicitly focus out of each field to ensure the latest value is captured + fields[i].transferFocus(); + inputValues[i] = fields[i].getText(); + } + + System.out.println(Arrays.toString(inputValues)); + setRequirements(fields); // Set the requirements dynamically + } catch (Exception ex) { + JOptionPane.showMessageDialog(modifiedParameterPanel, "Invalid input: " + ex.getMessage()); + } + } + + /** + * Construct a JPanel for the UI so that the user can input the requirements for this filter. + * @return A panel with fields, such as {@link javax.swing.JTextField JTextFields} + */ + default JPanel getParameterPanel() { + JPanel panel = new JPanel(); + panel.putClientProperty("fields", new JTextField[]{}); + return panel; + } +} diff --git a/src/plp/filter/FilterManager.java b/src/plp/filter/FilterManager.java new file mode 100644 index 0000000..cfebb67 --- /dev/null +++ b/src/plp/filter/FilterManager.java @@ -0,0 +1,27 @@ +package plp.filter; + +import java.util.ArrayList; +import java.util.List; + +import plp.location.LocationCell; + +public class FilterManager { + private final List filters = new ArrayList<>(); + + public void addFilter(Filter filter) { + filters.add(filter); + } + + public List applyFilters(List locations) { + List filteredLocations = locations; + System.out.println("Inital bounds: " + filteredLocations.size()); + + for (Filter filter : filters) { + filter.setLocations(filteredLocations); + filteredLocations = filter.process(); + System.out.println("After " + filter.getClass().getSimpleName() + ": " + filteredLocations.size()); + } + + return filteredLocations; + } +} diff --git a/src/plp/filter/InitialFilter.java b/src/plp/filter/InitialFilter.java new file mode 100644 index 0000000..bf3f45a --- /dev/null +++ b/src/plp/filter/InitialFilter.java @@ -0,0 +1,18 @@ +package plp.filter; + +import java.util.List; + +import plp.location.LocationCell; + +/* + * This type of filter can be used at the beginning of a sequence to get points from nothing. + */ +public interface InitialFilter extends Filter { + + /** + * Get the valid cells of the filter. + * Must not require {@link plp.filter.Filter#setLocations(List)} to be called first. + * @return List of Cell h3 indexes + */ + List getValidCells(); +} diff --git a/src/plp/filters/BoundingBoxFilter.java b/src/plp/filters/BoundingBoxFilter.java new file mode 100644 index 0000000..7cc76f0 --- /dev/null +++ b/src/plp/filters/BoundingBoxFilter.java @@ -0,0 +1,159 @@ +package plp.filters; + +import java.awt.GridLayout; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import com.uber.h3core.H3Core; +import com.uber.h3core.util.LatLng; + +import plp.Config; +import plp.filter.InitialFilter; +import plp.location.LocationCell; + +public class BoundingBoxFilter implements InitialFilter { + private double minLatitude; + private double maxLatitude; + private double minLongitude; + private double maxLongitude; + private List validCells; + private List locations; + private H3Core h3; + + public BoundingBoxFilter() { + try { + h3 = H3Core.newInstance(); + } catch (IOException e) { + throw new RuntimeException("Failed to initialize H3 library", e); + } + } + + @Override + public void setRequirements(JPanel modifiedParameterPanel) { + JTextField[] fields = (JTextField[]) modifiedParameterPanel.getClientProperty("fields"); // Get the first component (the filter's parameter panel), Extract input fields + + try { + String[] inputValues = new String[fields.length]; + for (int i = 0; i < fields.length; i++) { + // Explicitly focus out of each field to ensure the latest value is captured + fields[i].transferFocus(); + inputValues[i] = fields[i].getText(); + } + + System.out.println(Arrays.toString(inputValues)); + double minLat = Double.parseDouble(inputValues[0]); + double maxLat = Double.parseDouble(inputValues[1]); + double minLon = Double.parseDouble(inputValues[2]); + double maxLon = Double.parseDouble(inputValues[3]); + setRequirements(new double[]{minLat, maxLat, minLon, maxLon}); + } catch (Exception ex) { + JOptionPane.showMessageDialog(modifiedParameterPanel, "Invalid input: " + ex.getMessage()); + } + } + + @Override + public void setRequirements(Object requirements) throws IllegalArgumentException { + if (requirements instanceof double[]) { + double[] bounds = (double[]) requirements; + if (bounds.length == 4) { + validateBounds(bounds); + this.minLatitude = bounds[0]; + this.maxLatitude = bounds[1]; + this.minLongitude = bounds[2]; + this.maxLongitude = bounds[3]; + + validCells = h3.polygonToCells(Arrays.asList( + new LatLng(minLatitude, minLongitude), + new LatLng(minLatitude, maxLongitude), + new LatLng(maxLatitude, maxLongitude), + new LatLng(maxLatitude, minLongitude)), + null, Config.H3_RESOLUTION); + } else { + throw new IllegalArgumentException("Bounding box requires exactly 4 values: [minLat, maxLat, minLon, maxLon]"); + } + } else { + throw new IllegalArgumentException("Invalid requirement type for BoundingBoxFilter"); + } + } + + @Override + public String getRequirements() { + // TODO Auto-generated method stub + return String.valueOf(minLatitude) + " -> " + String.valueOf(maxLatitude) + ", " + String.valueOf(minLongitude) + " -> " + String.valueOf(maxLongitude); + } + + private void validateBounds(double[] bounds) { + double minLat = bounds[0]; + double maxLat = bounds[1]; + double minLon = bounds[2]; + double maxLon = bounds[3]; + + if (minLat < -90 || maxLat > 90) { + throw new IllegalArgumentException("Latitude values must be between -90 and 90."); + } + + if (minLat > maxLat) { + throw new IllegalArgumentException("Latitude minLat must be <= maxLat."); + } + + if (minLon < -180 || maxLon > 180) { + throw new IllegalArgumentException("Longitude values must be between -180 and 180."); + } + + if (minLon > maxLon) { + throw new IllegalArgumentException("Longitude minLon must be <= maxLon."); + } + } + + @Override + public void setLocations(List locations) { + this.locations = locations; + } + + @Override + public List process() { + return locations.stream() + .filter(cell -> validCells.contains(cell.getH3Index())) + .toList(); + } + + /** + * Get the valid cells of the bounding box + * @return List of Cell h3 indexes + */ + public List getValidCells() { + return validCells.stream() + .map(LocationCell::new) + .toList(); + } + + @Override + public JPanel getParameterPanel() { + JPanel panel = new JPanel(new GridLayout(0, 2)); + + panel.add(new JLabel("Min Latitude:")); + JTextField minLatField = new JTextField("33.5"); + panel.add(minLatField); + + panel.add(new JLabel("Max Latitude:")); + JTextField maxLatField = new JTextField("34.2"); + panel.add(maxLatField); + + panel.add(new JLabel("Min Longitude:")); + JTextField minLonField = new JTextField("-116.5"); + panel.add(minLonField); + + panel.add(new JLabel("Max Longitude:")); + JTextField maxLonField = new JTextField("-115"); + panel.add(maxLonField); + + panel.putClientProperty("fields", new JTextField[]{minLatField, maxLatField, minLonField, maxLonField}); + return panel; + } +} diff --git a/src/plp/filters/BoundingEllipseFilter.java b/src/plp/filters/BoundingEllipseFilter.java new file mode 100644 index 0000000..1611e57 --- /dev/null +++ b/src/plp/filters/BoundingEllipseFilter.java @@ -0,0 +1,491 @@ +package plp.filters; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; + +import org.openstreetmap.gui.jmapviewer.Coordinate; +import org.openstreetmap.gui.jmapviewer.JMapViewer; +import org.openstreetmap.gui.jmapviewer.MapMarkerDot; +import org.openstreetmap.gui.jmapviewer.MapPolygonImpl; +import org.openstreetmap.gui.jmapviewer.Tile; +import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate; +import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker; +import org.openstreetmap.gui.jmapviewer.interfaces.MapPolygon; + +import com.uber.h3core.H3Core; +import com.uber.h3core.util.LatLng; + +import plp.Config; +import plp.filter.InitialFilter; +import plp.location.LocationCell; + +public class BoundingEllipseFilter implements InitialFilter { + private LatLng center; + private double majorAxis; + private double minorAxis; + private double rotation; + private List validCells; + private List locations; + private H3Core h3; + + public BoundingEllipseFilter() { + try { + h3 = H3Core.newInstance(); + } catch (IOException e) { + throw new RuntimeException("Failed to initialize H3 library", e); + } + center = null; // Center is not initialized until user interaction + majorAxis = 1.0; // Default major axis length in degrees + minorAxis = 1.0; // Default minor axis length in degrees + rotation = 0.0; // Default rotation angle in degrees + } + + @Override + public void setRequirements(JPanel modifiedParameterPanel) { + MapPanel mapPanel = (MapPanel) modifiedParameterPanel.getClientProperty("mapPanel"); + if (mapPanel == null) { + throw new IllegalArgumentException("Parameter panel does not contain the required map panel."); + } + + center = mapPanel.getCenter(); + majorAxis = mapPanel.getMajorAxis(); + minorAxis = mapPanel.getMinorAxis(); + rotation = mapPanel.getRotation(); + + // Generate H3 indexes within the ellipse boundary + validCells = h3.polygonToCells(getEllipseBoundary(), null, Config.H3_RESOLUTION); + } + + @Override + public void setRequirements(Object requirements) throws IllegalArgumentException { + if (requirements instanceof EllipseRequirements req) { + validateEllipseParameters(req.center, req.majorAxis, req.minorAxis, req.rotation); + this.center = req.center; + this.majorAxis = req.majorAxis; + this.minorAxis = req.minorAxis; + this.rotation = req.rotation; + + // Generate H3 indexes within the ellipse boundary + validCells = h3.polygonToCells(getEllipseBoundary(), null, Config.H3_RESOLUTION); + } else { + throw new IllegalArgumentException("Invalid requirement type for BoundingEllipseFilter"); + } + } + + private void validateEllipseParameters(LatLng center, double majorAxis, double minorAxis, double rotation) { + if (center == null) { + throw new IllegalArgumentException("Center point cannot be null."); + } + if (majorAxis <= 0 || minorAxis <= 0) { + throw new IllegalArgumentException("Major and minor axes must be positive values."); + } + if (rotation < 0 || rotation >= 360) { + throw new IllegalArgumentException("Rotation must be between 0 and 360 degrees."); + } + } + + @Override + public String getRequirements() { + if (center == null) { + return "Ellipse not initialized"; + } + return String.format("Center: %s, Major Axis: %.2f, Minor Axis: %.2f, Rotation: %.2f", center, majorAxis, minorAxis, rotation); + } + + @Override + public void setLocations(List locations) { + this.locations = locations; + } + + @Override + public List process() { + return locations.stream() + .filter(cell -> validCells.contains(cell.getH3Index())) + .toList(); + } + + @Override + public List getValidCells() { + return validCells.stream() + .map(LocationCell::new) + .toList(); + } + + @Override + public JPanel getParameterPanel() { + JPanel panel = new JPanel(new BorderLayout()); + + // Create a map panel + MapPanel mapPanel = new MapPanel(); + panel.add(mapPanel, BorderLayout.CENTER); + + JLabel helpLabel = new JLabel("Use right mouse button to move, mouse wheel to zoom.", SwingConstants.CENTER); + panel.add(helpLabel, BorderLayout.SOUTH); + + // Attach the mapPanel as a client property for later retrieval + panel.putClientProperty("mapPanel", mapPanel); + + return panel; + } + + private List getEllipseBoundary() { + System.out.println(getRequirements()); + List boundary = new ArrayList<>(); + int numPoints = 100; + double radiansRotation = Math.toRadians(rotation); + + for (int i = 0; i < numPoints; i++) { + double angle = 2 * Math.PI * i / numPoints; + double x = majorAxis * Math.cos(angle); + double y = minorAxis * Math.sin(angle); + + // Rotate point + double rotatedX = x * Math.cos(radiansRotation) - y * Math.sin(radiansRotation); + double rotatedY = x * Math.sin(radiansRotation) + y * Math.cos(radiansRotation); + + // Translate to center + boundary.add(new LatLng(center.lat + rotatedY, center.lng + rotatedX)); + } + + return boundary; + } + + private class MapPanel extends JPanel { + private final JMapViewer mapViewer; + private MapMarkerDot centerMarker; + private MapMarkerDot majorAxisMarker; + private MapMarkerDot minorAxisMarker; + private MapMarkerDot rotationMarker; + private MapPolygon ellipsePolygon; + private MapMarkerDot selectedMarker; + + private boolean loaded; + + private class JMapViewerFit extends JMapViewer { + + @Override + public void tileLoadingFinished(Tile tile, boolean success) { + super.tileLoadingFinished(tile, success); + if (!loaded & success) { + loaded = true; + setDisplayToFitMapElements(true, false, true); + } + } + } + + public MapPanel() { + setLayout(new BorderLayout()); + mapViewer = new JMapViewerFit(); + setPreferredSize(new Dimension(400, 300)); + mapViewer.setDisplayPosition(new Coordinate(39.8283, -98.5795), 3); + + centerMarker = null; // Center marker not initialized until user sets it + majorAxisMarker = null; + minorAxisMarker = null; + rotationMarker = null; + ellipsePolygon = null; + selectedMarker = null; + + if (center != null) { // Has existing data, but the UI is being re-initialized. Most common for nested operators. + initializeWithExistingEllipse(); + } + + mapViewer.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (!isEnabled()) { + return; + } + if (centerMarker == null) { + Point clickPoint = e.getPoint(); + ICoordinate coord = mapViewer.getPosition(clickPoint); + + center = new LatLng(coord.getLat(), coord.getLon()); + centerMarker = new MapMarkerDot(coord.getLat(), coord.getLon()); + centerMarker.getStyle().setBackColor(Color.GREEN); // Differentiating center marker + mapViewer.addMapMarker(centerMarker); + + // Get current viewport + double latitudeExtent = mapViewer.getPosition(new Point(0, mapViewer.getHeight())).getLat() - + mapViewer.getPosition(new Point(0, 0)).getLat(); + double longitudeExtent = mapViewer.getPosition(new Point(mapViewer.getWidth(), 0)).getLon() - + mapViewer.getPosition(new Point(0, 0)).getLon(); + + // Initialize axis markers relative to center + majorAxisMarker = new MapMarkerDot(coord.getLat(), coord.getLon() + longitudeExtent / 4); + minorAxisMarker = new MapMarkerDot(coord.getLat() - latitudeExtent / 4, coord.getLon()); + rotationMarker = new MapMarkerDot(coord.getLat(), coord.getLon() + longitudeExtent / 8); + rotationMarker.getStyle().setBackColor(Color.RED); // Differentiating rotation marker + + mapViewer.addMapMarker(majorAxisMarker); + mapViewer.addMapMarker(minorAxisMarker); + mapViewer.addMapMarker(rotationMarker); + + majorAxis = calculateDistance(centerMarker, majorAxisMarker); + minorAxis = calculateDistance(centerMarker, minorAxisMarker); + + updateEllipse(); + mapViewer.repaint(); + } + } + + @Override + public void mousePressed(MouseEvent e) { + if (!isEnabled()) { + return; + } + if (centerMarker == null) { + return; // Do nothing if center is not set + } + + Point clickPoint = e.getPoint(); + MapMarkerDot closestMarker = getClosestMarker(clickPoint); + if (calculateDistance(clickPoint, closestMarker) > 8) return; // Too far, assume misclick + selectedMarker = closestMarker; + } + + @Override + public void mouseReleased(MouseEvent e) { + if (!isEnabled()) { + return; + } + selectedMarker = null; // Release the selected marker + updateEllipse(); + } + }); + + mapViewer.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseDragged(MouseEvent e) { + if (!isEnabled()) { + return; + } + if (centerMarker == null || selectedMarker == null) { + return; // Do nothing if center or a selected marker is not set + } + + Point dragPoint = e.getPoint(); + ICoordinate coord = mapViewer.getPosition(dragPoint); + + if (selectedMarker == centerMarker) { + center = new LatLng(coord.getLat(), coord.getLon()); + centerMarker.setLat(coord.getLat()); + centerMarker.setLon(coord.getLon()); + + // Constrain axis markers to remain inline with the center + updateAxisMarkers(); + } else if (selectedMarker == majorAxisMarker) { + adjustMarkerAlongAxis(coord, true); + majorAxis = calculateDistance(centerMarker, majorAxisMarker); + } else if (selectedMarker == minorAxisMarker) { + adjustMarkerAlongAxis(coord, false); + minorAxis = calculateDistance(centerMarker, minorAxisMarker); + } else if (selectedMarker == rotationMarker) { + rotationMarker.setLat(coord.getLat()); + rotationMarker.setLon(coord.getLon()); + rotation = calculateRotation(centerMarker, rotationMarker); + updateAxisMarkers(); // Rotate axis markers around the center + } + + mapViewer.repaint(); + } + }); + + /* + mapViewer.addTileLoaderListener(new TileLoaderListener() { + @Override + public void tileLoadingFinished(Tile tile, boolean success) { + + + } + });*/ + + add(mapViewer, BorderLayout.CENTER); + } + + private void initializeWithExistingEllipse() { + this.centerMarker = new MapMarkerDot(center.lat, center.lng); + + // Initialize axis markers + double radiansRotation = Math.toRadians(rotation); + this.majorAxisMarker = new MapMarkerDot( + center.lat + majorAxis * Math.sin(radiansRotation), + center.lng + majorAxis * Math.cos(radiansRotation)); + this.minorAxisMarker = new MapMarkerDot( + center.lat + minorAxis * Math.cos(radiansRotation), + center.lng - minorAxis * Math.sin(radiansRotation)); + this.rotationMarker = new MapMarkerDot( + center.lat + majorAxis / 2 * Math.sin(radiansRotation), + center.lng + majorAxis / 2 * Math.cos(radiansRotation)); + this.rotationMarker.getStyle().setBackColor(Color.RED); + + // Add markers to the map + mapViewer.addMapMarker(centerMarker); + mapViewer.addMapMarker(majorAxisMarker); + mapViewer.addMapMarker(minorAxisMarker); + mapViewer.addMapMarker(rotationMarker); + + updateEllipse(); + } + + private MapMarkerDot getClosestMarker(Point clickPoint) { + double minDistance = Double.MAX_VALUE; + MapMarkerDot closestMarker = null; + + for (MapMarkerDot marker : List.of(centerMarker, majorAxisMarker, minorAxisMarker, rotationMarker)) { + if (marker == null) continue; + + Point markerPoint = mapViewer.getMapPosition(new Coordinate(marker.getLat(), marker.getLon())); + if (markerPoint != null) { + double distance = clickPoint.distance(markerPoint); + if (distance < minDistance) { + minDistance = distance; + closestMarker = marker; + } + } + } + + return closestMarker; + } + + private double calculateDistance(MapMarkerDot marker1, MapMarkerDot marker2) { + double dx = marker1.getLat() - marker2.getLat(); + double dy = marker1.getLon() - marker2.getLon(); + return Math.sqrt(dx * dx + dy * dy); + } + + private double calculateDistance(Point clickPoint, MapMarker marker) { + Point markerPoint = mapViewer.getMapPosition(new Coordinate(marker.getLat(), marker.getLon())); + if (markerPoint == null) return Integer.MAX_VALUE; + + return Math.sqrt((clickPoint.x - markerPoint.x) * (clickPoint.x - markerPoint.x) + + (clickPoint.y - markerPoint.y) * (clickPoint.y - markerPoint.y)); + } + + private double calculateRotation(MapMarkerDot center, MapMarkerDot rotationMarker) { + double dx = rotationMarker.getLon() - center.getLon(); + double dy = rotationMarker.getLat() - center.getLat(); + return Math.toDegrees(Math.atan2(dy, dx)); + } + + private void updateAxisMarkers() { + if (centerMarker == null || majorAxisMarker == null || minorAxisMarker == null) return; + + double radians = Math.toRadians(rotation); + + // Calculate rotated major axis position + double majorDx = majorAxis * Math.cos(radians); + double majorDy = majorAxis * Math.sin(radians); + majorAxisMarker.setLat(centerMarker.getLat() + majorDy); + majorAxisMarker.setLon(centerMarker.getLon() + majorDx); + + // Calculate rotated minor axis position + double minorDx = minorAxis * Math.cos(radians + Math.PI / 2); + double minorDy = minorAxis * Math.sin(radians + Math.PI / 2); + minorAxisMarker.setLat(centerMarker.getLat() + minorDy); + minorAxisMarker.setLon(centerMarker.getLon() + minorDx); + } + + /** + * Adjusts the given marker along the rotated major or minor axis. + * + * @param coord The new coordinate for the marker. + * @param isMajorAxis True if the marker is for the major axis; false for the minor axis. + */ + private void adjustMarkerAlongAxis(ICoordinate coord, boolean isMajorAxis) { + if (centerMarker == null) { + return; // Do nothing if the center marker is not initialized + } + + double dx = coord.getLon() - centerMarker.getLon(); + double dy = coord.getLat() - centerMarker.getLat(); + + // Determine the angle of the axis + double axisAngle = Math.toRadians(rotation); + if (!isMajorAxis) { + axisAngle += Math.PI / 2; // Minor axis is perpendicular to the major axis + } + + // Project the point onto the axis + double projectionLength = dx * Math.cos(axisAngle) + dy * Math.sin(axisAngle); + double projectedX = projectionLength * Math.cos(axisAngle); + double projectedY = projectionLength * Math.sin(axisAngle); + + // Update the marker position + if (isMajorAxis) { + majorAxisMarker.setLon(centerMarker.getLon() + projectedX); + majorAxisMarker.setLat(centerMarker.getLat() + projectedY); + } else { + minorAxisMarker.setLon(centerMarker.getLon() + projectedX); + minorAxisMarker.setLat(centerMarker.getLat() + projectedY); + } + } + + + public LatLng getCenter() { + if (centerMarker == null) { + throw new IllegalStateException("Center marker is not set"); + } + return new LatLng(centerMarker.getLat(), centerMarker.getLon()); + } + + public double getMajorAxis() { + return majorAxis; + } + + public double getMinorAxis() { + return minorAxis; + } + + public double getRotation() { + return rotation; + } + + private void updateEllipse() { + if (ellipsePolygon != null) { + mapViewer.removeMapPolygon(ellipsePolygon); + } + + if (centerMarker != null && majorAxis > 0 && minorAxis > 0) { + List coordinates = new ArrayList<>(); + + List boundaryPoints = getEllipseBoundary(); + for (LatLng point : boundaryPoints) { + coordinates.add(new Coordinate(point.lat, point.lng)); + } + + ellipsePolygon = new MapPolygonImpl(coordinates); + mapViewer.addMapPolygon(ellipsePolygon); + mapViewer.repaint(); + } + } + } + + public class EllipseRequirements { + public final LatLng center; + public final double majorAxis; + public final double minorAxis; + public final double rotation; + + public EllipseRequirements(LatLng center, double majorAxis, double minorAxis, double rotation) { + this.center = center; + this.majorAxis = majorAxis; + this.minorAxis = minorAxis; + this.rotation = rotation; + } + } + +} + diff --git a/src/plp/filters/BoundingPolygonFilter.java b/src/plp/filters/BoundingPolygonFilter.java new file mode 100644 index 0000000..6ec2921 --- /dev/null +++ b/src/plp/filters/BoundingPolygonFilter.java @@ -0,0 +1,305 @@ +package plp.filters; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.io.IOException; +import java.util.List; +import java.util.ArrayList; + +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.JButton; +import javax.swing.JLabel; + +import org.openstreetmap.gui.jmapviewer.Coordinate; +import org.openstreetmap.gui.jmapviewer.JMapViewer; +import org.openstreetmap.gui.jmapviewer.MapMarkerDot; +import org.openstreetmap.gui.jmapviewer.MapPolygonImpl; +import org.openstreetmap.gui.jmapviewer.Tile; +import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate; +import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker; +import org.openstreetmap.gui.jmapviewer.interfaces.MapPolygon; + +import com.uber.h3core.H3Core; +import com.uber.h3core.util.LatLng; + +import plp.Config; +import plp.filter.InitialFilter; +import plp.location.LocationCell; + +public class BoundingPolygonFilter implements InitialFilter { + + private List boundaryPoints; + private List validCells; + private List locations; + private H3Core h3; + + public BoundingPolygonFilter() { + try { + h3 = H3Core.newInstance(); + } catch (IOException e) { + throw new RuntimeException("Failed to initialize H3 library", e); + } + boundaryPoints = new ArrayList<>(); + } + + @Override + public void setRequirements(JPanel modifiedParameterPanel) { + MapPanel mapPanel = (MapPanel) modifiedParameterPanel.getClientProperty("mapPanel"); + if (mapPanel == null) { + throw new IllegalArgumentException("Parameter panel does not contain the required map panel."); + } + + List points = mapPanel.getBoundaryPoints(); + if (points.size() < 3) { + throw new IllegalArgumentException("At least 3 points are required to define a bounding polygon."); + } + + boundaryPoints.clear(); + boundaryPoints = points; + validCells = h3.polygonToCells(boundaryPoints, null, Config.H3_RESOLUTION); + } + + @SuppressWarnings("unchecked") + @Override + public void setRequirements(Object requirements) throws IllegalArgumentException { + if (requirements instanceof List) { + List points = (List) requirements; + if (points.size() < 3) { + throw new IllegalArgumentException("At least 3 points are required to define a bounding polygon."); + } + if (!(points.getFirst() instanceof LatLng)) { + throw new IllegalArgumentException("Must be an array of LatLng."); + } + boundaryPoints = (List) points; + validCells = h3.polygonToCells(boundaryPoints, null, Config.H3_RESOLUTION); + } else { + throw new IllegalArgumentException("Invalid requirement type for BoundingBoxFilter"); + } + } + + @Override + public String getRequirements() { + return boundaryPoints.toString(); + } + + @Override + public void setLocations(List locations) { + this.locations = locations; + } + + @Override + public List process() { + return locations.stream() + .filter(cell -> validCells.contains(cell.getH3Index())) + .toList(); + } + + public List getValidCells() { + return validCells.stream() + .map(LocationCell::new) + .toList(); + } + + @Override + public JPanel getParameterPanel() { + JPanel panel = new JPanel(new BorderLayout()); + + // Create a map panel + MapPanel mapPanel = new MapPanel(); + panel.add(mapPanel, BorderLayout.NORTH); + + // Add control buttons + JPanel controlPanel = new JPanel(new FlowLayout()); + JButton addPointButton = new JButton("Add Point"); + JButton removePointButton = new JButton("Remove Point"); + JButton clearPointsButton = new JButton("Clear Points"); + + controlPanel.add(addPointButton); + controlPanel.add(removePointButton); + controlPanel.add(clearPointsButton); + + panel.add(controlPanel, BorderLayout.CENTER); + + JLabel helpLabel = new JLabel("Use right mouse button to move, mouse wheel to zoom.", SwingConstants.CENTER); + panel.add(helpLabel, BorderLayout.SOUTH); + + // Attach the mapPanel as a client property for later retrieval + panel.putClientProperty("mapPanel", mapPanel); + + // Event listeners + addPointButton.addActionListener(e -> mapPanel.addPoint()); + removePointButton.addActionListener(e -> mapPanel.removeLastPoint()); + clearPointsButton.addActionListener(e -> mapPanel.clearPoints()); + + return panel; + } + + private class MapPanel extends JPanel { + private final JMapViewer mapViewer; + private final List markers; + private MapPolygon boundingPolygon; + private MapMarker draggedMarker; + + private boolean loaded; + + private class JMapViewerFit extends JMapViewer { + + @Override + public void tileLoadingFinished(Tile tile, boolean success) { + super.tileLoadingFinished(tile, success); + if (!loaded & success) { + loaded = true; + setDisplayToFitMapElements(true, false, true); + } + } + } + + public MapPanel() { + setLayout(new BorderLayout()); + mapViewer = new JMapViewerFit(); + mapViewer.setDisplayPosition(new Coordinate(39.8283, -98.5795), 3); + setPreferredSize(new Dimension(400, 300)); + markers = new ArrayList<>(); + importMarkersFromBoundaryPoints(); + + mapViewer.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (!isEnabled()) { + return; + } + Point clickPoint = e.getPoint(); + draggedMarker = markers.stream() + .min((marker1, marker2) -> { + double dist1 = calculateDistance(clickPoint, marker1); + double dist2 = calculateDistance(clickPoint, marker2); + return Double.compare(dist1, dist2); + }) + .filter(marker -> calculateDistance(clickPoint, marker) <= 8) // 8 pixels radius + .orElse(null); + } + + @Override + public void mouseReleased(MouseEvent e) { + if (!isEnabled()) { + return; + } + draggedMarker = null; // Stop dragging + updateBoundingPolygon(); + } + + @Override + public void mouseClicked(MouseEvent e) { + if (!isEnabled()) { + return; + } + if (draggedMarker == null) { + Point clickPoint = e.getPoint(); + ICoordinate coord = mapViewer.getPosition(clickPoint); + MapMarkerDot marker = new MapMarkerDot(coord.getLat(), coord.getLon()); + markers.add(marker); + mapViewer.addMapMarker(marker); + updateBoundingPolygon(); + } + } + }); + + mapViewer.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseDragged(MouseEvent e) { + if (!isEnabled()) { + return; + } + if (draggedMarker != null) { + Point dragPoint = e.getPoint(); + ICoordinate coord = mapViewer.getPosition(dragPoint); + draggedMarker.setLat(coord.getLat()); + draggedMarker.setLon(coord.getLon()); + mapViewer.repaint(); + } + } + }); + + add(mapViewer, BorderLayout.CENTER); + } + + private double calculateDistance(Point clickPoint, MapMarker marker) { + Point markerPoint = mapViewer.getMapPosition(new Coordinate(marker.getLat(), marker.getLon())); + if (markerPoint == null) return Integer.MAX_VALUE; + + return Math.sqrt((clickPoint.x - markerPoint.x) * (clickPoint.x - markerPoint.x) + + (clickPoint.y - markerPoint.y) * (clickPoint.y - markerPoint.y)); + } + + private void updateBoundingPolygon() { + if (boundingPolygon != null) { + mapViewer.removeMapPolygon(boundingPolygon); + } + + if (markers.size() >= 3) { + List coordinates = new ArrayList<>(); + for (MapMarker marker : markers) { + coordinates.add(new Coordinate(marker.getLat(), marker.getLon())); + } + // Close the polygon by adding the first point to the end + coordinates.add(coordinates.get(0)); + + boundingPolygon = new MapPolygonImpl(coordinates); + mapViewer.addMapPolygon(boundingPolygon); + mapViewer.repaint(); + } + } + + public void addPoint() { + ICoordinate center = mapViewer.getPosition(mapViewer.getWidth() / 2, mapViewer.getHeight() / 2); + MapMarkerDot marker; + if (center != null) { + marker = new MapMarkerDot(center.getLat(), center.getLon()); + } else { + marker = new MapMarkerDot(37.7749, -122.4194); // San Francisco + } + markers.add(marker); + mapViewer.addMapMarker(marker); + updateBoundingPolygon(); + } + + public void removeLastPoint() { + if (!markers.isEmpty()) { + MapMarker lastMarker = markers.remove(markers.size() - 1); + mapViewer.removeMapMarker(lastMarker); + updateBoundingPolygon(); + } + } + + public void clearPoints() { + for (MapMarker marker : markers) { + mapViewer.removeMapMarker(marker); + } + markers.clear(); + updateBoundingPolygon(); + } + + public List getBoundaryPoints() { + List boundaryPoints = new ArrayList<>(); + for (MapMarker marker : markers) { + boundaryPoints.add(new LatLng(marker.getLat(), marker.getLon())); + } + return boundaryPoints; + } + + private void importMarkersFromBoundaryPoints() { + for (LatLng point : boundaryPoints) { + markers.add(new MapMarkerDot(point.lat, point.lng)); + mapViewer.addMapMarker(markers.getLast()); + } + updateBoundingPolygon(); + } + } + +} diff --git a/src/plp/filters/LightPollutionFilter.java b/src/plp/filters/LightPollutionFilter.java new file mode 100644 index 0000000..8efa318 --- /dev/null +++ b/src/plp/filters/LightPollutionFilter.java @@ -0,0 +1,247 @@ +package plp.filters; + +import java.awt.GridLayout; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.io.FileInputStream; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; + +import com.uber.h3core.util.LatLng; + +import plp.filter.Filter; +import plp.location.LocationCell; +import plp.location.LocationUtils; + +public class LightPollutionFilter implements Filter { + private double minSQM; + private List locations; + private static final String TILE_PATH = "data/lightpollution/binary_tiles/2022/"; + private static final String TILE_URL_BASE = "https://github.com/djlorenz/djlorenz.github.io/raw/refs/heads/master/astronomy/binary_tiles/2022/"; + private static final Map tileDataCache = new HashMap<>(); // Cache for decompressed tiles + private static final int TILE_SIZE = 600; + + public LightPollutionFilter() { + LocationUtils.initialize(); + ensureLightPollutionTilesExist(); + preloadTiles(); + } + + /** + * Ensures the light pollution tiles are downloaded and available in TILE_PATH. + */ + private void ensureLightPollutionTilesExist() { + File tileDirectory = new File(TILE_PATH); + if (!tileDirectory.exists() || tileDirectory.list().length == 0) { + System.out.println("Light pollution tiles not found. Downloading..."); + tileDirectory.mkdirs(); + downloadLightPollutionTiles(); + } + } + + /** + * Downloads light pollution tiles from the specified GitHub URL and saves them locally. + */ + private void downloadLightPollutionTiles() { + try { + for (int x = 1; x <= 72; x++) { // 72 tiles in longitude (5 degrees each) + for (int y = 1; y <= 28; y++) { // 28 tiles in latitude + String fileName = "binary_tile_" + x + "_" + y + ".dat.gz"; + URL fileUrl = new URI(TILE_URL_BASE + fileName).toURL(); + Path localFilePath = Path.of(TILE_PATH, fileName); + + System.out.println("Downloading: " + fileUrl); + try (InputStream in = fileUrl.openStream()) { + Files.copy(in, localFilePath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + System.out.println("Light pollution data downloaded successfully."); + } catch (IOException e) { + throw new RuntimeException("Failed to download light pollution tiles: " + e.getMessage(), e); + } catch (URISyntaxException e) { + throw new RuntimeException("Failed to download light pollution tiles: " + e.getMessage(), e); + } + } + + /** + * Preloads all binary tiles into memory to optimize access. + */ + private static void preloadTiles() { + if (!tileDataCache.isEmpty()) return; + File dir = new File(TILE_PATH); + File[] files = dir.listFiles((d, name) -> name.endsWith(".dat.gz")); + if (files == null) { + throw new RuntimeException("Light pollution data folder is empty or invalid: " + TILE_PATH); + } + + for (File file : files) { + try { + String tileKey = file.getName().replace(".dat.gz", "").replace("binary_tile_", ""); + tileDataCache.put(tileKey, decompressTile(file)); + } catch (IOException e) { + System.err.println("Failed to load tile: " + file.getName()); + } + } + } + + /** + * Decompresses a GZIP file and returns its byte data. + */ + private static byte[] decompressTile(File file) throws IOException { + try (InputStream fileStream = new FileInputStream(file); + GZIPInputStream gzipStream = new GZIPInputStream(fileStream); + ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + + byte[] temp = new byte[1024]; + int bytesRead; + while ((bytesRead = gzipStream.read(temp)) != -1) { + buffer.write(temp, 0, bytesRead); + } + return buffer.toByteArray(); + } + } + + @Override + public void setRequirements(JPanel modifiedParameterPanel) throws IllegalArgumentException { + JTextField[] fields = (JTextField[]) modifiedParameterPanel.getClientProperty("fields"); + double minLat = Double.parseDouble(fields[0].getText()); + setRequirements(minLat); + } + + @Override + public void setRequirements(Object requirements) throws IllegalArgumentException { + if (requirements instanceof Double) { + this.minSQM = (Double) requirements; + System.out.println(this.minSQM); + } else { + throw new IllegalArgumentException("Invalid requirement type for LightPollutionFilter"); + } + } + + @Override + public void setLocations(List locations) { + this.locations = locations; + } + + @Override + public List process() { + // Simulate filtering locations based on light pollution + return locations.stream() + .filter(cell -> getSQM(LocationUtils.getLatLng(cell)) >= minSQM) + .toList(); + } + + @Override + public JPanel getParameterPanel() { + JPanel panel = new JPanel(new GridLayout(1, 2)); + panel.add(new JLabel("Min SQM:")); + JTextField minSQMField = new JTextField(minSQM == 0.0 ? "21.92" : String.valueOf(minSQM)); + panel.add(minSQMField); + panel.putClientProperty("fields", new JTextField[]{minSQMField}); + return panel; + } + + @Override + public String getRequirements() { + return "Minimum SQM: " + String.valueOf(minSQM); + } + + private double getSQM(LocationCell cell) { + return getSQM(LocationUtils.getLatLng(cell)); + } + + private double getSQM(LatLng coords) { + return getSQM(coords.lat, coords.lng); + } + + /** + * Computes the mean SQM for a given LocationCell using the Light Pollution Atlas binary tiles. + * + * @param latitude The latitude of the location. + * @param longitude The longitude of the location. + * @return The mean SQM value for the location. + */ + private double getSQM(double latitude, double longitude) { + + // Convert latitude and longitude to tile and grid indices + double lonFromDateLine = mod(longitude + 180.0, 360.0); + double latFromStart = latitude + 65.0; + + int tileX = (int) Math.floor(lonFromDateLine / 5.0) + 1; + int tileY = (int) Math.floor(latFromStart / 5.0) + 1; + + if (tileY < 1 || tileY > 28) { + throw new IllegalArgumentException("Location out of bounds (65S to 75N latitude)."); + } + + String tileKey = tileX + "_" + tileY; + byte[] data = tileDataCache.get(tileKey); + if (data == null) { + throw new RuntimeException("Tile not found in cache: " + tileKey); + } + + int ix = (int) Math.round(120.0 * (lonFromDateLine - 5.0 * (tileX - 1) + 1.0 / 240.0)); + int iy = (int) Math.round(120.0 * (latFromStart - 5.0 * (tileY - 1) + 1.0 / 240.0)); + + // Ensure indices are within bounds + if (ix < 0 || ix > 600 || iy < 0 || iy > 600) { + throw new RuntimeException("Grid indices out of bounds: ix=" + ix + ", iy=" + iy); + } + + int firstNumber = 128 * data[0] + data[1]; + int change = 0; + + for (int i = 1; i < iy; i++) { + change += data[TILE_SIZE * i + 1]; + } + + for (int i = 1; i < ix; i++) { + change += data[TILE_SIZE * (iy - 1) + 1 + i]; + } + + int compressed = firstNumber + change; + + // Ensure compressed value is valid + if (compressed < 0) { + throw new RuntimeException("Invalid compressed value: " + compressed); + } + + double brightnessRatio = compressed2full(compressed); + return 22.0 - 5.0 * Math.log10(1.0 + brightnessRatio) / Math.log10(100.0); + } + + /** + * Handles the modulo operation for positive and negative numbers. + * @param x + * @param y + * @return x mod y + */ + private static double mod(double x, double y) { + return ((x % y) + y) % y; + } + + /** + * Converts the compressed value to brightness ratio. + * + * @param compressed The compressed brightness value. + * @return The full brightness ratio. + */ + private static double compressed2full(int compressed) { + return (5.0/195.0) * ( Math.exp(0.0195*compressed) - 1.0); + } +} diff --git a/src/plp/filters/OperatorFilter.java b/src/plp/filters/OperatorFilter.java new file mode 100644 index 0000000..a190bbd --- /dev/null +++ b/src/plp/filters/OperatorFilter.java @@ -0,0 +1,96 @@ +package plp.filters; + +import java.awt.Component; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.BoxLayout; +import javax.swing.JPanel; + +import plp.filter.Filter; +import plp.location.LocationCell; +import plp.operator.LogicalOperator; +import plp.operator.OperatorFactory; + +public class OperatorFilter implements Filter { + private LogicalOperator operator; + private final List subFilters = new ArrayList<>(); + private List locations; + + public OperatorFilter() {} + + public void addFilter(Filter filter) { + subFilters.add(filter); + } + + @Override + public void setRequirements(Object requirements) { + if (requirements instanceof LogicalOperator) { + this.operator = (LogicalOperator) requirements; + } else { + throw new IllegalArgumentException("Invalid requirement type for LightPollutionFilter"); + } + } + + @Override + public void setLocations(List locations) { + for (Filter filter : subFilters) { + filter.setLocations(locations); + } + this.locations = locations; + } + + @Override + public List process() { + if (subFilters.isEmpty()) return List.of(); + + List result; + switch (operator) { + case OR -> result = new ArrayList(); + case NOT -> result = locations; + case XOR -> result = new ArrayList(); + default -> throw new IllegalArgumentException("Unsupported operator: " + operator); + } + + for (int i = 0; i < subFilters.size(); i++) { + List nextResult = subFilters.get(i).process(); + + switch (operator) { + case OR -> result = OperatorFactory.applyOr(result, nextResult); + case NOT -> result = OperatorFactory.applyNot(locations, nextResult); + case XOR -> result = OperatorFactory.applyExclusiveOr(result, nextResult); + default -> throw new IllegalArgumentException("Unsupported operator: " + operator); + } + } + return result; + } + + @Override + public JPanel getParameterPanel() { + JPanel allParametersPanel = new JPanel(); + allParametersPanel.setLayout(new BoxLayout(allParametersPanel, BoxLayout.X_AXIS)); + for (Filter filter : subFilters) { + JPanel panel = filter.getParameterPanel(); + Component[] comps = panel.getComponents(); + for (Component comp : comps) { + comp.setEnabled(false); + } + allParametersPanel.add(panel); + } + return allParametersPanel; + } + + @Override + public String getRequirements() { + String filterDetails = ""; + for (Filter filter : subFilters) { + filterDetails += filter.getClass().getSimpleName() + ": " + filter.getRequirements() + ","; + } + filterDetails = filterDetails.substring(0, filterDetails.length() - 1); + return operator + "[" + filterDetails + "]"; + } + + public LogicalOperator getOperator() { + return operator; + } +} diff --git a/src/plp/filters/SunWeatherFilter.java b/src/plp/filters/SunWeatherFilter.java new file mode 100644 index 0000000..b57d19b --- /dev/null +++ b/src/plp/filters/SunWeatherFilter.java @@ -0,0 +1,408 @@ +package plp.filters; + +import java.awt.Color; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.GridLayout; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.imageio.ImageIO; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSlider; +import javax.swing.plaf.basic.BasicSliderUI; + +import com.uber.h3core.util.LatLng; + +import plp.filter.Filter; +import plp.location.LocationCell; +import plp.location.LocationUtils; + +public class SunWeatherFilter implements Filter { + + private List locations; + private static final Map sunriseImages = new HashMap<>(); + private static final Map sunsetImages = new HashMap<>(); + private static final Map sunriseColorToPercentageCache = new HashMap<>(); + private static final Map sunsetColorToPercentageCache = new HashMap<>(); + private static final int barEnd = 197; + private static final int barStart = 886; + private Map> colorCache = new HashMap<>(); + private SunType selectedSunType; + private int percentage = -1; + + static { + try { + // Preload all sunrise images + sunriseImages.put("PT", ImageIO.read(new URI("https://sunsetwx.com/sunrise/sunrise_pt.png").toURL())); + sunriseImages.put("MT", ImageIO.read(new URI("https://sunsetwx.com/sunrise/sunrise_mt.png").toURL())); + sunriseImages.put("CT", ImageIO.read(new URI("https://sunsetwx.com/sunrise/sunrise_ct.png").toURL())); + sunriseImages.put("ET", ImageIO.read(new URI("https://sunsetwx.com/sunrise/sunrise_et.png").toURL())); + + // Preload all sunset images + sunsetImages.put("PT", ImageIO.read(new URI("https://sunsetwx.com/sunset/sunset_pt.png").toURL())); + sunsetImages.put("MT", ImageIO.read(new URI("https://sunsetwx.com/sunset/sunset_mt.png").toURL())); + sunsetImages.put("CT", ImageIO.read(new URI("https://sunsetwx.com/sunset/sunset_ct.png").toURL())); + sunsetImages.put("ET", ImageIO.read(new URI("https://sunsetwx.com/sunset/sunset_et.png").toURL())); + + // Create color to percentage maps + BufferedImage referenceImage = sunriseImages.get("ET"); // Assuming all color bars are the same + for (int y = barStart; y >= barEnd; y--) { + Color barColor = new Color(referenceImage.getRGB(1350, y)); + int percentage = (int) ((double) (y-barStart) / (barEnd-barStart) * 100); + sunriseColorToPercentageCache.put(barColor, percentage); + } + + referenceImage = sunsetImages.get("ET"); // Assuming all color bars are the same + for (int y = barStart; y >= barEnd; y--) { + Color barColor = new Color(referenceImage.getRGB(1350, y)); + int percentage = (int) ((double) (y-barStart) / (barEnd-barStart) * 100); + sunsetColorToPercentageCache.put(barColor, percentage); + } + + } catch (Exception e) { + throw new RuntimeException("Failed to preload weather images: " + e.getMessage(), e); + } + } + + public SunWeatherFilter() {} + + @Override + public String getRequirements() { + return selectedSunType + ", Min Quality: " + percentage + "%"; + } + + @Override + public void setLocations(List locations) { + this.locations = locations; + } + + @Override + public List process() { + colorCache = new HashMap<>(); + return locations.stream() + .filter(cell -> getPercentageFromColor(getColorAt(LocationUtils.getLatLng(cell))) >= percentage) + .toList(); + } + + @Override + public void setRequirements(JPanel modifiedParameterPanel) throws IllegalArgumentException { + JComboBox comboBox = (JComboBox) modifiedParameterPanel.getClientProperty("sunTypeComboBox"); + JSlider slider = (JSlider) modifiedParameterPanel.getClientProperty("percentageSlider"); + + if (comboBox == null || slider == null) { + throw new IllegalArgumentException("Parameters are missing in the panel."); + } + + setRequirements(new SunWeatherRequirements((SunType) comboBox.getSelectedItem(), slider.getValue())); + } + + @Override + public void setRequirements(Object requirements) throws IllegalArgumentException { + if (requirements instanceof String stringRequirement) { + String[] parts = stringRequirement.split(","); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid string format. Expected 'SunType,Percentage'."); + } + + try { + this.selectedSunType = SunType.valueOf(parts[0].trim().toUpperCase()); + this.percentage = Integer.parseInt(parts[1].trim()); + if (this.percentage < 0 || this.percentage > 100) { + throw new IllegalArgumentException("Percentage must be between 0 and 100."); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid SunType or Percentage: " + e.getMessage()); + } + } else if (requirements instanceof SunWeatherRequirements swr) { + this.selectedSunType = swr.getSunType(); + this.percentage = swr.getPercentage(); + if (this.percentage < 0 || this.percentage > 100) { + throw new IllegalArgumentException("Percentage must be between 0 and 100."); + } + } else { + throw new IllegalArgumentException("Unsupported requirements type: " + requirements.getClass().getName()); + } + } + + @Override + public JPanel getParameterPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new GridLayout(2, 2)); + + JLabel sunTypeLabel = new JLabel("Sun Event:"); + JComboBox sunTypeComboBox = new JComboBox<>(SunType.values()); + if (selectedSunType != null) {sunTypeComboBox.setSelectedItem(selectedSunType);} + panel.add(sunTypeLabel); + panel.add(sunTypeComboBox); + + JLabel percentageLabel = new JLabel("Minimum Quality Percentage:"); + ColorBarSlider percentageSlider = new ColorBarSlider(0, 100, percentage == -1 ? 50 : percentage); + percentageSlider.setMajorTickSpacing(20); + percentageSlider.setPaintTicks(true); + percentageSlider.setPaintLabels(true); + percentageSlider.setSunType(selectedSunType); + + sunTypeComboBox.addActionListener(e -> { + selectedSunType = (SunType) sunTypeComboBox.getSelectedItem(); + percentageSlider.setSunType(selectedSunType); + }); + + panel.add(percentageLabel); + panel.add(percentageSlider); + + panel.putClientProperty("sunTypeComboBox", sunTypeComboBox); + panel.putClientProperty("percentageSlider", percentageSlider); + + return panel; + } + + /** + * Gets the appropriate weather image based on the longitude and selected sun type. + * + * @param longitude The longitude of the location. + * @return The corresponding BufferedImage. + */ + private BufferedImage getWeatherImage(double longitude) { + Map imageMap = selectedSunType == SunType.Sunrise ? sunriseImages : sunsetImages; + if (longitude <= -113) { + return imageMap.get("PT"); + } else if (longitude <= -98) { + return imageMap.get("MT"); + } else if (longitude <= -83) { + return imageMap.get("CT"); + } else { + return imageMap.get("ET"); + } + } + + /** + * Gets the color of the weather image at the specified coordinates. + * + * @param x The x-coordinate of the pixel. + * @param y The y-coordinate of the pixel. + * @return The Color of the pixel at the given coordinates. + */ + private Color getColorAt(LatLng location) { + BufferedImage weatherImage = getWeatherImage(location.lng); + int x = (int) (21.2182 * location.lng + 2726.27); + int y = (int) (-25.4166 * location.lat + 1504.25); + return getColorAt(weatherImage, x, y); + } + + /** + * Gets the color of the weather image at the specified coordinates. + * + * @param image The BufferedImage to analyze. + * @param x The x-coordinate of the pixel. + * @param y The y-coordinate of the pixel. + * @return The Color of the pixel at the given coordinates, adjusted for border effects. + */ + private Color getColorAt(BufferedImage image, int x, int y) { + if (image == null) { + throw new IllegalStateException("Weather image not initialized."); + } + if (colorCache.containsKey(x) && colorCache.get(x).containsKey(y)) { + return colorCache.get(x).get(y); + } + int rgb = image.getRGB(x, y); + Color color = new Color(rgb); + + // Adjust for potential border dithering + if (!isOnCurrentColorbar(color)) { + color = adjustForBorder(image, x, y); + } + + colorCache.putIfAbsent(x, new HashMap()); + colorCache.get(x).put(y, color); + return color; + } + + /** + * Adjusts the color by averaging nearby pixels to mitigate border effects. + * + * @param image The image being analyzed. + * @param x The x-coordinate of the pixel. + * @param y The y-coordinate of the pixel. + * @return The adjusted color. + */ + private Color adjustForBorder(BufferedImage image, int x, int y) { + int sampleRadius = 1; // Check neighboring pixels within this radius + int r = 0, g = 0, b = 0, count = 0; + + for (int dx = -sampleRadius; dx <= sampleRadius; dx++) { + for (int dy = -sampleRadius; dy <= sampleRadius; dy++) { + int nx = x + dx; + int ny = y + dy; + + if (nx >= 0 && nx < image.getWidth() && ny >= 0 && ny < image.getHeight()) { + Color neighborColor = new Color(image.getRGB(nx, ny)); + if (isOnCurrentColorbar(neighborColor)) { // Only use valid colors + r += neighborColor.getRed(); + g += neighborColor.getGreen(); + b += neighborColor.getBlue(); + count++; + } + } + } + } + + if (count > 0) { + return new Color(r / count, g / count, b / count); + } else { + return new Color(0, 0, 0); // Fallback to black if all neighbors are borders + } + } + + /** + * Matches a given color to a percentage based on a predefined color bar. + * The pink end represents 0%, and the red end represents 100%. + * + * @param inputColor The input color to match. + * @return The corresponding percentage (0–100). + */ + private int getPercentageFromColor(Color inputColor) { + + // Check cache for exact match first + if (selectedSunType == SunType.Sunrise) { + if (sunriseColorToPercentageCache.containsKey(inputColor)) { + return sunriseColorToPercentageCache.get(inputColor); + } + } else { + if (sunsetColorToPercentageCache.containsKey(inputColor)) { + return sunsetColorToPercentageCache.get(inputColor); + } + } + + // Fallback + BufferedImage referenceImage = selectedSunType == SunType.Sunrise ? sunriseImages.get("ET") : sunsetImages.get("ET"); + + int barEnd = 197; + int barStart = 886; + int closestY = -1; + double closestDistance = Double.MAX_VALUE; + + for (int y = barStart; y >= barEnd; y--) { // Traverse the color bar from bottom to top + Color barColor = new Color(referenceImage.getRGB(1350, y)); // 1350 is the middle of the colorbar in the image + double distance = calculateColorDistance(barColor, inputColor); + + if (distance < closestDistance) { + closestDistance = distance; + closestY = y; + } + } + + return (int) ((double) (closestY-barStart) / (barEnd-barStart) * 100); + } + + /** + * Determine if the current color is one from the colorbar. + * + * @param inputColor The input color to check. + * @return Whether the color has a perfect match. + */ + private boolean isOnCurrentColorbar(Color inputColor) { + + // Check cache for exact match first + if (selectedSunType == SunType.Sunrise) { + if (sunriseColorToPercentageCache.containsKey(inputColor)) { + return true; + } + } else { + if (sunsetColorToPercentageCache.containsKey(inputColor)) { + return true; + } + } + + return false; + } + + /** + * Calculates the distance between two colors in RGB space. + * + * @param c1 The first color. + * @param c2 The second color. + * @return The distance between the two colors. + */ + private double calculateColorDistance(Color c1, Color c2) { + int redDiff = c1.getRed() - c2.getRed(); + int greenDiff = c1.getGreen() - c2.getGreen(); + int blueDiff = c1.getBlue() - c2.getBlue(); + return Math.sqrt(redDiff * redDiff + greenDiff * greenDiff + blueDiff * blueDiff); + } + + /** + * SunWeatherRequirements - Helper class for structured input. + */ + public static class SunWeatherRequirements { + private final SunType sunType; + private final int percentage; + + public SunWeatherRequirements(SunType sunType, int percentage) { + this.sunType = sunType; + this.percentage = percentage; + } + + public SunType getSunType() { + return sunType; + } + + public int getPercentage() { + return percentage; + } + } + + enum SunType { + Sunrise, Sunset + } + + /** + * Custom JSlider to display the color bar as its background. + */ + public static class ColorBarSlider extends JSlider { + private SunType sunType; + static final int barEnd = 197; // From sunsetwx image + static final int barStart = 886; // From sunsetwx image + + public ColorBarSlider(int min, int max, int value) { + super(min, max, value); + setUI(new ColorBarSliderUI(this)); + } + + public void setSunType(SunType sunType) { + this.sunType = sunType; + repaint(); + } + + private static class ColorBarSliderUI extends BasicSliderUI { + public ColorBarSliderUI(JSlider b) { + super(b); + } + + @Override + public void paintTrack(Graphics grphcs) { + Graphics2D g2 = (Graphics2D) grphcs.create(); + BufferedImage referenceImage = ((ColorBarSlider) slider).sunType == SunType.Sunset + ? sunsetImages.get("ET") : sunriseImages.get("ET"); + + if (referenceImage != null) { + int trackWidth = trackRect.width; + int trackHeight = trackRect.height; + int trackStart = trackRect.x; + int trackEnd = trackStart + trackRect.width; + for (int x = trackStart; x < trackEnd; x++) { + Color barColor = new Color(referenceImage.getRGB(1350, (int)(barStart - (((x-trackStart)*1.0 / trackWidth) * (barStart - barEnd)) ))); + g2.setColor(barColor); + g2.fillRect(x, 0, 1, trackHeight); + } + } + g2.dispose(); + } + } + } +} diff --git a/src/plp/location/LocationCell.java b/src/plp/location/LocationCell.java new file mode 100644 index 0000000..d736a66 --- /dev/null +++ b/src/plp/location/LocationCell.java @@ -0,0 +1,18 @@ +package plp.location; + +public class LocationCell { + private final Long h3Index; + + public LocationCell(Long h3Index) { + this.h3Index = h3Index; + } + + public Long getH3Index() { + return h3Index; + } + + @Override + public String toString() { + return String.valueOf(h3Index); + } +} \ No newline at end of file diff --git a/src/plp/location/LocationUtils.java b/src/plp/location/LocationUtils.java new file mode 100644 index 0000000..391ccf9 --- /dev/null +++ b/src/plp/location/LocationUtils.java @@ -0,0 +1,32 @@ +package plp.location; + +import java.io.IOException; + +import com.uber.h3core.H3Core; +import com.uber.h3core.util.LatLng; + +public class LocationUtils { + private static H3Core h3; + + /** + * Initializes the Util with H3 + */ + public static void initialize() { + if (h3 != null) return; + try { + h3 = H3Core.newInstance(); + } catch (IOException e) { + throw new RuntimeException("Failed to initialize H3 library", e); + } + } + + /** + * Get the latitude and longitude of the center of a cell + * @param LocationCell cell + * @return The LatLng H3 object for the position of the cell + */ + public static LatLng getLatLng(LocationCell cell) { + if (h3 == null) initialize(); + return h3.cellToLatLng(cell.getH3Index()); + } +} diff --git a/src/plp/operator/LogicalOperator.java b/src/plp/operator/LogicalOperator.java new file mode 100644 index 0000000..7eaed21 --- /dev/null +++ b/src/plp/operator/LogicalOperator.java @@ -0,0 +1,5 @@ +package plp.operator; + +public enum LogicalOperator { + OR, XOR, NOT +} diff --git a/src/plp/operator/OperatorFactory.java b/src/plp/operator/OperatorFactory.java new file mode 100644 index 0000000..fdd0656 --- /dev/null +++ b/src/plp/operator/OperatorFactory.java @@ -0,0 +1,42 @@ +package plp.operator; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import plp.location.LocationCell; + +public class OperatorFactory { + public static List applyAnd(List list1, List list2) { + return list1.stream().filter(list2::contains).toList(); + } + + public static List applyOr(List list1, List list2) { + Set resultSet = new HashSet<>(list1); + resultSet.addAll(list2); + return new ArrayList<>(resultSet); + } + + public static List applyIntersect(List list1, List list2) { + return applyAnd(list1, list2); + } + + public static List applyNot(List list1, List list2) { + return list1.stream() + .filter(cell -> !list2.contains(cell)) + .toList(); + } + + public static List applyExclusiveOr(List list1, List list2) { + Set resultSet = new HashSet<>(list1); + resultSet.addAll(list2); + + Set intersection = new HashSet<>(list1); + intersection.retainAll(list2); + + resultSet.removeAll(intersection); + + return new ArrayList<>(resultSet); + } +} diff --git a/src/plp/output/KMLGenerator.java b/src/plp/output/KMLGenerator.java new file mode 100644 index 0000000..6c7b003 --- /dev/null +++ b/src/plp/output/KMLGenerator.java @@ -0,0 +1,275 @@ +package plp.output; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.awt.Desktop; +import java.io.File; + +import com.uber.h3core.H3Core; +import com.uber.h3core.util.LatLng; + +import de.micromata.opengis.kml.v_2_2_0.Document; +import de.micromata.opengis.kml.v_2_2_0.Kml; +import de.micromata.opengis.kml.v_2_2_0.LinearRing; +import de.micromata.opengis.kml.v_2_2_0.Placemark; +import de.micromata.opengis.kml.v_2_2_0.Polygon; +import plp.location.LocationCell; + +public class KMLGenerator { + public static void generateKML(List locations, String fileName) { + System.out.println("Generating KML: " + fileName); + try { + H3Core h3 = H3Core.newInstance(); + Kml kml = new Kml(); + Document document = kml.createAndSetDocument().withName("Filtered Hexagons"); + + System.out.println("\tAmalgomating..."); + List> outerBoundaries = amalgamateHexagons(locations, h3); + + // Separate outer rings and holes + System.out.println("\tAssociating holes..."); + Map, List>> polygonsWithHoles = detectAndAssociateHoles(outerBoundaries); + + System.out.println("\tDrawing..."); + for (Map.Entry, List>> entry : polygonsWithHoles.entrySet()) { + List outerRing = entry.getKey(); + List> holes = entry.getValue(); + + Placemark placemark = document.createAndAddPlacemark().withName(String.valueOf(outerRing.getFirst())); + placemark.createAndAddStyle().createAndSetPolyStyle().withColor("aa0000ff"); // Red color in KML (ABGR format) + Polygon polygon = placemark.createAndSetPolygon(); + + // Add outer boundary + LinearRing outerBoundary = polygon.createAndSetOuterBoundaryIs().createAndSetLinearRing(); + for (LatLng coord : outerRing) { + outerBoundary.addToCoordinates(coord.lng, coord.lat, 0); + } + // Close the outer ring + LatLng first = outerRing.get(0); + outerBoundary.addToCoordinates(first.lng, first.lat, 0); + + // Add holes as inner boundaries + for (List hole : holes) { + LinearRing innerBoundary = polygon.createAndAddInnerBoundaryIs().createAndSetLinearRing(); + for (LatLng coord : hole) { + innerBoundary.addToCoordinates(coord.lng, coord.lat, 0); + } + // Close the hole ring + LatLng holeFirst = hole.get(0); + innerBoundary.addToCoordinates(holeFirst.lng, holeFirst.lat, 0); + } + } + + kml.marshal(new File(fileName)); + System.out.println("KML file generated: " + fileName); + } catch (IOException e) { + System.err.println("Failed to generate KML: " + e.getMessage()); + } + } + + /** + * Amalgamates connected hexagon cells by merging shared edges and returning outer boundaries. + * + * @param hexagonCells List of LocationCells (H3 indexes). + * @param h3 H3Core instance. + * @return List of outer boundaries represented as lists of GeoCoords. + */ + private static List> amalgamateHexagons(List hexagonCells, H3Core h3) { + // Store the unique edges of all hexagons + Set edgeSet = new HashSet<>(); + + for (LocationCell cell : hexagonCells) { + Long h3Index = cell.getH3Index(); + List boundary = h3.cellToBoundary(h3Index); + + // Loop through vertices to create edges + for (int i = 0; i < boundary.size(); i++) { + LatLng start = boundary.get(i); + LatLng end = boundary.get((i + 1) % boundary.size()); + + Edge edge = new Edge(start, end); + if (!edgeSet.remove(edge)) { + edgeSet.add(edge); + } + } + } + + // Build polygons from remaining unique edges + return buildPolygonsFromEdges(edgeSet); + } + + /** + * Builds polygons from unique edges by connecting them into rings. + * + * @param edges Set of unique edges. + * @return List of polygons represented as lists of GeoCoords. + */ + private static List> buildPolygonsFromEdges(Set edges) { + List> polygons = new ArrayList<>(); + + while (!edges.isEmpty()) { + List polygon = new ArrayList<>(); + Edge currentEdge = edges.iterator().next(); + edges.remove(currentEdge); + + polygon.add(currentEdge.start); + LatLng nextPoint = currentEdge.end; + + while (!nextPoint.equals(polygon.get(0))) { + Iterator iterator = edges.iterator(); + while (iterator.hasNext()) { + Edge edge = iterator.next(); + if (edge.start.equals(nextPoint)) { + polygon.add(edge.start); + nextPoint = edge.end; + iterator.remove(); + break; + } else if (edge.end.equals(nextPoint)) { + polygon.add(edge.end); + nextPoint = edge.start; + iterator.remove(); + break; + } + } + } + + polygons.add(polygon); + } + + return polygons; + } + + /** + * Detects and associates holes and islands with their correct parent polygons. + * + * @param allBoundaries List of all boundaries (both outer polygons and potential holes). + * @return A map of outer boundaries to their associated holes and islands. + */ + private static Map, List>> detectAndAssociateHoles(List> allBoundaries) { + Map, List>> result = new LinkedHashMap<>(); + + // Step 1: Sort boundaries by size (larger boundaries first) + allBoundaries.sort((a, b) -> Integer.compare(b.size(), a.size())); // Descending size + + // Step 2: Build a hierarchy of polygons + List nodes = new ArrayList<>(); + for (List boundary : allBoundaries) { + PolygonNode node = new PolygonNode(boundary); + for (PolygonNode parent : nodes) { + if (isPolygonInsidePolygon(node.polygon, parent.polygon)) { + parent.children.add(node); + node.parent = parent; + break; + } + } + if (node.parent == null) { // Top-level outer boundary + nodes.add(node); + } + } + + // Step 3: Convert hierarchy into a map of outer boundaries to holes + for (PolygonNode node : nodes) { + result.put(node.polygon, collectHoles(node)); + } + + return result; + } + + /** + * Collects all holes (children) under a given polygon node. + */ + private static List> collectHoles(PolygonNode node) { + List> holes = new ArrayList<>(); + for (PolygonNode child : node.children) { + holes.add(child.polygon); // Child polygons are treated as holes + } + return holes; + } + + /** + * Determines if one polygon is inside another using the point-in-polygon test. + */ + private static boolean isPolygonInsidePolygon(List inner, List outer) { + return isPointInsidePolygon(outer, inner.getFirst()); + } + + /** + * Checks if a point is inside a polygon using the ray-casting algorithm. + */ + private static boolean isPointInsidePolygon(List polygon, LatLng point) { + boolean result = false; + int j = polygon.size() - 1; + + for (int i = 0; i < polygon.size(); i++) { + if ((polygon.get(i).lat > point.lat) != (polygon.get(j).lat > point.lat) && + (point.lng < (polygon.get(j).lng - polygon.get(i).lng) * + (point.lat - polygon.get(i).lat) / + (polygon.get(j).lat - polygon.get(i).lat) + polygon.get(i).lng)) { + result = !result; + } + j = i; + } + return result; + } + + /** + * Represents an edge between two GeoCoords, ensuring symmetry. + */ + private static class Edge { + LatLng start, end; + + Edge(LatLng start, LatLng end) { + this.start = start; + this.end = end; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Edge other = (Edge) obj; + return (start.equals(other.start) && end.equals(other.end)) || + (start.equals(other.end) && end.equals(other.start)); + } + + @Override + public int hashCode() { + return start.hashCode() + end.hashCode(); + } + } + + /** + * A helper class representing a polygon node in the hierarchy. + */ + private static class PolygonNode { + List polygon; // The polygon boundary + PolygonNode parent; // Parent polygon + List children; // Child polygons (holes or islands) + + PolygonNode(List polygon) { + this.polygon = polygon; + this.children = new ArrayList<>(); + } + } + + public static void openKMLInGoogleEarth(String fileName) { + try { + File kmlFile = new File(fileName); + + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(kmlFile); + System.out.println("Opening KML file in Google Earth Pro..."); + } else { + System.out.println("Desktop not supported. Please open " + fileName + " manually."); + } + } catch (IOException e) { + System.err.println("Error opening KML file: " + e.getMessage()); + } + } +} diff --git a/src/plp/ui/FilterUI.java b/src/plp/ui/FilterUI.java new file mode 100644 index 0000000..c26d246 --- /dev/null +++ b/src/plp/ui/FilterUI.java @@ -0,0 +1,400 @@ +package plp.ui; + +import org.reflections.Reflections; + +import plp.filter.DataFilter; +import plp.filter.Filter; +import plp.filter.InitialFilter; +import plp.filters.OperatorFilter; +import plp.location.LocationCell; +import plp.operator.LogicalOperator; +import plp.output.KMLGenerator; + +import javax.swing.*; +import java.awt.*; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * FilterUI - A dynamic UI for managing and configuring filters. + * + * This class provides a Swing-based graphical user interface that dynamically loads + * filters from the "filters" package, renders appropriate parameter input components, + * allows users to configure the filters, and executes the filter pipeline to generate + * a KML file. + * + * Key Features: + * - Dynamic filter discovery using reflection. + * - Custom parameter input components for each filter. + * - Visual list of configured filters. + * - Execution of filters and KML generation. + */ +public class FilterUI extends JFrame { + private JComboBox filterSelectionBox; + private JPanel parameterPanel; + private DefaultListModel filterListModel; + private ArrayList addedFilters; + private Map availableFilters; + + public FilterUI() throws Exception { + super("PrecisionLocationProcessor"); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setLayout(new BorderLayout()); + setSize(800, 600); + + // Progress bar setup + JProgressBar progressBar = new JProgressBar(); + progressBar.setStringPainted(true); + progressBar.setString("Loading filters..."); + add(progressBar, BorderLayout.SOUTH); + + SwingWorker worker = new SwingWorker<>() { + @Override + protected Void doInBackground() throws Exception { + loadAvailableFilters(progressBar); // Load all filters dynamically + return null; + } + + @Override + protected void done() { + remove(progressBar); + initializeUIComponents(); + revalidate(); + repaint(); + } + }; + + worker.execute(); + + + } + + private void initializeUIComponents() { + // UI Components + filterSelectionBox = new JComboBox<>(availableFilters.keySet().toArray(new String[0])); + parameterPanel = new JPanel(); + filterListModel = new DefaultListModel<>(); + JList filterList = new JList<>(filterListModel); + addedFilters = new ArrayList<>(); + + JButton addButton = new JButton("Add Filter"); + JButton runButton = new JButton("Run Filters"); + JButton addCompositeButton = new JButton("Add Composite Filter"); // Button to add composite filters + JButton removeButton = new JButton("Remove Filter"); + + // Layout: Top Panel - Filter Selection + JPanel topPanel = new JPanel(); + topPanel.add(new JLabel("Select Filter:")); + topPanel.add(filterSelectionBox); + topPanel.add(addButton); + topPanel.add(addCompositeButton); + topPanel.add(removeButton); + + // Layout: Center Panel - Parameter input and filter list + add(topPanel, BorderLayout.NORTH); + add(parameterPanel, BorderLayout.CENTER); + add(new JScrollPane(filterList), BorderLayout.EAST); + add(runButton, BorderLayout.SOUTH); + + filterSelectionBox.addActionListener(e -> { + try { + updateParameterPanel(); + } catch (Exception e1) { + // TODO Auto-generated catch block + e1.printStackTrace(); + } + }); // Load parameter panel dynamically + + addButton.addActionListener(e -> { + try { + addFilter(); + } catch (Exception e1) { + // TODO Auto-generated catch block + e1.printStackTrace(); + } + }); // Add filter to the list + + removeButton.addActionListener(e -> { + int selectedIndex = filterList.getSelectedIndex(); + if (selectedIndex != -1) { + filterListModel.remove(selectedIndex); + addedFilters.remove(selectedIndex); + } else { + JOptionPane.showMessageDialog(this, "No filter selected to remove."); + } + }); + + addCompositeButton.addActionListener(e -> addCompositeFilter()); // Add composite filters + runButton.addActionListener(e -> runFilters()); // Execute pipeline and generate KML + + try { + updateParameterPanel(); + } catch (Exception e1) { + // TODO Auto-generated catch block + e1.printStackTrace(); + } // Initialize with the first filter's parameters + } + + /** + * Dynamically loads all classes in the "filters" package that implement the Filter interface. + * Populates the availableFilters map with filter names and instances. + */ + private void loadAvailableFilters(JProgressBar progressBar) { + availableFilters = new HashMap<>(); + Reflections reflections = new Reflections("plp.filters"); + Set> classes = reflections.getSubTypesOf(Filter.class); + classes.removeIf(filterClass -> filterClass.equals(OperatorFilter.class) || Modifier.isAbstract(filterClass.getModifiers())); + + int totalClasses = classes.size(); + int progress = 0; + + for (Class filterClass : classes) { + progressBar.setString("Loading filters... " + (progress+1) + "/" + totalClasses + " – " + filterClass.getSimpleName()); + try { + Filter filter = filterClass.getDeclaredConstructor().newInstance(); + availableFilters.put(filterClass.getSimpleName(), filter); + } catch (Exception e) { + e.printStackTrace(); + } + + progress++; + int progressPercentage = (int) ((progress / (double) totalClasses) * 100); + progressBar.setValue(progressPercentage); + } + + progressBar.setIndeterminate(false); + progressBar.setValue(100); + progressBar.setString("Filters loaded."); + } + + /** + * Updates the parameter input panel to match the selected filter. + * This dynamically renders the input fields defined by the selected filter. + * @throws SecurityException + * @throws NoSuchMethodException + * @throws InvocationTargetException + * @throws IllegalArgumentException + * @throws IllegalAccessException + * @throws InstantiationException + */ + private void updateParameterPanel() throws Exception { + parameterPanel.removeAll(); // Clear existing components + String selectedFilter = (String) filterSelectionBox.getSelectedItem(); + Filter filter = availableFilters.get(selectedFilter).getClass().getDeclaredConstructor().newInstance(); + if (filter != null) { + parameterPanel.add(filter.getParameterPanel()); // Add the filter's parameter UI + } + parameterPanel.revalidate(); + parameterPanel.repaint(); + } + + /** + * Adds a filter instance with user-specified parameters to the pipeline. + * Validates user input and displays the filter in the configured filter list. + * @throws SecurityException + * @throws NoSuchMethodException + * @throws InvocationTargetException + * @throws IllegalArgumentException + * @throws IllegalAccessException + * @throws InstantiationException + * + */ + private void addFilter() throws Exception { + + String selectedFilter = (String) filterSelectionBox.getSelectedItem(); + Filter filter = availableFilters.get(selectedFilter).getClass().getDeclaredConstructor().newInstance(); + + if (filter != null) { + try { + JPanel filterParameterPanel = (JPanel) parameterPanel.getComponent(0); + filter.setRequirements(filterParameterPanel); // Set the requirements dynamically + addedFilters.add(filter); // Add filter instance to the list + filterListModel.addElement(selectedFilter + ": " + filter.getRequirements()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(rootPane, "Invalid input: " + ex.getMessage()); + } + } + } + + /** + * Adds a composite filter with logical operators and sub-filters. + * Allows the user to set parameters for sub-filters dynamically. + */ + private void addCompositeFilter() { + createCompositeFilter().thenAccept(compositeFilter -> { + if (compositeFilter != null) { + addedFilters.add(compositeFilter); + filterListModel.addElement(compositeFilter.getRequirements()); + } + }).exceptionally(ex -> { + ex.printStackTrace(); // Log errors + return null; + }); + } + + /** + * Adds a composite filter with logical operators and sub-filters. + * Allows the user to set parameters for sub-filters dynamically. + */ + private CompletableFuture createCompositeFilter() { + + CompletableFuture future = new CompletableFuture<>(); + + // Create a dialog to select sub-filters and operators + JFrame dialog = new JFrame("Create Composite Filter"); + dialog.setSize(700, 800); + dialog.setLayout(new BorderLayout()); + dialog.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + + DefaultListModel selectedFiltersModel = new DefaultListModel<>(); + + // Get operators dynamically from LogicalOperator enum + JComboBox operatorBox = new JComboBox<>(Arrays.stream(LogicalOperator.values()) + .map(Enum::name).toArray(String[]::new)); + + JPanel parameterContainer = new JPanel(); + parameterContainer.setLayout(new BoxLayout(parameterContainer, BoxLayout.Y_AXIS)); + + JButton addSubFilterButton = new JButton("Add Sub-Filter"); + addSubFilterButton.addActionListener(e -> { + try { + String selectedFilter = (String) filterSelectionBox.getSelectedItem(); + selectedFiltersModel.addElement(selectedFilter); + Filter filter = availableFilters.get(selectedFilter).getClass().getDeclaredConstructor().newInstance(); + if (filter != null) { + JPanel subFilterPanel = filter.getParameterPanel(); + subFilterPanel.setBorder(BorderFactory.createTitledBorder(selectedFilter)); + parameterContainer.add(subFilterPanel); + parameterContainer.revalidate(); + parameterContainer.repaint(); + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Exception Adding new sub-filter: " + ex.getMessage()); + } + }); + + JButton addNestedCompositeButton = new JButton("Add Nested Composite Filter"); + addNestedCompositeButton.addActionListener(e -> { + createCompositeFilter().thenAccept(nestedComposite -> { + if (nestedComposite != null) { + selectedFiltersModel.addElement("Nested Composite Filter (" + nestedComposite.getOperator().toString() + ")"); + JPanel nestedPanel = nestedComposite.getParameterPanel(); + nestedPanel.setBorder(BorderFactory.createTitledBorder("Nested Composite Filter (" + nestedComposite.getOperator().toString() + ")")); + nestedPanel.putClientProperty("filter", nestedComposite); + parameterContainer.add(nestedPanel); + parameterContainer.revalidate(); + parameterContainer.repaint(); + } + }); + }); + + JButton createButton = new JButton("Create Composite Filter"); + createButton.addActionListener(e -> { + String operator = (String) operatorBox.getSelectedItem(); + OperatorFilter compositeFilter = new OperatorFilter(); + compositeFilter.setRequirements(LogicalOperator.valueOf(operator)); + + for (Component component : parameterContainer.getComponents()) { + if (component instanceof JPanel panel) { + Object filterProperty = panel.getClientProperty("filter"); + + if (filterProperty instanceof OperatorFilter nestedComposite) { + compositeFilter.addFilter(nestedComposite); + } else { + try { + String filterName = selectedFiltersModel.getElementAt(parameterContainer.getComponentZOrder(panel)); + Filter subFilter = availableFilters.get(filterName).getClass().getDeclaredConstructor().newInstance(); + + if (subFilter != null) { + try { + subFilter.setRequirements(panel); + compositeFilter.addFilter(subFilter); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Invalid input for sub-filter " + filterName + ": " + ex.getMessage()); + future.completeExceptionally(ex); // Handle exceptions + return; + } + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Exception creating composite filter: " + ex.getMessage()); + } + } + } + } + + future.complete(compositeFilter); // Complete the future + dialog.dispose(); + }); + + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> { + future.complete(null); // Signal cancellation + dialog.dispose(); + }); + + JPanel buttonPanel = new JPanel(); + buttonPanel.add(addSubFilterButton); + buttonPanel.add(addNestedCompositeButton); + buttonPanel.add(createButton); + buttonPanel.add(cancelButton); + + dialog.add(operatorBox, BorderLayout.NORTH); + dialog.add(new JScrollPane(parameterContainer), BorderLayout.CENTER); + dialog.add(buttonPanel, BorderLayout.SOUTH); + + dialog.setVisible(true); + return future; // Return the constructed composite filter + } + + + /** + * Executes the filter pipeline, applies all configured filters, and generates a KML file. + * Displays a success message upon completion. + */ + private void runFilters() { + DataFilter dataFilter = null; + InitialFilter initialBounds = null; + + // Find the first InitialFilter in the list + for (Filter filter : addedFilters) { + if (filter instanceof InitialFilter) { + initialBounds = (InitialFilter) filter; + dataFilter = new DataFilter(initialBounds); + break; + } + } + + if (dataFilter == null) { + JOptionPane.showMessageDialog(this, "Error: An InitialFilter is required to start the pipeline.", + "Missing BoundingBoxFilter", JOptionPane.ERROR_MESSAGE); + return; + } + + // Add all configured filters to the pipeline + for (Filter filter : addedFilters) { + if (filter.equals(initialBounds)) continue; + dataFilter.addFilter(filter); + } + + // Run the filters and generate KML + List filteredLocations = dataFilter.filterLocations(); + KMLGenerator.generateKML(filteredLocations, "ui_filtered_hexagons.kml"); + KMLGenerator.openKMLInGoogleEarth("ui_filtered_hexagons.kml"); + JOptionPane.showMessageDialog(this, "Filters applied! KML file generated: ui_filtered_hexagons.kml"); + } + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + try { + new FilterUI().setVisible(true); + } catch (Exception e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + }); + } +} +