Skip to content

Instantly share code, notes, and snippets.

@jsanz
Last active July 20, 2018 08:51
Show Gist options
  • Save jsanz/29d5871fa5822e0dd651c649737d45b6 to your computer and use it in GitHub Desktop.
Save jsanz/29d5871fa5822e0dd651c649737d45b6 to your computer and use it in GitHub Desktop.
Hackarto.js City Finder

Hackarto.js · City Finder

Small geography game using CARTO.js v4

Author:

  • Jorge Sanz
  • Dani Carrión

Improved version (post Hackarto.js) here

<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Hackarto.js · City finder</title>
<meta name="description" content="Hackarto.js geography game, try to find a city in the minimum number of movements">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- styles -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css" integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="container">
<h1>City Finder · Hackarto.js</h1>
</header>
<div id="wrapper" class="container">
<div class="row justify-content-md-center">
<!-- Instructions -->
<div class="col col-xl">
<div class="container">
<div class="col">
<div class="row">
<h2>How to play</h2>
<p>Put your name in the input, then try to navigate to the city mentioned in the minimum number of movements. Use the distance and the <tt>hot or cold</tt> description to guide you through the map.</p>
<p>
To finish the game you need to zoom in until you see the city labeled. Then the map will block and the number of movements done will be sent to the server.
</p>
<p>Enjoy!</p>
</div>
<div class="row">
<h2>Instructions</h2>
<div v-if="gamer_name">
<div v-if="yayOrNay">
<strong>Yay!!</strong> you found {{ city }},
click <a href="#" onclick="location.reload()">HERE</a>
or reload to play again
</div>
<div v-else>
<p>
Navigate to <br/>
<a class="btn btn-info" style="color:white" v-if="city" id="cityname" @click="fly">
{{city}}
</a>
<span v-if="this.map && this.map.getZoom() >= this.resultZoom">
({{ country}})
</span>
</p>
<p>You are <br/>
<span class="btn" :class="hotOrColdClass">
{{ hotOrCold }}
</span>
</p>
<p>
Cities on screen on average
are at <br/>
<span class="btn btn-info">{{ km_distance }}km</span>
</p>
<p>
Your movements <br>
<span class="btn btn-danger">{{ gamer_attempts}}</span>
</p>
</div>
</div>
<div v-else>
<div id="gamer">
<input v-model.lazy="gamer_name" type="text" placeholder="Put your name here"/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map -->
<div class="col-sm" >
<div id="map" :style="{borderColor: resultColor}"></div>
</div>
<!-- Widgets -->
<div class="col col-lg">
<div class="container">
<div class="row">
<div v-if="map && gamer_name" id="widgets">
<h2>Scoreboard</h2>
<div id="widgets">
<div id="averageAttemptsWidget"></div>
<div id="minAttemptsWidget"></div>
<div id="gamesWidget"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script src="https://cdn.rawgit.com/CartoDB/cartodb.js/@4.0.0-alpha.28/carto.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega/3.0.7/vega.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-lite/2.0.1/vega-lite.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-embed/3.0.0-rc7/vega-embed.js"></script>
<script src="script.js"></script>
</body>
</html>
document.addEventListener('DOMContentLoaded', function () {
const API_KEY = 'default_public',
USER_NAME = 'solutions',
SQL_CLIENT = axios.create({
method: 'get',
url: `https://${USER_NAME}.carto.com/api/v1/sql?`,
params: {
api_key: API_KEY
}
});
/* Main Vue instance */
vm = new Vue({
el: '#wrapper',
data: {
city_id: null,
city: null,
country: null,
city_coordinates: [],
map: null,
resultZoom: 8,
maxValue: null,
km_distance: null,
distancesHistory: [],
gamer_name: '',
gamer_attempts: 0
},
computed: {
opacity: function () {
return 1 - (this.distancesHistory.slice(-1)[0] / 10007543.0) * 2;
},
yayOrNay: function () {
return this.getZoom() >= this.resultZoom && this.maxValue === 1000;
},
resultColor: function () {
if (this.map) {
let col = this.yayOrNay ? '255,0,0' : '0,0,255';
return `rgba(${col},${this.opacity})`;
}
},
hotOrCold: function () {
const op = Math.round(this.opacity * 100);
let result = '';
if (op < 20) {
result = 'super cold';
} else if (op < 40) {
result = 'cold';
} else if (op < 60) {
result = 'warm';
} else if (op < 90) {
result = 'hot';
} else {
result = 'burning!';
}
if (op > 80 && this.zoom < this.resultZoom) {
result = `${result} but you need to get closer`;
}
return result;
},
hotOrColdClass: function () {
const op = Math.round(this.opacity * 100);
classObj = '';
if (op < 20) {
classObj = 'btn-light';
} else if (op < 40) {
classObj = 'btn-secondary';
} else if (op < 60) {
classObj = 'btn-info';
} else if (op < 90) {
classObj = 'btn-warning';
} else {
classObj = 'btn-danger';
}
let obj = {};
obj[classObj] = true;
return obj;
}
},
events: {},
watch: {
gamer_name: function (name) {
/* Initialize the amps */
this.initialize();
},
yayOrNay: function (value) {
if (value === true) {
// stop the map interaction
let map = this.map;
map.dragging.disable();
map.touchZoom.disable();
map.doubleClickZoom.disable();
map.scrollWheelZoom.disable();
map.boxZoom.disable();
map.keyboard.disable();
// don't send the results
/*
if (vm.gamer_name) {
const query = `SELECT insert_game('${this.gamer_name}',${this.gamer_attempts} + 1)`;
SQL_CLIENT.request({
params: {
q: query
}
});
}
*/
}
}
},
methods: {
initialize: function () {
const vm = this;
// Get the city
SQL_CLIENT.request({
params: {
q: `with r AS (select ceil(random()*202) as value)
select cartodb_id, pp.name as name, sov0name, st_x(the_geom) as lon, st_y(the_geom) as lat
from capitals pp, r
where id = r.value`
},
}).then(function (response) {
if (response && response.data) {
let result = response.data.rows[0];
// Save the challenge
vm.city = result.name;
vm.country = result.sov0name;
vm.city_coordinates = [result.lat, result.lon];
vm.city_id = result.cartodb_id;
// Create a map
vm.map = L.map('map', {
center: [0, 0],
zoom: 1,
minZoom: 1,
maxZoom: 11
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy;<a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy;<a href="https://carto.com/attribution">CARTO</a>'
}).addTo(vm.map);
// CARTO objects
let client = new carto.Client({
apiKey: API_KEY,
username: USER_NAME
});
let ppSQL = new carto.source.SQL(
`SELECT ca.cartodb_id,
ca.the_geom,
ca.the_geom_webmercator,
ca.name,
1000 - levenshtein(lower('${vm.city}'),lower(ca.name)) as distance,
round(ST_DistanceSphere(ca.the_geom,co.the_geom) / 10000) as km_distance
FROM capitals ca, (
SELECT the_geom
FROM capitals
WHERE cartodb_id = ${vm.city_id}
) as co`);
let ppStyle = new carto.style.CartoCSS(`
#layer::back {
marker-width: 15;
marker-fill: #FF583E;
marker-fill-opacity: 0.4;
marker-line-opacity: 0;
marker-type: ellipse;
marker-allow-overlap: true;
}
#layer::front {
marker-width: 7;
marker-fill: #FF583E;
marker-fill-opacity: 0.9;
marker-line-opacity: 0;
marker-type: ellipse;
marker-allow-overlap: true;
marker-comp-op: multiply;
}
#layer::labels[zoom>${vm.resultZoom - 1}] {
text-name: [name];
text-face-name: 'Lato Regular';
text-size: 16;
text-fill: #FFFFFF;
text-label-position-tolerance: 0;
text-halo-radius: 1.5;
text-halo-fill: #003863;
text-dy: -10;
text-allow-overlap: true;
text-character-spacing: 1.5;
text-placement: point;
text-placement-type: dummy;
}
`);
let populatedPlaces = new carto.layer.Layer(ppSQL, ppStyle);
// Create the layer and add it to the map
client.addLayer(populatedPlaces);
client.getLeafletLayer().addTo(vm.map);
// Data view, get the top 5
let populatedPlacesDataview = new carto.dataview.Category(ppSQL, 'name', {
limit: 1,
operation: carto.operation.MAX,
operationColumn: 'distance'
});
populatedPlacesDataview.on('dataChanged', function (newData) {
if (newData && newData.max) {
// Save the value to render it in red
vm.maxValue = newData.max;
// Compute the distance for the opacity and the label
let dist = vm.map.distance(
vm.map.getCenter(),
vm.city_coordinates
);
vm.distancesHistory.push(dist);
}
});
//Data view for the distance
let averageDistanceDataView = new carto.dataview.Formula(ppSQL, 'km_distance', {
operation: carto.operation.AVG
});
averageDistanceDataView.on('dataChanged', function (newData) {
if (newData && newData.result) {
vm.km_distance = newData.result.toFixed(1);
}
});
client.addDataview(populatedPlacesDataview);
client.addDataview(averageDistanceDataView);
var boundingBoxFilter = new carto.filter.BoundingBoxLeaflet(vm.map);
populatedPlacesDataview.addFilter(boundingBoxFilter);
averageDistanceDataView.addFilter(boundingBoxFilter);
// Register game moves
vm.map.on('moveend', function () {
vm.gamer_attempts = vm.gamer_attempts + 1;
});
// Widgets
var gamesDataset = new carto.source.Dataset('games');
var refreshWidget = function (categories, widget, title, yAxis, desc) {
var $widget = document.querySelector(widget);
if (desc) {
categories.reverse();
}
let vegaSpec = {
"$schema": "https://vega.github.io/schema/vega-lite/v2.json",
"title": title,
"data": {
"values": categories.slice(0,5)
},
"mark": "bar",
"encoding": {
"y": {
"field": "name",
"type": "nominal",
"sort": null,
"axis": {
"title": "Name"
}
},
"x": {
"field": "value",
"type": "quantitative",
"axis": {
"title": yAxis
}
}
}
};
vegaEmbed(widget, vegaSpec, {
"actions": false
});
};
// Average attempts
var averageAttemptsDataview = new carto.dataview.Category(gamesDataset, 'name', {
limit: 1000,
operation: carto.operation.AVG,
operationColumn: 'attempts'
});
averageAttemptsDataview.on('dataChanged', function (newData) {
refreshWidget(newData.categories, '#averageAttemptsWidget', 'Average game', 'Attempts', true);
});
// Fast games (minimun number of attempts)
var minAttemptsDataview = new carto.dataview.Category(gamesDataset, 'name', {
limit: 1000,
operation: carto.operation.MIN,
operationColumn: 'attempts'
});
minAttemptsDataview.on('dataChanged', function (newData) {
refreshWidget(newData.categories, '#minAttemptsWidget', 'Fastest game', 'Attempts', true);
});
// Total number of games
var gamesDataview = new carto.dataview.Category(gamesDataset, 'name', {
limit: 1000,
operation: carto.operation.COUNT
});
gamesDataview.on('dataChanged', function (newData) {
refreshWidget(newData.categories, '#gamesWidget', 'Total games', 'Games');
});
client.addDataview(averageAttemptsDataview);
client.addDataview(minAttemptsDataview);
client.addDataview(gamesDataview);
} else {
console.log('Something wrong happened');
}
});
},
getZoom: function () {
if (this.map) {
return this.map.getZoom();
} else {
return null;
}
},
fly: function () {
this.map.flyTo(this.city_coordinates);
},
getTextColor: function () {
if (this.opacity > 0.5) {
return 'white';
} else {
return 'black';
}
}
},
});
});
#wrapper {
min-height: 675px;
}
#map {
width: 100%;
height: 675px;
margin: auto;
border: 3px solid blue;
-webkit-background-clip: padding-box;
background-clip: padding-box;
}
#cityname {
text-transform: uppercase;
font-weight: bold;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment