source: OpenWorkouts-current/ow/static/js/ow.js @ 02b96c5

current
Last change on this file since 02b96c5 was 02b96c5, checked in by Borja Lopez <borja@…>, 5 years ago

(#7) Added calendar heatmap chart to the profile page.

The calendar shows the current month, each day without a workout represented
by a light grey square, each day with workout data in a red/pink color, picked
up from a gradient generated based on the app main colors, and calculated based
on the amount of workout time for the day.

A tooltip is shown on hover with more info (only the total workouts time for
now)

  • Property mode set to 100644
File size: 24.0 KB
Line 
1
2/*
3
4  OpenWorkouts Javascript code
5
6*/
7
8
9// Namespace
10var owjs = {};
11
12
13owjs.map = function(spec) {
14
15    "use strict";
16
17    // parameters provided when creating an "instance" of a map
18    var map_id = spec.map_id;
19    var latitude = spec.latitude;
20    var longitude = spec.longitude;
21    var zoom = spec.zoom;
22    var gpx_url = spec.gpx_url;
23    var start_icon = spec.start_icon;
24    var end_icon = spec.end_icon;
25    var shadow = spec.shadow;
26    var elevation = spec.elevation;
27    var zoom_control = spec.zoom_control;
28
29    // OpenStreetMap urls and references
30    var openstreetmap_url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
31    var openstreetmap_attr = 'Map data &copy; <a href="http://www.osm.org">OpenStreetMap</a>';
32
33    // Some vars reused through the code
34    var map;
35    var gpx;
36    var elevation;
37
38    var create_map = function create_map(latitude, longitude, zoom) {
39        /* Create a Leaflet map, set center point and add tiles */
40        map = L.map(map_id, {zoomControl: zoom_control});
41        map.setView([latitude, longitude], zoom);
42        var tile_layer = L.tileLayer(openstreetmap_url, {
43            attribution: openstreetmap_attr
44        });
45        tile_layer.addTo(map);
46    };
47
48    var add_elevation_chart = function add_elevation_chart() {
49        /*
50           Add the elevation chart support to the map.
51           This has to be called *after* create_map and *before* load_gpx.
52        */
53
54        elevation = L.control.elevation({
55            position: "bottomright",
56            theme: "openworkouts-theme",
57            useHeightIndicator: true, //if false a marker is drawn at map position
58            interpolation: d3.curveLinear,
59            elevationDiv: "#elevation",
60            detachedView: true,
61            responsiveView: true,
62            gpxOptions: {
63                async: true,
64                marker_options: {
65                    startIconUrl: null,
66                    endIconUrl: null,
67                    shadowUrl: null,
68                },
69                polyline_options: {
70                    color: '#EE4056',
71                    opacity: 0.75,
72                    weight: 5,
73                    lineCap: 'round'
74                },
75            },
76        });
77        elevation.loadGPX(map, gpx_url);
78        // var ele_container = elevation.addTo(map);
79    };
80
81    var load_gpx = function load_gpx(gpx_url) {
82        /*
83          Load the gpx from the given url, add it to the map and feed it to the
84          elevation chart
85        */
86        var gpx = new L.GPX(gpx_url, {
87            async: true,
88            marker_options: {
89                startIconUrl: start_icon,
90                endIconUrl: end_icon,
91                shadowUrl: shadow,
92            },
93            polyline_options: {
94                color: '#EE4056',
95                opacity: 0.75,
96                weight: 5,
97                lineCap: 'round'
98            },
99        });
100
101        gpx.on('loaded', function(e) {
102            map.fitBounds(e.target.getBounds());
103        });
104
105        if (elevation) {
106            gpx.on("addline",function(e){
107                elevation.addData(e.line);
108            });
109        };
110
111        gpx.addTo(map);
112    };
113
114    var render = function render() {
115        // create the map, add elevation, load gpx (only if needed, as the
116        // elevation plugin already loads the gpx data)
117        create_map(latitude, longitude, zoom);
118        if (elevation) {
119            add_elevation_chart();
120        }
121        else {
122            load_gpx(gpx_url);
123        }
124    };
125
126    var that = {}
127    that.render = render;
128    return that
129
130};
131
132
133owjs.week_chart = function(spec) {
134
135    "use strict";
136
137    // parameters provided when creating an "instance" of the chart
138    var chart_selector = spec.chart_selector,
139        url = spec.url,
140        current_day_name = spec.current_day_name
141
142    // Helpers
143    function select_x_axis_label(d) {
144        /* Given a value, return the label associated with it */
145        return d3.select('.x-axis')
146            .selectAll('text')
147            .filter(function(x) { return x == d.name; });
148    }
149
150    // Methods
151    var render = function render() {
152        /*
153           Build a d3 bar chart, populated with data from the given url.
154         */
155        var chart = d3.select(chart_selector),
156            margin = {top: 17, right: 0, bottom: 20, left: 0},
157
158            width = +chart.attr("width") - margin.left - margin.right,
159            height = +chart.attr("height") - margin.top - margin.bottom,
160            g = chart.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"),
161            x = d3.scaleBand().rangeRound([0, width]).padding(0.1),
162            y = d3.scaleLinear().rangeRound([height, 0]);
163
164        d3.json(url, {credentials: "same-origin"}).then(function (data) {
165            x.domain(data.map(function (d) {
166                return d.name;
167            }));
168
169            y.domain([0, d3.max(data, function (d) {
170                return Number(d.distance);
171            })]);
172
173            g.append("g")
174                .attr('class', 'x-axis')
175                .attr("transform", "translate(0," + height + ")")
176                .call(d3.axisBottom(x))
177
178            g.selectAll(".bar")
179                .data(data)
180                .enter().append("rect")
181                .attr("class", function(d) {
182                    if (d.name == current_day_name){
183                        select_x_axis_label(d).attr('style', "font-weight: bold;");
184                        return 'bar current'
185                    }
186                    else {
187                        return 'bar'
188                    }
189                })
190                .attr("x", function (d) {
191                    return x(d.name);
192                })
193                .attr("y", function (d) {
194                    return y(Number(d.distance));
195                })
196                .attr("width", x.bandwidth())
197                .attr("height", function (d) {
198                    return height - y(Number(d.distance));
199                })
200                .on('mouseover', function(d) {
201                    if (d.name != current_day_name){
202                        select_x_axis_label(d).attr('style', "font-weight: bold;");
203                    }
204                })
205                .on('mouseout', function(d) {
206                    if (d.name != current_day_name){
207                        select_x_axis_label(d).attr('style', "font-weight: regular;");
208                    }
209                });
210
211            g.selectAll(".text")
212                .data(data)
213                .enter()
214                .append("text")
215                .attr("class","label")
216                .attr("x", function (d) {
217                    return x(d.name) + x.bandwidth()/2;
218                })
219                .attr("y", function (d) {
220                    /*
221                      Get the value for the current bar, then get the maximum
222                      value to be displayed in the bar, which is used to
223                      calculate the proper position of the label for this bar,
224                      relatively to its height (1% above the bar)
225                     */
226                    var max = y.domain()[1];
227                    return y(d.distance + y.domain()[1] * 0.02);
228            })
229                .text(function(d) {
230                    if (Number(d.distance) > 0) {
231                        return d.distance;
232                    }
233                });
234
235        });
236    };
237
238    var that = {}
239    that.render = render;
240    return that
241
242};
243
244
245owjs.year_chart = function(spec) {
246
247    "use strict";
248
249    // parameters provided when creating an "instance" of the chart
250    var chart_selector = spec.chart_selector,
251        filters_selector = spec.filters_selector,
252        switcher_selector = spec.switcher_selector,
253        is_active_class = spec.is_active_class,
254        is_active_selector = '.' + is_active_class,
255        urls = spec.urls,
256        current_month = spec.current_month,
257        current_week = spec.current_week,
258        y_axis_labels = spec.y_axis_labels,
259        filter_by = spec.filter_by,
260        url = spec.url;
261
262    // Helpers
263    function select_x_axis_label(d) {
264        /* Given a value, return the label associated with it */
265        return d3.select('.x-axis-b')
266            .selectAll('text')
267            .filter(function(x) { return x == d.name; });
268    };
269
270    function get_y_value(d, filter_by) {
271        return Number(d[filter_by]);
272    };
273
274    function get_y_axis_label(filter_by) {
275        return y_axis_labels[filter_by];
276    };
277
278    function get_name_for_x(d) {
279        if (d.week == undefined || d.week == 0) {
280            // Monthly chart or first week of the weekly chart, return
281            // the name of the month, which will be shown in the x-axis
282            // ticks
283            return d.name;
284        }
285        else {
286            // Weekly chart, week other than the first, return the id so
287            // we can place it in the chart, we won't show this to the
288            // user
289            return d.id
290        }
291    }
292
293    // Methods
294    var filters_setup = function filters_setup() {
295        $(filters_selector).on('click', function(e) {
296            e.preventDefault();
297            $(filters_selector + is_active_selector).removeClass(is_active_class);
298            /* $(this).removeClass('is-active'); */
299            filter_by = $(this).attr('class').split('-')[1]
300            $(this).addClass(is_active_class);
301            var chart = d3.select(chart_selector);
302            chart.selectAll("*").remove();
303            render(filter_by, url);
304
305        });
306    };
307
308    var switcher_setup = function switcher_setup() {
309        $(switcher_selector).on('click', function(e) {
310            e.preventDefault();
311            $(switcher_selector + is_active_selector).removeClass(is_active_class);
312            url = $(this).attr('class').split('-')[1]
313            $(this).addClass(is_active_class);
314            var chart = d3.select(chart_selector);
315            chart.selectAll("*").remove();
316            render(filter_by, url);
317        });
318    };
319
320    var render = function render(filter_by, url) {
321        /*
322          Build a d3 bar chart, populated with data from the given url.
323        */
324        var chart = d3.select(chart_selector),
325            margin = {top: 20, right: 20, bottom: 30, left: 50},
326            width = +chart.attr("width") - margin.left - margin.right,
327            height = +chart.attr("height") - margin.top - margin.bottom,
328            g = chart.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")"),
329            x = d3.scaleBand().rangeRound([0, width]).padding(0.1),
330            y = d3.scaleLinear().rangeRound([height, 0]);
331
332        d3.json(urls[url], {credentials: "same-origin"}).then(function (data) {
333            x.domain(data.map(function (d) {
334                return get_name_for_x(d);
335                // return d.name;
336            }));
337
338            y.domain([0, d3.max(data, function (d) {
339                return get_y_value(d, filter_by);
340            })]);
341
342            g.append("g")
343                .attr('class', 'x-axis-b')
344                .attr("transform", "translate(0," + height + ")")
345                .call(d3.axisBottom(x))
346
347            g.append("g")
348                .call(d3.axisLeft(y))
349                .append("text")
350                .attr("fill", "#000")
351                .attr("transform", "rotate(-90)")
352                .attr("y", 6)
353                .attr("dy", "0.71em")
354                .attr("text-anchor", "end")
355                .text(get_y_axis_label(filter_by));
356
357            g.selectAll(".bar")
358                .data(data)
359                .enter().append("rect")
360                .attr("class", function(d) {
361                    var sel_week = current_month + '-' + current_week;
362                    if (d.id == current_month || d.id == sel_week){
363                        /* Bar for the currently selected month or week */
364                        select_x_axis_label(d).attr('style', "font-weight: bold;");
365                        return 'bar current';
366                    }
367                    else {
368                        if (!current_week && d.id.indexOf(current_month) >=0 ) {
369                            /*
370                               User selected a month, then switched to weekly
371                               view, we do highlight all the bars for weeks in
372                               that month
373                            */
374                            select_x_axis_label(d).attr('style', "font-weight: bold;");
375                            return 'bar current';
376                        }
377                        else {
378                            /* Non-selected bar */
379                            return 'bar';
380                        }
381
382                    }
383                })
384                .attr("x", function (d) {
385                    return x(get_name_for_x(d));
386                })
387                .attr("y", function (d) {
388                    return y(get_y_value(d, filter_by));
389                })
390                .attr("width", x.bandwidth())
391                .attr("height", function (d) {
392                    return height - y(get_y_value(d, filter_by));
393                })
394                .on('mouseover', function(d) {
395                    if (d.id != current_month){
396                        select_x_axis_label(d).attr('style', "font-weight: bold;");
397                    }
398                })
399                .on('mouseout', function(d) {
400                    if (d.id != current_month){
401                        select_x_axis_label(d).attr('style', "font-weight: regular;");
402                    }
403                })
404                .on('click', function(d) {
405                    window.location.href = d.url;
406                });
407
408            if (url == 'monthly') {
409                g.selectAll(".text")
410                    .data(data)
411                    .enter()
412                    .append("text")
413                    .attr("class","label")
414                    .attr("x", function (d) {
415                        return x(get_name_for_x(d)) + x.bandwidth()/2;
416                    })
417                    .attr("y", function (d) {
418                        /*
419                          Get the value for the current bar, then get the maximum
420                          value to be displayed in the bar, which is used to
421                          calculate the proper position of the label for this bar,
422                          relatively to its height (1% above the bar)
423                        */
424                        var value = get_y_value(d, filter_by);
425                        var max = y.domain()[1];
426                        return y(value + y.domain()[1] * 0.01);
427                    })
428                    .text(function(d) {
429                        var value = get_y_value(d, filter_by)
430                        if ( value > 0) {
431                            return value;
432                        }
433                    });
434            }
435
436            if (url == 'weekly') {
437                g.selectAll(".tick")
438                    .each(function (d, i) {
439                        /*
440                          Remove from the x-axis tickets those without letters
441                          on them (useful for the weekly chart)
442                        */
443                        if (d !== parseInt(d, 10)) {
444                            if(!d.match(/[a-z]/i)) {
445                                this.remove();
446                            }
447                        }
448                    });
449            }
450        });
451    };
452
453    var that = {}
454    that.filters_setup = filters_setup;
455    that.switcher_setup = switcher_setup;
456    that.render = render;
457    return that
458
459};
460
461owjs.calendar_heatmap_chart = function(spec) {
462
463    "use strict"
464
465    var chart_selector = spec.chart_selector,
466        url = spec.url,
467        day_names_list = spec.day_names_list;
468
469    var calendar_rows = function(month) {
470        /*
471          Given a date object that "marks" the beginning of a month
472          return the number of "rows" we need to show all days in that
473          month
474        */
475        // m will be actually the last day of the previous month
476        var m = d3.timeMonth.floor(month);
477        return d3.timeWeeks(d3.timeWeek.floor(m),
478                            d3.timeMonth.offset(m,1)).length;
479    }
480
481    // defaults for the squares/"cells" we will show with the info
482    var cell_margin = 3,
483        cell_size = 25;
484
485
486    var render = function render() {
487        /*
488          Method to actually perform all the loading and rendering of
489          the chart
490        */
491        var chart = d3.select(chart_selector);
492
493        d3.json(url, {credentials: "same-origin"}).then(function (data) {
494
495            var min_date = d3.min(data, function(d) {
496                return new Date(d.day);
497            });
498
499            var max_date = d3.max(data, function(d) {
500                return new Date(d.day);
501            });
502
503            // sunday-starting week:
504            // day = d3.timeFormat("%w")
505            // week = d3.timeFormat("%W")
506            // monday-starting week:
507            // day = function(d) {return d3.timeFormat("%u")(d) - 1}
508            // week = d3.timeFormat("%W")
509            var day = function(d) {return d3.timeFormat("%u")(d) - 1},
510                week = d3.timeFormat("%W"),
511                format = d3.timeFormat("%Y-%m-%d"),
512                titleFormat = d3.utcFormat("%a, %d %b"),
513                monthName = d3.timeFormat("%B / %Y"),
514                months = d3.timeMonth.range(d3.timeMonth.floor(min_date), max_date),
515                rows = calendar_rows(max_date);
516
517            // Build the svg image where the chart will be
518            var svg = chart.selectAll("svg")
519                .data(months)
520                .enter().append("svg")
521                .attr("class", "month")
522                .attr("width", (cell_size * 7) + (cell_margin * 8))
523                .attr("height", function(d) {
524                    // we add 50 extra so the month/year label fits
525                    return (cell_size * rows) + (cell_margin * (rows + 1)) + 50;
526                })
527                .append("g");
528
529            // This adds the month/year label above the chart
530            svg.append("text")
531                .attr("class", "month-name")
532                .attr("x", ((cell_size * 7) + (cell_margin * 8)) / 2 )
533                .attr("y", 15)
534                .attr("text-anchor", "middle")
535                .text(function(d) { return monthName(d); });
536
537            // Now, go through each day and add a square/cell for them
538            var rect = svg.selectAll("rect.day")
539                .data(function(d, i) {
540                    return d3.timeDays(
541                        d, new Date(d.getFullYear(), d.getMonth()+1, 1));
542                })
543                .enter().append("rect")
544                .attr("class", "day")
545                .attr("width", cell_size)
546                .attr("height", cell_size)
547                .attr("rx", 6).attr("ry", 6) // rounded corners
548                .attr("fill", '#eaeaea') // default light grey fill
549                .attr("x", function(d) {
550                    return (day(d) * cell_size) + (day(d) * cell_margin) + cell_margin;
551                })
552                .attr("y", function(d) {
553                    var base_value = (week(d) - week(new Date(d.getFullYear(), d.getMonth(), 1)));
554                    return (base_value * cell_size) + (base_value * cell_margin) + cell_margin + 20;
555                })
556                .on("mouseover", function(d) {
557                    d3.select(this).classed('hover', true);
558                })
559                .on("mouseout", function(d) {
560                    d3.select(this).classed('hover', false);
561                })
562                .datum(format);
563
564            rect.append("title")
565                .text(function(d) {return titleFormat(new Date(d));});
566
567            // Add the row with the names of the days
568            var day_names = svg.selectAll('.day-name')
569                .data(day_names_list)
570                .enter().append("text")
571                .attr("class", "day-name")
572                .attr("width", cell_size)
573                .attr("height", cell_size)
574                .attr("x", function(d) {
575                    return (day_names_list.indexOf(d) * cell_size) + (day_names_list.indexOf(d) * cell_margin) + cell_margin * 2;
576                })
577                .attr("y", function(d) {
578                    return ((cell_size * rows) + (cell_margin * (rows + 1))) + 40;
579                })
580                .text(function(d) {
581                    return d;
582                });
583
584            var find_day = function(day) {
585                var found = data.find(function(d) {
586                    return d.day == day;
587                });
588                return found;
589            }
590
591            var lookup = d3.nest()
592                .key(function(d) {
593                    return d.day;
594                })
595                .rollup(function(leaves) {
596                    return leaves[0].time;
597                })
598                .object(data);
599
600            var count = d3.nest()
601                .key(function(d) {
602                    return d.day;
603                })
604                .rollup(function(leaves) {
605                    return leaves[0].time;
606                })
607                .entries(data);
608
609            var scale = d3.scaleLinear()
610                .domain(d3.extent(count, function(d) {
611                    return d.value;
612                }))
613                .range(['#f8b5be', '#f60002']);
614
615            rect.filter(function(d) {
616                return d in lookup;
617            })
618                .style("fill", function(d) {
619                    // Fill in with the proper color
620                    return scale([lookup[d]]);
621                })
622                .classed("clickable", true)
623                .on("click", function(d){
624                    if(d3.select(this).classed('focus')){
625                        d3.select(this).classed('focus', false);
626                    } else {
627                        d3.select(this).classed('focus', true)
628                    }
629                    // doSomething();
630                })
631                .select("title")
632                .text(function(d) {
633                    // Update the title adding some more info
634                    var day = find_day(d);
635                    return titleFormat(new Date(d)) + ":  " + day.time_formatted; });
636        });
637
638    };
639
640    var that = {}
641    that.render = render;
642    return that
643
644};
645
646
647owjs.map_shots = function(spec) {
648
649    "use strict";
650
651    var img_selector = spec.img_selector;
652
653    var run = function run(){
654        $(img_selector).each(function(){
655            var img = $(this);
656            var a = $(this).parent();
657            var url = a.attr('href') + 'map-shot';
658            var jqxhr = $.getJSON(url, function(info) {
659                img.fadeOut('fast', function () {
660                    img.attr('src', info['url']);
661                    img.fadeIn('fast');
662                });
663                img.removeClass('js-needs-map');
664            });
665        });
666    };
667
668    var that = {}
669    that.run = run;
670    return that
671
672};
673
674
675owjs.sport_stats = function(spec) {
676
677    "use strict";
678
679    var link_selector = spec.link_selector;
680    var stats_selector = spec.stats_selector;
681    var selected = spec.selected;
682    var dropdown_selector = spec.dropdown_selector;
683    var current_year = spec.current_year;
684    var year_link_selector = spec.year_link_selector;
685
686    var setup = function setup() {
687        // Hide all sports stats by default
688        $(stats_selector).hide();
689        // Show the pre-selected one
690        $(selected).show();
691
692        $(link_selector).on('click', function(e) {
693            e.preventDefault();
694            var selected = $(this).attr('class').split(' ')[1];
695            var sport = selected.split('-')[1]
696            // Hide them all
697            $(stats_selector).hide();
698            // Show the selected one
699            $(stats_selector + '.' + selected).show();
700            // Update the sport on the sports selector widget
701            $(dropdown_selector + ' strong').html(sport);
702            // finally "click" on the proper year to be displayed for this sport
703            $(year_link_selector + sport + '-' + current_year).click();
704        });
705    };
706
707    var that = {}
708    that.setup = setup;
709    return that
710
711};
712
713
714owjs.year_stats = function(spec) {
715
716    "use strict";
717
718    var link_selector = spec.link_selector;
719    var stats_selector = spec.stats_selector;
720    var selected = spec.selected;
721    var dropdown_selector = spec.dropdown_selector;
722
723    var setup = function setup() {
724        // Hide all years stats by default
725        $(stats_selector).hide();
726        // Show the pre-selected one
727        $(selected).show();
728
729        $(link_selector).on('click', function(e) {
730            e.preventDefault();
731            var selected = $(this).attr('class').split(' ')[1];
732            var sport = selected.split('-')[1]
733            var year = selected.split('-')[2]
734            // Hide them all
735            $(stats_selector).hide();
736            // Show the selected one
737            $(stats_selector + '.' + selected).show();
738            // Update the year on the years selector widget
739            $(dropdown_selector + sport + ' strong').html(year);
740        });
741    };
742
743    var that = {}
744    that.setup = setup;
745    return that
746
747};
Note: See TracBrowser for help on using the repository browser.