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

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
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 data = new LinkedHashMap();
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
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.

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\n" +
"\n" +
"\n" +
kml_body + "\n" +
" \n" +
" \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 =
"\n" +
"wms overlay \n" +
"4Dffffff \n" + // hex color code for 70% transparency
"\n" +
"" + _img_path + " \n" +
" \n" +
"\n" +
"" + _extent.split(",")[3] + " \n" +
"" + _extent.split(",")[1] + " \n" +
"" + _extent.split(",")[2] + " \n" +
"" + _extent.split(",")[0] + " \n" +
" \n" +
" \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)
"\n" +
// create placemark
"\n" +
"Tweet_ID: "+ tweet_id +" \n" +
// create description
"" +
"" + tweet +"" +
"user_id: " + user_id + "" +
"created at: " + created_at.split("\\+")[0] + "
" +
"]]>" +
" " +
// enable time series visualization
"" + created_at.replace(' ', 'T') + ":00" + " " +
// apply style
"#pyramid_style " +
// define geometry of marker by putting together all sides of a pyramid
"\n" +
"\n"+
"0 \n"+
"relativeToGround \n"+
"\n"+
"\n"+
"\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" +
" \n"+
" \n"+
" \n"+
" \n" +
"\n"+
"0 \n"+
"relativeToGround \n"+
"\n"+
"\n"+
"\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" +
" \n"+
" \n"+
" \n"+
" \n" +
"\n"+
"0 \n"+
"relativeToGround \n"+
"\n"+
"\n"+
"\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" +
" \n"+
" \n"+
" \n"+
" \n" +
"\n"+
"0 \n"+
"relativeToGround \n"+
"\n"+
"\n"+
"\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" +
" \n"+
" \n"+
" \n"+
" \n" +
"\n"+
"0 \n"+
"relativeToGround \n"+
"\n"+
"\n"+
"\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" +
" \n"+
" \n"+
" \n"+
" \n" +
" \n" +
" \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 "e050e7fc ";
}
// otherwise set color to blue
else {
return "e0835d12 ";
}
}
}
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
- 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.