commit 1eca8867751df644a62752fbbfbc6a6de849de74
parent bfda3532ea58a48533ceaa417b1bd5c3f5137be3
Author: Étienne Simon <esimon@esimon.eu>
Date: Mon, 11 May 2015 20:00:04 -0400
Add visualizer.
Lasciate ogni speranza voi ch'entrate:
I am the bone of my javascript
DOM is my body and JQuery is my blood
I have created over a thousand lines
Unknown to death
Nor known to life
Have withstood pain to create many functions
Yet those hands shall never type anything
So, as I pray, Unlimited Openlayers Works
Diffstat:
10 files changed, 1557 insertions(+), 0 deletions(-)
diff --git a/visualizer/HTTPServer.py b/visualizer/HTTPServer.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python
+
+import os
+import sys
+import urllib
+import SimpleHTTPServer
+import SocketServer
+from cStringIO import StringIO
+
+import h5py
+
+import data
+from data.hdf5 import TaxiDataset
+from visualizer import Vlist, Path
+
+
+visualizer_path = os.path.join(data.path, 'visualizer')
+source_path = os.path.split(os.path.realpath(__file__))[0]
+
+test_data = None
+train_data = None
+
+class VisualizerHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+ def send_head(self):
+ spath = self.path.split('?')[0]
+ path = spath.split('/')[1:]
+ if len(path) == 1:
+ if path[0] == '':
+ path[0] = 'index.html'
+ file_path = os.path.join(source_path, path[0])
+ return self.send_file(file_path)
+ elif path[0] == 'ls':
+ return self.send_datalist()
+ elif path[0] == 'get':
+ return self.send_file(os.path.join(visualizer_path, spath[5:]))
+ elif path[0] == 'extract':
+ return self.send_extract(spath[9:])
+
+ def send_file(self, file_path):
+ file_path = urllib.unquote(file_path)
+ ctype = self.guess_type(file_path)
+
+ try:
+ f = open(file_path, 'rb')
+ except IOError:
+ self.send_error(404, 'File not found')
+ return None
+ try:
+ self.send_response(200)
+ self.send_header('Content-type', ctype)
+ fs = os.fstat(f.fileno())
+ self.send_header('Content-Length', str(fs[6]))
+ self.send_header('Last-Modified', self.date_time_string(fs.st_mtime))
+ self.end_headers()
+ return f
+ except:
+ f.close()
+ raise
+
+ def send_datalist(self):
+ l = []
+ for path, subs, files in os.walk(visualizer_path):
+ for file in files:
+ mtime = os.stat('%s/%s' % (path, file))[8]
+ l.append('{"path":["%s"],"name":"%s","mtime":%d}' % ('","'.join(path[len(visualizer_path):].split('/')), file, mtime))
+ l.sort()
+ f = StringIO()
+ f.write("[")
+ f.write(','.join(l))
+ f.write("]")
+ length = f.tell()
+ f.seek(0)
+ self.send_response(200)
+ encoding = sys.getfilesystemencoding()
+ self.send_header("Content-type", "text/html; charset=%s" % encoding)
+ self.send_header("Content-Length", str(length))
+ self.end_headers()
+ return f
+
+ def send_extract(self, query):
+ f = StringIO()
+ query = urllib.unquote(query)
+ content = Vlist()
+ for (i,sub) in enumerate(query.split(',')):
+ r = sub.split('-')
+ if len(r)==1:
+ if sub.strip()[0].lower()=='t':
+ sub=sub.strip()[1:]
+ content.append(Path(test_data.extract(int(sub)), 'T%s<br>'%sub))
+ else:
+ content.append(Path(train_data.extract(int(sub)), '%s<br>'%sub))
+ elif len(r)==2:
+ test = False
+ if r[0].strip()[0].lower()=='t':
+ test = True
+ r[0]=r[0].strip()[1:]
+ if r[1].strip()[0].lower()=='t':
+ r[1]=r[1].strip()[1:]
+ for i in xrange(int(r[0]), int(r[1])+1):
+ if test:
+ content.append(Path(test_data.extract(i), 'T%d<br>'%i))
+ else:
+ content.append(Path(train_data.extract(i), '%d<br>'%i))
+ elif len(r)>2:
+ self.send_error(404, 'File not found')
+ return None
+ content.write(f)
+ length = f.tell()
+ f.seek(0)
+ self.send_response(200)
+ encoding = sys.getfilesystemencoding()
+ self.send_header("Content-type", "text/html; charset=%s" % encoding)
+ self.send_header("Content-Length", str(length))
+ self.end_headers()
+ return f
+
+if __name__ == '__main__':
+ if len(sys.argv) != 2:
+ print >>sys.stderr, 'Usage: %s port' % sys.argv[0]
+
+ print >>sys.stderr, 'Loading dataset...',
+ path = os.path.join(data.path, 'data.hdf5')
+ train_data = TaxiDataset('train')
+ test_data = TaxiDataset('test')
+ print >>sys.stderr, 'done'
+
+ httpd = SocketServer.TCPServer(('', int(sys.argv[1])), VisualizerHTTPRequestHandler)
+ httpd.serve_forever()
diff --git a/visualizer/__init__.py b/visualizer/__init__.py
@@ -0,0 +1,130 @@
+import os
+import json
+import getpass
+from datetime import datetime
+import itertools
+
+import numpy
+
+import data
+
+
+class NumpyEncoder(json.JSONEncoder):
+ def default(self, o):
+ if type(o).__module__ == numpy.__name__:
+ return o.item()
+ super(NumpyEncoder, self).default(o)
+
+
+class EGJ(object):
+ def save(self, path=getpass.getuser(), append=False):
+ path = os.path.join(data.path, 'visualizer', path)
+ if append:
+ if not os.path.isdir(path):
+ raise ValueError("Can't append to the given directory")
+ name = str(1+max(map(int, filter(str.isdigit, os.listdir(path)))+[-1]))
+ path = os.path.join(path, name)
+ else:
+ while os.path.isdir(path):
+ path = os.path.join(path, '0')
+
+ with open(path, 'w') as f:
+ self.write(f)
+
+ def write(self, file):
+ file.write(json.dumps(self.object(), cls=NumpyEncoder))
+
+ def type(self):
+ return 'raw'
+
+ def options(self):
+ return []
+
+ def object(self):
+ return {
+ 'type': self.type(),
+ 'data': {
+ 'type': 'FeatureCollection',
+ 'crs': {
+ 'type': 'name',
+ 'properties': {
+ 'name': 'urn:ogc:def:crs:OGC:1.3:CRS84'
+ }
+ },
+ 'features': self.features()
+ }
+ }
+
+
+class Point(EGJ):
+ def __init__(self, latitude, longitude, info=None):
+ self.latitude = latitude
+ self.longitude = longitude
+ self.info = info
+
+ def features(self):
+ d = {
+ 'type': 'Feature',
+ 'geometry': {
+ 'type': 'Point',
+ 'coordinates': [self.longitude, self.latitude]
+ }
+ }
+ if self.info is not None:
+ d['properties'] = { 'info': self.info }
+ return [d]
+
+
+class Path(EGJ):
+ def __init__(self, path, info=''):
+ self.path = path
+ self.info = info
+
+ def features(self):
+ info = self.info + '''trip_id: %(trip_id)s<br>
+ call_type: %(call_type_f)s<br>
+ origin_call: %(origin_call)d<br>
+ origin_stand: %(origin_stand)d<br>
+ taxi_id: %(taxi_id)d<br>
+ timestamp: %(timestamp_f)s<br>
+ day_type: %(day_type_f)s<br>
+ missing_data: %(missing_data)d<br>''' \
+ % dict(self.path,
+ call_type_f = ['central', 'stand', 'street'][self.path['call_type']],
+ timestamp_f = datetime.fromtimestamp(self.path['timestamp']).strftime('%c'),
+ day_type_f = ['normal', 'holiday', 'holiday eve'][self.path['day_type']])
+
+ return [{
+ 'type': 'Feature',
+ 'properties': {
+ 'info': info,
+ 'display': 'path',
+ 'timestamp': self.path['timestamp']
+ },
+ 'geometry': {
+ 'type': 'LineString',
+ 'coordinates': [[lon, lat] for (lat, lon) in zip(self.path['latitude'], self.path['longitude'])]
+ }
+ }]
+
+
+class Vlist(EGJ, list):
+ def __init__(self, cluster=False, heatmap=False, *args):
+ list.__init__(self, *args)
+ self.cluster = cluster
+ self.heatmap = heatmap
+
+ def type(self):
+ if self.cluster or self.heatmap:
+ if all(isinstance(c, Point) for c in self):
+ if self.cluster:
+ return 'cluster'
+ elif self.heatmap:
+ return 'heatmap'
+ else:
+ raise ValueError('Building a %s with something that is not a Point' % ('cluster' if self.cluster else 'heatmap'))
+ else:
+ return 'raw'
+
+ def features(self):
+ return list(itertools.chain.from_iterable(p.features() for p in self))
diff --git a/visualizer/extract_all.sh b/visualizer/extract_all.sh
@@ -0,0 +1 @@
+find extractor -type f -print -exec {} \;
diff --git a/visualizer/extractor/destinations.py b/visualizer/extractor/destinations.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+
+from data.hdf5 import taxi_it
+from visualizer import Vlist, Point
+
+
+_sample_size = 5000
+
+if __name__ == '__main__':
+ points = Vlist(cluster=True)
+ for line in taxi_it('train'):
+ if len(line['latitude'])>0:
+ points.append(Point(line['latitude'][-1], line['longitude'][-1]))
+ if len(points) >= _sample_size:
+ break
+ points.save('destinations (cluster)')
+ points.cluster = False
+ points.heatmap = True
+ points.save('destinations (heatmap)')
diff --git a/visualizer/extractor/stands.py b/visualizer/extractor/stands.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+
+from data.hdf5 import taxi_it
+from visualizer import Vlist, Point
+
+
+if __name__ == '__main__':
+ it = taxi_it('stands')
+ next(it) # Ignore the "no stand" entry
+
+ points = Vlist()
+ for (i, line) in enumerate(it):
+ points.append(Point(line['stands_latitude'], line['stands_longitude'], 'Stand (%d): %s' % (i+1, line['stands_name'])))
+ points.save('stands')
diff --git a/visualizer/extractor/test_positions.py b/visualizer/extractor/test_positions.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python
+
+from data.hdf5 import taxi_it
+from visualizer import Vlist, Point
+
+
+if __name__ == '__main__':
+ points = Vlist(heatmap=True)
+ for line in taxi_it('test'):
+ for (lat, lon) in zip(line['latitude'], line['longitude']):
+ points.append(Point(lat, lon))
+ points.save('test positions')
diff --git a/visualizer/extractor/train_poi.py b/visualizer/extractor/train_poi.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python
+
+import os
+
+import data
+from data.hdf5 import TaxiDataset
+from visualizer import Path
+
+
+poi = {
+ 'longest': 1492417
+}
+
+if __name__ == '__main__':
+ prefix = os.path.join(data.path, 'visualizer', 'Train POI')
+ if not os.path.isdir(prefix):
+ os.mkdir(prefix)
+
+ d = TaxiDataset('train')
+ for (k, v) in poi.items():
+ Path(d.extract(v)).save(os.path.join('Train POI', k))
diff --git a/visualizer/index.html b/visualizer/index.html
@@ -0,0 +1,71 @@
+<html lang="en">
+<head>
+ <title>Taxi Visualizer</title>
+ <meta charset="UTF-8" />
+ <link rel="stylesheet" href="http://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
+ <link rel="stylesheet" href="http://openlayers.org/en/v3.4.0/css/ol.css">
+ <link rel="stylesheet" href="style.css">
+
+ <script src="http://code.jquery.com/jquery-1.10.2.js"></script>
+ <script src="http://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
+ <script src="http://openlayers.org/en/v3.4.0/build/ol.js"></script>
+ <script src="script.js"></script>
+</head>
+<body>
+ <div id="map" class="map">
+ <div id="featureinfo">
+ <div id="featureinfo-static"></div>
+ <div id="featureinfo-dynamic"></div>
+ </div>
+ <div id="coordinfo"></div>
+ </div>
+ <div id="config">
+ <ul>
+ <li>Measure
+ <ul id="config-measure">
+ <li>Enable</li>
+ <li style="display: none;">Disable</li>
+ <li><input type="checkbox" id="measure-euclidean"><label for="measure-euclidean">Euclidean</label></li>
+ <li><input type="checkbox" id="measure-equirectangular"><label for="measure-equirectangular">Equirectangular</label></li>
+ <li><input type="checkbox" id="measure-haversine" checked><label for="measure-haversine">Haversine</label></li>
+ <li>Clear</li>
+ </ul>
+ </li>
+ <li>Base layer
+ <ul id="config-layer">
+ <li>OSM</li>
+ <li>Bing</li>
+ <li>Bing (no labels)</li>
+ </ul>
+ </li>
+ <li>Path draw options
+ <ul id="config-pathdraw">
+ <li>No points</li>
+ <li>Endpoints</li>
+ <li>All points</li>
+ <li>Set resolution</li>
+ </ul>
+ </li>
+ <li>Heatmap options
+ <ul id="config-heatmap">
+ <li>Set radius</li>
+ <li>Set blur</li>
+ </ul>
+ </li>
+ <li style="display: none;">Enable coord</li>
+ <li>Disable coord</li>
+ <li>Set player speed</li>
+ </ul>
+ </div>
+ <div id="datalist">
+ <div id="datalist-tree">
+ <ul></ul>
+ </div>
+ <div id="dataextract">
+ <input type="text">
+ <button type="button">Clear</button>
+ <button type="button">Refresh</button>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/visualizer/script.js b/visualizer/script.js
@@ -0,0 +1,1037 @@
+/***************/
+/*** General ***/
+/***************/
+
+window.app = {};
+var app = window.app;
+
+app.mainLayer = new ol.layer.Tile({ source: new ol.source.OSM() });
+
+
+/****************/
+/*** Geometry ***/
+/****************/
+
+app.geometry = {}
+app.geometry.REarth = 6371000;
+app.geometry.toRadians = function(x){ return x * Math.PI / 180; };
+
+app.geometry.haversine = function(lat1, lon1, lat2, lon2){
+ var lat1 = app.geometry.toRadians(lat1);
+ var lon1 = app.geometry.toRadians(lon1);
+ var lat2 = app.geometry.toRadians(lat2);
+ var lon2 = app.geometry.toRadians(lon2);
+
+ var dlat = Math.abs(lat1-lat2);
+ var dlon = Math.abs(lon1-lon2);
+
+ var alpha = Math.pow(Math.sin(dlat/2), 2) + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon/2), 2);
+ var d = Math.atan2(Math.sqrt(alpha), Math.sqrt(1-alpha));
+
+ return 2 * app.geometry.REarth * d;
+};
+
+app.geometry.equirectangular = function(lat1, lon1, lat2, lon2){
+ var lat1 = app.geometry.toRadians(lat1);
+ var lon1 = app.geometry.toRadians(lon1);
+ var lat2 = app.geometry.toRadians(lat2);
+ var lon2 = app.geometry.toRadians(lon2);
+ var x = (lon2-lon1) * Math.cos((lat1+lat2)/2);
+ var y = (lat2-lat1);
+ return Math.sqrt(x*x + y*y) * app.geometry.REarth;
+};
+
+
+/***************/
+/*** Measure ***/
+/***************/
+
+app.measure = {};
+app.measure.tooltip_list = [];
+
+app.measure.source = new ol.source.Vector();
+
+app.measure.layer = new ol.layer.Vector({
+ source: app.measure.source,
+ style: new ol.style.Style({
+ fill: new ol.style.Fill({
+ color: 'rgba(255, 255, 255, 0.2)'
+ }),
+ stroke: new ol.style.Stroke({
+ color: '#FC3',
+ width: 2
+ }),
+ image: new ol.style.Circle({
+ radius: 7,
+ fill: new ol.style.Fill({
+ color: '#FC3'
+ })
+ })
+ })
+});
+
+app.measure.pointerMoveHandler = function(evt){
+ if(evt.dragging){ return; }
+ var tooltipCoord = evt.coordinate;
+
+ if(app.measure.sketch){
+ var output;
+ var geom = (app.measure.sketch.getGeometry());
+ if(geom instanceof ol.geom.LineString){
+ output = app.measure.formatLength((geom));
+ tooltipCoord = geom.getLastCoordinate();
+ }
+ app.measure.tooltipElement.innerHTML = output;
+ app.measure.tooltip.setPosition(tooltipCoord);
+ }
+};
+
+app.measure.addInteraction = function(){
+ app.measure.draw = new ol.interaction.Draw({
+ source: app.measure.source,
+ type: ('LineString'),
+ style: new ol.style.Style({
+ fill: new ol.style.Fill({
+ color: 'rgba(255, 255, 255, 0.2)'
+ }),
+ stroke: new ol.style.Stroke({
+ color: 'rgba(0, 0, 0, 0.5)',
+ lineDash: [10, 10],
+ width: 2
+ }),
+ image: new ol.style.Circle({
+ radius: 5,
+ stroke: new ol.style.Stroke({
+ color: 'rgba(0, 0, 0, 0.7)'
+ }),
+ fill: new ol.style.Fill({
+ color: 'rgba(255, 255, 255, 0.2)'
+ })
+ })
+ })
+ });
+ app.map.addInteraction(app.measure.draw);
+
+ app.measure.createTooltip();
+
+ app.measure.draw.on('drawstart',
+ function(evt){
+ app.measure.sketch = evt.feature;
+ }, this);
+
+ app.measure.draw.on('drawend',
+ function(evt){
+ app.measure.tooltipElement.className = 'measure-tooltip measure-tooltip-static';
+ app.measure.tooltip.setOffset([0, -7]);
+ app.measure.sketch = null;
+ app.measure.tooltipElement = null;
+ app.measure.createTooltip();
+ }, this);
+};
+
+app.measure.createTooltip = function(){
+ if(app.measure.tooltipElement){
+ app.measure.tooltipElement.parentNode.removeChild(app.measure.tooltipElement);
+ }
+ app.measure.tooltipElement = document.createElement('div');
+ app.measure.tooltipElement.className = 'measure-tooltip measure-tooltip-value';
+ app.measure.tooltip = new ol.Overlay({
+ element: app.measure.tooltipElement,
+ offset: [0, -15],
+ positioning: 'bottom-center'
+ });
+ app.measure.tooltip_list.push(app.measure.tooltip);
+ app.map.addOverlay(app.measure.tooltip);
+};
+
+app.measure.formatLength = function(line){
+ var length_euclidean = line.getLength();
+ var length_equirectangular = 0;
+ var length_haversine = 0;
+ var coordinates = line.getCoordinates();
+ var sourceProj = app.map.getView().getProjection();
+ for(var i = 0, ii = coordinates.length - 1; i < ii; ++i){
+ var c1 = ol.proj.transform(coordinates[i], sourceProj, 'EPSG:4326');
+ var c2 = ol.proj.transform(coordinates[i + 1], sourceProj, 'EPSG:4326');
+ length_equirectangular += app.geometry.equirectangular(c1[1], c1[0], c2[1], c2[0]);
+ length_haversine += app.geometry.haversine(c1[1], c1[0], c2[1], c2[0]);
+ }
+
+ var disp = function(x){
+ if(x > 100){
+ return Math.round(x / 1000 * 1000) / 1000 + 'km';
+ } else {
+ return Math.round(x * 1000) / 1000 + 'm';
+ }
+ }
+
+ var length_euclidean = disp(length_euclidean);
+ var length_equirectangular = disp(length_equirectangular);
+ var length_haversine = disp(length_haversine);
+
+ var display_euclidean = $('input#measure-euclidean').prop('checked');
+ var display_equirectangular = $('input#measure-equirectangular').prop('checked');
+ var display_haversine = $('input#measure-haversine').prop('checked');
+
+ var header = true;
+ if(display_euclidean + display_equirectangular + display_haversine == 1){
+ header = false;
+ }
+
+ var str = '';
+ if(display_euclidean){
+ if(header){ str += 'euclidean: '; }
+ str += length_euclidean;
+ }
+ if(display_equirectangular){
+ if(header){ if(display_euclidean){ str += '<br>'; } str += 'equirectangular: '; }
+ str += length_equirectangular;
+ }
+ if(display_haversine){
+ if(header){ str += '<br> haversine: '; }
+ str += length_haversine;
+ }
+ return str;
+};
+
+
+/*******************/
+/*** DataDisplay ***/
+/*******************/
+
+app.dataDisplay = {};
+app.dataDisplay.layers = {};
+app.dataDisplay.heatmapRadius = 5;
+app.dataDisplay.heatmapBlur = 5;
+app.dataDisplay.pathPointMode = 1; // endpoints
+app.dataDisplay.pathPointResolution = 50;
+
+app.dataDisplay.loadLayer = function(path){
+ $.ajax({url: path, cache: false, dataType: 'json',
+ success: function(result){
+ app.dataDisplay.layers[path] = app.dataDisplay.preprocess(result);
+ app.map.addLayer(app.dataDisplay.layers[path]);
+ }
+ });
+};
+
+app.dataDisplay.unloadLayer = function(path){
+ app.map.removeLayer(app.dataDisplay.layers[path]);
+ delete app.dataDisplay.layers[path];
+};
+
+app.dataDisplay.rawStyle = function(feature, resolution){
+ var style = [ new ol.style.Style({
+ stroke: new ol.style.Stroke({
+ color: '#00F',
+ width: 5
+ }),
+ image: new ol.style.Circle({
+ radius: 5,
+ fill: new ol.style.Fill({
+ color: '#00F'
+ })
+ })
+ }),
+ new ol.style.Style({
+ stroke: new ol.style.Stroke({
+ color: '#000',
+ width: 2
+ }),
+ image: new ol.style.Circle({
+ radius: 2,
+ fill: new ol.style.Fill({
+ color: '#FFF'
+ })
+ })
+ })
+ ];
+
+ if(feature.get('display') == 'path' && resolution < app.dataDisplay.pathPointResolution){
+ if(app.dataDisplay.pathPointMode == 2){
+ var polyline = feature.getGeometry();
+ var points = polyline.getCoordinates();
+ for(var i=1; i<points.length-1; ++i){
+ var point = points[i];
+ var pos = i/points.length;
+ var red = 0;
+ var green = 0;
+ if(pos < 0.5){
+ green = 255;
+ red = Math.round(pos*2*255);
+ } else {
+ red = 255;
+ green = Math.round((1-pos)*2*255);
+ }
+ style.push(new ol.style.Style({
+ geometry: new ol.geom.Point(point),
+ image: new ol.style.Circle({
+ radius: 3,
+ fill: new ol.style.Fill({
+ color: 'rgb('+red+','+green+',0)'
+ })
+ })
+ }));
+ }
+ }
+ if(app.dataDisplay.pathPointMode >= 1){
+ var polyline = feature.getGeometry();
+ var first = polyline.getFirstCoordinate();
+ var last = polyline.getLastCoordinate();
+ style.push(new ol.style.Style({
+ geometry: new ol.geom.Point(first),
+ image: new ol.style.Circle({
+ radius: 5,
+ fill: new ol.style.Fill({
+ color: '#0F0'
+ })
+ })
+ }));
+ style.push(new ol.style.Style({
+ geometry: new ol.geom.Point(last),
+ image: new ol.style.Circle({
+ radius: 5,
+ fill: new ol.style.Fill({
+ color: '#F00'
+ })
+ })
+ }));
+ }
+ }
+
+ return style;
+};
+
+app.dataDisplay.clusterStyleCache = {};
+app.dataDisplay.clusterStyle = function(feature, resolution){
+ var size = feature.get('features').length;
+ var style = app.dataDisplay.clusterStyleCache[size];
+ if(!style){
+ style = [new ol.style.Style({
+ image: new ol.style.Circle({
+ radius: 10,
+ stroke: new ol.style.Stroke({
+ color: '#FFF'
+ }),
+ fill: new ol.style.Fill({
+ color: '#39C'
+ })
+ }),
+ text: new ol.style.Text({
+ text: size.toString(),
+ fill: new ol.style.Fill({
+ color: '#FFF'
+ })
+ })
+ })];
+ app.dataDisplay.clusterStyleCache[size] = style;
+ }
+ return style;
+};
+
+app.dataDisplay.preprocess = function(egj){
+ var source = new ol.source.GeoJSON({
+ projection: 'EPSG:3857',
+ object: egj.data
+ });
+
+ if(egj.type == 'raw'){
+ return new ol.layer.Vector({
+ source: source,
+ style: app.dataDisplay.rawStyle
+ });
+
+ } else if(egj.type == 'cluster'){
+ return new ol.layer.Vector({
+ source: new ol.source.Cluster({
+ distance: 40,
+ source: source
+ }),
+ style: app.dataDisplay.clusterStyle
+ });
+
+ } else if(egj.type == 'heatmap'){
+ return new ol.layer.Heatmap({
+ source: source,
+ blur: app.dataDisplay.heatmapBlur,
+ radius: app.dataDisplay.heatmapRadius
+ });
+ }
+};
+
+app.dataDisplay.reloadPathes = function(){
+ for(var layer in app.dataDisplay.layers){
+ if(app.dataDisplay.layers[layer].getSource().getFeatures()[0].get('display') == 'path'){
+ app.dataDisplay.layers[layer].changed();
+ }
+ }
+};
+
+app.dataDisplay.reloadHeatmaps = function(){
+ for(var key in app.dataDisplay.layers){
+ var layer = app.dataDisplay.layers[key];
+ if(layer instanceof ol.layer.Heatmap){
+ layer.setBlur(app.dataDisplay.heatmapBlur);
+ layer.setRadius(app.dataDisplay.heatmapRadius);
+ }
+ }
+};
+
+
+/****************/
+/*** DataList ***/
+/****************/
+
+app.dataList = {};
+app.dataList.current = {};
+app.dataList.idgen = 0;
+
+app.dataList.init = function(){
+ app.dataList.elementTree = {};
+ app.dataList.elementTree.parent = null;
+ app.dataList.elementTree.children = {};
+ app.dataList.elementTree.checkbox = null;
+ app.dataList.elementTree.ul = $('#datalist-tree ul');
+
+ app.dataList.updateList();
+ setInterval(app.dataList.updateList, 1000);
+};
+
+app.dataList.updateList = function(){
+ $.ajax({url: '/ls/', cache: false, dataType: 'json',
+ success: function(result){
+ result.forEach(function(file){
+ file.uri = file.path.join('/') + '/' + file.name
+ if(file.uri in app.dataList.current){
+ if(file.mtime > app.dataList.current[file.uri].mtime){
+ var act = app.dataList.current[file.uri];
+ if(act.checkbox.prop('checked')){
+ app.dataList.unloadLayer(file.uri);
+ app.dataList.loadLayer(file.uri);
+ }
+ act.mtime = file.mtime;
+ }
+ } else {
+ app.dataList.insert(file);
+ }
+ });
+ }
+ });
+};
+
+app.dataList.insert = function(file){
+ var cur = app.dataList.elementTree;
+ var prev = null;
+ for(var i = 1; i<file.path.length; i++){
+ if(!(file.path[i] in cur.children)){
+ var n = {};
+ n.uri = file.path.slice(0, i+1).join('/');
+ n.children = {};
+ n.parent = cur;
+ n.ul = $('<ul>')
+ .prop('id', 'folder-'+app.dataList.idgen)
+ .hide();
+
+ var hidelink = $('<a>')
+ .prop('href', '')
+ .append('hide')
+ .hide();
+ var showlink = $('<a>')
+ .prop('href', '')
+ .append('show');
+
+ var playlink = $('<a>')
+ .prop('href', '')
+ .append('play');
+ var stoplink = $('<a>')
+ .prop('href', '')
+ .append('stop')
+ .hide();
+
+ n.checkbox = $('<input>')
+ .prop('type', 'checkbox')
+ .prop('id', 'data-'+app.dataList.idgen)
+ .prop('name', n.uri);
+ n.checkbox.change(app.dataList.selectData);
+ var item = $('<li>')
+ .append(n.checkbox)
+ .append($('<label>')
+ .prop('for', 'data-'+app.dataList.idgen)
+ .append(file.path[i]))
+ .append(' ')
+ .append(hidelink)
+ .append(showlink)
+ .append(' ')
+ .append(playlink)
+ .append(stoplink)
+ .append(n.ul)
+ app.dataList.idgen++;
+ cur.ul.append(item);
+ cur.children[file.path[i]] = n;
+ app.dataList.current[n.uri] = n;
+
+ var foldertoggler = function(){
+ hidelink.toggle();
+ showlink.toggle();
+ n.ul.toggle();
+ return false;
+ };
+ hidelink.click(foldertoggler);
+ showlink.click(foldertoggler);
+
+ playlink.click(function(){
+ playlink.toggle();
+ stoplink.toggle();
+ app.dataPlayer.play(n);
+ return false;
+ });
+ stoplink.click(function(){
+ playlink.toggle();
+ stoplink.toggle();
+ app.dataPlayer.stop(n);
+ return false;
+ });
+ }
+ prev = cur;
+ cur = cur.children[file.path[i]];
+ }
+
+ file.parent = cur;
+ file.checkbox = $('<input>')
+ .prop('type', 'checkbox')
+ .prop('id', 'data-'+app.dataList.idgen)
+ .prop('name', file.uri);
+ file.checkbox.change(app.dataList.selectData);
+ var item = $('<li>')
+ .append(file.checkbox)
+ .append($('<label>')
+ .prop('for', 'data-'+app.dataList.idgen)
+ .append(file.name))
+ app.dataList.idgen++;
+ cur.ul.append(item);
+ cur.children[file.name] = file;
+ app.dataList.current[file.uri] = file;
+
+ if(cur.checkbox && cur.checkbox.prop('checked')){
+ file.checkbox.prop('checked', true);
+ app.dataList.updateData(file);
+ }
+};
+
+app.dataList.updateData = function(cur){
+ if(cur.checkbox.prop('checked')){
+ app.dataList.loadLayer(cur.uri);
+ } else {
+ app.dataList.unloadLayer(cur.uri);
+ }
+};
+
+app.dataList.updateCheckboxes = function(cur){
+ if(cur.checkbox.prop('checked')){
+ app.dataList.check(cur);
+ } else {
+ app.dataList.uncheck(cur);
+ }
+};
+
+app.dataList.selectData = function(e){
+ var cur = app.dataList.current[e.target.name];
+ if(!('children' in cur)){
+ app.dataList.updateData(cur);
+ }
+ app.dataList.updateCheckboxes(cur);
+};
+
+app.dataList.changeChildren = function rec(cur, val){
+ cur.checkbox.prop('checked', val);
+ if('children' in cur){
+ for(var child in cur.children){
+ rec(cur.children[child], val);
+ }
+ } else {
+ app.dataList.updateData(cur);
+ }
+};
+
+app.dataList.check = function(cur){
+ // Check all parents
+ var p = cur.parent;
+ while(p.checkbox != null){
+ p.checkbox.prop('checked', true);
+ p = p.parent;
+ }
+
+ // Check all children
+ for(var child in cur.children){
+ app.dataList.changeChildren(cur.children[child], true);
+ }
+};
+
+app.dataList.uncheck = function(cur){
+ // Uncheck empty parents
+ var p = cur.parent;
+ while(p.checkbox != null && p.checkbox.prop('checked')){
+ var cc = false;
+ for(var child in p.children){
+ cc = cc || p.children[child].checkbox.prop('checked');
+ }
+ if(cc){
+ break;
+ }
+ p.checkbox.prop('checked', false);
+ p = p.parent;
+ }
+
+ // Uncheck all children
+ for(var child in cur.children){
+ app.dataList.changeChildren(cur.children[child], false);
+ }
+};
+
+
+app.dataList.loadLayer = function(uri){
+ app.dataDisplay.loadLayer('/get'+uri);
+};
+
+app.dataList.unloadLayer = function(uri){
+ app.dataDisplay.unloadLayer('/get'+uri);
+};
+
+
+/*******************/
+/*** DataExtract ***/
+/*******************/
+
+app.dataExtract = {};
+app.dataExtract.current = null;
+
+app.dataExtract.init = function(){
+ $('#dataextract button:contains("Refresh")').click(app.dataExtract.display);
+ $('#dataextract input').keypress(function(e){
+ if(e.keyCode == 13){
+ app.dataExtract.display();
+ }
+ });
+ $('#dataextract button:contains("Clear")').click(app.dataExtract.clear);
+};
+
+app.dataExtract.display = function(){
+ if(app.dataExtract.current){
+ app.dataDisplay.unloadLayer('/extract/' + app.dataExtract.current);
+ }
+ app.dataExtract.current = $('#dataextract input').val();
+ app.dataDisplay.loadLayer('/extract/' + app.dataExtract.current);
+};
+
+app.dataExtract.clear = function(){
+ if(app.dataExtract.current){
+ app.dataDisplay.unloadLayer('/extract/' + app.dataExtract.current);
+ app.dataExtract.current = null;
+ }
+};
+
+
+
+/******************/
+/*** DataPlayer ***/
+/******************/
+
+app.dataPlayer = {};
+app.dataPlayer.current = {};
+app.dataPlayer.updateFrequency = 200;
+app.dataPlayer.time = 0;
+
+app.dataPlayer.init = function(){
+ app.dataPlayer.intervalId = setInterval(app.dataPlayer.update, app.dataPlayer.updateFrequency);
+};
+
+app.dataPlayer.updateInterval = function(){
+ clearInterval(app.dataPlayer.intervalId);
+ app.dataPlayer.intervalId = setInterval(app.dataPlayer.update, app.dataPlayer.updateFrequency);
+};
+
+app.dataPlayer.play = function(cur){
+ app.dataPlayer.updateKeys(cur);
+ if(cur.keys.length == 0){
+ alert("ERROR: No number in directory.");
+ return;
+ }
+ app.dataList.uncheck(cur);
+ cur.checkbox.prop('checked', true);
+ cur.playIndex = 0;
+ app.dataPlayer.current[cur.uri] = cur;
+ for(var key in cur.context){
+ var child = cur.children[cur.context[key]];
+ child.checkbox.prop('checked', true);
+ if(!('children' in child)){
+ app.dataList.updateData(child);
+ }
+ app.dataList.updateCheckboxes(child);
+ }
+};
+
+app.dataPlayer.updateKeys = function(cur){
+ var keys = Object.keys(cur.children);
+ cur.keys = keys.map(Number).filter(function(x){ return !isNaN(x); }).sort(function(l,r){return l-r;});
+ cur.context = keys.filter(function(x){ return isNaN(Number(x)); });
+};
+
+app.dataPlayer.stop = function(cur){
+ delete app.dataPlayer.current[cur.uri];
+ delete cur.keys;
+ delete cur.context;
+ delete cur.playIndex;
+};
+
+app.dataPlayer.update = function(){
+ for(var key in app.dataPlayer.current){
+ var cur = app.dataPlayer.current[key];
+ var prev = cur.children[cur.keys[cur.playIndex]];
+ cur.playIndex++;
+ if(cur.playIndex >= cur.keys.length){
+ app.dataPlayer.updateKeys(cur);
+ if(cur.playIndex >= cur.keys.length){
+ cur.playIndex = 0;
+ }
+ }
+ var next = cur.children[cur.keys[cur.playIndex]];
+
+ prev.checkbox.prop('checked', false);
+ app.dataList.updateData(prev);
+ next.checkbox.prop('checked', true);
+ app.dataList.updateData(next);
+ }
+};
+
+
+/*****************/
+/*** CoordInfo ***/
+/*****************/
+
+app.coordInfo = {};
+app.coordInfo.init = function(){
+ app.coordInfo.element = $('#coordInfo');
+ app.coordInfo.enable();
+};
+
+app.coordInfo.enable = function(){
+ app.map.on('pointermove', app.coordInfo.update);
+ app.coordInfo.element.show();
+};
+
+app.coordInfo.disable = function(){
+ app.coordInfo.element.hide();
+ app.map.un('pointermove', app.coordInfo.update);
+};
+
+app.coordInfo.update = function(evt){
+ var coord = ol.proj.transform(app.map.getEventCoordinate(evt.originalEvent), app.map.getView().getProjection(), 'EPSG:4326');
+ app.coordInfo.element.text(coord[1] + ', ' + coord[0]);
+};
+
+
+/*******************/
+/*** FeatureInfo ***/
+/*******************/
+
+app.featureInfo = {};
+
+app.featureInfo.init = function(){
+ app.featureInfo.element = $('#featureinfo');
+ app.featureInfo.static = $('#featureinfo-static');
+ app.featureInfo.dynamic = $('#featureinfo-dynamic');
+ app.featureInfo.element.hide();
+ app.featureInfo.enable();
+};
+
+app.featureInfo.enable = function(){
+ app.map.on('pointermove', app.featureInfo.updateMove);
+ app.map.on('click', app.featureInfo.updateClick);
+};
+
+app.featureInfo.disable = function(){
+ app.map.un('pointermove', app.featureInfo.updateMove);
+ app.map.un('click', app.featureInfo.updateClick);
+};
+
+app.featureInfo.updateMove = function(evt){
+ if(evt.dragging){
+ app.featureInfo.element.hide();
+ return;
+ }
+ app.featureInfo.display(evt);
+};
+
+app.featureInfo.updateClick = function(evt){
+ app.featureInfo.display(evt);
+};
+
+app.featureInfo.display = function(evt){
+ app.featureInfo.element.css({
+ left: (evt.pixel[0] + 10) + 'px',
+ top: evt.pixel[1] + 'px'
+ });
+ var feature = app.map.forEachFeatureAtPixel(evt.pixel, function(feature, layer) {
+ return feature;
+ });
+ if(feature && feature.get('info')){
+ if(feature.get('display') == 'path'){
+ var dtime = app.featureInfo.interpolateTime(feature.getGeometry(), evt.coordinate);
+ var date = new Date(1000*(feature.get('timestamp') + dtime*15));
+ var desc = 'index: '+dtime+'<br>';
+ desc += 'date: '+date.toISOString()+'<br>';
+ app.featureInfo.dynamic.html(desc);
+ } else {
+ app.featureInfo.dynamic.html('');
+ }
+ app.featureInfo.static.html(feature.get('info'));
+ app.featureInfo.element.show();
+ } else {
+ app.featureInfo.element.hide();
+ }
+};
+
+app.featureInfo.interpolateTime = function(polyline, coord){
+ var closest = polyline.getClosestPoint(coord);
+ var best = 1000;
+ var bestStart = -1;
+ var bestEnd = -1;
+ var bestI = -1;
+ var i = 0;
+ var points = polyline.getCoordinates();
+ for(var i=0; i<points.length-1; i++){
+ var start = points[i];
+ var end = points[i+1];
+ var dist = Math.abs((end[0]-start[0])*closest[1] - (end[1]-start[1])*closest[0] + end[1]*start[0] - end[0]*start[1]) / Math.sqrt(Math.pow(end[0]-start[0], 2) + Math.pow(end[1]-start[1], 2));
+ if(dist<best){
+ best = dist;
+ bestStart = start;
+ bestEnd = end;
+ bestI = i;
+ }
+ }
+
+ if(bestI == -1){
+ return 0;
+ }
+
+ var distClosest = app.geometry.equirectangular(bestStart[1], bestStart[0], closest[1], closest[0]);
+ var distEnd = app.geometry.equirectangular(bestStart[1], bestStart[0], bestEnd[1], bestEnd[0]);
+ var ratio = distClosest / distEnd;
+ return bestI + ratio;
+};
+
+
+/***************/
+/*** Control ***/
+/***************/
+
+app.control = {};
+
+app.control.OpenConfigControl = function(opt_options){
+ var options = opt_options || {};
+
+ var button = document.createElement('button');
+ button.innerHTML = '⚙';
+
+ button.addEventListener('click', function(e){ $('#config').toggle() }, false);
+ button.addEventListener('touchstart', function(e){ $('#config').toggle() }, false);
+
+ var element = document.createElement('div');
+ element.className = 'open-config ol-unselectable ol-control';
+ element.appendChild(button);
+
+ ol.control.Control.call(this, {
+ element: element,
+ target: options.target
+ });
+};
+ol.inherits(app.control.OpenConfigControl, ol.control.Control);
+
+app.control.OpenDatalist = function(opt_options){
+ var options = opt_options || {};
+
+ var button = document.createElement('button');
+ button.innerHTML = '«';
+
+ var toggler = function(e){
+ $('#datalist').toggle();
+ if(button.innerHTML == '«'){
+ button.innerHTML = '»';
+ $('.open-datalist').css('right', '20.5em');
+ }
+ else{
+ button.innerHTML = '«';
+ $('.open-datalist').css('right', '.5em');
+ }
+ };
+ button.addEventListener('click', toggler, false);
+ button.addEventListener('touchstart', toggler, false);
+
+ var element = document.createElement('div');
+ element.className = 'open-datalist ol-unselectable ol-control';
+ element.appendChild(button);
+
+ ol.control.Control.call(this, {
+ element: element,
+ target: options.target
+ });
+};
+ol.inherits(app.control.OpenDatalist, ol.control.Control);
+
+
+/************/
+/*** Menu ***/
+/************/
+
+app.menu = {};
+
+app.menu.init = function(){
+ $('#config ul').menu();
+
+ $('#config ul li').click(function(e){
+ switch($(this).text()){
+ case 'Enable coord':
+ app.coordInfo.enable();
+ $('#config ul li:contains("Enable coord")').toggle();
+ $('#config ul li:contains("Disable coord")').toggle();
+ break;
+ case 'Disable coord':
+ app.coordInfo.disable();
+ $('#config ul li:contains("Enable coord")').toggle();
+ $('#config ul li:contains("Disable coord")').toggle();
+ break;
+ case 'Set player speed':
+ var tmp = prompt("Player update frequency (milliseconds)", app.dataPlayer.updateFrequency);
+ if(tmp){
+ app.dataPlayer.updateFrequency = parseInt(tmp);
+ app.dataPlayer.updateInterval();
+ }
+ break;
+ }
+ });
+
+ $('ul#config-measure li').click(function(e){
+ switch($(this).text()){
+ case 'Enable':
+ app.measure.addInteraction();
+ app.map.on('pointermove', app.measure.pointerMoveHandler);
+ app.featureInfo.disable();
+ $('ul#config-measure li:contains("Enable")').toggle();
+ $('ul#config-measure li:contains("Disable")').toggle();
+ break;
+ case 'Disable':
+ app.featureInfo.enable();
+ app.map.un('pointermove', app.measure.pointerMoveHandler);
+ app.map.removeInteraction(app.measure.draw);
+ app.measure.draw = null;
+ $('ul#config-measure li:contains("Enable")').toggle();
+ $('ul#config-measure li:contains("Disable")').toggle();
+ break;
+ case 'Clear':
+ app.measure.source.clear();
+ app.measure.tooltip_list.forEach(function(e){
+ app.map.removeOverlay(e);
+ });
+ app.measure.tooltip_list.length = 0;
+ if(app.measure.draw){
+ app.map.removeInteraction(app.measure.draw);
+ app.measure.addInteraction();
+ }
+ break;
+ }
+ });
+
+ $('ul#config-layer li').click(function(e){
+ switch($(this).text()){
+ case 'OSM':
+ app.mainLayer = new ol.layer.Tile({ source: new ol.source.OSM() });
+ break;
+ case 'Bing':
+ app.mainLayer = new ol.layer.Tile({ source: new ol.source.BingMaps({
+ key: 'Ak-dzM4wZjSqTlzveKz5u0d4IQ4bRzVI309GxmkgSVr1ewS6iPSrOvOKhA-CJlm3',
+ imagerySet: 'AerialWithLabels',
+ maxZoom: 19
+ }) });
+ break;
+ case 'Bing (no labels)':
+ app.mainLayer = new ol.layer.Tile({ source: new ol.source.BingMaps({
+ key: 'Ak-dzM4wZjSqTlzveKz5u0d4IQ4bRzVI309GxmkgSVr1ewS6iPSrOvOKhA-CJlm3',
+ imagerySet: 'Aerial',
+ maxZoom: 19
+ }) });
+ break;
+ }
+ app.map.getLayers().setAt(0, app.mainLayer);
+ });
+
+ $('ul#config-pathdraw li').click(function(e){
+ switch($(this).text()){
+ case 'Set resolution':
+ var tmp = prompt("Path points resolution", app.dataDisplay.pathPointResolution);
+ if(tmp){
+ app.dataDisplay.pathPointResolution = parseInt(tmp);
+ app.dataDisplay.reloadPathes();
+ }
+ break;
+ case 'No points':
+ app.dataDisplay.pathPointMode = 0;
+ app.dataDisplay.reloadPathes();
+ break;
+ case 'Endpoints':
+ app.dataDisplay.pathPointMode = 1;
+ app.dataDisplay.reloadPathes();
+ break;
+ case 'All points':
+ app.dataDisplay.pathPointMode = 2;
+ app.dataDisplay.reloadPathes();
+ break;
+ }
+ });
+
+ $('ul#config-heatmap li').click(function(e){
+ switch($(this).text()){
+ case 'Set blur':
+ var tmp = prompt("Heatmap blur", app.dataDisplay.heatmapBlur);
+ if(tmp){
+ app.dataDisplay.heatmapBlur = parseInt(tmp);
+ app.dataDisplay.reloadHeatmaps();
+ }
+ break;
+ case 'Set radius':
+ var tmp = prompt("Heatmap radius", app.dataDisplay.heatmapRadius);
+ if(tmp){
+ app.dataDisplay.heatmapRadius = parseInt(tmp);
+ app.dataDisplay.reloadHeatmaps();
+ }
+ break;
+ }
+ });
+};
+
+
+/**********************/
+/*** Initialization ***/
+/**********************/
+
+$(function(){
+ app.map = new ol.Map({
+ controls: ol.control.defaults().extend([
+ new app.control.OpenConfigControl(),
+ new app.control.OpenDatalist()
+ ]),
+ target: 'map',
+ layers: [ app.mainLayer, app.measure.layer ],
+ view: new ol.View({
+ center: ol.proj.transform([-8.621953, 41.162142], 'EPSG:4326', 'EPSG:3857'),
+ zoom: 13
+ })
+ });
+
+ app.menu.init();
+ app.coordInfo.init();
+ app.featureInfo.init();
+ app.dataList.init();
+ app.dataExtract.init();
+ app.dataPlayer.init();
+});
diff --git a/visualizer/style.css b/visualizer/style.css
@@ -0,0 +1,124 @@
+.map {
+ position: fixed;
+ left: 0; right:0; top:0; bottom:0;
+ z-index: 0;
+}
+
+.open-datalist {
+ top: .5em;
+ right: .5em;
+ z-index: 10;
+}
+
+#datalist {
+ position: fixed;
+ right:0; top:0; bottom:0;
+ width: 20em;
+ display: none;
+ z-index: 1;
+ background-color: #fff;
+ border-left: solid 1px black;
+}
+
+#datalist-tree {
+ position: absolute;
+ left:0; top:0; bottom:4em;
+ width: 100%;
+ overflow-y: auto;
+}
+
+#datalist-tree > ul {
+ padding-left: 0.5em;
+}
+
+#datalist-tree li > ul {
+ padding-left: 1.5em;
+}
+
+#datalist-tree li {
+ list-style-type: none;
+}
+
+#dataextract {
+ position: fixed;
+ bottom: 0;
+ height: 3.5em;
+}
+
+#dataextract input { width: 100%; }
+
+.open-config {
+ top: 65px;
+ left: .5em;
+ z-index: 10;
+}
+
+#config {
+ position: absolute;
+ left: 2em; top: 65px;
+ display: none;
+ z-index: 2;
+}
+
+#config ul ul {
+ top: 0px !important;
+ width: 12em;
+}
+
+.measure-tooltip {
+ position: relative;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 4px;
+ color: white;
+ padding: 4px 8px;
+ opacity: 0.7;
+ white-space: nowrap;
+}
+
+.measure-tooltip-value {
+ opacity: 1;
+ font-weight: bold;
+}
+
+.measure-tooltip-static {
+ background-color: #ffcc33;
+ color: black;
+ border: 1px solid #fff;
+}
+
+.measure-tooltip-value:before,
+.measure-tooltip-static:before {
+ border-top: 6px solid rgba(0, 0, 0, 0.5);
+ border-right: 6px solid transparent;
+ border-left: 6px solid transparent;
+ content: "";
+ position: absolute;
+ bottom: -6px;
+ margin-left: -7px;
+ left: 50%;
+}
+
+.measure-tooltip-static:before {
+ border-top-color: #ffcc33;
+}
+
+#featureinfo {
+ position: absolute;
+ z-index: 100;
+ color: #fff;
+ background-color: #000;
+ border: solid 1px #fff;
+ border-radius: 10px;
+ padding: 4px;
+ opacity: 0.75;
+}
+
+#coordinfo {
+ position: absolute;
+ left: 0; bottom: 0;
+ z-index: 100;
+ color: #fff;
+ background-color: #000;
+ padding: 3px;
+ opacity: 0.66;
+}