integrate world map

pull/119/head
Thibault Duplessis 2014-08-25 10:09:16 +02:00
parent ab9831dafa
commit 7794a3075a
25 changed files with 769 additions and 54 deletions

View File

@ -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
}

View File

@ -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(_)
}
}
}

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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

View File

@ -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)

View File

@ -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]))
}

View File

@ -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
// }

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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 {

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1,3 @@
package lila
package object worldMap extends PackageObject with WithPlay

View File

@ -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)
)

View File

@ -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

View File

@ -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;
}

11
public/worldMap/raphael-min.js vendored 100644

File diff suppressed because one or more lines are too long

View File

@ -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();
});

View File

@ -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