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: '©<a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>, ©<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; | |
} |