GeoIP Map (#142)

* First working version of world map chart

* Cleanup code, fix aspect ratio, add GeoIP Map header

* Remove limited-height on session list with already limited content

* Update package lock

* Integrate map into service page

* Adjust map colors

* Adjust colors further

Co-authored-by: R. Miles McCain <github@sendmiles.email>
This commit is contained in:
Casper Verswijvelt 2021-06-13 21:50:01 +02:00 committed by GitHub
parent 83b20643d2
commit 9832de0c19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1101 additions and 41 deletions

1020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@
"@fortawesome/fontawesome-free": "^5.15.1", "@fortawesome/fontawesome-free": "^5.15.1",
"a17t": "^0.5.1", "a17t": "^0.5.1",
"apexcharts": "^3.24.0", "apexcharts": "^3.24.0",
"datamaps": "^0.5.9",
"flag-icon-css": "^3.5.0", "flag-icon-css": "^3.5.0",
"inter-ui": "^3.15.0", "inter-ui": "^3.15.0",
"litepicker": "^2.0.11" "litepicker": "^2.0.11"

View File

@ -11,6 +11,11 @@
max-height: 400px; max-height: 400px;
} }
.force-limited-height {
max-height: 400px;
overflow: hidden;
}
.rf { .rf {
text-align: right !important; text-align: right !important;
} }

View File

@ -12,11 +12,13 @@
<script src="{% static 'apexcharts/dist/apexcharts.min.js'%}"></script> <script src="{% static 'apexcharts/dist/apexcharts.min.js'%}"></script>
<script src="{% static 'litepicker/dist/nocss/litepicker.js' %}"></script> <script src="{% static 'litepicker/dist/nocss/litepicker.js' %}"></script>
<script src="{% static 'litepicker/dist/plugins/ranges.js' %}"></script> <script src="{% static 'litepicker/dist/plugins/ranges.js' %}"></script>
<link rel="stylesheet" href="{% static 'litepicker/dist/css/litepicker.css' %}"> <script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'topojson/build/topojson.min.js' %}"></script>
<script src="{% static 'datamaps/dist/datamaps.world.min.js' %}"></script>
<script src="{% static 'dashboard/js/base.js' %}"></script> <script src="{% static 'dashboard/js/base.js' %}"></script>
<link rel="stylesheet" href="{% static 'dashboard/css/global.css' %}"> <link rel="stylesheet" href="{% static 'dashboard/css/global.css' %}">
<link rel="stylesheet" href="{% static 'flag-icon-css/css/flag-icon.min.css' %}"> <link rel="stylesheet" href="{% static 'flag-icon-css/css/flag-icon.min.css' %}">
<link rel="stylesheet" href="{% static 'litepicker/dist/css/litepicker.css' %}">
{% block extra_head %} {% block extra_head %}
{% endblock %} {% endblock %}
</head> </head>

View File

@ -0,0 +1,59 @@
{% load helpers %}
<div id="map-chart" class="relative"></div>
<script>
// Colors
const lightBlue = "#C4B5FD";
const highlightBlue = "#8B5CF6";
const white = "#ffffff";
// Data maps
const countryMapData = {};
const countryMapColors = {};
const countryMap = {
{% for country in countries %}"{{country.country|safe|datamap_id}}": {{country.count}},
{% endfor %}
};
// Max session count will be full opacity
const maxSessionCount = Math.max(...Object.values(countryMap));
// Color scale starts from opacity 0.1 - 1.0, 0 sessions gets opacity 0
const minPercentage = 0.1
// Loop over country map and transform data for Datamaps use
const keys = Object.keys(countryMap);
const length = keys.length;
for (let i = 0; i < length; i++) {
countryMapData[keys[i]] = {
sessionCount: countryMap[keys[i]],
color: `rgba(124, 58, 237, ${countryMap[keys[i]] === 0 ? 0 : minPercentage + (countryMap[keys[i]] / maxSessionCount * (1 - minPercentage))})`
};
countryMapColors[keys[i]] = countryMapData[keys[i]].color;
}
// Create datamap
const map = new Datamap({
element: document.getElementById('map-chart'),
projection: 'mercator',
responsive: true,
geographyConfig: {
borderColor: lightBlue,
highlightBorderColor: highlightBlue,
highlightBorderWidth: 1.5,
highlightFillColor: (geography) => geography.color || white,
highlightFillOpacity: 0.9,
popupTemplate: (geography, data) => '<div class="hoverinfo"><strong>' + geography.properties.name + '</strong>: ' + data.sessionCount + ' sessions</div>'
},
fills: {
defaultFill: white
},
data: countryMapData,
aspectRatio: 0.68
});
map.updateChoropleth(countryMapColors);
// Handle resize. TODO: debounce?
window.onresize = () => map.resize();
</script>

View File

@ -132,6 +132,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card ~neutral !low force-limited-height py-2 overflow-y-hidden">
<p class="text-sm font-semibold mx-2 p-2 border-b mb-2">Sessions by Geography</p>
{% include 'dashboard/includes/map_chart.html' with countries=stats.countries %}
</div>
<div class="card ~neutral !low limited-height py-2"> <div class="card ~neutral !low limited-height py-2">
<table class="table"> <table class="table">
<thead class="text-sm"> <thead class="text-sm">
@ -166,40 +170,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card ~neutral !low limited-height py-2">
<table class="table">
<thead class="text-sm">
<tr>
<th>Country</th>
<th class="rf">Sessions</th>
</tr>
</thead>
<tbody>
{% for country in stats.countries %}
<tr>
<td class="truncate w-full max-w-0 relative" title="{{country.country|country_name}}">
{% include 'dashboard/includes/bar.html' with count=country.count max=stats.countries.0.count total=stats.session_count %}
<div class="relative flex items-center">
<span class="flex-none {{country.country|flag_class}}"></span> {{country.country|country_name}}
</div>
</td>
<td>
<div class="flex justify-end items-center">
{{country.count|intcomma}}
<span class="text-xs rf" style="min-width: 48px">
({{country.count|percent:stats.session_count}})
</span>
</div>
</td>
</tr>
{% empty %}
<tr>
<td><span class="text-gray-600">No data yet...</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height py-2"> <div class="card ~neutral !low limited-height py-2">
<table class="table"> <table class="table">
<thead class="text-sm"> <thead class="text-sm">
@ -304,7 +274,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="card ~neutral !low limited-height py-2"> <div class="card ~neutral !low py-2">
{% include 'dashboard/includes/session_list.html' %} {% include 'dashboard/includes/session_list.html' %}
<hr class="sep h-8 md:h-12"> <hr class="sep h-8 md:h-12">
<a href="{% contextual_url 'dashboard:service_session_list' service.uuid %}" class="button ~neutral w-auto mb-2">View more <a href="{% contextual_url 'dashboard:service_session_list' service.uuid %}" class="button ~neutral w-auto mb-2">View more

View File

@ -43,6 +43,14 @@ def country_name(isocode):
return "Unknown" return "Unknown"
@register.filter
def datamap_id(isocode):
try:
return pycountry.countries.get(alpha_2=isocode).alpha_3
except:
return "UNKNOWN"
@register.simple_tag @register.simple_tag
def relative_stat_tone( def relative_stat_tone(
start, start,

View File

@ -310,6 +310,9 @@ NPM_FILE_PATTERNS = {
"stimulus": [os.path.join("dist", "stimulus.umd.js")], "stimulus": [os.path.join("dist", "stimulus.umd.js")],
"inter-ui": [os.path.join("Inter (web)", "*")], "inter-ui": [os.path.join("Inter (web)", "*")],
"@fortawesome": [os.path.join("fontawesome-free", "js", "all.min.js")], "@fortawesome": [os.path.join("fontawesome-free", "js", "all.min.js")],
"datamaps": [os.path.join("dist", "datamaps.world.min.js")],
"d3": ["d3.min.js"],
"topojson": [os.path.join("build", "topojson.min.js")],
"flag-icon-css": [ "flag-icon-css": [
os.path.join("css", "flag-icon.min.css"), os.path.join("css", "flag-icon.min.css"),
os.path.join("flags", "*"), os.path.join("flags", "*"),