integrate world map
parent
ab9831dafa
commit
7794a3075a
|
@ -132,4 +132,5 @@ object Env {
|
|||
def donation = lila.donation.Env.current
|
||||
def qa = lila.qa.Env.current
|
||||
def history = lila.history.Env.current
|
||||
def worldMap = lila.worldMap.Env.current
|
||||
}
|
||||
|
|
|
@ -40,12 +40,6 @@ object Main extends LilaController {
|
|||
}
|
||||
}
|
||||
|
||||
def stream = Action.async {
|
||||
import lila.round.MoveBroadcast
|
||||
Env.round.moveBroadcast ? MoveBroadcast.GetEnumerator mapTo
|
||||
manifest[Enumerator[String]] map { e => Ok.feed(e) }
|
||||
}
|
||||
|
||||
def captchaCheck(id: String) = Open { implicit ctx =>
|
||||
Env.hub.actor.captcher ? ValidCaptcha(id, ~get("solution")) map {
|
||||
case valid: Boolean => Ok(valid fold (1, 0))
|
||||
|
@ -61,13 +55,13 @@ object Main extends LilaController {
|
|||
|
||||
def developers = Open { implicit ctx =>
|
||||
fuccess {
|
||||
views.html.site.developers()
|
||||
html.site.developers()
|
||||
}
|
||||
}
|
||||
|
||||
def irc = Open { implicit ctx =>
|
||||
ctx.me ?? Env.team.api.mine map {
|
||||
views.html.site.irc(_)
|
||||
html.site.irc(_)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package controllers
|
||||
|
||||
import play.api.libs.EventSource
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc._, Results._
|
||||
|
||||
import lila.app._
|
||||
import views._
|
||||
|
||||
object WorldMap extends LilaController {
|
||||
|
||||
def index = Action {
|
||||
Ok(views.html.site.worldMap())
|
||||
}
|
||||
|
||||
def stream = Action {
|
||||
Ok.chunked(
|
||||
Env.worldMap.stream.producer &> EventSource()
|
||||
) as "text/event-stream"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
@()
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Lichess World Map</title>
|
||||
<link rel="shortcut icon" href="@routes.Assets.at("images/favicon-32-white.png")" type="image/x-icon" />
|
||||
@cssAt("worldMap/main.css")
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="http://lichess.org">lichess<span class="extension">.org</span></a> activity</h1>
|
||||
<div id="worldmap"></div>
|
||||
<div id="stats">
|
||||
<div class="wrapL">
|
||||
<div id="time">time: <span></span></div>
|
||||
<div id="moves">moves: <span></span></div>
|
||||
<div id="countries">countries: <span></span></div>
|
||||
</div>
|
||||
<div class="wrapL">
|
||||
<div id="topCountries"></div>
|
||||
</div>
|
||||
</div>
|
||||
@jQueryTag
|
||||
@jsAt("worldMap/raphael-min.js")
|
||||
@jsAt("worldMap/world.js")
|
||||
@jsAt("worldMap/time.js")
|
||||
@jsAt("worldMap/app.js")
|
||||
@jsAt("worldMap/stats.js")
|
||||
</body>
|
||||
</html>
|
|
@ -284,11 +284,14 @@ POST /api/puzzle controllers.Puzzle.importBatch
|
|||
|
||||
# Misc
|
||||
POST /cli controllers.Cli.command
|
||||
GET /captcha/$id<\w{8}> controllers.Main.captchaCheck(id: String)
|
||||
GET /captcha/$id<\w{8}> controllers.Main.captchaCheck(id: String)
|
||||
GET /developers controllers.Main.developers
|
||||
GET /embed controllers.Main.embed
|
||||
GET /irc controllers.Main.irc
|
||||
GET /stream controllers.Main.stream
|
||||
|
||||
# Map
|
||||
GET /world-map controllers.WorldMap.index
|
||||
GET /world-map/stream controllers.WorldMap.stream
|
||||
|
||||
# Pages
|
||||
GET /thanks controllers.Page.thanks
|
||||
|
|
|
@ -21,6 +21,12 @@ object Builder {
|
|||
def expiry[K, V](ttl: Duration): Cache[K, V] =
|
||||
cacheBuilder[K, V](ttl).build[K, V]
|
||||
|
||||
def size[K, V](max: Int): Cache[K, V] =
|
||||
CacheBuilder.newBuilder()
|
||||
.maximumSize(max)
|
||||
.asInstanceOf[CacheBuilder[K, V]]
|
||||
.build[K, V]
|
||||
|
||||
private def cacheBuilder[K, V](ttl: Duration): CacheBuilder[K, V] =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(ttl, TimeUnit.MILLISECONDS)
|
||||
|
|
|
@ -46,7 +46,6 @@ final class Env(
|
|||
val CollectionReminder = config getString "collection.reminder"
|
||||
val CasualOnly = config getBoolean "casual_only"
|
||||
val ActiveTtl = config duration "active.ttl"
|
||||
val StreamApiSalt = config getString "stream_api.salt"
|
||||
}
|
||||
import settings._
|
||||
|
||||
|
@ -179,7 +178,6 @@ final class Env(
|
|||
uciMemo = uciMemo,
|
||||
prefApi = prefApi)
|
||||
|
||||
lazy val moveBroadcast = system.actorOf(Props(classOf[MoveBroadcast], StreamApiSalt))
|
||||
lazy val tvBroadcast = system.actorOf(Props(classOf[TvBroadcast]))
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,33 @@
|
|||
package lila.round
|
||||
// package lila.round
|
||||
|
||||
import akka.actor._
|
||||
// import akka.actor._
|
||||
|
||||
import com.roundeights.hasher.Implicits._
|
||||
// import com.roundeights.hasher.Implicits._
|
||||
|
||||
import lila.hub.actorApi.round.MoveEvent
|
||||
import play.api.libs.iteratee._
|
||||
// import lila.hub.actorApi.round.MoveEvent
|
||||
// import play.api.libs.iteratee._
|
||||
|
||||
private final class MoveBroadcast(salt: String) extends Actor {
|
||||
// private final class MoveBroadcast(salt: String) extends Actor {
|
||||
|
||||
context.system.lilaBus.subscribe(self, 'moveEvent)
|
||||
// context.system.lilaBus.subscribe(self, 'moveEvent)
|
||||
|
||||
override def postStop() {
|
||||
context.system.lilaBus.unsubscribe(self)
|
||||
}
|
||||
// override def postStop() {
|
||||
// context.system.lilaBus.unsubscribe(self)
|
||||
// }
|
||||
|
||||
private val (enumerator, channel) = Concurrent.broadcast[String]
|
||||
// private val (enumerator, channel) = Concurrent.broadcast[String]
|
||||
|
||||
def receive = {
|
||||
// def receive = {
|
||||
|
||||
case MoveBroadcast.GetEnumerator => sender ! enumerator
|
||||
// case MoveBroadcast.GetEnumerator => sender ! enumerator
|
||||
|
||||
case move: MoveEvent => channel push s"${tokenOf(move.gameId)} ${move.ip}"
|
||||
}
|
||||
// case move: MoveEvent => channel push s"${tokenOf(move.gameId)} ${move.ip}"
|
||||
// }
|
||||
|
||||
def tokenOf(gameId: String) = gameId.salt(salt).md5.hex take 8
|
||||
}
|
||||
// def tokenOf(gameId: String) = gameId.salt(salt).md5.hex take 8
|
||||
// }
|
||||
|
||||
object MoveBroadcast {
|
||||
// object MoveBroadcast {
|
||||
|
||||
case object GetEnumerator
|
||||
}
|
||||
// case object GetEnumerator
|
||||
// }
|
||||
|
|
|
@ -15,14 +15,11 @@ case class VersionedEvent(
|
|||
watcher: Boolean,
|
||||
troll: Boolean) {
|
||||
|
||||
def jsFor(m: Member): JsObject = visibleBy(m).fold(
|
||||
Json.obj(
|
||||
"v" -> version,
|
||||
"t" -> typ,
|
||||
"d" -> data
|
||||
),
|
||||
Json.obj("v" -> version)
|
||||
)
|
||||
def jsFor(m: Member): JsObject = if (visibleBy(m)) {
|
||||
if (data == JsNull) Json.obj("v" -> version, "t" -> typ)
|
||||
else Json.obj("v" -> version, "t" -> typ, "d" -> data)
|
||||
}
|
||||
else Json.obj("v" -> version)
|
||||
|
||||
private def visibleBy(m: Member): Boolean =
|
||||
if (watcher && m.owner) false
|
||||
|
|
|
@ -42,7 +42,7 @@ final class Env(
|
|||
|
||||
lazy val forms = new DataForm(captcher = captcher)
|
||||
|
||||
private lazy val geoIP = new GeoIP(
|
||||
lazy val geoIP = new GeoIP(
|
||||
file = GeoIPFile,
|
||||
cacheSize = GeoIPCacheSize)
|
||||
|
||||
|
|
|
@ -2,19 +2,11 @@ package lila.security
|
|||
|
||||
import com.sanoma.cda.geoip.{ MaxMindIpGeo, IpLocation }
|
||||
|
||||
import lila.memo.AsyncCache
|
||||
import scala.concurrent.Future
|
||||
final class GeoIP(file: String, cacheSize: Int) {
|
||||
|
||||
private[security] final class GeoIP(file: String, cacheSize: Int) {
|
||||
private val geoIp = MaxMindIpGeo(file, cacheSize)
|
||||
|
||||
private val geoIp = MaxMindIpGeo(file, 0)
|
||||
|
||||
private val cache = AsyncCache(
|
||||
f = (ip: String) => Future { geoIp getLocation ip },
|
||||
maxCapacity = cacheSize)
|
||||
|
||||
def apply(ip: String): Future[Location] =
|
||||
cache(ip) map (_.fold(Location.unknown)(Location.apply))
|
||||
def apply(ip: String): Option[Location] = geoIp getLocation ip map Location.apply
|
||||
}
|
||||
|
||||
case class Location(
|
||||
|
|
|
@ -35,7 +35,7 @@ object UserSpy {
|
|||
objs ← $find(Json.obj("user" -> user.id))
|
||||
ips = objs.flatMap(_ str "ip").distinct
|
||||
blockedIps ← (ips map firewall.blocksIp).sequenceFu
|
||||
locations <- (ips map geoIP.apply).sequenceFu
|
||||
locations <- scala.concurrent.Future { ips flatMap geoIP.apply }
|
||||
users ← explore(Set(user), Set.empty, Set(user))
|
||||
} yield UserSpy(
|
||||
ips = ips zip blockedIps zip locations map {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package lila.worldMap
|
||||
|
||||
import com.typesafe.config.Config
|
||||
|
||||
import com.sanoma.cda.geoip.MaxMindIpGeo
|
||||
import lila.common.PimpedConfig._
|
||||
|
||||
final class Env(
|
||||
system: akka.actor.ActorSystem,
|
||||
config: Config) {
|
||||
|
||||
private val GeoIPFile = config getString "geoip.file"
|
||||
private val GeoIPCacheSize = config getInt "geoip.cache_size"
|
||||
private val PlayersCacheSize = config getInt "players.cache_size"
|
||||
|
||||
lazy val players = new Players(PlayersCacheSize)
|
||||
|
||||
lazy val stream = new Stream(
|
||||
system = system,
|
||||
players = players,
|
||||
geoIp = MaxMindIpGeo(GeoIPFile, GeoIPCacheSize))
|
||||
}
|
||||
|
||||
object Env {
|
||||
|
||||
lazy val current: Env = "[boot] worldMap" describes new Env(
|
||||
system = lila.common.PlayApp.system,
|
||||
config = lila.common.PlayApp loadConfig "worldMap")
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package lila.worldMap
|
||||
|
||||
import lila.memo.Builder
|
||||
|
||||
private[worldMap] final class Players(cacheSize: Int) {
|
||||
|
||||
// to each game ID, associate list of player Locations
|
||||
// there can be 0, 1 or 2 players per game ID,
|
||||
// but this constraint is not expressed by the cache type :(
|
||||
private val cache = Builder.size[String, List[Location]](cacheSize)
|
||||
|
||||
def getOpponentLocation(gameId: String, myLocation: Location): Option[Location] =
|
||||
|
||||
Option(cache getIfPresent gameId) getOrElse Nil match {
|
||||
|
||||
// new game ID, store player location
|
||||
case Nil =>
|
||||
cache.put(gameId, List(myLocation)); None
|
||||
|
||||
// only my location is known
|
||||
case List(loc) if loc == myLocation => None
|
||||
|
||||
// only opponent location is known. Store mine
|
||||
case List(loc) =>
|
||||
cache.put(gameId, List(loc, myLocation)); Some(loc)
|
||||
|
||||
// both locations are known
|
||||
case List(l1, l2) if l1 == myLocation => Some(l2)
|
||||
|
||||
// both locations are known
|
||||
case List(l1, l2) if l2 == myLocation => Some(l1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package lila.worldMap
|
||||
|
||||
import akka.actor._
|
||||
import com.google.common.cache.LoadingCache
|
||||
import com.sanoma.cda.geoip.{ MaxMindIpGeo, IpLocation }
|
||||
import java.io.File
|
||||
import lila.hub.actorApi.round.MoveEvent
|
||||
import play.api.libs.iteratee._
|
||||
import play.api.libs.json._
|
||||
|
||||
final class Stream(
|
||||
system: ActorSystem,
|
||||
players: Players,
|
||||
geoIp: MaxMindIpGeo) {
|
||||
|
||||
private val (enumerator, channel) = Concurrent.broadcast[MoveEvent]
|
||||
|
||||
private val processor: Enumeratee[MoveEvent, String] =
|
||||
Enumeratee.mapInput[MoveEvent].apply[String] {
|
||||
case Input.El(move) =>
|
||||
geoIp getLocation move.ip flatMap Location.apply match {
|
||||
case None => Input.Empty
|
||||
case Some(loc) =>
|
||||
val opponentLoc = players.getOpponentLocation(move.gameId, loc)
|
||||
Input.El(Json.stringify {
|
||||
Json.obj(
|
||||
"country" -> loc.country,
|
||||
"lat" -> loc.lat,
|
||||
"lon" -> loc.lon,
|
||||
"oLat" -> opponentLoc.map(_.lat),
|
||||
"oLon" -> opponentLoc.map(_.lon)
|
||||
)
|
||||
})
|
||||
}
|
||||
case _ => Input.Empty
|
||||
}
|
||||
|
||||
val producer = enumerator &> processor
|
||||
|
||||
system.lilaBus.subscribe(system.actorOf(Props(new Actor {
|
||||
def receive = {
|
||||
case move: MoveEvent => channel push move
|
||||
}
|
||||
})), 'moveEvent)
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package lila.worldMap
|
||||
|
||||
import com.sanoma.cda.geoip.IpLocation
|
||||
|
||||
case class Location(
|
||||
country: String,
|
||||
lat: Double,
|
||||
lon: Double)
|
||||
|
||||
object Location {
|
||||
|
||||
def apply(ipLoc: IpLocation): Option[Location] = for {
|
||||
country <- ipLoc.countryName
|
||||
point <- ipLoc.geoPoint
|
||||
} yield Location(country, point.latitude, point.longitude)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package lila
|
||||
|
||||
package object worldMap extends PackageObject with WithPlay
|
|
@ -41,7 +41,7 @@ object ApplicationBuild extends Build {
|
|||
ai, analyse, mod, monitor, site, round, lobby, setup,
|
||||
importer, tournament, pool, relation, report, pref, // simulation,
|
||||
evaluation, chat, puzzle, tv, coordinate, blog, donation, qa,
|
||||
swisssystem, history)
|
||||
swisssystem, history, worldMap)
|
||||
|
||||
lazy val moduleRefs = modules map projectToRef
|
||||
lazy val moduleCPDeps = moduleRefs map { new sbt.ClasspathDependency(_, None) }
|
||||
|
@ -62,6 +62,10 @@ object ApplicationBuild extends Build {
|
|||
libraryDependencies ++= provided(play.api, RM, PRM)
|
||||
)
|
||||
|
||||
lazy val worldMap = project("worldMap", Seq(common, hub, memo)).settings(
|
||||
libraryDependencies ++= provided(play.api, maxmind)
|
||||
)
|
||||
|
||||
lazy val qa = project("qa", Seq(common, db, memo, user, security)).settings(
|
||||
libraryDependencies ++= provided(play.api, RM, PRM)
|
||||
)
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
var stats = {
|
||||
nMoves: 0,
|
||||
countries: {}
|
||||
};
|
||||
|
||||
$(function() {
|
||||
var worldWith = window.innerWidth - 30,
|
||||
mapRatio = 0.4,
|
||||
worldHeight = worldWith * mapRatio,
|
||||
scale = worldWith / 1000;
|
||||
var paper = Raphael(document.getElementById("worldmap"), worldWith, worldHeight);
|
||||
paper.rect(0, 0, worldWith, worldHeight, 10).attr({
|
||||
stroke: "none"
|
||||
});
|
||||
paper.setStart();
|
||||
for (var country in worldmap.shapes) {
|
||||
paper.path(worldmap.shapes[country]).attr({
|
||||
stroke: "#343C40",
|
||||
fill: "#67777F",
|
||||
"stroke-opacity": 0.25
|
||||
}).transform("s" + scale + "," + scale + " 0,0");
|
||||
}
|
||||
var world = paper.setFinish();
|
||||
world.getXY = function(lat, lon) {
|
||||
return {
|
||||
cx: lon * (2.6938 * scale) + (465.4 * scale),
|
||||
cy: lat * (-2.6938 * scale) + (227.066 * scale)
|
||||
};
|
||||
};
|
||||
|
||||
// position legend and stats
|
||||
var worldOffset = $('#worldmap').offset();
|
||||
$('#stats').offset({
|
||||
top: worldOffset.top + 250 * scale,
|
||||
left: worldOffset.left + 7.6 * scale
|
||||
}).attr({
|
||||
width: 195 * scale,
|
||||
height: 172 * scale
|
||||
});
|
||||
|
||||
if ( !! window.EventSource) {
|
||||
var density = {};
|
||||
var source = new EventSource("/stream");
|
||||
|
||||
source.addEventListener('message', function(e) {
|
||||
var data = JSON.parse(e.data);
|
||||
var densityKey = data.lat + "" + data.lon;
|
||||
if (typeof density[densityKey] == 'undefined') density[densityKey] = 0;
|
||||
else density[densityKey]++;
|
||||
var dot = paper.circle().attr({
|
||||
fill: "#FE7727",
|
||||
r: density[densityKey] + 2,
|
||||
'stroke-width': 0
|
||||
});
|
||||
var orig = world.getXY(data.lat, data.lon);
|
||||
dot.attr(orig);
|
||||
setTimeout(function() {
|
||||
density[densityKey]--;
|
||||
setTimeout(function() {
|
||||
dot.remove();
|
||||
}, 1000);
|
||||
}, 700);
|
||||
if (data.oLat) {
|
||||
var dest = world.getXY(data.oLat, data.oLon);
|
||||
var str = "M" + orig.cx + "," + orig.cy + "T" + dest.cx + "," + dest.cy;
|
||||
var line = paper.path(str);
|
||||
line.attr({
|
||||
opacity: 0.35,
|
||||
stroke: "#FE7727",
|
||||
'arrow-end': 'oval-wide-long'
|
||||
});
|
||||
setTimeout(function() {
|
||||
line.remove();
|
||||
}, 700);
|
||||
}
|
||||
|
||||
// moves
|
||||
stats.nMoves++;
|
||||
$('#moves > span').text(stats.nMoves);
|
||||
// top countries
|
||||
if (data.country in stats.countries) stats.countries[data.country]++;
|
||||
else stats.countries[data.country] = 1;
|
||||
}, false);
|
||||
source.addEventListener('open', function(e) {
|
||||
// Connection was opened.
|
||||
// console.log("connection opened");
|
||||
}, false);
|
||||
source.addEventListener('error', function(e) {
|
||||
if (e.readyState == EventSource.CLOSED) {
|
||||
// Connection was closed.
|
||||
// console.log("connection closed");
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 210 KiB |
|
@ -0,0 +1,41 @@
|
|||
body {
|
||||
/* based on lichess dark theme */
|
||||
font: 12px 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, Sans-Serif;
|
||||
color: #909090;
|
||||
background: url("bluegrid.jpg") center repeat;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-family: 'Trebuchet MS', 'Helvetica Neue', Arial, Sans-Serif;
|
||||
font-size: 30px;
|
||||
font-weight: normal;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
h1 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.extension {
|
||||
color: #606060;
|
||||
}
|
||||
|
||||
.wrapL {
|
||||
float: left;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#stats {
|
||||
position: absolute;
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,27 @@
|
|||
$(function() {
|
||||
|
||||
var topCountries = function() {
|
||||
var toSort = [];
|
||||
for (var c in stats.countries) {
|
||||
toSort.push([c, stats.countries[c]]);
|
||||
}
|
||||
toSort.sort(function(a, b) { return b[1] - a[1]; });
|
||||
var top10 = toSort.slice(0, 10);
|
||||
$('#topCountries').html('');
|
||||
$.each(top10, function(i, v) {
|
||||
$('#topCountries').append(
|
||||
'<div>'+v[0]+': '+v[1]+'</div>'
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
var updateStats = function() {
|
||||
$('#countries > span').text(Object.keys(stats.countries).length);
|
||||
topCountries();
|
||||
|
||||
setTimeout(function() { updateStats(); }, 500);
|
||||
};
|
||||
|
||||
updateStats();
|
||||
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
$(function() {
|
||||
var since = Date.now();
|
||||
function startTime()
|
||||
{
|
||||
var now = Date.now(),
|
||||
elapsed = new Date(now - since),
|
||||
h=formatTime(elapsed.getUTCHours()),
|
||||
m=formatTime(elapsed.getUTCMinutes()),
|
||||
s=formatTime(elapsed.getUTCSeconds());
|
||||
$('#time > span').text(h+":"+m+":"+s);
|
||||
setTimeout(function(){startTime();},500);
|
||||
}
|
||||
|
||||
function formatTime(i)
|
||||
{
|
||||
if (i < 10) {
|
||||
i="0" + i;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
startTime();
|
||||
});
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue