diff --git a/public/infantry-red.svg b/public/infantry-red.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c8b743b29b1c12afca0e305ec67923cad188e804
--- /dev/null
+++ b/public/infantry-red.svg
@@ -0,0 +1,23 @@
+<?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: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>
\ No newline at end of file
diff --git a/src/App.css b/src/App.css
index b3004ac40734d1b05cd893b254948d77559da7dc..7a4add6f468dbdf25d90c5425991b36d765e2ea5 100644
--- a/src/App.css
+++ b/src/App.css
@@ -233,3 +233,45 @@ div.login button:hover {
   display: flex;
   flex-direction: column;
 }
+
+.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 {
+}
diff --git a/src/App.js b/src/App.js
index b701a6e8845cb04a430eb2d0baaa03976bc061ec..4a1bbae8a594404193575f3200b425dd48f8a69b 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import "../node_modules/leaflet-draw/dist/leaflet.draw.css";
 import "./App.css";
+
 import ClientSocket from "./components/Socket";
 import {
   BrowserRouter as Router,
@@ -12,6 +13,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() {
@@ -140,6 +142,11 @@ export default class App extends Component {
       />
     );
   };
+
+  replay = () => {
+    return <ReplayMap />;
+  };
+
   render() {
     // TODO: think better solution to wait for authenticator
     if (!this.state.authenticateComplete) {
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/ReplayMap.js b/src/components/ReplayMap.js
new file mode 100644
index 0000000000000000000000000000000000000000..c1396c0ac5b9a20cbbcaddde36ac1f4bcb84c033
--- /dev/null
+++ b/src/components/ReplayMap.js
@@ -0,0 +1,170 @@
+// https://github.com/linghuam/Leaflet.TrackPlayBack
+
+import React from "react";
+import L from "leaflet";
+import { Map, TileLayer, ZoomControl, Marker, Popup } from "react-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 DrawGeoJSON from "./DrawGeoJSON";
+
+export default class ReplayMap extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      // stores the playback object
+      playback: null,
+      // stores all drawings from backend
+      allGeoJSON: [],
+      // stores all active drawings on the map
+      activeGeoJSON: []
+    };
+    this.map = React.createRef();
+  }
+
+  async componentDidMount() {
+    await this.fetchPlayerData();
+    await this.fetchDrawingData();
+    this.tickDrawings();
+    this.replay();
+  }
+
+  componentWillReceiveProps(nextProps) {}
+
+  // cloud game a1231e2b-aa29-494d-b687-ea2d48cc23df
+  // fetch player locations from the game
+  fetchPlayerData = async () => {
+    await fetch(
+      `${
+        process.env.REACT_APP_API_URL
+      }/replay/players/a1231e2b-aa29-494d-b687-ea2d48cc23df`,
+      {
+        method: "GET"
+      }
+    )
+      .then(async res => await res.json())
+      .then(
+        async res => {
+          await this.setState({ data: res });
+        },
+        // Note: it's important to handle errors here
+        // instead of a catch() block so that we don't swallow
+        // exceptions from actual bugs in components.
+        error => {
+          console.log(error);
+        }
+      );
+  };
+
+  fetchDrawingData = async () => {
+    await fetch(
+      /*       `${
+        process.env.REACT_APP_API_URL
+      }/replay/a1231e2b-aa29-494d-b687-ea2d48cc23df`, */
+      `http://localhost:5000/replay/15e9563b-e621-4ba1-a440-1b21c7774923`,
+      {
+        method: "GET"
+      }
+    )
+      .then(async res => await res.json())
+      .then(
+        async res => {
+          await this.setState({ allGeoJSON: res });
+        },
+        error => {
+          console.log(error);
+        }
+      );
+  };
+
+  tickDrawings = () => {
+    let activeDrawings = [];
+    this.state.allGeoJSON.map(drawing => {
+      activeDrawings.push(drawing[0]);
+      this.setState({
+        activeGeoJSON: {
+          type: "FeatureCollection",
+          features: [...activeDrawings]
+        }
+      });
+    });
+  };
+
+  replay = () => {
+    this.map = L.map(this.refs.map).setView([62.3, 25.7], 15);
+    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);
+    this.trackplayback = new L.TrackPlayBack(this.state.data, this.map, {
+      trackPointOptions: {
+        // whether to draw track point
+        isDraw: false,
+        // whether to use canvas to draw it, if false, use leaflet api `L.circleMarker`
+        useCanvas: true,
+        stroke: true,
+        color: "#ef0300",
+        fill: true,
+        fillColor: "#ef0300",
+        opacity: 0.3,
+        radius: 4
+      },
+      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: "../infantry-red.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
+      }
+    });
+    this.setState({
+      playback: this.trackplayback
+    });
+    this.trackplaybackControl = L.trackplaybackcontrol(this.trackplayback);
+    this.trackplaybackControl.addTo(this.map);
+  };
+
+  render() {
+    return (
+      /*       <Map
+        className="map"
+        ref={this.map}
+        center={[62.3, 25.7]}
+        zoom={15}
+        minZoom="7"
+        maxZoom="17"
+        zoomControl={false}
+      >
+        <TileLayer
+          attribution='&copy; <a href="https://www.maanmittauslaitos.fi/">Maanmittauslaitos</a>'
+          url={"https://tiles.kartat.kapsi.fi/taustakartta/{z}/{x}/{y}.jpg"}
+        />
+        <ZoomControl position="topright" />
+        {this.state.activeGeoJSON.features && (
+          <DrawGeoJSON geoJSONLayer={this.state.activeGeoJSON} />
+        )}
+      </Map> */
+      <div className="map" ref="map">
+        {/*         {this.state.activeGeoJSON.features && (
+          <DrawGeoJSON geoJSONLayer={this.state.activeGeoJSON} />
+        )} */}
+      </div>
+    );
+  }
+}
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..e2ad4727e7e800222e41edf42af5602e2fb3f2d8
--- /dev/null
+++ b/src/track-playback/src/control.trackplayback/control.playback.js
@@ -0,0 +1,334 @@
+import L from "leaflet";
+
+export const TrackPlayBackControl = L.Control.extend({
+  options: {
+    position: "topright",
+    showOptions: true,
+    showInfo: true,
+    showSlider: true,
+    autoPlay: false
+  },
+
+  initialize: function(trackplayback, options) {
+    L.Control.prototype.initialize.call(this, options);
+    this.trackPlayBack = trackplayback;
+    this.trackPlayBack.on("tick", this._tickCallback, this);
+  },
+
+  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._pointCbx = this._createCheckbox(
+      "show trackPoint",
+      "show-trackpoint",
+      this._optionsContainer,
+      this._showTrackPoint
+    );
+    this._lineCbx = this._createCheckbox(
+      "show trackLine",
+      "show-trackLine",
+      this._optionsContainer,
+      this._showTrackLine
+    );
+
+    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(
+      "slow",
+      "btn-slow",
+      this._buttonContainer,
+      this._slow
+    );
+    this._quickSpeedBtn = this._createButton(
+      "quick",
+      "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);
+
+    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;
+  },
+
+  _showTrackPoint(e) {
+    if (e.target.checked) {
+      this.trackPlayBack.showTrackPoint();
+    } else {
+      this.trackPlayBack.hideTrackPoint();
+    }
+  },
+
+  _showTrackLine(e) {
+    if (e.target.checked) {
+      this.trackPlayBack.showTrackLine();
+    } else {
+      this.trackPlayBack.hideTrackLine();
+    }
+  },
+
+  _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();
+    }
+  }
+});
+
+export const trackplaybackcontrol = function(trackplayback, options) {
+  return new TrackPlayBackControl(trackplayback, 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..7100dc7f3eb6242a922d112016528b14071caabf
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/draw.js
@@ -0,0 +1,353 @@
+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
+  },
+
+  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);
+
+    this._showTrackPoint = this.trackPointOptions.isDraw;
+    this._showTrackLine = this.trackLineOptions.isDraw;
+
+    this._map = map;
+    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);
+    }
+
+    // 目标如果使用图片,先加载图片
+    if (this.targetOptions.useImg) {
+      const img = new Image();
+      img.onload = () => {
+        this._targetImg = img;
+      };
+      img.onerror = () => {
+        throw new Error("img load error!");
+      };
+      img.src = this.targetOptions.imgUrl;
+    }
+  },
+
+  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("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)
+      );
+    }
+  },
+
+  _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._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);
+    }
+    this._canvas.style.cursor = "default";
+    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];
+    if (this.targetOptions.useImg && this._targetImg) {
+      this._drawShipImage(targetPoint);
+    } 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;
+    this._ctx.save();
+    for (let i = 0, len = trackpoints.length; i < len; i++) {
+      if (trackpoints[i].isOrigin) {
+        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) {
+    for (let i = 0, len = trackpoints.length; i < len; i++) {
+      if (trackpoints[i].isOrigin) {
+        let latLng = L.latLng(trackpoints[i].lat, trackpoints[i].lng);
+        let cricleMarker = L.circleMarker(latLng, this.trackPointOptions);
+        cricleMarker.bindTooltip(
+          this._getTooltipText(trackpoints[i]),
+          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();
+  },
+
+  _drawShipImage: function(trackpoint) {
+    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);
+    this._ctx.drawImage(
+      this._targetImg,
+      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) {
+      for (let i = 0, len = targetobj.info.length; i < len; i++) {
+        content.push("<tr>");
+        content.push("<td>" + targetobj.info[i].key + "</td>");
+        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..2e8157437f541500e2aa0774329d2c9969a7d6ce
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/trackcontroller.js
@@ -0,0 +1,72 @@
+import L from "leaflet";
+
+import { isArray } from "./util";
+import { Track } from "./track";
+
+/**
+ * 控制器类
+ * 控制轨迹和绘制
+ */
+export const TrackController = L.Class.extend({
+  initialize: function(tracks = [], draw, options) {
+    L.setOptions(this, options);
+
+    this._tracks = [];
+    this.addTrack(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();
+    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);
+    }
+  },
+
+  _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..a03027b93dcdfaba4ee183889de73c9d2cc28546
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/tracklayer.js
@@ -0,0 +1,69 @@
+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..460102ff1b594c01fad7396cb9517366a04242f8
--- /dev/null
+++ b/src/track-playback/src/leaflet.trackplayback/trackplayback.js
@@ -0,0 +1,131 @@
+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
+    }
+    this.tracks = this._initTracks(data)
+    this.draw = new Draw(map, drawOptions)
+    this.trackController = new TrackController(this.tracks, 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
+  },
+  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, 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]'
+}