Changeset 02b96c5 in OpenWorkouts-current


Ignore:
Timestamp:
Mar 12, 2019, 12:47:38 PM (5 years ago)
Author:
Borja Lopez <borja@…>
Branches:
current
Children:
8ba32d9
Parents:
d9453fc
Message:

(#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)

Location:
ow
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • ow/static/css/main.css

    rd9453fc r02b96c5  
    15521552  -moz-outline-style: none;
    15531553}
     1554/* Calendar heatmap */
     1555.calendar-heatmap {
     1556  margin: 20px;
     1557}
     1558.calendar-heatmap .month {
     1559  margin-right: 8px;
     1560}
     1561.calendar-heatmap .month-name {
     1562  font-size: 85%;
     1563  fill: #777;
     1564}
     1565.calendar-heatmap .day-name {
     1566  font-size: 85%;
     1567  fill: #777;
     1568}
     1569.calendar-heatmap .day:hover {
     1570  stroke: #e1e1e1;
     1571  stroke-width: 2;
     1572}
     1573.calendar-heatmap .day:focus {
     1574  stroke: #e1e1e1;
     1575  stroke-width: 2;
     1576}
    15541577.verify-account-content {
    15551578  background-position: center;
  • ow/static/js/ow.js

    rd9453fc r02b96c5  
    459459};
    460460
     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
    461646
    462647owjs.map_shots = function(spec) {
  • ow/static/less/pages/profile.less

    rd9453fc r02b96c5  
    154154    }
    155155}
     156
     157
     158/* Calendar heatmap */
     159.calendar-heatmap {
     160    margin: 20px;
     161    .month {
     162        margin-right: 8px;
     163    }
     164    .month-name {
     165        font-size: 85%;
     166        fill: #777;
     167    }
     168    .day-name {
     169        font-size: 85%;
     170        fill: #777;
     171    }
     172    .day {
     173        &:hover {
     174            stroke: #e1e1e1;
     175            stroke-width: 2;
     176        }
     177        &:focus {
     178            stroke: #e1e1e1;
     179            stroke-width: 2;
     180        }
     181    }
     182}
  • ow/templates/profile.pt

    rd9453fc r02b96c5  
    5151            </ul>
    5252          </div>
     53          <div class="calendar-heatmap js-calendar-heatmap">
     54          </div>
    5355        </div>
    5456
     
    462464     sport_stats.setup();
    463465
     466     var heatmap_chart = owjs.calendar_heatmap_chart({
     467         chart_selector: 'div.js-calendar-heatmap',
     468         url: "${request.resource_url(user, 'month')}",
     469         // Trick to have all those shortened day names translated
     470         day_names_list: "${_('Mo Tu We Th Fr Sa Su')}".split(' ')
     471     });
     472     heatmap_chart.render();
     473
    464474     var year_stats = owjs.year_stats({
    465475         link_selector: 'a.js-choose-year-stats',
  • ow/views/user.py

    rd9453fc r02b96c5  
    438438        }
    439439        json_stats.append(day_stats)
     440    return Response(content_type='application/json',
     441                    charset='utf-8',
     442                    body=json.dumps(json_stats))
     443
     444
     445@view_config(
     446    context=User,
     447    permission='view',
     448    name='month')
     449def month_stats(context, request):
     450    """
     451    For the given month, return a json-encoded stream containing
     452    per-day workouts information.
     453    """
     454    localizer = get_localizer(request)
     455    now = datetime.now(timezone.utc)
     456    year = int(request.GET.get('year', now.year))
     457    month = int(request.GET.get('month', now.month))
     458    workouts = context.workouts(year, month)
     459    stats = {}
     460
     461    for workout in workouts:
     462        start = workout.start.strftime('%Y-%m-%d')
     463        if start not in stats.keys():
     464            stats[start] = {
     465                'time': 0,  # seconds
     466                'distance': 0,  # kilometers
     467                'elevation': 0,  # meters
     468            }
     469        duration = getattr(workout, 'duration', None) or timedelta(0)
     470        stats[start]['time'] += duration.seconds
     471        distance = getattr(workout, 'distance', None) or 0
     472        stats[start]['distance'] += int(round(distance))
     473        elevation = getattr(workout, 'uphill', None) or 0
     474        stats[start]['elevation'] += int(elevation)
     475
     476    json_stats = []
     477    for day in stats.keys():
     478        hms = timedelta_to_hms(timedelta(seconds=stats[day]['time']))
     479        hours_label = _('hour')
     480        if hms[0] > 1:
     481            hours_label = _('hours')
     482        time_formatted = ' '.join([
     483            str(hms[0]).zfill(2), localizer.translate(hours_label),
     484            str(hms[1]).zfill(2), localizer.translate(_('min.'))
     485        ])
     486        json_stats.append({
     487            'day': day,
     488            'time': stats[day]['time'],
     489            'time_formatted': time_formatted,
     490            'distance': stats[day]['distance'],
     491            'elevation': stats[day]['elevation']
     492        })
     493
    440494    return Response(content_type='application/json',
    441495                    charset='utf-8',
Note: See TracChangeset for help on using the changeset viewer.