diff --git a/package.json b/package.json
index 91b670f85cb70e95340396820573962e7fb8e5fe..1dc26e15271ed4fb049478589a5b9e8bbf7a4a01 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,6 @@
     "react-leaflet-draw": "0.19.0",
     "react-router-dom": "^5.0.1",
     "react-scripts": "3.0.1",
-    "universal-cookie": "^4.0.0",
     "socket.io": "^2.2.0",
     "socket.io-client": "^2.2.0"
   },
diff --git a/public/infantry.svg b/public/infantry.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ac9e8bcfed3b9100dcf635fd161aa2cb5a0e612a
--- /dev/null
+++ b/public/infantry.svg
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   version="1.1"
+   width="591.0625"
+   height="372.0625"
+   id="svg2">
+  <defs
+     id="defs4">
+    <linearGradient
+       id="linearGradient3777">
+      <stop
+         id="stop3779"
+         style="stop-color:#b35800;stop-opacity:1"
+         offset="0" />
+    </linearGradient>
+  </defs>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     transform="translate(-72.28125,-210.75)"
+     id="layer1">
+    <path
+       d="M 585.90976,366.37406 4.2663332,3.7357361"
+       transform="translate(72.28125,210.75)"
+       id="path3881"
+       style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+    <path
+       d="M 584.7946,6.0174311 5.6411055,365.7946"
+       transform="translate(72.28125,210.75)"
+       id="path3883"
+       style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+    <rect
+       width="583.06555"
+       height="364.06042"
+       x="76.266335"
+       y="214.73877"
+       id="rect3859"
+       style="fill:none;stroke:#ff0000;stroke-width:24;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+  </g>
+</svg>
diff --git a/public/light-infantry.svg b/public/light-infantry.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4e7d1c7347c4c0b6aa04d0250fdec374a9252ba7
--- /dev/null
+++ b/public/light-infantry.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" width="591.0625" height="372.0625" id="svg2">
+  <defs id="defs4">
+    <linearGradient id="linearGradient3777">
+      <stop id="stop3779" style="stop-color:#b35800;stop-opacity:1" offset="0"/>
+    </linearGradient>
+  </defs>
+  <metadata id="metadata7">
+    <rdf:RDF>
+      <cc:Work rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+        <dc:title/>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g transform="translate(-72.28125,-210.75)" id="layer1">
+    <path d="M 585.90976,366.37406 4.2663332,3.7357361" transform="translate(72.28125,210.75)" id="path3881" style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
+    <path d="M 584.7946,6.0174311 5.6411055,365.7946" transform="translate(72.28125,210.75)" id="path3883" style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
+    <rect width="583.06555" height="364.06042" x="76.266335" y="214.73877" id="rect3859" style="fill:none;stroke:#0b04fb;stroke-width:8;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
+  </g>
+  <text x="293.67578" y="344.20807" id="text2988" xml:space="preserve" style="font-size:50px;font-style:normal;font-weight:normal;text-align:center;line-height:125%;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;font-family:Sans"><tspan x="293.67578" y="344.20807" id="tspan2990" style="font-size:100px">L</tspan></text>
+</svg>
\ No newline at end of file
diff --git a/public/mechanized.svg b/public/mechanized.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b3857efd4debbee06f8e812ba6e3db13c359b3fa
--- /dev/null
+++ b/public/mechanized.svg
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="591.0625"
+   height="372.0625"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.48.4 r9939"
+   sodipodi:docname="NATO Map Symbol - Infantry.svg">
+  <defs
+     id="defs4">
+    <linearGradient
+       id="linearGradient3777"
+       osb:paint="solid">
+      <stop
+         style="stop-color:#b35800;stop-opacity:1;"
+         offset="0"
+         id="stop3779" />
+    </linearGradient>
+  </defs>
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="1.595432"
+     inkscape:cx="295.53125"
+     inkscape:cy="186.03125"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     fit-margin-top="0"
+     fit-margin-left="0"
+     fit-margin-right="0"
+     fit-margin-bottom="0"
+     inkscape:window-width="1401"
+     inkscape:window-height="960"
+     inkscape:window-x="22"
+     inkscape:window-y="22"
+     inkscape:window-maximized="0" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(-72.28125,-210.75)">
+    <path
+       sodipodi:type="arc"
+       style="fill:none;stroke:#000000;stroke-width:12;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+       id="path3879"
+       sodipodi:cx="294.37698"
+       sodipodi:cy="184.34384"
+       sodipodi:rx="160.69855"
+       sodipodi:ry="76.793999"
+       d="m 455.07553,184.34384 a 160.69855,76.793999 0 1 1 -321.39709,0 160.69855,76.793999 0 1 1 321.39709,0 z"
+       transform="matrix(1.4300046,0,0,1.5757504,-53.16133,106.2891)" />
+    <path
+       style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
+       d="M 585.90976,366.37406 4.2663332,3.7357361"
+       id="path3881"
+       inkscape:connector-curvature="0"
+       transform="translate(72.28125,210.75)" />
+    <path
+       style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
+       d="M 584.7946,6.0174311 5.6411055,365.7946"
+       id="path3883"
+       inkscape:connector-curvature="0"
+       transform="translate(72.28125,210.75)" />
+    <rect
+       style="fill:none;stroke:#ff0000;stroke-width:24;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+       id="rect3859"
+       width="583.06555"
+       height="364.06042"
+       x="76.266335"
+       y="214.73877" />
+  </g>
+</svg>
diff --git a/public/recon.svg b/public/recon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4e89e6116e69adc6ec87efe92c20630cb8d907f9
--- /dev/null
+++ b/public/recon.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.1"
+   width="591.0625"
+   height="372.0625"
+   id="svg2"
+   inkscape:version="0.48.4 r9939"
+   sodipodi:docname="NATO Map Symbol - Infantry.svg">
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="640"
+     inkscape:window-height="480"
+     id="namedview11"
+     showgrid="false"
+     inkscape:zoom="0.53801417"
+     inkscape:cx="295.53125"
+     inkscape:cy="186.03125"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg2" />
+  <defs
+     id="defs4">
+    <linearGradient
+       id="linearGradient3777">
+      <stop
+         id="stop3779"
+         style="stop-color:#b35800;stop-opacity:1"
+         offset="0" />
+    </linearGradient>
+  </defs>
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <path
+     inkscape:connector-curvature="0"
+     style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+     id="path3883"
+     d="M 584.7946,6.0174311 5.6411055,365.7946" />
+  <rect
+     style="fill:none;stroke:#ff0000;stroke-width:24;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+     id="rect3859"
+     y="3.9887695"
+     x="3.9850845"
+     height="364.06042"
+     width="583.06555" />
+</svg>
diff --git a/src/App.css b/src/App.css
index 5c9f07ee39e8b44af5cc40b69cfa789f956cafb9..c5f8a1782d6d1dacffb288c5afa5bd8b588c39dd 100644
--- a/src/App.css
+++ b/src/App.css
@@ -14,7 +14,7 @@ body {
 /* UserMap */
 .map {
   position: absolute;
-  margin-top: 50px;
+  /* margin-top: 50px; */
   height: 95vh;
   width: 100vw;
 }
@@ -213,8 +213,14 @@ div.login button:hover {
 }
 
 .gamelist {
+  border: 2px solid white;
+  max-width: 800px;
+  max-height: 500px;
+  overflow: scroll;
+}
+
+.gamelist-item {
   background-color: #1d1d1b;
-  max-width: 500px;
   padding: 10px;
 }
 
@@ -234,3 +240,115 @@ div.login button:hover {
   display: flex;
   flex-direction: column;
 }
+
+.notification-popup {
+  position: absolute;
+  z-index: 1010;
+}
+
+.notification-popup.warning {
+  background-color: yellow;
+  color: black;
+}
+
+.notification-popup.alert {
+  background-color: red;
+}
+
+.leaflet-control-playback {
+  position: relative;
+  background-color: #7cbdf5;
+  padding: 10px;
+}
+.leaflet-control-playback .optionsContainer {
+  position: relative;
+}
+.leaflet-control-playback .optionsContainer > div {
+  display: inline-block;
+}
+.leaflet-control-playback .buttonContainer {
+}
+.leaflet-control-playback .buttonContainer a {
+  display: inline-block;
+  width: 32px;
+  height: 32px;
+  text-decoration: none;
+}
+.leaflet-control-playback .buttonContainer .btn-stop {
+  background: url(icons/icon-play.png) no-repeat center;
+}
+.leaflet-control-playback .buttonContainer .btn-start {
+  background: url(icons/icon-stop.png) no-repeat center;
+}
+.leaflet-control-playback .buttonContainer .btn-restart {
+  background: url(icons/icon-restart.png) no-repeat center;
+}
+.leaflet-control-playback .buttonContainer .btn-slow {
+  background: url(icons/icon-slow.png) no-repeat center;
+}
+.leaflet-control-playback .buttonContainer .btn-quick {
+  background: url(icons/icon-quick.png) no-repeat center;
+}
+.leaflet-control-playback .buttonContainer .btn-close {
+  background: url(icons/icon-close.png) no-repeat center;
+}
+.leaflet-control-playback .infoContainer {
+}
+.leaflet-control-playback .sliderContainer {
+}
+
+.leaflet-tooltip {
+  background-color: rgba(0, 0, 0, 0.5);
+  color: white;
+}
+
+.filterContainer input:hover {
+  cursor: pointer;
+}
+
+/* Score Counter */
+/* div.scoreContainer {
+  border-radius: 8px;
+  position: absolute;
+  display: flex;
+  flex-direction: row;
+  color: white;
+  background-color: #1d1d1b;
+  opacity: 0.9;
+  height: 50px;
+  max-width: 400px;
+  margin: auto;
+  top: 5px;
+  left: calc(50vw - 100px);
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+div.scoreBlock {
+  flex: 1;
+  padding: 5px;
+  max-width: 120px;
+  text-align: center;
+  justify-content: center;
+  letter-spacing: 0.05em;
+  font-size: 1.5em;
+}
+
+div.scoreDivider {
+  background-color: white;
+  float: left;
+  flex: 1;
+  width: 2px;
+  height: 70%;
+  margin: 5px;
+}
+
+span.scoreCircle {
+  margin: 10px;
+  height: 25px;
+  width: 25px;
+  background-color: red;
+  border-radius: 50%;
+  display: inline-block;
+} */
diff --git a/src/App.js b/src/App.js
index 71bb3453a73f820887f93fbc1e734902789d9ac4..076ed8c301ea470b47b6e878b02e258b370d4c8d 100644
--- a/src/App.js
+++ b/src/App.js
@@ -11,6 +11,7 @@ import LoginForm from "./components/LoginForm";
 import RegisterForm from "./components/RegisterForm";
 import GameSelection from "./components/GameSelection";
 import GameView from "./components/GameView";
+import ReplayMap from "./components/ReplayMap";
 
 export default class App extends Component {
   constructor() {
@@ -124,6 +125,11 @@ export default class App extends Component {
       />
     );
   };
+
+  replay = () => {
+    return <ReplayMap />;
+  };
+
   render() {
     // TODO: think better solution to wait for authenticator
     if (!this.state.authenticateComplete) {
@@ -160,9 +166,9 @@ export default class App extends Component {
                 <Redirect from="*" to="/" />
               </Switch>
             )}
-
             {this.state.logged && (
               <Switch>
+                <Route exact path="/replay" component={this.replay} />
                 <Route
                   path="/game"
                   component={() => {
diff --git a/src/components/DrawGeoJSON.js b/src/components/DrawGeoJSON.js
new file mode 100644
index 0000000000000000000000000000000000000000..fd7f4f3b464f6c3c4814217db17fe1056a3fcb25
--- /dev/null
+++ b/src/components/DrawGeoJSON.js
@@ -0,0 +1,134 @@
+import React from "react";
+import L from "leaflet";
+import "leaflet-draw";
+import {
+  Circle,
+  Marker,
+  Polygon,
+  Polyline,
+  Rectangle,
+  Tooltip
+} from "react-leaflet";
+
+// an empty icon for textboxes
+let noIcon = L.divIcon({
+  className: "",
+  iconSize: [20, 20],
+  iconAnchor: [10, 20]
+});
+
+class DrawGeoJSON extends React.Component {
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    return (
+      <React.Fragment>
+        {/* iterate through every element fetched from back-end */}
+        {this.props.geoJSONLayer.features.map(feature => {
+          let id = feature.mapDrawingHistoryId;
+          let coords = feature.data.geometry.coordinates;
+          let type = feature.data.geometry.type;
+          let color = feature.data.properties.color;
+          let radius = feature.data.properties.radius;
+          let text = feature.data.properties.text;
+          let rectangle = feature.data.properties.rectangle;
+          if (type === "Point") {
+            // GeoJSON saves latitude first, not longitude like usual. swapping
+            let position = [coords[1], coords[0]];
+            if (radius) {
+              return (
+                // keys are required to be able to edit
+                <Circle
+                  key={Math.random()}
+                  center={position}
+                  id={id}
+                  radius={radius}
+                  color={color}
+                />
+              );
+            } else if (text) {
+              return (
+                <Marker
+                  key={Math.random()}
+                  position={position}
+                  id={id}
+                  color={color}
+                  icon={noIcon}
+                >
+                  <Tooltip
+                    direction="bottom"
+                    permanent
+                    className="editable"
+                    interactive={true}
+                  >
+                    <div class="editable">
+                      <div
+                        contenteditable="true"
+                        placeholder="Click out to save"
+                      >
+                        {text}
+                      </div>
+                    </div>
+                  </Tooltip>
+                </Marker>
+              );
+            } else {
+              // unknown if color changes anything. need to test
+              return (
+                <Marker
+                  key={Math.random()}
+                  position={position}
+                  id={id}
+                  color={color}
+                />
+              );
+            }
+          } else if (rectangle) {
+            // instead of an array of four coordinates, rectangles only have two corners
+            let bounds = coords[0].map(coord => {
+              return [coord[1], coord[0]];
+            });
+            return (
+              <Rectangle
+                key={Math.random()}
+                bounds={bounds}
+                id={id}
+                color={color}
+              />
+            );
+          } else if (type === "Polygon") {
+            // Polygon coordinates are wrapped under a one element array, for some reason
+            let positions = coords[0].map(coord => {
+              return [coord[1], coord[0]];
+            });
+            return (
+              <Polygon
+                key={Math.random()}
+                positions={positions}
+                id={id}
+                color={color}
+              />
+            );
+          } else if (type === "LineString") {
+            // Polyline coordinates are a normal array, unlike Polygon
+            let positions = coords.map(coord => {
+              return [coord[1], coord[0]];
+            });
+            return (
+              <Polyline
+                key={Math.random()}
+                positions={positions}
+                id={id}
+                color={color}
+              />
+            );
+          }
+        })}
+      </React.Fragment>
+    );
+  }
+}
+
+export default DrawGeoJSON;
diff --git a/src/components/DrawTools.js b/src/components/DrawTools.js
index be1b7b35dedab72665c5c3221072c5781656b027..8040ce5920a3f990eebb6ce94081538103d2a6b7 100644
--- a/src/components/DrawTools.js
+++ b/src/components/DrawTools.js
@@ -246,51 +246,54 @@ class DrawTools extends Component {
       // The elements you draw will be added to this FeatureGroup layer,
       // when you hit edit button only items in this layer will be edited."
       <FeatureGroup>
-        <EditControl
-          position="topright"
-          onCreated={this._onCreated}
-          onEdited={this._onEdited}
-          onEditStart={this._onEditDeleteStart}
-          onEditStop={this._onEditDeleteStop}
-          onDeleted={this._onDeleted}
-          onDeleteStart={this._onEditDeleteStart}
-          onDeleteStop={this._onEditDeleteStop}
-          draw={{
-            circle: {
-              repeatMode: false, // allows using the tool again after finishing the previous shape
-              shapeOptions: {
-                color: "#f9f10c",
-                opacity: 1 // affects the outline only. for some reason it wasn't at full opacity, so this is needed for more clarity
-              }
-            },
-            rectangle: {
-              repeatMode: false
-            },
-            polygon: {
-              repeatMode: true,
-              allowIntersection: false, // Restricts shapes to simple polygons
-              drawError: {
-                color: "#e1e100", // Color the shape will turn when intersects
-                message: "<strong>Oh snap!<strong> you can't draw that!" // Message that will show when intersect
+        {(this.props.role === "admin" ||
+          this.props.role === "factionleader") && (
+          <EditControl
+            position="topright"
+            onCreated={this._onCreated}
+            onEdited={this._onEdited}
+            onEditStart={this._onEditDeleteStart}
+            onEditStop={this._onEditDeleteStop}
+            onDeleted={this._onDeleted}
+            onDeleteStart={this._onEditDeleteStart}
+            onDeleteStop={this._onEditDeleteStop}
+            draw={{
+              circle: {
+                repeatMode: false, // allows using the tool again after finishing the previous shape
+                shapeOptions: {
+                  color: "#f9f10c",
+                  opacity: 1 // affects the outline only. for some reason it wasn't at full opacity, so this is needed for more clarity
+                }
               },
-              shapeOptions: {
-                color: "#ed2572",
-                opacity: 1
-              }
-            },
-            polyline: {
-              repeatMode: true,
-              shapeOptions: {
-                color: "#ed2572",
-                opacity: 1
-              }
-            },
-            marker: {
-              repeatMode: false
-            },
-            circlemarker: false
-          }}
-        />
+              rectangle: {
+                repeatMode: false
+              },
+              polygon: {
+                repeatMode: true,
+                allowIntersection: false, // Restricts shapes to simple polygons
+                drawError: {
+                  color: "#e1e100", // Color the shape will turn when intersects
+                  message: "<strong>Oh snap!<strong> you can't draw that!" // Message that will show when intersect
+                },
+                shapeOptions: {
+                  color: "#ed2572",
+                  opacity: 1
+                }
+              },
+              polyline: {
+                repeatMode: true,
+                shapeOptions: {
+                  color: "#ed2572",
+                  opacity: 1
+                }
+              },
+              marker: {
+                repeatMode: false
+              },
+              circlemarker: false
+            }}
+          />
+        )}
 
         {/* iterate through every element fetched from back-end */}
         {this.props.geoJSONLayer.features.map(feature => {
diff --git a/src/components/EditGameForm.js b/src/components/EditGameForm.js
index 2423ea337d3334f8b56db2b5efab876285d83bbc..1111a85d91291c5d593e60db96dece29add5c487 100644
--- a/src/components/EditGameForm.js
+++ b/src/components/EditGameForm.js
@@ -30,19 +30,14 @@ export default class EditGameForm extends React.Component {
       objectivePoints: [],
       capture_time: 300,
       confirmation_time: 60,
-      displayColorPicker: false
+      displayColorPicker: false,
+      saved: true
     };
-
-    this.handleMapDrag = this.handleMapDrag.bind(this);
   }
 
-  handleError = error => {
-    this.setState({ errorMsg: error });
-  };
-
   handleChange = e => {
     const { name, value } = e.target;
-    this.setState({ [name]: value });
+    this.setState({ [name]: value, saved: false });
   };
 
   handleFactionAdd = e => {
@@ -185,7 +180,12 @@ export default class EditGameForm extends React.Component {
 
   // show/hide this form
   handleView = e => {
-    this.props.toggleView(this.props.view);
+    if (
+      this.state.saved ||
+      window.confirm("Are you sure you want to leave without saving?")
+    ) {
+      this.props.toggleView(this.props.view);
+    }
   };
 
   // remove view with ESC
@@ -195,17 +195,7 @@ export default class EditGameForm extends React.Component {
     }
   };
 
-  handleMapDrag = e => {
-    this.setState({
-      mapCenter: e.target.getCenter()
-    });
-  };
-
-  handleMapScroll = e => {
-    this.setState({
-      zoom: e.target.getZoom()
-    });
-  };
+  handleMapScroll = e => {};
 
   handleGameSave = e => {
     e.preventDefault();
@@ -221,6 +211,8 @@ export default class EditGameForm extends React.Component {
       objectivePoints = [];
     }
 
+    console.log(objectivePoints);
+
     // Object the form sends to server
     let gameObject = {
       name: this.state.gamename,
@@ -230,24 +222,21 @@ export default class EditGameForm extends React.Component {
       enddate: endDate,
       center: this.state.mapCenter,
       factions: this.state.factions,
-      objective_points: objectivePoints
-    };
-
-    // Add node settings to the game if the game has objective points
-    if (objectivePoints.length > 0) {
-      gameObject.nodesettings = {
+      objective_points: objectivePoints,
+      nodesettings: {
         node_settings: {
-          capture_time: this.state.capture_time,
-          confirmation_time: this.state.confirmation_time,
+          capture_time: parseInt(this.state.capture_time),
+          confirmation_time: parseInt(this.state.confirmation_time),
           owner: 0,
           capture: 0,
           buttons_available: 16,
           heartbeat_interval: 60
         }
-      };
-    }
+      }
+    };
 
     let token = sessionStorage.getItem("token");
+    let error = false;
 
     // Send Game info to the server
     fetch(`${process.env.REACT_APP_API_URL}/game/edit/${this.props.gameId}`, {
@@ -261,15 +250,23 @@ export default class EditGameForm extends React.Component {
     })
       .then(res => {
         if (!res.ok) {
-          throw Error(res.statusMessage);
-        } else {
-          return res.json();
+          error = true;
         }
+        return res.json();
       })
       .then(result => {
         alert(result.message);
-        this.props.onEditSave();
-        this.handleView();
+        if (!error) {
+          this.setState(
+            {
+              saved: true
+            },
+            () => {
+              this.handleView();
+              this.props.onEditSave();
+            }
+          );
+        }
       })
       .catch(error => console.log("Error: ", error));
   };
@@ -446,7 +443,7 @@ export default class EditGameForm extends React.Component {
             onSubmit={this.handleObjectivePointAdd}
           />
 
-          <h1>Demo Game Editor</h1>
+          <h1>Game Editor</h1>
           <br />
           <input
             placeholder="Game name"
@@ -515,10 +512,10 @@ export default class EditGameForm extends React.Component {
           />
           <br />
           <br />
-
           <label>Factions</label>
           <br />
           <input
+            id="editGameFactionNameInput"
             name="factionNameInput"
             id="factionNameInput"
             value={this.state.factionNameInput}
@@ -528,15 +525,18 @@ export default class EditGameForm extends React.Component {
             form="factionAddFrom"
           />
           <input
+            id="editGameFactionPasswordInput"
             name="factionPasswordInput"
             id="factionPasswordInput"
             value={this.state.factionPasswordInput}
             minLength="3"
             onChange={this.handleChange}
             placeholder="Faction password"
+            type="password"
             form="factionAddFrom"
           />
           <div
+            id="editGameColorPickerButton"
             style={styles.swatch}
             onClick={() =>
               this.setState({
@@ -548,6 +548,7 @@ export default class EditGameForm extends React.Component {
           </div>
           {this.state.displayColorPicker && (
             <div
+              id="editGameColorPicker"
               style={styles.cover}
               onClick={() => this.setState({ displayColorPicker: false })}
             >
@@ -559,7 +560,11 @@ export default class EditGameForm extends React.Component {
               />
             </div>
           )}
-          <button type="submit" form="factionAddFrom" id="factionAddButton">
+          <button
+            id="editGameFactionSubmitButton"
+            type="submit"
+            form="factionAddFrom"
+          >
             Add
           </button>
           <ul>{factions}</ul>
@@ -568,6 +573,7 @@ export default class EditGameForm extends React.Component {
           <label>Objective points</label>
           <br />
           <input
+            id="editGameObjectivePointDescriptionInput"
             name="objectivePointDescriptionInput"
             id="objectivePointDescriptionInput"
             type="number"
@@ -578,6 +584,7 @@ export default class EditGameForm extends React.Component {
             form="objectivePointAddFrom"
           />
           <input
+            id="editGameObjectivePointMultiplierInput"
             name="objectivePointMultiplierInput"
             id="objectivePointMultiplierInput"
             type="number"
@@ -586,7 +593,11 @@ export default class EditGameForm extends React.Component {
             placeholder="Objective point multiplier"
             form="objectivePointAddFrom"
           />
-          <button type="submit" form="objectivePointAddFrom" id="objectivePointAddButton">
+          <button
+            id="editGameObjectivePointSubmitButton"
+            type="submit"
+            form="objectivePointAddFrom"
+          >
             Add
           </button>
           <ul>{objectivePoints}</ul>
@@ -599,6 +610,7 @@ export default class EditGameForm extends React.Component {
             Capture time:
           </label>
           <input
+            id="editGameCaptureTimeInput"
             name="capture_time"
             id="captureTimeInput"
             type="number"
@@ -608,6 +620,7 @@ export default class EditGameForm extends React.Component {
           />
           <label className="">Confimation time:</label>
           <input
+            id="editGameConfirmationTimeInput"
             name="confirmation_time"
             id="confirmationTimeInput"
             type="number"
@@ -626,8 +639,8 @@ export default class EditGameForm extends React.Component {
             zoom={this.state.zoom}
             maxZoom="13"
             style={{ height: "400px", width: "400px" }}
-            onmoveend={this.handleMapDrag}
-            onzoomend={this.handleMapScroll}
+            onmoveend={e => this.setState({ mapCenter: e.target.getCenter() })}
+            onzoomend={e => this.setState({ zoom: e.target.getZoom() })}
           >
             <TileLayer
               attribution="Maanmittauslaitoksen kartta"
@@ -636,7 +649,7 @@ export default class EditGameForm extends React.Component {
           </Map>
           <br />
           <button
-            id="gameDeleteButton"
+            id="editGameDeleteGameButton"
             style={{ backgroundColor: "red" }}
             type="submit"
             form="gameDeletionForm"
diff --git a/src/components/GameCard.js b/src/components/GameCard.js
index bfa10a6097d919d4339e6341e99e753b4d41b202..18a1e67a7a5287cffa1d189caf2b8d926d171b64 100644
--- a/src/components/GameCard.js
+++ b/src/components/GameCard.js
@@ -58,6 +58,13 @@ export default class GameCard extends React.Component {
             Select
           </button>
         </Link>
+        <Link
+          to={{ pathname: "/replay", search: "?id=" + this.state.gameInfo.id }}
+        >
+          <button id={`replay${this.state.gameInfo.name}`} type="button">
+            Replay
+          </button>
+        </Link>
       </div>
     );
   }
diff --git a/src/components/GameInfoView.js b/src/components/GameInfoView.js
new file mode 100644
index 0000000000000000000000000000000000000000..dc961d61a855e0bccf28c2a5a82c335817de66a4
--- /dev/null
+++ b/src/components/GameInfoView.js
@@ -0,0 +1,70 @@
+import React from "react";
+import { Map, TileLayer } from "react-leaflet";
+
+export default class GameInfoView extends React.Component {
+  componentDidMount() {
+    document.addEventListener("keyup", this.handleEsc);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener("keyup", this.handleEsc);
+  }
+
+  handleEsc = e => {
+    if (e.keyCode === 27) {
+      this.props.toggleView();
+    }
+  };
+
+  render() {
+    if (this.props.gameInfo === undefined) {
+      return false;
+    }
+    return (
+      <div className="fade-main">
+        <div className="sticky">
+          <span
+            id="closeGameInfoX"
+            className="close"
+            onClick={this.props.toggleView}
+          >
+            ×
+          </span>
+        </div>
+        <div className="">
+          <h1>Game Info</h1>
+          <p>Game name: {this.props.gameInfo.name}</p>
+          <p>Description: {this.props.gameInfo.desc}</p>
+          <p>Start date: {this.props.gameInfo.startdate}</p>
+          <p>End date: {this.props.gameInfo.enddate}</p>
+          <h2>Factions</h2>
+          <div>
+            {this.props.gameInfo.factions.map(faction => (
+              <p key={faction.factionId} style={{ color: faction.colour }}>
+                {faction.factionName}
+              </p>
+            ))}
+          </div>
+          <div>
+            <Map
+              id="gameInfoCenterMap"
+              className=""
+              center={[
+                this.props.gameInfo.center.lat,
+                this.props.gameInfo.center.lng
+              ]}
+              zoom="13"
+              maxZoom="13"
+              style={{ height: "400px", width: "400px" }}
+            >
+              <TileLayer
+                attribution="Maanmittauslaitoksen kartta"
+                url=" https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg"
+              />
+            </Map>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/src/components/GameList.js b/src/components/GameList.js
index fcb3091c15867045b985157192b864789dabc204..c7df36e8ae8147d2e051ac1976101689f208abee 100644
--- a/src/components/GameList.js
+++ b/src/components/GameList.js
@@ -35,8 +35,6 @@ class GameList extends React.Component {
               ? games[0].id
               : undefined
         });
-        // taking the initialized gameID to App.js (GameList.js -> GameSidebar.js -> Header.js -> App.js)
-        this.props.handleGameChange(games[0].id);
       })
       .catch(error => {
         console.log(error);
@@ -80,20 +78,18 @@ class GameList extends React.Component {
   };
 
   render() {
-    let gamelistItems = this.props.games.map(game => {
-      return (
-        <GameCard
-          key={game.id}
-          gameId={game.id}
-          onEditSave={this.props.onEditSave}
-        />
-      );
-    });
+    let gamelistItems = this.props.games.map(game => (
+      <GameCard
+        key={game.id}
+        gameId={game.id}
+        onEditSave={this.props.onEditSave}
+      />
+    ));
 
     return (
-      <Fragment>
-        <div className="gamelist">{gamelistItems}</div>
-      </Fragment>
+      <div className="gamelist">
+        <div className="gamelist-item">{gamelistItems}</div>
+      </div>
     );
   }
 }
diff --git a/src/components/GameStateButtons.js b/src/components/GameStateButtons.js
index c770ed7896facdddaf56adecde4c689d10dbeb3f..946f9e5b14003d7ad793f08bc9a8f67da8196dea 100644
--- a/src/components/GameStateButtons.js
+++ b/src/components/GameStateButtons.js
@@ -6,53 +6,71 @@ export default class GameStateButtons extends React.Component {
   };
 
   setGameState(state) {
-    console.log(state);
-    let token = sessionStorage.getItem("token");
-    let error = false;
-    fetch(
-      `${process.env.REACT_APP_API_URL}/game/edit-state/${this.props.gameId}`,
-      {
-        method: "PUT",
-        headers: {
-          Authorization: "Bearer " + token,
-          Accept: "application/json",
-          "Content-Type": "application/json"
-        },
-        body: JSON.stringify({
-          id: this.props.gameId,
-          state: state
-        })
-      }
-    )
-      .then(res => {
-        if (!res.ok) {
-          error = true;
-        }
-        return res.json();
-      })
-      .then(res => {
-        if (error) {
-          console.log(res);
-        } else {
-          alert(`Game state changed to ${state}`);
-          this.setState({ gameState: state });
+    if (
+      window.confirm(`Are you sure you want to change game state to ${state}?`)
+    ) {
+      let token = sessionStorage.getItem("token");
+      let error = false;
+      fetch(
+        `${process.env.REACT_APP_API_URL}/game/edit-state/${this.props.gameId}`,
+        {
+          method: "PUT",
+          headers: {
+            Authorization: "Bearer " + token,
+            Accept: "application/json",
+            "Content-Type": "application/json"
+          },
+          body: JSON.stringify({
+            id: this.props.gameId,
+            state: state
+          })
         }
-      })
-      .catch(error => console.log(error));
+      )
+        .then(res => {
+          if (!res.ok) {
+            error = true;
+          }
+          return res.json();
+        })
+        .then(res => {
+          if (error) {
+            console.log(res);
+          } else {
+            alert(`Game state changed to ${state}`);
+            this.setState({ gameState: state });
+          }
+        })
+        .catch(error => console.log(error));
+    }
   }
 
   render() {
     if (this.state.gameState === "CREATED") {
       return (
-        <button onClick={() => this.setGameState("STARTED")}>Start</button>
+        <button
+          id="gameStateStartButton"
+          onClick={() => this.setGameState("STARTED")}
+        >
+          Start
+        </button>
       );
     }
 
     if (this.state.gameState === "STARTED") {
       return (
         <Fragment>
-          <button onClick={() => this.setGameState("PAUSED")}>Pause</button>
-          <button onClick={() => this.setGameState("ENDED")}>Stop</button>
+          <button
+            id="gameStatePauseButton"
+            onClick={() => this.setGameState("PAUSED")}
+          >
+            Pause
+          </button>
+          <button
+            id="gameStateStopButton"
+            onClick={() => this.setGameState("ENDED")}
+          >
+            Stop
+          </button>
         </Fragment>
       );
     }
@@ -60,8 +78,18 @@ export default class GameStateButtons extends React.Component {
     if (this.state.gameState === "PAUSED") {
       return (
         <Fragment>
-          <button onClick={() => this.setGameState("STARTED")}>Continue</button>
-          <button onClick={() => this.setGameState("ENDED")}>Stop</button>
+          <button
+            id="gameStateContinueButton"
+            onClick={() => this.setGameState("STARTED")}
+          >
+            Continue
+          </button>
+          <button
+            id="gameStateStopButton"
+            onClick={() => this.setGameState("ENDED")}
+          >
+            Stop
+          </button>
         </Fragment>
       );
     }
diff --git a/src/components/GameView.js b/src/components/GameView.js
index 32474d7a8c4e6c520fd69fc468156cbfc1c567cf..2215bf242a3920b0c208e8ee0bdf770cdc202c0e 100644
--- a/src/components/GameView.js
+++ b/src/components/GameView.js
@@ -8,6 +8,8 @@ import PlayerlistView from "./PlayerlistView";
 import NotificationView from "./NotificationView";
 import GameStateButtons from "./GameStateButtons";
 import ClientSocket from "./Socket";
+import NotificationPopup from "./NotificationPopup";
+import GameInfoView from "./GameInfoView";
 
 export default class GameView extends React.Component {
   state = {
@@ -18,35 +20,19 @@ export default class GameView extends React.Component {
     lng: 25.7597186,
     zoom: 13,
     mapUrl: "https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg",
-    socketSignal: null
+    socketSignal: null,
+    socket: null
   };
 
   componentDidMount() {
     let gameId = new URL(window.location.href).searchParams.get("id");
-    let token = sessionStorage.getItem("token");
-
-    fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`)
-      .then(res => {
-        if (!res.ok) {
-          throw Error();
-        }
-      })
-      .catch(error => {
-        alert("Game not found");
-        window.document.location.href = "/";
-      });
+    this.getGameInfo(gameId);
+    this.getPlayerRole(gameId);
+  }
 
-    // Get game info
-    fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`)
-      .then(res => res.json())
-      .then(res => {
-        this.setState({
-          gameInfo: res
-        });
-      })
-      .catch(error => console.log(error));
+  getPlayerRole(gameId) {
+    let token = sessionStorage.getItem("token");
 
-    // Get Role
     fetch(`${process.env.REACT_APP_API_URL}/faction/check-faction/${gameId}`, {
       method: "GET",
       headers: {
@@ -60,6 +46,26 @@ export default class GameView extends React.Component {
       .catch(error => console.log(error));
   }
 
+  getGameInfo(gameId) {
+    fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`)
+      .then(res => {
+        if (!res.ok) {
+          throw Error();
+        } else {
+          return res.json();
+        }
+      })
+      .then(res => {
+        this.setState({
+          gameInfo: res
+        });
+      })
+      .catch(error => {
+        alert("Game not found");
+        window.document.location.href = "/";
+      });
+  }
+
   handleLeaveFaction = e => {
     let token = sessionStorage.getItem("token");
     let error = false;
@@ -76,23 +82,54 @@ export default class GameView extends React.Component {
     )
       .then(res => {
         if (!res.ok) {
-          error = true;
+          throw Error();
+        } else {
+          return res.json();
         }
-        return res.json();
       })
       .then(res => {
         alert(res.message);
+        this.getPlayerRole(this.state.gameInfo.id);
       })
-      .catch(error => console.log(error));
+      .catch(error => {
+        alert("Game not found");
+        window.document.location.href = "/";
+      });
+  };
+
+  handleLeaveFaction = e => {
+    if (window.confirm("Are you sure you want to leave your faction?")) {
+      let token = sessionStorage.getItem("token");
+      fetch(
+        `${process.env.REACT_APP_API_URL}/faction/leave/${
+          this.state.gameInfo.id
+        }`,
+        {
+          method: "DELETE",
+          headers: {
+            Authorization: "Bearer " + token
+          }
+        }
+      )
+        .then(res => {
+          if (!res.ok) {
+          }
+          return res.json();
+        })
+        .then(res => {
+          alert(res.message);
+          this.getPlayerRole(this.state.gameInfo.id);
+        })
+        .catch(error => console.log(error));
+    }
   };
 
   // setting the socket signal automatically fires shouldComponentUpdate function where socketSignal prop is present
   // setting socketSignal to null immediately after to avoid multiple database fetches
-  getSocketSignal = type => {
-    console.log(type);
+  getSocketSignal = data => {
     this.setState(
       {
-        socketSignal: type
+        socketSignal: data
       },
       () => {
         this.setState({
@@ -102,13 +139,21 @@ export default class GameView extends React.Component {
     );
   };
 
-  render() {
-    const initialPosition = [this.state.lat, this.state.lng];
+  onSocketChange = newSocket => {
+    this.setState({
+      socket: newSocket
+    });
+  };
 
+  render() {
+    console.log(this.state.gameInfo);
+    const initialPosition = this.state.gameInfo
+      ? [this.state.gameInfo.center.lat, this.state.gameInfo.center.lng]
+      : null;
     return (
       <div>
         <Link to="/">
-          <button id="selectGameButton">Game selection</button>
+          <button id="gameViewGameSelectionButton">Game selection</button>
         </Link>
         {this.state.gameInfo !== null && (
           <div>
@@ -116,6 +161,7 @@ export default class GameView extends React.Component {
               <ClientSocket
                 gameId={this.state.gameInfo.id}
                 getSocketSignal={this.getSocketSignal}
+                onSocketChange={this.onSocketChange}
               />
             )}
             <div>Game Name: {this.state.gameInfo.name}</div>
@@ -125,14 +171,21 @@ export default class GameView extends React.Component {
             {this.state.role !== "" && (
               <div>Your role in this game: {this.state.role}</div>
             )}
-            {this.state.role === "admin" && (
-              <button
-                id="editGameButton"
-                onClick={() => this.setState({ form: "edit" })}
-              >
-                Edit
-              </button>
-            )}
+            {this.state.role === "admin" &&
+              this.state.gameInfo.state === "CREATED" && (
+                <button
+                  id="editGameButton"
+                  onClick={() => this.setState({ form: "edit" })}
+                >
+                  Edit
+                </button>
+              )}
+            <button
+              id="gameInfoButton"
+              onClick={() => this.setState({ form: "info" })}
+            >
+              Game Info
+            </button>
             {this.state.role === "" && (
               <button
                 id="joinGameButton"
@@ -159,6 +212,7 @@ export default class GameView extends React.Component {
               <TaskListButton
                 gameId={this.state.gameInfo.id}
                 role={this.state.role}
+                factions={this.state.gameInfo.factions}
               />
             )}
             {this.state.role !== "admin" && this.state.role !== "" && (
@@ -172,27 +226,34 @@ export default class GameView extends React.Component {
                 gameId={this.state.gameInfo.id}
               />
             )}
-            <UserMap
-              position={initialPosition}
-              zoom={this.state.zoom}
-              mapUrl={this.state.mapUrl}
-              currentGameId={this.state.gameInfo.id}
-              socketSignal={this.state.socketSignal}
-            />
+            {initialPosition && (
+              <UserMap
+                position={initialPosition}
+                zoom={this.state.zoom}
+                mapUrl={this.state.mapUrl}
+                currentGameId={this.state.gameInfo.id}
+                socketSignal={
+                  this.state.socketSignal === null
+                    ? null
+                    : this.state.socketSignal.type
+                }
+                role={this.state.role}
+              >
+                <NotificationPopup socketSignal={this.state.socketSignal} />
+              </UserMap>
+            )}
             {this.state.form === "edit" && (
               <EditGameForm
                 gameId={this.state.gameInfo.id}
                 toggleView={() => this.setState({ form: "" })}
-                onEditSave={() => {
-                  this.getGameInfo();
-                }}
+                onEditSave={() => this.getGameInfo(this.state.gameInfo.id)}
               />
             )}
             {this.state.form === "join" && (
               <JoinGameForm
                 gameId={this.state.gameInfo.id}
                 toggleView={() => this.setState({ form: "" })}
-                onJoin={() => console.log("joinde")}
+                onJoin={() => this.getPlayerRole(this.state.gameInfo.id)}
               />
             )}
             {this.state.form === "players" && (
@@ -206,6 +267,19 @@ export default class GameView extends React.Component {
               <NotificationView
                 gameId={this.state.gameInfo.id}
                 toggleView={() => this.setState({ form: "" })}
+                socket={this.state.socket}
+                role={this.state.role}
+                gameState={
+                  this.state.gameInfo !== undefined
+                    ? this.state.gameInfo.state
+                    : ""
+                }
+              />
+            )}
+            {this.state.form === "info" && (
+              <GameInfoView
+                gameInfo={this.state.gameInfo}
+                toggleView={() => this.setState({ form: "" })}
               />
             )}
           </div>
diff --git a/src/components/JoinGameForm.js b/src/components/JoinGameForm.js
index 3b8d97fd0b60ad09c727d30a79561a4abc74f29d..c2a43d7399cf4e4b1e6a6dbac7f29143aa46be89 100644
--- a/src/components/JoinGameForm.js
+++ b/src/components/JoinGameForm.js
@@ -14,7 +14,7 @@ export default class JoinGameForm extends React.Component {
   }
 
   // Get game info
-  //TODO: from props
+  //TODO: gameinfo from props
   componentDidMount() {
     if (this.props.gameId === undefined) {
       alert("game not selected");
@@ -122,6 +122,7 @@ export default class JoinGameForm extends React.Component {
               onChange={e =>
                 this.setState({ factionPasswordInput: e.target.value })
               }
+              type="password"
               placeholder="Password"
               minLength="3"
               required
diff --git a/src/components/LoginForm.js b/src/components/LoginForm.js
index f7154f038be5f5ccdf6852f1ed242276d0b7ca4d..d1e90cb994a8abfd30e15ad81aaaac2ac6c64ad9 100644
--- a/src/components/LoginForm.js
+++ b/src/components/LoginForm.js
@@ -87,7 +87,7 @@ export class LoginForm extends React.Component {
             <h2>{this.state.errorMsg}</h2>
           </form>
           <Link to="/register">
-            <button id="openRegisterFormButton">Create account</button>
+            <button id="loginRegisterButton">Create account</button>
           </Link>
         </div>
       </div>
diff --git a/src/components/NotificationCard.js b/src/components/NotificationCard.js
new file mode 100644
index 0000000000000000000000000000000000000000..cf49d28f285a8f6f662007b947b1d7a42cc682fc
--- /dev/null
+++ b/src/components/NotificationCard.js
@@ -0,0 +1,11 @@
+import React from "react";
+
+export default class NotificationCard extends React.Component {
+  render() {
+    return (
+      <div>
+        {this.props.notification.type} : {this.props.notification.message}
+      </div>
+    );
+  }
+}
diff --git a/src/components/NotificationPopup.js b/src/components/NotificationPopup.js
new file mode 100644
index 0000000000000000000000000000000000000000..a3e9a723a6b62f03d7ace1024c1912e8f32ee38a
--- /dev/null
+++ b/src/components/NotificationPopup.js
@@ -0,0 +1,58 @@
+import React from "react";
+
+export default class NotificationPopup extends React.Component {
+  state = {
+    lastNotification: null,
+    visible: true
+  };
+
+  componentDidUpdate(prevProps, prevState) {
+    if (
+      prevProps.socketSignal !== null &&
+      prevProps.socketSignal !== this.state.lastNotification
+    ) {
+      if (prevProps.socketSignal.type === "alert") {
+        this.setState({
+          lastNotification: prevProps.socketSignal,
+          visible: true
+        });
+      }
+      if (prevProps.socketSignal.type === "note") {
+        this.setState({
+          lastNotification: prevProps.socketSignal,
+          visible: true
+        });
+      }
+    }
+  }
+
+  render() {
+    if (this.state.lastNotification !== null && this.state.visible) {
+      return (
+        <div
+          className={
+            this.state.lastNotification.type === "alert"
+              ? "notification-popup alert"
+              : "notification-popup warning"
+          }
+        >
+          <button
+            id="NotificationPopupCloseButton"
+            onClick={() => {
+              this.setState({ visible: false });
+            }}
+          >
+            Close
+          </button>
+          <br />
+          <label>
+            {this.state.lastNotification.type === "alert" ? "ALERT" : "Note"}
+          </label>
+          <p>{this.state.lastNotification.message}</p>
+        </div>
+      );
+    }
+
+    return false;
+  }
+}
diff --git a/src/components/NotificationView.js b/src/components/NotificationView.js
index 5fcf80141e569562492417d719854bd7b654ced1..d63d0253b0f585b9ad7d904fb9eaced0fa28b3d9 100644
--- a/src/components/NotificationView.js
+++ b/src/components/NotificationView.js
@@ -1,7 +1,99 @@
 import React from "react";
+import NotificationCard from "./NotificationCard";
 
 export default class NotificationView extends React.Component {
+  state = {
+    notifications: [],
+    notificationInput: "",
+    notificationTypeInput: "note"
+  };
+
+  componentDidMount() {
+    this.getNotifications(this.props.gameId);
+  }
+
+  getNotifications(gameId) {
+    let token = sessionStorage.getItem("token");
+    fetch(`${process.env.REACT_APP_API_URL}/notifications/${gameId}`, {
+      headers: {
+        Authorization: "Bearer " + token
+      }
+    })
+      .then(res => res.json())
+      .then(res => {
+        this.setState({ notifications: res.reverse() });
+      });
+  }
+
+  handleSend = e => {
+    e.preventDefault();
+
+    if (this.state.notificationInput === "") {
+      alert("notification message can't be empty");
+    } else if (
+      window.confirm("Are you sure you want to send the notification")
+    ) {
+      this.props.socket.emit("game-info", {
+        type: this.state.notificationTypeInput,
+        message: this.state.notificationInput,
+        game: this.props.gameId
+      });
+      alert("Notification sent");
+      this.getNotifications(this.props.gameId);
+      this.setState({ notificationInput: "" });
+    }
+  };
+
   render() {
-    return false;
+    let notifications = this.state.notifications.map(notification => (
+      <NotificationCard key={notification.id} notification={notification} />
+    ));
+
+    return (
+      <div className="fade-main">
+        <button
+          id="notificationViewCloseButton"
+          onClick={() => this.props.toggleView()}
+        >
+          Close
+        </button>
+        <div>
+          {this.props.role === "admin" &&
+            this.props.gameState !== "ENDED" &&
+            this.props.gameState !== "CREATED" && (
+              <form onSubmit={this.handleSend}>
+                <select
+                  id="notificationViewTypeSelect"
+                  value={this.state.notificationTypeInput}
+                  onChange={e =>
+                    this.setState({ notificationTypeInput: e.target.value })
+                  }
+                >
+                  <option value="note">Note</option>
+                  <option value="alert">Alert</option>
+                </select>
+                <input
+                  id="notificationViewMessageInput"
+                  type="text"
+                  value={this.state.notificationInput}
+                  onChange={e =>
+                    this.setState({ notificationInput: e.target.value })
+                  }
+                  placeholder="Notification message..."
+                />
+                <button id="notificationSubmitButton" type="submit">
+                  Send Notification
+                </button>
+              </form>
+            )}
+          {this.props.role === "admin" &&
+            (this.props.gameState === "ENDED" ||
+              this.props.gameState === "CREATED") && (
+              <p>Notifications can only be sent if the game is ongoing</p>
+            )}
+        </div>
+        {notifications}
+      </div>
+    );
   }
 }
diff --git a/src/components/PlayerlistPlayerCard.js b/src/components/PlayerlistPlayerCard.js
index ebc2ddeb5c6eb75958cf6390404206f488f73878..7260d5abaa4b4f617cb32ea7b428756f5ab606ba 100644
--- a/src/components/PlayerlistPlayerCard.js
+++ b/src/components/PlayerlistPlayerCard.js
@@ -69,13 +69,20 @@ export default class PlayerlistPlayerCard extends React.Component {
         <div>
           {this.props.player.person.name} :{" "}
           <select
+            id={"playerCardRoleSelect" + this.props.player.person.name}
             value={this.state.roleInput}
             onChange={e => this.setState({ roleInput: e.target.value })}
           >
             {roleOptions()}
           </select>
-          <button onClick={this.handleSave}>Save</button>
           <button
+            id={"playerCardSaveButton" + this.props.player.person.name}
+            onClick={this.handleSave}
+          >
+            Save
+          </button>
+          <button
+            id={"playerCardCancelButton" + this.props.player.person.name}
             onClick={() => {
               this.setState({ edit: false, roleInput: this.props.player.role });
             }}
@@ -92,16 +99,13 @@ export default class PlayerlistPlayerCard extends React.Component {
         {this.props.player.person.name} : {this.state.edit && roleOptions()}
         {!this.state.edit && this.props.player.role}
         {this.props.role === "admin" && !this.state.edit && (
-          <button onClick={() => this.setState({ edit: !this.state.edit })}>
+          <button
+            id={"playerCardEditButton" + this.props.player.person.name}
+            onClick={() => this.setState({ edit: !this.state.edit })}
+          >
             Edit
           </button>
         )}
-        {this.state.edit && (
-          <Fragment>
-            <button>Save</button>
-            <button>Cancel</button>
-          </Fragment>
-        )}
       </div>
     );
   }
diff --git a/src/components/PlayerlistView.js b/src/components/PlayerlistView.js
index 6967b0e2e0dc25017ff4940710e3d1e4488a6150..60c294fc257c3f12cd9fded17659c8e0323be4c5 100644
--- a/src/components/PlayerlistView.js
+++ b/src/components/PlayerlistView.js
@@ -71,7 +71,7 @@ export default class PlayerlistView extends React.Component {
       <div className="fade-main">
         <div className="sticky">
           <span
-            id="closeEditGameFormX"
+            id="closePlayerlistX"
             className="close"
             onClick={() => this.props.toggleView()}
           >
diff --git a/src/components/RegisterForm.js b/src/components/RegisterForm.js
index 7bbb278999bc691757b769bf4dbb7dd41b9c22f0..a28c4502bec30600a1f15c91ba7f346168b1d7d4 100644
--- a/src/components/RegisterForm.js
+++ b/src/components/RegisterForm.js
@@ -72,6 +72,7 @@ export class RegisterForm extends React.Component {
             <h1>Register</h1>
             <br />
             <input
+              id="registerUsernameInput"
               placeholder="Enter Username"
               name="username"
               value={this.state.username}
@@ -82,6 +83,7 @@ export class RegisterForm extends React.Component {
             />
             <br />
             <input
+              id="registerPasswordInput"
               placeholder="Enter password"
               type="password"
               name="password"
@@ -91,6 +93,7 @@ export class RegisterForm extends React.Component {
             />
             <br />
             <input
+              id="registerPasswordVerifyInput"
               placeholder="Verify password"
               type="password"
               name="password2"
diff --git a/src/components/ReplayConfig.js b/src/components/ReplayConfig.js
new file mode 100644
index 0000000000000000000000000000000000000000..9eea9de7fcf39dd7eea0bce866e486632b7866ef
--- /dev/null
+++ b/src/components/ReplayConfig.js
@@ -0,0 +1,45 @@
+import React from "react";
+
+var options = {
+  trackPointOptions: {
+    // whether to draw track point
+    isDraw: true,
+    // whether to use canvas to draw it, if false, use leaflet api `L.circleMarker`
+    useCanvas: false,
+    stroke: true,
+    color: "#000000",
+    fill: true,
+    fillColor: "rgba(0,0,0,0)",
+    opacity: 0,
+    radius: 12
+  },
+  targetOptions: {
+    // whether to use an image to display target, if false, the program provides a default
+    useImg: true,
+    // if useImg is true, provide the imgUrl
+    imgUrl: "../light-infantry.svg",
+    // the width of target, unit: px
+    width: 60,
+    // the height of target, unit: px
+    height: 40,
+    // the stroke color of target, effective when useImg set false
+    color: "#00f",
+    // the fill color of target, effective when useImg set false
+    fillColor: "#9FD12D"
+  },
+  clockOptions: {
+    // the default speed
+    // caculate method: fpstime * Math.pow(2, speed - 1)
+    // fpstime is the two frame time difference
+    speed: 10,
+    // the max speed
+    maxSpeed: 100
+  },
+  toolTipOptions: {
+    offset: [0, 0],
+    direction: "top",
+    permanent: false
+  }
+};
+
+export default options;
diff --git a/src/components/ReplayMap.js b/src/components/ReplayMap.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec30ce883a4ef3fdcbed4bfc925c2f0d2951e63a
--- /dev/null
+++ b/src/components/ReplayMap.js
@@ -0,0 +1,93 @@
+// https://github.com/linghuam/Leaflet.TrackPlayBack
+
+import React from "react";
+import { Link } from "react-router-dom";
+import L from "leaflet";
+import "../track-playback/src/leaflet.trackplayback/clock";
+import "../track-playback/src/leaflet.trackplayback/index";
+import "../track-playback/src/control.trackplayback/control.playback";
+import "../track-playback/src/control.trackplayback/index";
+import options from "./ReplayConfig";
+
+export default class ReplayMap extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      // stores game's initial location
+      location: [62.3, 25.7],
+      // stores player locations from backend
+      players: [],
+      // stores all factions from the game
+      factions: [],
+      // stores all scores from the game
+      scores: [],
+      // stores all drawings from backend
+      drawings: []
+    };
+  }
+
+  async componentDidMount() {
+    // set gameId to state from URL
+    await this.setState({
+      gameId: await new URL(window.location.href).searchParams.get("id")
+    });
+    // fetch all data and set it to state
+    let replaydata = await this.fetchReplayData();
+    await this.setState({
+      location: replaydata.location,
+      players: replaydata.players,
+      factions: replaydata.factions,
+      scores: replaydata.scores,
+      drawings: replaydata.drawings
+    });
+    replaydata ? this.replay() : alert("No replay data was found");
+  }
+
+  fetchReplayData = async () => {
+    let res = await fetch(
+      `${process.env.REACT_APP_API_URL}/replay/${this.state.gameId}`
+    );
+    if (await res.ok) {
+      return await res.json();
+    } else {
+      alert("Game not found");
+      window.document.location.href = "/";
+    }
+  };
+
+  tickDrawings = () => {
+    return this.state.allGeoJSON.map(drawing => {
+      return drawing[0];
+    });
+  };
+
+  replay = () => {
+    // create a map for the replay, setView to game's center cords
+    this.map = L.map(this.refs.map).setView(
+      this.state.location || [62.3, 25.7],
+      14
+    );
+    L.tileLayer("https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg", {
+      attribution:
+        '&copy; <a href="https://www.maanmittauslaitos.fi/">Maanmittauslaitos</a>'
+    }).addTo(this.map);
+    // import options from ReplayConfig.js
+    this.trackplayback = new L.TrackPlayBack(this.state, this.map, options);
+    this.trackplaybackControl = L.trackplaybackcontrol(
+      this.trackplayback,
+      this.map
+    );
+    this.trackplaybackControl.addTo(this.map);
+  };
+
+  render() {
+    return (
+      <React.Fragment>
+        <Link to="/">
+          <button>Game selection</button>
+        </Link>
+        <div className="map" ref="map" />
+      </React.Fragment>
+    );
+  }
+}
diff --git a/src/components/Socket.js b/src/components/Socket.js
index 06cc2fd899f90ce897e5bbecdd598410aca02cb1..07142e51ea875131ffa3605ce2f83fcee746fd01 100644
--- a/src/components/Socket.js
+++ b/src/components/Socket.js
@@ -20,8 +20,8 @@ export default class ClientSocket extends React.Component {
     console.log("hi socket");
     // need to explicitly update drawings and trackings when gameID first becomes available
     if (this.props.gameId !== null) {
-      await this.props.getSocketSignal("drawing-update");
-      await this.props.getSocketSignal("tracking-update");
+      await this.props.getSocketSignal({ type: "drawing-update" });
+      await this.props.getSocketSignal({ type: "tracking-update" });
     }
     this.initSocket();
   }
@@ -38,7 +38,7 @@ export default class ClientSocket extends React.Component {
 
   // disconnect the socket on component dismount
   componentWillUnmount() {
-    console.log("bye socket");
+    // console.log("bye socket");
     this.state.sock.disconnect();
   }
 
@@ -47,12 +47,12 @@ export default class ClientSocket extends React.Component {
 
     // set the socket to listen gameId-thread
     socket.on(this.props.gameId, data => {
-      console.log(data);
-      this.props.getSocketSignal(data.type);
+      this.props.getSocketSignal(data);
       // check socket update type
-      this.setState({ update: data.type });
+      this.setState({ update: data });
     });
 
+    this.props.onSocketChange(socket);
     this.setState({ sock: socket });
   };
 
diff --git a/src/components/TaskItem.js b/src/components/TaskItem.js
index b9dca50947d36503a5457c9310546180bb8769fe..d4b91559d878b513d2e4b2261653fd46e4a9d488 100644
--- a/src/components/TaskItem.js
+++ b/src/components/TaskItem.js
@@ -14,12 +14,6 @@ class TaskItem extends React.Component {
     this.getFactionlist(this.props.gameId);
   }
 
-  onEditClick = e => {
-    this.setState({
-      edit: !this.state.edit
-    });
-  };
-
   getFactionlist(gameId) {
     fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`, {
       method: "GET"
@@ -70,15 +64,11 @@ class TaskItem extends React.Component {
   };
 
   render() {
-    let factionlistItems = [];
-    for (let i = 0; i < this.state.factions.length; i++) {
-      const faction = this.state.factions[i];
-      factionlistItems.push(
-        <option key={faction.factionId} value={faction.factionId}>
-          {faction.factionName}
-        </option>
-      );
-    }
+    let factionlistItems = this.state.factions.map(faction => (
+      <option key={faction.factionId} value={faction.factionId}>
+        {faction.factionName}
+      </option>
+    ));
 
     return (
       <div className="tasklist-item">
@@ -100,11 +90,17 @@ class TaskItem extends React.Component {
           )}
         </div>
         {this.props.task.taskIsActive && this.props.role === "admin" && (
-          <button onClick={this.onEditClick}>Edit</button>
+          <button
+            id={"taskEditButton" + this.props.task.taskName}
+            onClick={() => this.setState({ edit: !this.state.edit })}
+          >
+            {this.state.edit ? "Cancel" : "Set Winner"}
+          </button>
         )}
         {this.state.edit && (
           <form onSubmit={this.onSaveSubmit}>
             <select
+              id={"taskWinnerSelect" + this.props.task.taskName}
               value={this.state.selectedFactionId}
               onChange={e =>
                 this.setState({ selectedFactionId: e.target.value })
@@ -117,6 +113,7 @@ class TaskItem extends React.Component {
         )}
         {this.props.role === "admin" && (
           <button
+            id={"taskDeleteButton" + this.props.task.taskName}
             onClick={this.onTaskDelete}
             style={{ backgroundColor: "red" }}
           >
diff --git a/src/components/TaskList.js b/src/components/TaskList.js
index ce08afc64a2f4ccfab6d1d77cd0f8594e1cce963..1b8e1374f85cb8c81b6212368e370e9c1495c3cf 100644
--- a/src/components/TaskList.js
+++ b/src/components/TaskList.js
@@ -6,17 +6,15 @@ class TaskList extends React.Component {
   constructor(props) {
     super(props);
     this.state = {
-      taskNameInput: "", // >= 3
-      taskDescriptionInput: "", // no limits
+      taskNameInput: "", // 3-31 chars
+      taskDescriptionInput: "", // 0-255
       tasks: [],
-      factionlist: [],
       selectedFactionId: ""
     };
   }
 
   componentDidMount() {
     this.getTasks(this.props.gameId);
-    this.getFactionlist(this.props.gameId); // TODO: remove if the user is not admin?
   }
 
   getTasks(gameId) {
@@ -42,25 +40,6 @@ class TaskList extends React.Component {
       .catch(error => console.log(error));
   }
 
-  getFactionlist(gameId) {
-    fetch(`${process.env.REACT_APP_API_URL}/game/${gameId}`, {
-      method: "GET"
-    })
-      .then(result => {
-        if (!result.ok) {
-          throw Error(result.responseText);
-        } else {
-          return result.json();
-        }
-      })
-      .then(result => {
-        this.setState({
-          factionlist: result.factions
-        });
-      })
-      .catch(error => console.log(error));
-  }
-
   handleTaskCreation = e => {
     e.preventDefault();
     if (this.state.taskNameInput === "") {
@@ -206,10 +185,10 @@ class TaskList extends React.Component {
       }
     }
 
-    let factionlistItems = this.state.factionlist.map(item => {
+    let factionlistItems = this.props.factions.map(faction => {
       return (
-        <option key={item.factionId} value={item.factionId}>
-          {item.factionName}
+        <option key={faction.factionId} value={faction.factionId}>
+          {faction.factionName}
         </option>
       );
     });
@@ -232,6 +211,7 @@ class TaskList extends React.Component {
               type="text"
               placeholder="Task name"
               minLength="3"
+              maxLength="31"
               value={this.state.taskNameInput}
               onChange={e => this.setState({ taskNameInput: e.target.value })}
             />
@@ -239,6 +219,7 @@ class TaskList extends React.Component {
               id="taskDescriptionInput"
               placeholder="Task description"
               value={this.state.taskDescriptionInput}
+              maxLength="255"
               onChange={e =>
                 this.setState({ taskDescriptionInput: e.target.value })
               }
diff --git a/src/components/TaskListButton.js b/src/components/TaskListButton.js
index c0e5f8c1257edaaae2e062fbc8dce562538aa5c1..bb56314129f92204ab45d226b3d0f69593b18d05 100644
--- a/src/components/TaskListButton.js
+++ b/src/components/TaskListButton.js
@@ -45,7 +45,11 @@ export default class TaskListButton extends React.Component {
           Tasks
         </button>
         {this.state.open && (
-          <TaskList gameId={this.props.gameId} role={this.props.role} />
+          <TaskList
+            gameId={this.props.gameId}
+            role={this.props.role}
+            factions={this.props.factions}
+          />
         )}
       </Fragment>
     );
diff --git a/src/components/UserMap.js b/src/components/UserMap.js
index c5347fa78df07b4092296702fae1e70ee1704f8f..83f4de8d09da8efa58426855273b27fbf4145b0b 100644
--- a/src/components/UserMap.js
+++ b/src/components/UserMap.js
@@ -39,6 +39,7 @@ class UserMap extends Component {
   }
 
   componentDidUpdate(prevProps, prevState) {
+    console.log(prevProps.socketSignal);
     if (prevProps.socketSignal === "drawing-update") {
       this.fetchGeoJSON();
     }
@@ -167,6 +168,7 @@ class UserMap extends Component {
           sendGeoJSON={this.sendGeoJSON}
           geoJSONLayer={this.state.geoJSONLayer}
           currentGameId={this.props.currentGameId}
+          role={this.props.role}
         />
         {this.state.ownLat !== null && (
           <Marker position={[this.state.ownLat, this.state.ownLng]}>
@@ -180,6 +182,7 @@ class UserMap extends Component {
           currentGameId={this.props.currentGameId}
           socketSignal={this.props.socketSignal}
         />
+        {this.props.children}
       </Map>
     );
   }
diff --git a/src/icons/icon-close.png b/src/icons/icon-close.png
new file mode 100644
index 0000000000000000000000000000000000000000..81bef1ad48176c82033dcb527940cff736a53818
Binary files /dev/null and b/src/icons/icon-close.png differ
diff --git a/src/icons/icon-play.png b/src/icons/icon-play.png
new file mode 100644
index 0000000000000000000000000000000000000000..3af78948ec238cc2fc13e5a2e16569c516b1cd22
Binary files /dev/null and b/src/icons/icon-play.png differ
diff --git a/src/icons/icon-quick.png b/src/icons/icon-quick.png
new file mode 100644
index 0000000000000000000000000000000000000000..3dc45fd114d7f5813be62cebde2bd95ccedcdceb
Binary files /dev/null and b/src/icons/icon-quick.png differ
diff --git a/src/icons/icon-restart.png b/src/icons/icon-restart.png
new file mode 100644
index 0000000000000000000000000000000000000000..022c4e60fdec44ee1a2d605c85b1f156c16c7cb6
Binary files /dev/null and b/src/icons/icon-restart.png differ
diff --git a/src/icons/icon-slow.png b/src/icons/icon-slow.png
new file mode 100644
index 0000000000000000000000000000000000000000..eaa1fbdaf382287502240e0987e1df0b8840ee8e
Binary files /dev/null and b/src/icons/icon-slow.png differ
diff --git a/src/icons/icon-stop.png b/src/icons/icon-stop.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ee3137966264afc7119579bf52a7a1bb457e753
Binary files /dev/null and b/src/icons/icon-stop.png differ
diff --git a/src/track-playback/src/control.trackplayback/control.playback.js b/src/track-playback/src/control.trackplayback/control.playback.js
new file mode 100644
index 0000000000000000000000000000000000000000..56d311958ee2b2c5fdb1f28a5ccc6c5bcce7c51f
--- /dev/null
+++ b/src/track-playback/src/control.trackplayback/control.playback.js
@@ -0,0 +1,484 @@
+import L from "leaflet";
+
+export const TrackPlayBackControl = L.Control.extend({
+  options: {
+    position: "topright",
+    showOptions: true,
+    showInfo: true,
+    showSlider: true,
+    autoPlay: false
+  },
+
+  initialize: function(trackplayback, map, options) {
+    this.map = map;
+    L.Control.prototype.initialize.call(this, options);
+    this.trackPlayBack = trackplayback;
+    this.trackPlayBack.on("tick", this._tickCallback, this);
+    this.leafletDrawings = [];
+  },
+
+  // absolutely disgusting, never use geojson
+  // init object to pass drawing data to right function
+  _drawFunction: function(data) {
+    // create leaflet objects
+    var createMarker = data => {
+      data.geometry.coordinates = [
+        data.geometry.coordinates[1],
+        data.geometry.coordinates[0]
+      ];
+      return L.marker(data.geometry.coordinates).addTo(this.map);
+    };
+    var createPolyline = data => {
+      data.geometry.coordinates = data.geometry.coordinates.map(cords => {
+        return [cords[1], cords[0]];
+      });
+      return L.polyline(data.geometry.coordinates, {
+        color: data.properties.color
+      }).addTo(this.map);
+    };
+    var createPolygon = data => {
+      // geoJSON has lat lng wrong way so turn them around
+      data.geometry.coordinates = data.geometry.coordinates[0].map(cords => {
+        return [cords[1], cords[0]];
+      });
+      return L.polygon(data.geometry.coordinates, {
+        color: data.properties.color
+      }).addTo(this.map);
+    };
+    var createRectangle = data => {
+      return L.rectangle(data.geometry.coordinates, {
+        color: data.properties.color
+      }).addTo(this.map);
+    };
+    var createCircle = data => {
+      data.geometry.coordinates = [
+        data.geometry.coordinates[1],
+        data.geometry.coordinates[0]
+      ];
+      return L.circle(data.geometry.coordinates, {
+        radius: data.properties.radius
+      }).addTo(this.map);
+    };
+    // handle faulty cords
+    if (!data.geometry.coordinates[0][0] && !data.geometry.type === "Point") {
+      return null;
+    }
+    if (data.geometry.type === "Point" && !data.properties.radius) {
+      data.geometry.type = "Marker";
+    }
+    var obj = {
+      Marker: createMarker,
+      LineString: createPolyline,
+      Polygon: createPolygon,
+      Rectangle: createRectangle,
+      Point: createCircle
+    };
+    return obj[data.geometry.type](data);
+  },
+
+  onAdd: function(map) {
+    this._initContainer();
+    return this._container;
+  },
+
+  onRemove: function(map) {
+    this.trackPlayBack.dispose();
+    this.trackPlayBack.off("tick", this._tickCallback, this);
+  },
+
+  /**
+   * 根据unix时间戳(单位:秒)获取时间字符串
+   * @param  {[int]} time     [时间戳(精确到秒)]
+   * @param  {[string]} accuracy [精度,日:d, 小时:h,分钟:m,秒:s]
+   * @return {[string]}          [yy:mm:dd hh:mm:ss]
+   */
+  getTimeStrFromUnix: function(time, accuracy = "s") {
+    return `
+      ${new Date(time).toLocaleDateString("en-US")} 
+      ${new Date(time * 1e3).toISOString().slice(-13, -5)}
+    `;
+    /*     time = parseInt(time * 1000);
+    let newDate = new Date(time);
+    let year = newDate.getFullYear();
+    let month =
+      newDate.getMonth() + 1 < 10
+        ? "0" + (newDate.getMonth() + 1)
+        : newDate.getMonth() + 1;
+    let day =
+      newDate.getDate() < 10 ? "0" + newDate.getDate() : newDate.getDate();
+    let hours =
+      newDate.getHours() < 10 ? "0" + newDate.getHours() : newDate.getHours();
+    let minuts =
+      newDate.getMinutes() < 10
+        ? "0" + newDate.getMinutes()
+        : newDate.getMinutes();
+    let seconds =
+      newDate.getSeconds() < 10
+        ? "0" + newDate.getSeconds()
+        : newDate.getSeconds();
+    let ret;
+    if (accuracy === "d") {
+      ret = year + "-" + month + "-" + day;
+    } else if (accuracy === "h") {
+      ret = year + "-" + month + "-" + day + " " + hours;
+    } else if (accuracy === "m") {
+      ret = year + "-" + month + "-" + day + " " + hours + ":" + minuts;
+    } else {
+      ret =
+        year +
+        "-" +
+        month +
+        "-" +
+        day +
+        " " +
+        hours +
+        ":" +
+        minuts +
+        ":" +
+        seconds;
+    }
+    return ret; */
+  },
+
+  _initContainer: function() {
+    var className = "leaflet-control-playback";
+    this._container = L.DomUtil.create("div", className);
+    L.DomEvent.disableClickPropagation(this._container);
+
+    this._optionsContainer = this._createContainer(
+      "optionsContainer",
+      this._container
+    );
+    this._buttonContainer = this._createContainer(
+      "buttonContainer",
+      this._container
+    );
+    this._infoContainer = this._createContainer(
+      "infoContainer",
+      this._container
+    );
+    this._sliderContainer = this._createContainer(
+      "sliderContainer",
+      this._container
+    );
+    this._filterContainer = this._createContainer(
+      "filterContainer",
+      this._container
+    );
+    /*     this._lineCbx = this._createCheckbox(
+      "show trackLine",
+      "show-trackLine",
+      this._optionsContainer,
+      this._showTrackLine
+    ); */
+    // create checkboxes for filtering persons based on their class
+    this._filterInfantry = this._createCheckbox(
+      "show infantry units",
+      "show-infantry",
+      this._filterContainer,
+      this._showInfantry
+    );
+    this._filterRecon = this._createCheckbox(
+      "show recon units",
+      "show-recon",
+      this._filterContainer,
+      this._showRecon
+    );
+    this._filterMechanized = this._createCheckbox(
+      "show mechanized units",
+      "show-mechanized",
+      this._filterContainer,
+      this._showMechanized
+    );
+    // show some text between class based and faction based filtering
+    this._factionText = this._createInfo(
+      "Faction filtering:",
+      "",
+      "faction-text-filter",
+      this._filterContainer
+    );
+    // get factions in replay
+    let factions = this.trackPlayBack.passFactions();
+    // create checkboxes for filtering persons based on their faction
+    this._factionCheckboxes = factions.map(faction => {
+      return this._createCheckbox(
+        `show ${faction.name}`,
+        `show-${faction.name}`,
+        this._filterContainer,
+        this._showFaction
+      );
+    });
+    // create a div container for score replay
+    this._scoreContainer = this._createContainer(
+      "scoreContainer",
+      this._container
+    );
+    // create score blocks for each faction
+    this._factionScoreboxes = factions.map(faction => {
+      return this._createInfo(
+        `${faction.name}: `,
+        0,
+        "scoreBlock",
+        this._scoreContainer
+      );
+    });
+
+    this._playBtn = this._createButton(
+      "play",
+      "btn-stop",
+      this._buttonContainer,
+      this._play
+    );
+    this._restartBtn = this._createButton(
+      "replay",
+      "btn-restart",
+      this._buttonContainer,
+      this._restart
+    );
+    this._slowSpeedBtn = this._createButton(
+      "decrease speed",
+      "btn-slow",
+      this._buttonContainer,
+      this._slow
+    );
+    this._quickSpeedBtn = this._createButton(
+      "increase speed",
+      "btn-quick",
+      this._buttonContainer,
+      this._quick
+    );
+    /*     this._closeBtn = this._createButton(
+      "close",
+      "btn-close",
+      this._buttonContainer,
+      this._close
+    ); */
+
+    this._infoStartTime = this._createInfo(
+      "Game started: ",
+      this.getTimeStrFromUnix(this.trackPlayBack.getStartTime()),
+      "info-start-time",
+      this._infoContainer
+    );
+    this._infoEndTime = this._createInfo(
+      "Game ended: ",
+      this.getTimeStrFromUnix(this.trackPlayBack.getEndTime()),
+      "info-end-time",
+      this._infoContainer
+    );
+    this._infoCurTime = this._createInfo(
+      "Current time: ",
+      this.getTimeStrFromUnix(this.trackPlayBack.getCurTime()),
+      "info-cur-time",
+      this._infoContainer
+    );
+    this._infoSpeedRatio = this._createInfo(
+      "speed: ",
+      `X${this.trackPlayBack.getSpeed()}`,
+      "info-speed-ratio",
+      this._infoContainer
+    );
+
+    this._slider = this._createSlider(
+      "time-slider",
+      this._sliderContainer,
+      this._scrollchange
+    );
+
+    return this._container;
+  },
+
+  _createContainer: function(className, container) {
+    return L.DomUtil.create("div", className, container);
+  },
+
+  _createCheckbox: function(labelName, className, container, fn) {
+    let divEle = L.DomUtil.create(
+      "div",
+      className + " trackplayback-checkbox",
+      container
+    );
+
+    let inputEle = L.DomUtil.create("input", "trackplayback-input", divEle);
+    let inputId = `trackplayback-input-${L.Util.stamp(inputEle)}`;
+    inputEle.setAttribute("type", "checkbox");
+    inputEle.setAttribute("id", inputId);
+    inputEle.checked = true;
+
+    let labelEle = L.DomUtil.create("label", "trackplayback-label", divEle);
+    labelEle.setAttribute("for", inputId);
+    labelEle.innerHTML = labelName;
+
+    L.DomEvent.on(inputEle, "change", fn, this);
+
+    return divEle;
+  },
+
+  _createButton: function(title, className, container, fn) {
+    let link = L.DomUtil.create("a", className, container);
+    link.href = "#";
+    link.title = title;
+
+    /*
+     * Will force screen readers like VoiceOver to read this as "Zoom in - button"
+     */
+    link.setAttribute("role", "button");
+    link.setAttribute("aria-label", title);
+
+    L.DomEvent.disableClickPropagation(link);
+    L.DomEvent.on(link, "click", fn, this);
+
+    return link;
+  },
+
+  _createInfo: function(title, info, className, container) {
+    let infoContainer = L.DomUtil.create("div", "info-container", container);
+    let infoTitle = L.DomUtil.create("span", "info-title", infoContainer);
+    infoTitle.innerHTML = title;
+    let infoEle = L.DomUtil.create("span", className, infoContainer);
+    infoEle.innerHTML = info;
+    return infoEle;
+  },
+
+  _createSlider: function(className, container, fn) {
+    let sliderEle = L.DomUtil.create("input", className, container);
+    sliderEle.setAttribute("type", "range");
+    sliderEle.setAttribute("min", this.trackPlayBack.getStartTime());
+    sliderEle.setAttribute("max", this.trackPlayBack.getEndTime());
+    sliderEle.setAttribute("value", this.trackPlayBack.getCurTime());
+
+    L.DomEvent.on(
+      sliderEle,
+      "click mousedown dbclick",
+      L.DomEvent.stopPropagation
+    )
+      .on(sliderEle, "click", L.DomEvent.preventDefault)
+      .on(sliderEle, "change", fn, this)
+      .on(sliderEle, "mousemove", fn, this);
+
+    return sliderEle;
+  },
+
+  _showTrackLine(e) {
+    if (e.target.checked) {
+      this.trackPlayBack.showTrackLine();
+    } else {
+      this.trackPlayBack.hideTrackLine();
+    }
+  },
+
+  _showInfantry(e) {
+    this.trackPlayBack.toggleInfantry(e.target.checked);
+  },
+
+  _showRecon(e) {
+    this.trackPlayBack.toggleRecon(e.target.checked);
+  },
+
+  _showMechanized(e) {
+    this.trackPlayBack.toggleMechanized(e.target.checked);
+  },
+  _showFaction(e) {
+    this.trackPlayBack.toggleFactions(
+      e.target.checked,
+      e.target.parentNode.className.substring(
+        5,
+        e.target.parentNode.className.indexOf(" ")
+      )
+    );
+  },
+
+  _play: function() {
+    let hasClass = L.DomUtil.hasClass(this._playBtn, "btn-stop");
+    if (hasClass) {
+      L.DomUtil.removeClass(this._playBtn, "btn-stop");
+      L.DomUtil.addClass(this._playBtn, "btn-start");
+      this._playBtn.setAttribute("title", "stop");
+      this.trackPlayBack.start();
+    } else {
+      L.DomUtil.removeClass(this._playBtn, "btn-start");
+      L.DomUtil.addClass(this._playBtn, "btn-stop");
+      this._playBtn.setAttribute("title", "play");
+      this.trackPlayBack.stop();
+    }
+  },
+
+  _restart: function() {
+    // 播放开始改变播放按钮样式
+    L.DomUtil.removeClass(this._playBtn, "btn-stop");
+    L.DomUtil.addClass(this._playBtn, "btn-start");
+    this._playBtn.setAttribute("title", "stop");
+    this.trackPlayBack.rePlaying();
+  },
+
+  _slow: function() {
+    this.trackPlayBack.slowSpeed();
+    let sp = this.trackPlayBack.getSpeed();
+    this._infoSpeedRatio.innerHTML = `X${sp}`;
+  },
+
+  _quick: function() {
+    this.trackPlayBack.quickSpeed();
+    let sp = this.trackPlayBack.getSpeed();
+    this._infoSpeedRatio.innerHTML = `X${sp}`;
+  },
+
+  _close: function() {
+    L.DomUtil.remove(this._container);
+    if (this.onRemove) {
+      this.onRemove(this._map);
+    }
+    return this;
+  },
+
+  _scrollchange: function(e) {
+    let val = Number(e.target.value);
+    this.trackPlayBack.setCursor(val);
+  },
+
+  _tickCallback: function(e) {
+    // 更新时间
+    let time = this.getTimeStrFromUnix(e.time);
+    this._infoCurTime.innerHTML = time;
+    this._slider.value = e.time;
+    // 播放结束后改变播放按钮样式
+    if (e.time >= this.trackPlayBack.getEndTime()) {
+      L.DomUtil.removeClass(this._playBtn, "btn-start");
+      L.DomUtil.addClass(this._playBtn, "btn-stop");
+      this._playBtn.setAttribute("title", "play");
+      this.trackPlayBack.stop();
+    }
+    // tick scores
+    for (let i = 0; i < this._factionScoreboxes.length; i++) {
+      this._factionScoreboxes[i].innerHTML = this.trackPlayBack.passScores(i);
+    }
+    // tick drawings
+    let drawings = this.trackPlayBack.passDrawings();
+    for (let i = 0; i < drawings.length; i++) {
+      // skip if undefined
+      if (!drawings[i] && this.leafletDrawings[i]) {
+        this.map.removeLayer(this.leafletDrawings[i]);
+        this.leafletDrawings[i] = null;
+        return;
+      }
+      if (!drawings[i]) return;
+      // remove if it's not active
+      if (!drawings[i].drawingIsActive && this.leafletDrawings[i]) {
+        this.map.removeLayer(this.leafletDrawings[i]);
+        this.leafletDrawings[i] = null;
+        return;
+      }
+      // else draw the marker if it's not drawn
+      if (drawings[i].drawingIsActive && !this.leafletDrawings[i]) {
+        this.leafletDrawings[i] = this._drawFunction(drawings[i].data);
+        console.log(this.leafletDrawings[i]);
+      }
+    }
+    //
+    // 更新时间轴
+  }
+});
+
+export const trackplaybackcontrol = function(trackplayback, map, options) {
+  return new TrackPlayBackControl(trackplayback, map, options);
+};
diff --git a/src/track-playback/src/control.trackplayback/index.js b/src/track-playback/src/control.trackplayback/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..599a2df5932224ccd4af7d3146dfa8d078d9c6ab
--- /dev/null
+++ b/src/track-playback/src/control.trackplayback/index.js
@@ -0,0 +1,5 @@
+import L from "leaflet";
+import { TrackPlayBackControl, trackplaybackcontrol } from "./control.playback";
+
+L.TrackPlayBackControl = TrackPlayBackControl;
+L.trackplaybackcontrol = trackplaybackcontrol;
diff --git a/src/track-playback/src/leaflet.trackplayback/clock.js b/src/track-playback/src/leaflet.trackplayback/clock.js
new file mode 100644
index 0000000000000000000000000000000000000000..83ff85bdcfeb6f7e85707282e250052deb37c622
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/clock.js
@@ -0,0 +1,132 @@
+import L from "leaflet";
+/**
+ * 时钟类,控制轨迹播放动画
+ */
+export const Clock = L.Class.extend({
+  includes: L.Mixin.Events,
+
+  options: {
+    // 播放速度
+    // 计算方法 fpstime * Math.pow(2, this._speed - 1)
+    speed: 12,
+    // 最大播放速度
+    maxSpeed: 65
+  },
+
+  initialize: function(trackController, options) {
+    L.setOptions(this, options);
+
+    this._trackController = trackController;
+    this._endTime = this._trackController.getMaxTime();
+    this._curTime = this._trackController.getMinTime();
+    this._speed = this.options.speed;
+    this._maxSpeed = this.options.maxSpeed;
+    this._intervalID = null;
+    this._lastFpsUpdateTime = 0;
+  },
+
+  start: function() {
+    if (this._intervalID) return;
+    this._intervalID = L.Util.requestAnimFrame(this._tick, this);
+  },
+
+  stop: function() {
+    if (!this._intervalID) return;
+    L.Util.cancelAnimFrame(this._intervalID);
+    this._intervalID = null;
+    this._lastFpsUpdateTime = 0;
+  },
+
+  rePlaying: function() {
+    this.stop();
+    this._curTime = this._trackController.getMinTime();
+    this.start();
+  },
+
+  slowSpeed: function() {
+    this._speed = this._speed <= 1 ? this._speed : this._speed - 1;
+    if (this._intervalID) {
+      this.stop();
+      this.start();
+    }
+  },
+
+  quickSpeed: function() {
+    this._speed = this._speed >= this._maxSpeed ? this._speed : this._speed + 1;
+    if (this._intervalID) {
+      this.stop();
+      this.start();
+    }
+  },
+
+  getSpeed: function() {
+    return this._speed;
+  },
+
+  getCurTime: function() {
+    return this._curTime;
+  },
+
+  getStartTime: function() {
+    return this._trackController.getMinTime();
+  },
+
+  getEndTime: function() {
+    return this._trackController.getMaxTime();
+  },
+
+  isPlaying: function() {
+    return !!this._intervalID;
+  },
+
+  setCursor: function(time) {
+    this._curTime = time;
+    this._trackController.drawTracksByTime(this._curTime);
+    this.fire("tick", {
+      time: this._curTime
+    });
+  },
+
+  setSpeed: function(speed) {
+    this._speed = speed;
+    if (this._intervalID) {
+      this.stop();
+      this.start();
+    }
+  },
+
+  // 计算两帧时间间隔,单位:秒
+  _caculatefpsTime: function(now) {
+    let time;
+    if (this._lastFpsUpdateTime === 0) {
+      time = 0;
+    } else {
+      time = now - this._lastFpsUpdateTime;
+    }
+    this._lastFpsUpdateTime = now;
+    // 将毫秒转换成秒
+    time = time / 1000;
+    return time;
+  },
+
+  _tick: function() {
+    let now = +new Date();
+    let fpstime = this._caculatefpsTime(now);
+    let isPause = false;
+    let stepTime = fpstime * Math.pow(2, this._speed - 1);
+    this._curTime += stepTime;
+    if (this._curTime >= this._endTime) {
+      this._curTime = this._endTime;
+      isPause = true;
+    }
+    this._trackController.drawTracksByTime(this._curTime);
+    this.fire("tick", {
+      time: this._curTime
+    });
+    if (!isPause) this._intervalID = L.Util.requestAnimFrame(this._tick, this);
+  }
+});
+
+export const clock = function(trackController, options) {
+  return new Clock(trackController, options);
+};
diff --git a/src/track-playback/src/leaflet.trackplayback/draw.js b/src/track-playback/src/leaflet.trackplayback/draw.js
new file mode 100644
index 0000000000000000000000000000000000000000..8fdfefcb7a6e9813126ec42f006944ab3854bd53
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/draw.js
@@ -0,0 +1,402 @@
+import L from "leaflet";
+
+import { TrackLayer } from "./tracklayer";
+
+/**
+ * 绘制类
+ * 完成轨迹线、轨迹点、目标物的绘制工作
+ */
+export const Draw = L.Class.extend({
+  trackPointOptions: {
+    isDraw: false,
+    useCanvas: true,
+    stroke: false,
+    color: "#ef0300",
+    fill: true,
+    fillColor: "#ef0300",
+    opacity: 0.3,
+    radius: 4
+  },
+  trackLineOptions: {
+    isDraw: false,
+    stroke: true,
+    color: "#1C54E2", // stroke color
+    weight: 2,
+    fill: false,
+    fillColor: "#000",
+    opacity: 0.3
+  },
+  targetOptions: {
+    useImg: false,
+    imgUrl: "../../static/images/ship.png",
+    showText: false,
+    width: 8,
+    height: 18,
+    color: "#00f", // stroke color
+    fillColor: "#9FD12D"
+  },
+  toolTipOptions: {
+    offset: [0, 0],
+    direction: "top",
+    permanent: false
+  },
+  filterOptions: {
+    infantry: true,
+    recon: true,
+    mechanized: true,
+    factions: []
+  },
+
+  initialize: function(map, options) {
+    L.extend(this.trackPointOptions, options.trackPointOptions);
+    L.extend(this.trackLineOptions, options.trackLineOptions);
+    L.extend(this.targetOptions, options.targetOptions);
+    L.extend(this.toolTipOptions, options.toolTipOptions);
+    L.extend(this.filterOptions, options.filterOptions);
+    L.extend(this.scoreOptions, options.scoreOptions);
+
+    this._showTrackPoint = this.trackPointOptions.isDraw;
+    this._showTrackLine = this.trackLineOptions.isDraw;
+
+    this._map = map;
+    this._map.on("click", this._onmouseclickEvt, this);
+    this._map.on("mousemove", this._onmousemoveEvt, this);
+
+    this._trackLayer = new TrackLayer().addTo(map);
+    this._trackLayer.on("update", this._trackLayerUpdate, this);
+
+    this._canvas = this._trackLayer.getContainer();
+    this._ctx = this._canvas.getContext("2d");
+
+    this._bufferTracks = [];
+
+    if (!this.trackPointOptions.useCanvas) {
+      this._trackPointFeatureGroup = L.featureGroup([]).addTo(map);
+    }
+
+    // setup array for images
+    this._targetImg = [];
+  },
+
+  update: function() {
+    this._trackLayerUpdate();
+  },
+
+  drawTrack: function(trackpoints) {
+    this._bufferTracks.push(trackpoints);
+    this._drawTrack(trackpoints);
+  },
+
+  showTrackPoint: function() {
+    this._showTrackPoint = true;
+    this.update();
+  },
+
+  hideTrackPoint: function() {
+    this._showTrackPoint = false;
+    this.update();
+  },
+
+  showTrackLine: function() {
+    this._showTrackLine = true;
+    this.update();
+  },
+
+  hideTrackLine: function() {
+    this._showTrackLine = false;
+    this.update();
+  },
+
+  remove: function() {
+    this._bufferTracks = [];
+    this._trackLayer.off("update", this._trackLayerUpdate, this);
+    this._map.off("click", this._onmouseclickEvt, this);
+    this._map.off("mousemove", this._onmousemoveEvt, this);
+    if (this._map.hasLayer(this._trackLayer)) {
+      this._map.removeLayer(this._trackLayer);
+    }
+    if (this._map.hasLayer(this._trackPointFeatureGroup)) {
+      this._map.removeLayer(this._trackPointFeatureGroup);
+    }
+  },
+
+  clear: function() {
+    this._clearLayer();
+    this._bufferTracks = [];
+  },
+
+  _trackLayerUpdate: function() {
+    if (this._bufferTracks.length) {
+      this._clearLayer();
+      this._bufferTracks.forEach(
+        function(element, index) {
+          this._drawTrack(element);
+        }.bind(this)
+      );
+    }
+  },
+
+  // changes cursor icon to pointer and shows information about tracked player
+  _onmousemoveEvt: function(e) {
+    if (!this._showTrackPoint) {
+      return;
+    }
+    let point = e.layerPoint;
+    if (this._bufferTracks.length) {
+      for (let i = 0, leni = this._bufferTracks.length; i < leni; i++) {
+        for (let j = 0, len = this._bufferTracks[i].length; j < len; j++) {
+          let tpoint = this._getLayerPoint(this._bufferTracks[i][j]);
+          if (point.distanceTo(tpoint) <= this.trackPointOptions.radius) {
+            this._canvas.style.cursor = "pointer";
+            return;
+          }
+        }
+      }
+    }
+    this._canvas.style.cursor = "grab";
+  },
+
+  // on click event that shows popup about tracked player
+  _onmouseclickEvt: function(e) {
+    if (!this._showTrackPoint) {
+      return;
+    }
+    let point = e.layerPoint;
+    if (this._bufferTracks.length) {
+      for (let i = 0, leni = this._bufferTracks.length; i < leni; i++) {
+        for (let j = 0, len = this._bufferTracks[i].length; j < len; j++) {
+          let tpoint = this._getLayerPoint(this._bufferTracks[i][j]);
+          if (point.distanceTo(tpoint) <= this.trackPointOptions.radius) {
+            this._opentoolTip(this._bufferTracks[i][j]);
+            return;
+          }
+        }
+      }
+    }
+    if (this._map.hasLayer(this._tooltip)) {
+      this._map.removeLayer(this._tooltip);
+    }
+    this._canvas.style.cursor = "pointer";
+  },
+
+  _opentoolTip: function(trackpoint) {
+    if (this._map.hasLayer(this._tooltip)) {
+      this._map.removeLayer(this._tooltip);
+    }
+    let latlng = L.latLng(trackpoint.lat, trackpoint.lng);
+    let tooltip = (this._tooltip = L.tooltip(this.toolTipOptions));
+    tooltip.setLatLng(latlng);
+    tooltip.addTo(this._map);
+    tooltip.setContent(this._getTooltipText(trackpoint));
+  },
+
+  _drawTrack: function(trackpoints) {
+    // 画轨迹线
+    if (this._showTrackLine) {
+      this._drawTrackLine(trackpoints);
+    }
+    // 画船
+    let targetPoint = trackpoints[trackpoints.length - 1];
+    // get info from first trackpoint
+    let info = trackpoints[0].info;
+    let skip = false;
+    // check if faction has been filtered and skip drawing if it is
+    this.filterOptions.factions.forEach(faction => {
+      if (
+        !faction.active &&
+        trackpoints[0].info[1]["value"] === faction.colour
+      ) {
+        skip = true;
+      }
+    });
+
+    // compare icon to filter, draw if true else skip
+    if (!skip && this.filterOptions[info[0]["value"].slice(0, -4)]) {
+      this._drawShipImage(targetPoint, info);
+    }
+    /*     else {
+      this._drawShipCanvas(targetPoint);
+    } */
+    // 画标注信息
+    if (this.targetOptions.showText) {
+      this._drawtxt(`航向:${parseInt(targetPoint.dir)}度`, targetPoint);
+    }
+    // 画经过的轨迹点
+    if (this._showTrackPoint) {
+      if (this.trackPointOptions.useCanvas) {
+        this._drawTrackPointsCanvas(trackpoints);
+      } else {
+        this._drawTrackPointsSvg(trackpoints);
+      }
+    }
+  },
+
+  _drawTrackLine: function(trackpoints) {
+    let options = this.trackLineOptions;
+    let tp0 = this._getLayerPoint(trackpoints[0]);
+    this._ctx.save();
+    this._ctx.beginPath();
+    // 画轨迹线
+    this._ctx.moveTo(tp0.x, tp0.y);
+    for (let i = 1, len = trackpoints.length; i < len; i++) {
+      let tpi = this._getLayerPoint(trackpoints[i]);
+      this._ctx.lineTo(tpi.x, tpi.y);
+    }
+    this._ctx.globalAlpha = options.opacity;
+    if (options.stroke) {
+      this._ctx.strokeStyle = options.color;
+      this._ctx.lineWidth = options.weight;
+      this._ctx.stroke();
+    }
+    if (options.fill) {
+      this._ctx.fillStyle = options.fillColor;
+      this._ctx.fill();
+    }
+    this._ctx.restore();
+  },
+
+  _drawTrackPointsCanvas: function(trackpoints) {
+    let options = this.trackPointOptions;
+    let i = trackpoints.length - 1;
+    this._ctx.save();
+    let latLng = L.latLng(trackpoints[i].lat, trackpoints[i].lng);
+    let radius = options.radius;
+    let point = this._map.latLngToLayerPoint(latLng);
+    this._ctx.beginPath();
+    this._ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, false);
+    this._ctx.globalAlpha = options.opacity;
+    if (options.stroke) {
+      this._ctx.strokeStyle = options.color;
+      this._ctx.stroke();
+    }
+    if (options.fill) {
+      this._ctx.fillStyle = options.fillColor;
+      this._ctx.fill();
+    }
+    this._ctx.restore();
+  },
+
+  _drawTrackPointsSvg: function(trackpoints) {
+    let i = trackpoints.length - 1;
+    let latLng = L.latLng(trackpoints[i].lat, trackpoints[i].lng);
+    let cricleMarker = L.circleMarker(latLng, this.trackPointOptions);
+    cricleMarker.bindTooltip(
+      this._getTooltipText(trackpoints[0]),
+      this.toolTipOptions
+    );
+    this._trackPointFeatureGroup.addLayer(cricleMarker);
+  },
+
+  _drawtxt: function(text, trackpoint) {
+    let point = this._getLayerPoint(trackpoint);
+    this._ctx.save();
+    this._ctx.font = "12px Verdana";
+    this._ctx.fillStyle = "#000";
+    this._ctx.textAlign = "center";
+    this._ctx.textBaseline = "bottom";
+    this._ctx.fillText(text, point.x, point.y - 12, 200);
+    this._ctx.restore();
+  },
+
+  _drawShipCanvas: function(trackpoint) {
+    let point = this._getLayerPoint(trackpoint);
+    let rotate = trackpoint.dir || 0;
+    let w = this.targetOptions.width;
+    let h = this.targetOptions.height;
+    let dh = h / 3;
+
+    this._ctx.save();
+    this._ctx.fillStyle = this.targetOptions.fillColor;
+    this._ctx.strokeStyle = this.targetOptions.color;
+    this._ctx.translate(point.x, point.y);
+    this._ctx.rotate((Math.PI / 180) * rotate);
+    this._ctx.beginPath();
+    this._ctx.moveTo(0, 0 - h / 2);
+    this._ctx.lineTo(0 - w / 2, 0 - h / 2 + dh);
+    this._ctx.lineTo(0 - w / 2, 0 + h / 2);
+    this._ctx.lineTo(0 + w / 2, 0 + h / 2);
+    this._ctx.lineTo(0 + w / 2, 0 - h / 2 + dh);
+    this._ctx.closePath();
+    this._ctx.fill();
+    this._ctx.stroke();
+    this._ctx.restore();
+  },
+
+  // used to draw image for tracking data
+  _drawShipImage: function(trackpoint, info) {
+    let point = this._getLayerPoint(trackpoint);
+    let width = this.targetOptions.width;
+    let height = this.targetOptions.height;
+    let offset = {
+      x: width / 2,
+      y: height / 2
+    };
+    this._ctx.save();
+    this._ctx.translate(point.x, point.y);
+    let image;
+    // use an existing image if it has the same icon as the new data
+    this._targetImg.map(img => {
+      if (img.icon == info[0]["value"]) {
+        image = img;
+      }
+    });
+    // else create a new global image with new icon
+    if (!image) {
+      let img = new Image();
+      img.onload = () => {
+        this._targetImg.push(img);
+      };
+      img.onerror = () => {
+        throw new Error("img load error!");
+      };
+      img.src = info[0]["value"];
+      img.icon = info[0]["value"];
+      image = img;
+    }
+    this._ctx.drawImage(image, 0 - offset.x, 0 - offset.y, width, height);
+    // draw rect based on faction colour
+    this._ctx.strokeStyle = info[1]["value"];
+    this._ctx.lineWidth = 3;
+    this._ctx.strokeRect(0 - offset.x, 0 - offset.y, width, height);
+    this._ctx.restore();
+  },
+
+  _getTooltipText: function(targetobj) {
+    let content = [];
+    content.push("<table>");
+    if (targetobj.info && targetobj.info.length) {
+      // skip first two as they're icon and faction colour
+      for (let i = 2, len = targetobj.info.length; i < len; i++) {
+        content.push("<tr>");
+        content.push("<td>" + targetobj.info[i].value + "</td>");
+        content.push("</tr>");
+      }
+    }
+    content.push("</table>");
+    content = content.join("");
+    return content;
+  },
+
+  _clearLayer: function() {
+    let bounds = this._trackLayer.getBounds();
+    if (bounds) {
+      let size = bounds.getSize();
+      this._ctx.clearRect(bounds.min.x, bounds.min.y, size.x, size.y);
+    } else {
+      this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
+    }
+    if (this._map.hasLayer(this._trackPointFeatureGroup)) {
+      this._trackPointFeatureGroup.clearLayers();
+    }
+  },
+
+  _getLayerPoint(trackpoint) {
+    return this._map.latLngToLayerPoint(
+      L.latLng(trackpoint.lat, trackpoint.lng)
+    );
+  }
+});
+
+export const draw = function(map, options) {
+  return new Draw(map, options);
+};
diff --git a/src/track-playback/src/leaflet.trackplayback/index.js b/src/track-playback/src/leaflet.trackplayback/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..defba9838814140a2047a331ff2825ddd0e22e1e
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/index.js
@@ -0,0 +1,9 @@
+import L from 'leaflet'
+
+import {
+  TrackPlayBack,
+  trackplayback
+} from './trackplayback'
+
+L.TrackPlayBack = TrackPlayBack
+L.trackplayback = trackplayback
diff --git a/src/track-playback/src/leaflet.trackplayback/track.js b/src/track-playback/src/leaflet.trackplayback/track.js
new file mode 100644
index 0000000000000000000000000000000000000000..c770c3f3e61a3b70b964be786f9cd77dcd39467f
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/track.js
@@ -0,0 +1,166 @@
+import L from "leaflet";
+
+import { isArray } from "./util";
+
+/**
+ * 轨迹类
+ */
+export const Track = L.Class.extend({
+  initialize: function(trackData = [], options) {
+    L.setOptions(this, options);
+
+    trackData.forEach(item => {
+      // 添加 isOrigin 字段用来标识是否是原始采样点,与插值点区分开
+      item.isOrigin = true;
+    });
+    this._trackPoints = trackData;
+    this._timeTick = {};
+    this._update();
+  },
+
+  addTrackPoint: function(trackPoint) {
+    if (isArray(trackPoint)) {
+      for (let i = 0, len = trackPoint.length; i < len; i++) {
+        this.addTrackPoint(trackPoint[i]);
+      }
+    }
+    this._addTrackPoint(trackPoint);
+  },
+
+  getTimes: function() {
+    let times = [];
+    for (let i = 0, len = this._trackPoints.length; i < len; i++) {
+      times.push(this._trackPoints[i].time);
+    }
+    return times;
+  },
+
+  getStartTrackPoint: function() {
+    return this._trackPoints[0];
+  },
+
+  getEndTrackPoint: function() {
+    return this._trackPoints[this._trackPoints.length - 1];
+  },
+
+  getTrackPointByTime: function(time) {
+    return this._trackPoints[this._timeTick[time]];
+  },
+
+  _getCalculateTrackPointByTime: function(time) {
+    // 先判断最后一个点是否为原始点
+    let endpoint = this.getTrackPointByTime(time);
+    let startPt = this.getStartTrackPoint();
+    let endPt = this.getEndTrackPoint();
+    let times = this.getTimes();
+    if (time < startPt.time || time > endPt.time) return;
+    let left = 0;
+    let right = times.length - 1;
+    let n;
+    // 处理只有一个点情况
+    if (left === right) {
+      return endpoint;
+    }
+    // 通过【二分查找】法查出当前时间所在的时间区间
+    while (right - left !== 1) {
+      n = parseInt((left + right) / 2);
+      if (time > times[n]) left = n;
+      else right = n;
+    }
+
+    let t0 = times[left];
+    let t1 = times[right];
+    let t = time;
+    let p0 = this.getTrackPointByTime(t0);
+    let p1 = this.getTrackPointByTime(t1);
+    startPt = L.point(p0.lng, p0.lat);
+    endPt = L.point(p1.lng, p1.lat);
+    let s = startPt.distanceTo(endPt);
+    // 不同时间在同一个点情形
+    if (s <= 0) {
+      endpoint = p1;
+      return endpoint;
+    }
+    // 假设目标在两点间做匀速直线运动
+    // 求解速度向量,并计算时间 t 目标所在位置
+    let v = s / (t1 - t0);
+    let sinx = (endPt.y - startPt.y) / s;
+    let cosx = (endPt.x - startPt.x) / s;
+    let step = v * (t - t0);
+    let x = startPt.x + step * cosx;
+    let y = startPt.y + step * sinx;
+    // 求目标的运动方向,0-360度
+    let dir =
+      endPt.x >= startPt.x
+        ? ((Math.PI * 0.5 - Math.asin(sinx)) * 180) / Math.PI
+        : ((Math.PI * 1.5 + Math.asin(sinx)) * 180) / Math.PI;
+
+    if (endpoint) {
+      if (endpoint.dir === undefined) {
+        endpoint.dir = dir;
+      }
+    } else {
+      endpoint = {
+        lng: x,
+        lat: y,
+        dir: endPt.dir || dir,
+        isOrigin: false,
+        time: time
+      };
+    }
+    return endpoint;
+  },
+
+  // 获取某个时间点之前走过的轨迹
+  getTrackPointsBeforeTime: function(time) {
+    let tpoints = [];
+    for (let i = 0, len = this._trackPoints.length; i < len; i++) {
+      if (this._trackPoints[i].time < time) {
+        tpoints.push(this._trackPoints[i]);
+      }
+    }
+    // 获取最后一个点,根据时间线性插值而来
+    let endPt = this._getCalculateTrackPointByTime(time);
+    if (endPt) {
+      tpoints.push(endPt);
+    }
+    return tpoints;
+  },
+
+  _addTrackPoint: function(trackPoint) {
+    trackPoint.isOrigin = true;
+    this._trackPoints.push(trackPoint);
+    this._update();
+  },
+
+  _update: function() {
+    this._sortTrackPointsByTime();
+    this._updatetimeTick();
+  },
+
+  // 轨迹点按时间排序 【冒泡排序】
+  _sortTrackPointsByTime: function() {
+    let len = this._trackPoints.length;
+    for (let i = 0; i < len; i++) {
+      for (let j = 0; j < len - 1 - i; j++) {
+        if (this._trackPoints[j].time > this._trackPoints[j + 1].time) {
+          let tmp = this._trackPoints[j + 1];
+          this._trackPoints[j + 1] = this._trackPoints[j];
+          this._trackPoints[j] = tmp;
+        }
+      }
+    }
+  },
+
+  // 为轨迹点建立时间索引,优化查找性能
+  _updatetimeTick: function() {
+    this._timeTick = {};
+    for (let i = 0, len = this._trackPoints.length; i < len; i++) {
+      this._timeTick[this._trackPoints[i].time] = i;
+    }
+  }
+});
+
+export const track = function(trackData, options) {
+  return new Track(trackData, options);
+};
diff --git a/src/track-playback/src/leaflet.trackplayback/trackcontroller.js b/src/track-playback/src/leaflet.trackplayback/trackcontroller.js
new file mode 100644
index 0000000000000000000000000000000000000000..99596410b3f27d226e8e00e450190fe1bd2968ed
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/trackcontroller.js
@@ -0,0 +1,97 @@
+import L from "leaflet";
+
+import { isArray } from "./util";
+import { Track } from "./track";
+
+/**
+ * 控制器类
+ * 控制轨迹和绘制
+ */
+export const TrackController = L.Class.extend({
+  initialize: function(data, draw, options) {
+    L.setOptions(this, options);
+    this._activeScores = [];
+    this._activeDrawings = [];
+    this._tracks = data.tracks;
+    this._scores = data.scores;
+    this._drawings = data.drawings;
+    this.addTrack(data.tracks || []);
+
+    this._draw = draw;
+
+    this._updateTime();
+  },
+
+  getMinTime: function() {
+    return this._minTime;
+  },
+
+  getMaxTime: function() {
+    return this._maxTime;
+  },
+
+  addTrack: function(track) {
+    if (isArray(track)) {
+      for (let i = 0, len = track.length; i < len; i++) {
+        this.addTrack(track[i]);
+      }
+    } else if (track instanceof Track) {
+      this._tracks.push(track);
+      this._updateTime();
+    } else {
+      throw new Error(
+        "tracks must be an instance of `Track` or an array of `Track` instance!"
+      );
+    }
+  },
+
+  drawTracksByTime: function(time) {
+    this._draw.clear();
+    // draw player locations
+    for (let i = 0, len = this._tracks.length; i < len; i++) {
+      let track = this._tracks[i];
+      let tps = track.getTrackPointsBeforeTime(time);
+      if (tps && tps.length) this._draw.drawTrack(tps);
+    }
+    // add scores to counter
+    for (let i = 0; i < this._scores.length; i++) {
+      let newScore = 0;
+      let scores = this._scores[i];
+      for (let j = 0; j < scores.length; j++) {
+        if (scores[j].timestamp < time) {
+          newScore = scores[j].score;
+        }
+      }
+      this._activeScores[i] = newScore;
+    }
+    // draw mapdrawings to map
+    for (let i = 0; i < this._drawings.length; i++) {
+      let drawing = null;
+      for (let j = 0; j < this._drawings[i].length; j++) {
+        if (this._drawings[i][j].timestamp < time) {
+          drawing = this._drawings[i][j];
+        }
+      }
+      this._activeDrawings[i] = drawing;
+    }
+  },
+
+  _updateTime: function() {
+    this._minTime = this._tracks[0].getStartTrackPoint().time;
+    this._maxTime = this._tracks[0].getEndTrackPoint().time;
+    for (let i = 0, len = this._tracks.length; i < len; i++) {
+      let stime = this._tracks[i].getStartTrackPoint().time;
+      let etime = this._tracks[i].getEndTrackPoint().time;
+      if (stime < this._minTime) {
+        this._minTime = stime;
+      }
+      if (etime > this._maxTime) {
+        this._maxTime = etime;
+      }
+    }
+  }
+});
+
+export const trackController = function(tracks, draw, options) {
+  return new TrackController(tracks, draw, options);
+};
diff --git a/src/track-playback/src/leaflet.trackplayback/tracklayer.js b/src/track-playback/src/leaflet.trackplayback/tracklayer.js
new file mode 100644
index 0000000000000000000000000000000000000000..08a21644a7ac2576a25a6c6fcd4650b61b8e576a
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/tracklayer.js
@@ -0,0 +1,67 @@
+import L from "leaflet";
+
+/**
+ * 轨迹图层
+ */
+export const TrackLayer = L.Renderer.extend({
+  initialize: function(options) {
+    L.Renderer.prototype.initialize.call(this, options);
+    this.options.padding = 0.1;
+  },
+
+  onAdd: function(map) {
+    this._container = L.DomUtil.create("canvas", "leaflet-zoom-animated");
+
+    var pane = map.getPane(this.options.pane);
+    pane.appendChild(this._container);
+
+    this._ctx = this._container.getContext("2d");
+
+    this._update();
+  },
+
+  onRemove: function(map) {
+    L.DomUtil.remove(this._container);
+  },
+
+  getContainer: function() {
+    return this._container;
+  },
+
+  getBounds: function() {
+    return this._bounds;
+  },
+
+  _update: function() {
+    if (this._map._animatingZoom && this._bounds) {
+      return;
+    }
+    L.Renderer.prototype._update.call(this);
+
+    var b = this._bounds;
+
+    var container = this._container;
+
+    var size = b.getSize();
+
+    var m = L.Browser.retina ? 2 : 1;
+
+    L.DomUtil.setPosition(container, b.min);
+
+    // set canvas size (also clearing it); use double size on retina
+    container.width = m * size.x;
+    container.height = m * size.y;
+    container.style.width = size.x + "px";
+    container.style.height = size.y + "px";
+
+    if (L.Browser.retina) {
+      this._ctx.scale(2, 2);
+    }
+
+    // translate so we use the same path coordinates after canvas element moves
+    this._ctx.translate(-b.min.x, -b.min.y);
+
+    // Tell paths to redraw themselves
+    this.fire("update");
+  }
+});
diff --git a/src/track-playback/src/leaflet.trackplayback/trackplayback.js b/src/track-playback/src/leaflet.trackplayback/trackplayback.js
new file mode 100644
index 0000000000000000000000000000000000000000..d707967afe0cb3fa5cb76daa9107924f730548d0
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/trackplayback.js
@@ -0,0 +1,166 @@
+import L from "leaflet";
+
+import { Track } from "./track";
+import { TrackController } from "./trackcontroller";
+import { Clock } from "./clock";
+import { Draw } from "./draw";
+import * as Util from "./util";
+
+/**
+ * single track data
+ * [{lat: 30, lng: 116, time: 1502529980, heading: 300, info:[]},{},....]
+ *
+ * mutiple track data
+ * [single track data, single track data, single track data]
+ */
+export const TrackPlayBack = L.Class.extend({
+  includes: L.Mixin.Events,
+
+  initialize: function(data, map, options = {}) {
+    let drawOptions = {
+      trackPointOptions: options.trackPointOptions,
+      trackLineOptions: options.trackLineOptions,
+      targetOptions: options.targetOptions,
+      toolTipOptions: options.toolTipOptions,
+      filterOptions: { factions: data.factions }
+    };
+    this.tracks = this._initTracks(data.players);
+    this.draw = new Draw(map, drawOptions);
+    this.trackController = new TrackController(
+      {
+        tracks: this.tracks,
+        scores: data.scores,
+        drawings: data.drawings
+      },
+      this.draw
+    );
+    this.clock = new Clock(this.trackController, options.clockOptions);
+
+    this.clock.on("tick", this._tick, this);
+  },
+  start: function() {
+    this.clock.start();
+    return this;
+  },
+  stop: function() {
+    this.clock.stop();
+    return this;
+  },
+  rePlaying: function() {
+    this.clock.rePlaying();
+    return this;
+  },
+  slowSpeed: function() {
+    this.clock.slowSpeed();
+    return this;
+  },
+  quickSpeed: function() {
+    this.clock.quickSpeed();
+    return this;
+  },
+  getSpeed: function() {
+    return this.clock.getSpeed();
+  },
+  getCurTime: function() {
+    return this.clock.getCurTime();
+  },
+  getStartTime: function() {
+    return this.clock.getStartTime();
+  },
+  getEndTime: function() {
+    return this.clock.getEndTime();
+  },
+  isPlaying: function() {
+    return this.clock.isPlaying();
+  },
+  setCursor: function(time) {
+    this.clock.setCursor(time);
+    return this;
+  },
+  setSpeed: function(speed) {
+    this.clock.setSpeed(speed);
+    return this;
+  },
+  showTrackPoint: function() {
+    this.draw.showTrackPoint();
+    return this;
+  },
+  hideTrackPoint: function() {
+    this.draw.hideTrackPoint();
+    return this;
+  },
+  showTrackLine: function() {
+    this.draw.showTrackLine();
+    return this;
+  },
+  hideTrackLine: function() {
+    this.draw.hideTrackLine();
+    return this;
+  },
+  // toggles the visibility of infantry units on the map
+  toggleInfantry: function(e) {
+    this.draw.filterOptions.infantry = e;
+    return this;
+  },
+  // toggles the visibility of recon units on the map
+  toggleRecon: function(e) {
+    this.draw.filterOptions.recon = e;
+    return this;
+  },
+  // toggles the visibility of mechanized units on the map
+  toggleMechanized: function(e) {
+    this.draw.filterOptions.mechanized = e;
+    return this;
+  },
+  // toggles the visibility of faction units on the map
+  toggleFactions: function(e, target) {
+    for (let faction of this.draw.filterOptions.factions) {
+      if (faction.name === target) {
+        faction.active = e;
+        break;
+      }
+    }
+  },
+  // pass the factions to control playback to show faction names on the control panel
+  passFactions: function() {
+    return this.draw.filterOptions.factions;
+  },
+  // pass current scores to control panel
+  passScores: function(i) {
+    return this.trackController._activeScores[i];
+  },
+  // pass current drawings to control panel
+  passDrawings: function() {
+    return this.trackController._activeDrawings;
+  },
+  dispose: function() {
+    this.clock.off("tick", this._tick);
+    this.draw.remove();
+    this.tracks = null;
+    this.draw = null;
+    this.trackController = null;
+    this.clock = null;
+  },
+  _tick: function(e) {
+    this.fire("tick", e);
+  },
+  _initTracks: function(data) {
+    let tracks = [];
+    if (Util.isArray(data)) {
+      if (Util.isArray(data[0])) {
+        // 多条轨迹
+        for (let i = 0, len = data.length; i < len; i++) {
+          tracks.push(new Track(data[i]));
+        }
+      } else {
+        // 单条轨迹
+        tracks.push(new Track(data));
+      }
+    }
+    return tracks;
+  }
+});
+
+export const trackplayback = function(data, map, options) {
+  return new TrackPlayBack(data.players, map, options);
+};
diff --git a/src/track-playback/src/leaflet.trackplayback/util.js b/src/track-playback/src/leaflet.trackplayback/util.js
new file mode 100644
index 0000000000000000000000000000000000000000..b40984d26d198f2dcb8dade0f18eba8153d44d87
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/util.js
@@ -0,0 +1,3 @@
+export function isArray (arr) {
+  return Array.isArray ? Array.isArray(arr) : Object.prototype.toString.call(arr) === '[object Array]'
+}