Masters programme | E-portfolio
Semester I & II
Application Development

Tweet mapper

Java app that maps tweets contained in a .csv file as a timeseries representation in Google Earth.

Table of Contents

Application Purpose

This java app realises a timeseries representation of tweets in GoogleEarthPro mapped on top of a base map retrieved from a WMS. Tweets are represented as markers whose size is proportional to the length of the tweets, i.e. the number of characters. As the used twitter.csv file mostly contains tweets sent in the New York area on 5 November 2012 on the eve of the presidential election, it was further decided to distinguish between election-related and non-election-related tweets using different marker colours. Once a user clicks on the marker, a pop-up displays the tweets content and datetime of its creation.

Architecture & Design Decisions

UML class diagramm showing the different components of the app

As shown in the class diagram, the Java application for mapping tweets consists of a total of six classes. GoogleEarthTweetMapper is the main class, in which objects of the other five classes are instantiated sequentially. The modular structure of the application with the grouping of related sub-steps as separate classes aims at the greatest possible degree of flexibility and expandability of the application. In the main class, all essential variables are visible, and the process logic of the programme is evident, while the complexity of the underlying algorithms is abstracted.

				
					package eot_aribisala_kroeber_sramo;

import java.awt.Desktop;
import java.io.File;
import java.io.IOException;

public class GoogleEarthTweetMapper {

	public static void main(String[] args) {

		// intial setup - setting workdir
		System.out.println("--- Setup ---");
		String work_dir = new WorkDirSetter().workdir;

		// part I - WMS Map Acquisition & Storage
		// works for example also with the following wms service:
		// String _wms_service = "http://giswebservices.massgis.state.ma.us/geoserver/wms";
		// String _layer = "Census 2000 Tracts";
		System.out.println();
		System.out.println("--- Step I: Retrieving basemap ---");
		String wms_service = "https://maps.heigit.org/osm-wms/service";
		String bbox = "-74.10,40.70,-73.85,40.80"; // bbox new york instead of boston: -71.13,42.32,-71.03,42.42;
		String download_dir = work_dir + File.separatorChar + "basemap.png";
		String layer = "osm_auto:all"; // equivalent to String _layer = "OSM WMS - osm-wms.de";
		WMSImageDownloader wms = new WMSImageDownloader(wms_service);
		wms.getmap(bbox, layer);
		wms.savemap(download_dir);

		// part II - twitter data preparation
		System.out.println();
		System.out.println("--- Step II: Reading twitter data ---");
		String twitter_file = work_dir + File.separatorChar + "twitter.csv";
		String tweet_varname = "tweet";
		TwitterCSVReader twitter_data = new TwitterCSVReader(twitter_file);
		twitter_data.calc_tweet_length(tweet_varname);
		twitter_data.check_election_related(tweet_varname);

		// part III - kml file creation
		System.out.println();
		System.out.println("--- Step III: Creating KML file ---");
		String basemap = new KMLElements().wmsgroundoverlay(download_dir, bbox);
		String markers = new KMLElements().tweetsvis(twitter_data);
		KMLGenerator kml = new KMLGenerator(new String[] {basemap, markers});
		String save_dir = work_dir + File.separatorChar + "tweets_mapped.kml";
		kml.savefile(save_dir);

		// part IV - launch GoogleEarth	with default application
		System.out.println();
		System.out.println("--- Step IV: Opening KML file ---");
		File kml_file = new File(save_dir);
		try {
			Desktop.getDesktop().open(kml_file);
		} catch (IOException e) {
			e.printStackTrace();
		}

	}

}
				
			

In the following, the design and functionality of the app is described by following the structure of the main class GoogleEarthTweetMapper. The java source code which is discussed can also be accessed via the repository linked below. The probably easiest way to modify, test and execute the java code locally is to clone this repo using the predefined functionality provided by the Eclipse IDE. The final .kml visualisation file (to be opened with Google Earth Pro) is provided as part of the repo, too.

a.) Setup

First, WorkDirSetter is called in the main class to define the working directory for reading the twitter file and writing the map and the kml file. The WorkDirSetter class contains a scanner that prompts the user of the programme to specify an appropriate directory, but also offers the option of using a default location. This default location corresponds to the package directory in which the source files are nested but also the twitter.csv is located. This makes it easy to use the programme (e.g. by cloning from GitHub) without having to specify paths at all. Within WorkDirSetter it is explicitly checked whether the twitter.csv required in the following steps is available in the specified or default path. If not, the user is asked to specify a path again using a while loop.
				
					package eot_aribisala_kroeber_sramo;

import java.io.File;
import java.util.Arrays;
import java.util.Scanner;

public class WorkDirSetter {

	// define basic instance variables
	public String workdir;

	// constructor to set workdir
	public WorkDirSetter() {
		System.out.println("Please enter the current working directory.\n" +
						   "This needs to be the path to the folder where " +
						   "the input twitter.csv file resides.\n" +
						   "You can also simply hit enter to use the sample twitter " +
						   "file (if you cloned the whole project from github).\n" +
						   "Enter directory:");
		Scanner _userinput = new Scanner(System.in);

		// while loop until valid input is given
		while(true) {
			// parse user input
			String _user_work_dir = _userinput.nextLine().toString();
			try {
				// set workdir to default loc or user-specified dir
				if(_user_work_dir.isBlank()) {
					workdir = System.getProperty("user.dir");
				} else {
					workdir = _user_work_dir;	
				}
				// check if mandatory tweet file is present
				String[] filenames;
				File path = new File(workdir);
				filenames = path.list();
				if (Arrays.asList(filenames).contains("twitter.csv")) {
					_userinput.close();
					break;
				} else {
					System.out.println("Your specified directory does not contain " +
							           "the necessary file 'twitter.csv'");
				}		
			} catch (NullPointerException e) {
				System.out.println("Your specified directory does not exist.");
			}	
		}
	}

}
				
			

b.) WMS map acquisition & download

After the setup, the connection to the WMS is established and the corresponding base map based on the service address, the layer, the bounding box and the file name under which the storage is to take place is queried and downloaded. The class WMSImageDownloader illustrates the general pattern of the other classes as well: Basic and compulsory processes are defined under the constructor and are thus executed as initialisation procedures during instantiation. When a WMS is requested, this includes establishing the connection and querying the capabilities as a prerequisite for all further interactions with the server. These further functionalities – in this case, the query of a specific layer and the subsequent local storage – are defined within the framework of public methods, which are called from the main class after instantiation. Auxiliary functions, on the other hand, which can only be used meaningfully within the subclass itself, are limited in their visibility to private. The definition of the connection to the WMS within the constructor leaves few design choices open and is straight forward. getmap() makes use of two auxiliary functions that seem to be additional overhead for the immediate implementation of the given task but give the class a generic character in the sense of an extended range of possible use cases. calc_img_size() allows to calculate the parameters of height and width when querying the WMS map based on the specified bounding box. Compared to hardcoding these parameters, this provides the flexibility to change the specification of the bounding box depending on the area of interest and automatically download the image file at an adjusted size. The size calculation is based on a targeted spatial resolution of the downloaded map (default value: 5 m), which in turn requires the construction of a while loop within getmap() to catch excessive downloads and resulting errors. convert_name_title() provides additional flexibility when querying the WMS by allowing both layer titles and layer names to be used. The resulting flexibility of WMSImageDownloader is made clear in GoogleEarthTweetMapper via alternative ways to query the WMS (see the parts that are commented out). The result of the getmap() request is in any case a BufferedImage stored as an attribute of the WMSImageDownloader object. Since the subsequent downloading of the image emerged from didactical considerations and is not particularly efficient and performant, writing to the disk was outsourced to a separate method savemap(). In hypothetical alternative use cases of the class, a direct reuse of the BufferedImage might be more desired and a use of savemap() therefore be disregarded.

				
					package eot_aribisala_kroeber_sramo;

import java.awt.Dimension;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.Arrays;

import javax.imageio.ImageIO;

import org.apache.commons.lang3.ArrayUtils;
import org.geotools.ows.ServiceException;
import org.geotools.ows.wms.Layer;
import org.geotools.ows.wms.WMSCapabilities;
import org.geotools.ows.wms.WMSUtils;
import org.geotools.ows.wms.WebMapServer;
import org.geotools.ows.wms.request.GetMapRequest;
import org.geotools.ows.wms.response.GetMapResponse;

import net.sf.geographiclib.Geodesic;
import net.sf.geographiclib.GeodesicMask;

public class WMSImageDownloader {

	// define basic instance variables
	// category a: wms connection variables
	public String wms_service;
	public WebMapServer wms_server;
	public WMSCapabilities wms_capabilities;
	public Layer[] wms_layers;
	// category b: wms getmap request variables
	public String bbox;
	public Layer layer;
	public Dimension img_size;
	public BufferedImage img;
	// category c: save map variables	
	public String download_dir;

	// constructor to initialize WMSImageDownlaoder
	public WMSImageDownloader(String _wms_service) {
		// set value for instance variable as part of initialization procedure
		wms_service = _wms_service;
		// establish connection to server
		wms_server = connectwmsserver(wms_service);
		// performing GetCapabilitiesRequest
		wms_capabilities = wms_server.getCapabilities();
		// get & print basic information about server
		String serverTitle = wms_capabilities.getService().getTitle().toString();
		System.out.println("WMS-Capabilities sucessfully retrieved from service: " + serverTitle);
		// get & print basic information about layers
		wms_layers = WMSUtils.getNamedLayers(wms_capabilities);
		System.out.println("Available layers: " + Arrays.toString(wms_layers));
	}


	// method for WMS GetMap request
	public void getmap(String _bbox, String _layername) {
		// set values for instance variables: bbox, layer, dimensions
		bbox = _bbox;
		img_size = calc_img_size(_bbox);
		layer = convert_name_title(_layername);
		// requesting map
		boolean retry = true;
		while(retry) {
			try {
				// in general do not retry in case of failure
				retry = false;
				// constructing the GetMap request by populating it with the parameter values
				GetMapRequest request = wms_server.createGetMapRequest();
				request.setFormat("image/png");
				request.setTransparent(true);
				request.setDimensions(img_size);
				request.setSRS("EPSG:4326");
				request.setBBox(bbox);
				request.addLayer(layer);
				// executing the GetMap request and parsing the response as a buffered image
				GetMapResponse response = (GetMapResponse) wms_server.issueRequest(request);
				img = ImageIO.read(response.getInputStream());
			} catch (ServiceException e) {
				// reduce img_size in case of images too large to download
				if (e.getMessage().equals("image size too large")) {
					System.out.println("GetMapRequest failed due to image size: " +
							           "Retrying using smaller resolution");
					img_size = new Dimension((int) img_size.width/2, (int) img_size.height/2);
					retry = true;
				} else {
					e.printStackTrace();
				};
			} catch (IOException e) {
				e.printStackTrace();
			}	
		}
	}

	// method for saving requested map to disk
	public void savemap(String _download_dir) {
		// set values for instance variable: download_dir
		download_dir = _download_dir;
		// open file & write to it
		try {
			File f = new File(download_dir);
			ImageIO.write(this.img, "png", f);
			System.out.println("File written to disk.");
		} catch(IOException e) {
			e.printStackTrace();
		}
	}

	// auxiliary function to establish wms server connection
	private WebMapServer connectwmsserver(String wms_service) {
		try { 
			URL url = new URL(wms_service + "?VERSION=1.1.1&Request=GetCapabilities&Service=WMS");
			wms_server = new WebMapServer(url);
		} catch (ServiceException | IOException e) {
			e.printStackTrace();
		}
		return wms_server;
	}

	// auxiliary function to set dimension based on specified bbox
	private Dimension calc_img_size(String bbox) {
		// define required resolution in m 
		double req_resolution = 5;
		// get bbox coordinates
		double lon1 = Double.parseDouble(bbox.split(",")[0]);
		double lat1 = Double.parseDouble(bbox.split(",")[1]);
		double lon2 = Double.parseDouble(bbox.split(",")[2]);
		double lat2 = Double.parseDouble(bbox.split(",")[3]);
		double mean_lat = (lat1+lat2)/2;
		double mean_lon = (lon1+lon2)/2;
		// calculate img_size proportional to bbox extent
		double map_height = Geodesic.WGS84.Inverse(lat1, mean_lon, lat2, mean_lon, GeodesicMask.DISTANCE).s12;
		double map_width = Geodesic.WGS84.Inverse(mean_lat, lon1, mean_lat, lon2, GeodesicMask.DISTANCE).s12;
		Dimension img_dims = new Dimension((int) (map_width/req_resolution), (int) (map_height/req_resolution));
		return img_dims;
	}

	// auxiliary function to allow specification of layer titles as well as layer names
	private Layer convert_name_title(String specified_layer) {
		try {
			// check for existence of specified layer title 
			if (Arrays.toString(wms_layers).contains(specified_layer)) {
				// get all layers by title
				String[] layer_titles = new String[wms_capabilities.getLayerList().size()-1];
				for (int i = 1; i < wms_capabilities.getLayerList().size(); i++) {
					layer_titles[i-1] = wms_capabilities.getLayerList().get(i).toString();
				}
				// get index of specified layer title
				int idx_layer = ArrayUtils.indexOf(layer_titles, specified_layer);
				// return specified layer
				return wms_layers[idx_layer];
			// check for existence of specified layer name
			} else {
				// get all layers by name
				String[] layer_names = new String[wms_capabilities.getLayerList().size()-1];
				for (int i = 1; i < wms_capabilities.getLayerList().size(); i++) {
					layer_names[i-1] = wms_capabilities.getLayerList().get(i).getName();
				};
				// get index of specified layer name
				int idx_layer = ArrayUtils.indexOf(layer_names, specified_layer);
				// return specified layer
				return wms_layers[idx_layer];
			}
		} catch (Exception e){
			// throw new error if neither layer title nor layer name is found
			throw new IllegalArgumentException("Layer not offered by the WMS. " +
											   "Please take a look at the list of available layers.");
		}
	}

}
				
			

c.) Twitter data preparation

As with WMSImageDownloader, in TwitterCSVReader, which is used to read and modify the tweet data, everything optional is offloaded to separate methods. This includes the calculation of the two new features “tweet_length” and “election_related” as of course other features could be chosen/calculated for visualization. The initial reading and parsing of the twitterfile, on the other hand, is a step that is required in any case and the corresponding procedure is therefore again found under the constructor. The most fundamental design decision concerns the choice of the data structure into which the data is to be parsed. In this case, a LinkedHashMap with string arrays as values was chosen. The advantage of the hashmap as a dictionary-like data format is the explicit addressability of the keys (= variable names). Nevertheless, since other characteristics of the hashmap (e.g. the possibility to store string arrays of different lengths for the individual keys) are less relevant in the given case, it should be noted that parsing into an alternative data format such as a 2D array would also be possible. The parsed data set is finally callable as the attribute .data of the WMSImageDownloader object. The information about the number of tweets, also relevant across classes (-> see KMLElements), is stored separately as an independent attribute and is therefore easily accessible.

				
					package eot_aribisala_kroeber_sramo;

import java.io.BufferedReader;
import java.io.FileReader;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;

public class TwitterCSVReader {

	// define basic instance variables	
	public Map<String, String[]> data = new LinkedHashMap<String, String[]>();
	public int linecount;

	// constructor to initialize TwitterCSVReader
	public TwitterCSVReader(String _file_source) { 
		try { 
			// initialize reader to read twitter file
			BufferedReader reader = new BufferedReader(new FileReader(_file_source, Charset.forName("UTF-8")));
			// examine length of csv file
			linecount = (int) Files.lines(Paths.get(_file_source)).count();
			// define csv separator
			String separator = ";";
			// iterating through csv lines & parse them into hashmap
			String line = null;
			int count = 0;
			while ((line = reader.readLine()) != null) {
				// get csv header to define the hashmap keys
				if(count == 0) {
					for (int i = 0; i < line.split(separator).length; i++) {
						String var_name = line.split(separator)[i];
						String[] values = new String[linecount-1];
						data.put(var_name, values);
					}
				// parse rest of file into the value arrays for each key
				} else {
					for (int i = 0; i < line.split(separator).length; i++) {
						String entry = data.keySet().stream().skip(i).findFirst().get();
						data.get(entry)[count-1] = line.split(separator)[i];
					}
				}
				count++;
			}
			// close reader to release memory & print info to console
			reader.close();
			System.out.println("Twitter file with " + linecount + " entries successfully parsed.");
		}  
		catch (Exception e) {  
			e.printStackTrace();  
		}  
	}

	// method for deriving tweet length for visualization
	public void calc_tweet_length(String _tweet_varname) {
		// get tweets
		String[] tweets = data.get(_tweet_varname);
		// create new key-value-pair (kvp) for tweet length
		String[] values = new String[linecount-1];
		data.put("tweet_length", values);
		// calculate length values for each tweet & populate the corresponding kvp
		for (int i = 1; i<linecount; i++) {
			data.get("tweet_length")[i-1] = String.valueOf(tweets[i-1].length());
		}
	}

	// method to check if tweet is election-related or not
	public void check_election_related(String _tweet_varname) {
		// create list of words that are likely to be election-related
		String[] election_words = {"vote", "voting", "election", "president", "barack", "obama", "mitt", "romney"};
		// get tweets
		String[] tweets = data.get(_tweet_varname);
		// create new key-value-pair (kvp) for references to election
		String[] values = new String[linecount-1];
		data.put("election_related", values);
		// fill values of kvp by checking each tweet for occurrence of election words
		for (int i = 1; i<linecount; i++) {
			for (int j = 0; j < election_words.length; j++) {
				if (tweets[i-1].toLowerCase().contains(election_words[j])) {
					data.get("election_related")[i-1] = "probably";
					break;
				}
				else {
					data.get("election_related")[i-1] = "probably not";
				}	
			}
		}
	}
	
}
				
			

d.) KML file creation

As can be seen from the class diagram, two related classes are used for the next step of the kml file generation. KMLGenerator is a very generic class that only provides the outer skeleton of a kml file as well as a method for saving the kml. The actual content is provided by KMLElements and embedded later. The generation of the individual kml elements, specifically the WMS overlay and the tweet markers, could easily be extended or reduced by further elements in the current form of the implementation. The concrete composition of the kml elements is based on the official kml documentation. The transparency of the WMS overlay, for example, is controlled by the first two hexadecimal digits of the value for the element.

When creating the markers for the tweets, it was decided not to use the kml-inherent possibilities for extruding a polygon. Instead, own 3D marker shapes (inverted pyramid shapes) were defined as these shapes are more intuitive and thus suited for visualisation purposes. Their custom implementation requires the individual side surfaces of the marker to be defined as separate polygons and combined under a multi-geometry element. To make the size of the markers (more precisely: their volume) proportional to the length of the tweets, the auxiliary function pyramid_vertices_coords() is used to calculate the corner points of the markers. Using a previously specified height/edge length ratio (h/a), the volume formula of the pyramid is made dependent solely on the edge length a, which can then be calculated based on the length of the tweets. Using the calculated edge length, the coordinates of the corner points of the markers can then be calculated starting from the centre point. Likewise, the height h of the marker results from a and the previously defined height/edge length ratio.

Geometry of the pyramid markers
				
					package eot_aribisala_kroeber_sramo;

import java.io.FileWriter;
import java.io.IOException;

public class KMLGenerator {

	// define basic instance variables
	public String kmlstring;

	// constructor to compose well-formed kml document given certain kml elements
	public KMLGenerator(String[] kml_elements) {
		// compose kml body
		String kml_body = "";
		for (int i=0; i<kml_elements.length; i++) {
			kml_body += kml_elements[i];
		}
		// embed body in kml skeleton
		kmlstring = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
					"<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n" +
						"<Document>\n" +
							kml_body + "\n" +
						"</Document>\n" +
					"</kml>\n";
	}

	// method to save kml file to disk
	public void savefile(String _save_dir) {
		FileWriter fileWriter;
		try {
			fileWriter = new FileWriter(_save_dir);
			fileWriter.write(kmlstring);
			fileWriter.close();	
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}
				
			
				
					package eot_aribisala_kroeber_sramo;

import net.sf.geographiclib.Geodesic;

public class KMLElements {

	// compose ground overlay element for wms map
	public String wmsgroundoverlay(String _img_path, String _extent) {
		String kml =
				"<GroundOverlay>\n" +
					"<name>wms overlay</name>\n" +
					"<color>4Dffffff</color>\n" + // hex color code for 70% transparency
					"<Icon>\n" +
						"<href>" + _img_path + "</href>\n" +
					"</Icon>\n" +
					"<LatLonBox>\n" +
						"<north>" + _extent.split(",")[3] + "</north>\n" +
						"<south>" + _extent.split(",")[1] + "</south>\n" +
						"<east>" + _extent.split(",")[2] + "</east>\n" +
						"<west>" + _extent.split(",")[0] + "</west>\n" +
					"</LatLonBox>\n" +
				"</GroundOverlay>\n";
		return kml;
	}

	// compose marker elements for tweets
	public String tweetsvis(TwitterCSVReader _twitter_reader) {
		String kml = "";
		// loop over all tweets
		for (int i=0; i<_twitter_reader.linecount-1; i++) {
			// get all relevant parameters for a single tweet
			String tweet_id = _twitter_reader.data.get("id")[i];
			String tweet = _twitter_reader.data.get("tweet")[i];
			String created_at = _twitter_reader.data.get("created_at")[i];
			String user_id = _twitter_reader.data.get("user_id")[i];
			String lat = _twitter_reader.data.get("lat")[i];
			String lon = _twitter_reader.data.get("lng")[i];
			String tweet_length = _twitter_reader.data.get("tweet_length")[i];
			String election_related = _twitter_reader.data.get("election_related")[i];
			// compose kml snippet for each tweet
			kml +=  // create style for marker (edge width & color)
					"<Style id='pyramid_style'>\n" +
					"<LineStyle>\n" +
						"<width>1</width>\n" +
					"</LineStyle>\n" +
					"<PolyStyle>\n" +
						pyramid_color(election_related) +
					"</PolyStyle>\n" +
					"</Style>\n" +
					// create placemark
					"<Placemark>\n" +
						"<name>Tweet_ID: "+ tweet_id +"</name>\n" +
						// create description
						"<description>" +
							"<![CDATA[" +
							"<p>" + tweet +"</p>" +
							"<p><i>user_id: " + user_id + "</br>" +
							"created at: " + created_at.split("\\+")[0] + "</i></p>" +
							"]]>" +
						"</description>" +
						// enable time series visualization
						"<TimeStamp><when>" + created_at.replace(' ', 'T') + ":00" + "</when></TimeStamp>" +
						// apply style
						"<styleUrl>#pyramid_style</styleUrl>" +
						// define geometry of marker by putting together all sides of a pyramid
						"<MultiGeometry>\n" +
							"<Polygon>\n"+
								"<extrude>0</extrude>\n"+
								"<altitudeMode>relativeToGround</altitudeMode>\n"+
								"<outerBoundaryIs>\n"+
									"<LinearRing>\n"+
										"<coordinates>\n"+
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
										"</coordinates>\n"+
									"</LinearRing>\n"+
								"</outerBoundaryIs>\n"+
							"</Polygon>\n" +
							"<Polygon>\n"+
								"<extrude>0</extrude>\n"+
								"<altitudeMode>relativeToGround</altitudeMode>\n"+
								"<outerBoundaryIs>\n"+
									"<LinearRing>\n"+
										"<coordinates>\n"+
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
										"</coordinates>\n"+
									"</LinearRing>\n"+
								"</outerBoundaryIs>\n"+
							"</Polygon>\n" +
							"<Polygon>\n"+
								"<extrude>0</extrude>\n"+
								"<altitudeMode>relativeToGround</altitudeMode>\n"+
								"<outerBoundaryIs>\n"+
									"<LinearRing>\n"+
										"<coordinates>\n"+
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
										"</coordinates>\n"+
									"</LinearRing>\n"+
								"</outerBoundaryIs>\n"+
							"</Polygon>\n" +
							"<Polygon>\n"+
								"<extrude>0</extrude>\n"+
								"<altitudeMode>relativeToGround</altitudeMode>\n"+
								"<outerBoundaryIs>\n"+
									"<LinearRing>\n"+
										"<coordinates>\n"+
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "bottom") + "\n" +
										"</coordinates>\n"+
									"</LinearRing>\n"+
								"</outerBoundaryIs>\n"+
							"</Polygon>\n" +
							"<Polygon>\n"+
								"<extrude>0</extrude>\n"+
								"<altitudeMode>relativeToGround</altitudeMode>\n"+
								"<outerBoundaryIs>\n"+
									"<LinearRing>\n"+
										"<coordinates>\n"+
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_west") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_south_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_east") + "\n" +
											pyramid_vertices_coords(lat, lon, tweet_length, "top_north_west") + "\n" +
										"</coordinates>\n"+
									"</LinearRing>\n"+
								"</outerBoundaryIs>\n"+
							"</Polygon>\n" +
						"</MultiGeometry>\n" +
					"</Placemark>\n";
		}
		return kml;
	};

	// auxiliary function to specify marker geometry
	private String pyramid_vertices_coords(String lat, String lon, String size_var, String type) {
		// define basic geometry of markers in terms of volume and edge length/height ratio
		// volume proportional to size_var
		double volume =  Double.parseDouble(size_var) * 500;
		double ratio_h_a = 2;
		// derive edge length and height in m
		double a = Math.pow(3*volume/ratio_h_a,1.0/3.0);
		String h = Double.toString(a*ratio_h_a);
		// calculate edge coordinates of pyramid marker based on central point's lat/lon & edge length in m
		String north_x = Double.toString(Geodesic.WGS84.Direct(Double.parseDouble(lat), Double.parseDouble(lon), 0.0, 0.5*a).lat2);
		String east_y = Double.toString(Geodesic.WGS84.Direct(Double.parseDouble(lat), Double.parseDouble(lon), 90.0, 0.5*a).lon2);
		String south_x = Double.toString(Geodesic.WGS84.Direct(Double.parseDouble(lat), Double.parseDouble(lon), 180.0, 0.5*a).lat2);
		String west_y = Double.toString(Geodesic.WGS84.Direct(Double.parseDouble(lat), Double.parseDouble(lon), 270.0, 0.5*a).lon2);
		// return kml-conform coordinates depending on the request
		switch(type) {
		case "top_north_east": return east_y + "," + north_x + "," + h;
		case "top_south_east": return east_y + "," + south_x + "," + h;
		case "top_south_west": return west_y + "," + south_x + "," + h;
		case "top_north_west": return west_y + "," + north_x + "," + h;
		case "bottom": return lon + "," + lat + ",0";
		default: return "";
		}
	};

	// auxiliary function to specify marker color
	private String pyramid_color(String color_var) {
		// if tweet is likely to be election related set color to yellow
		if (color_var == "probably") {
			return "<color>e050e7fc</color>";
		}
		// otherwise set color to blue
		else {
			return "<color>e0835d12</color>";
		}
	}

}

				
			

e.) Launching GoogleEarthPro

In the last part of the main class, the created file is then opened with the default application for kml files. If a corresponding local installation is available, this is usually GoogleEarthPro. If there is no such installation, the user is automatically asked how to open the kml file and can then choose a text editor, for example, to check the file for correctness. The implementation with Desktop.getDesktop() is considered superior to the possible alternative Runtime.getRuntime().exec(), since no local path installation has to be hardcoded, so that portability in the sense of proper execution of the programme across computers is ensured.

Potential future improvements

Depending on which hypothetic goal is still to be pursued with the current application, there is still potential for optimisation in the following points:
 
  • Since the WMSImageDownloader is a very generic class with many parameters, it also requires a lot of testing. Especially if a future extension of this class is desired, it may be useful to define a test suite for this class to systematically test the correct functioning of the class under a wide range of parameters.
  • The twitter data is currently simply parsed without any further data cleaning. Depending on the analysis goal, it might make sense to filter out parts of the messages (e.g. spaces, hashtags,…). It should also be noted that the classification of tweets into probably electionrelated and non-election-related ones is implemented in a very simplified form. Instead of searching for agreements with one of the eight hardcoded words, more elaborate methods of semantic analysis using machine learning could be used to categorise the content of the tweets.
  • The generation of pyramid markers is relatively inefficient and involves large kml files. In terms of scalability, this requires alternatives if tens of thousands of tweets were to be visualised. While the pyramid geometry itself can also be encoded as a single polygon, it has not yet been possible to apply the corresponding colour design to such an object. Therefore the current implementation still relies on the more complex multigeometry element.