Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/assets/config/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,3 @@
//= link leaflet/dist/images/marker-icon.png
//= link leaflet/dist/images/marker-icon-2x.png
//= link leaflet/dist/images/marker-shadow.png
//= link cal-heatmap/dist/cal-heatmap.css
163 changes: 63 additions & 100 deletions app/assets/javascripts/heatmap.js
Original file line number Diff line number Diff line change
@@ -1,91 +1,67 @@
//= require d3/dist/d3
//= require cal-heatmap/dist/cal-heatmap
//= require popper
//= require cal-heatmap/dist/plugins/Tooltip

/* global CalHeatmap, Tooltip */
document.addEventListener("DOMContentLoaded", () => {
const heatmapElement = document.querySelector("#cal-heatmap");

if (!heatmapElement) {
return;
$(function () {
const heatmap = $(".heatmap").removeClass("d-none").addClass("d-grid");
const weekInfo = getWeekInfo();
const maxPerDay = heatmap.data("max-per-day");
const weekdayLabels = heatmap.find("[data-weekday]");
let weekColumn = 1;
let previousMonth = null;

for (const day of weekdayLabels) {
const $day = $(day);
const weekday = $day.data("weekday");
if (weekday < weekInfo.firstDay % 7) {
$day.insertAfter(weekdayLabels.last());
}
const weekdayRow = getWeekdayRow(weekday);
if (weekdayRow % 2 === 0) $day.remove();
$day.css("grid-area", weekdayRow + " / 1");
}

/** @type {{date: string; max_id: number; total_changes: number}[]} */
const heatmapData = heatmapElement.dataset.heatmap ? JSON.parse(heatmapElement.dataset.heatmap) : [];
const displayName = heatmapElement.dataset.displayName;
const colorScheme = document.documentElement.getAttribute("data-bs-theme") ?? "auto";
const rangeColorsDark = ["#14432a", "#4dd05a"];
const rangeColorsLight = ["#4dd05a", "#14432a"];
const startDate = new Date(Date.now() - (365 * 24 * 60 * 60 * 1000));

const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");

let cal = new CalHeatmap();
let currentTheme = getTheme();

function renderHeatmap() {
cal.destroy();
cal = new CalHeatmap();

cal.paint({
itemSelector: "#cal-heatmap",
theme: currentTheme,
domain: {
type: "month",
gutter: 4,
label: {
text: (timestamp) => new Date(timestamp).toLocaleString(OSM.i18n.locale, { timeZone: "UTC", month: "short" }),
position: "top",
textAlign: "middle"
},
dynamicDimension: true
},
subDomain: {
type: "ghDay",
radius: 2,
width: 11,
height: 11,
gutter: 4
},
date: {
start: startDate
},
range: 13,
data: {
source: heatmapData,
type: "json",
x: "date",
y: "total_changes"
},
scale: {
color: {
type: "sqrt",
range: currentTheme === "dark" ? rangeColorsDark : rangeColorsLight,
domain: [0, Math.max(0, ...heatmapData.map(d => d.total_changes))]
}
for (const day of heatmap.find("[data-date]")) {
const $day = $(day);
const date = new Date($day.data("date"));
if (date.getUTCDay() === weekInfo.firstDay) {
weekColumn++;
const currentMonth = getMonthOfThisWeek(date);
if (previousMonth === null) {
previousMonth = currentMonth;
heatmap.find(`[data-month]:has( ~ [data-month="${previousMonth}"])`).remove();
heatmap.find("[data-month]").first().css("grid-column-start", 2);
}
}, [
[Tooltip, {
text: (date, value) => getTooltipText(date, value)
}]
]);

cal.on("mouseover", (event, timestamp, value) => {
if (!displayName || !value) return;
if (event.target.parentElement.nodeName === "a") return;
if (previousMonth % 12 !== currentMonth % 12) {
heatmap.find(`[data-month="${previousMonth}"]`).css("grid-column-end", weekColumn);
previousMonth++;
heatmap.find(`[data-month="${previousMonth}"]`).css("grid-column-start", weekColumn);
}
}
if (weekColumn === 1) {
$day.remove();
continue;
}
const count = $day.data("count") ?? 0;
const tooltipText = getTooltipText(date, count);
$day
.css("grid-area", getWeekdayRow(date.getUTCDay()) + " / " + weekColumn)
.attr("aria-label", tooltipText)
.tooltip({
title: tooltipText,
customClass: "wide",
delay: { show: 0, hide: 0 }
})
.find("div")
.css("opacity", Math.sqrt(count / maxPerDay));
}
heatmap.find(`[data-month="${previousMonth}"] ~ [data-month]`).remove();
heatmap.find("[data-month]").last().css("grid-column-end", weekColumn + 1);

for (const { date, max_id } of heatmapData) {
if (!max_id) continue;
if (timestamp !== Date.parse(date)) continue;
function getMonthOfThisWeek(date) {
const nextDate = new Date(date);
nextDate.setUTCDate(date.getUTCDate() + weekInfo.minimalDays - 1);
return nextDate.getUTCMonth() + 1;
}

const params = new URLSearchParams({ before: max_id + 1 });
const a = document.createElementNS("http://www.w3.org/2000/svg", "a");
a.setAttribute("href", `/user/${encodeURIComponent(displayName)}/history?${params}`);
$(event.target).wrap(a);
break;
}
});
function getWeekdayRow(weekday) {
return ((weekday - weekInfo.firstDay + 7) % 7) + 2;
}

function getTooltipText(date, value) {
Expand All @@ -98,22 +74,9 @@ document.addEventListener("DOMContentLoaded", () => {
return OSM.i18n.t("javascripts.heatmap.tooltip.no_contributions", { date: localizedDate });
}

function getTheme() {
if (colorScheme === "auto") {
return mediaQuery.matches ? "dark" : "light";
}

return colorScheme;
}

if (colorScheme === "auto") {
mediaQuery.addEventListener("change", (e) => {
currentTheme = e.matches ? "dark" : "light";
renderHeatmap();
});
function getWeekInfo() {
const weekInfo = { firstDay: 1, minimalDays: 4 }; // ISO 8601
const locale = new Intl.Locale(OSM.i18n.locale);
return { ...weekInfo, ...locale.weekInfo, ...locale.getWeekInfo?.() };
}

renderHeatmap();
});


31 changes: 29 additions & 2 deletions app/assets/stylesheets/common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,33 @@ img.trace_image {
}
}

.heatmap-wrapper {
height: 130px;
/* Rules for the heatmap */

.heatmap {
grid-template-columns: auto;
grid-auto-columns: minmax(1em, 1fr);
grid-template-rows: auto;
grid-auto-rows: minmax(1em, 1fr);
font-size: x-small;
gap: 0.3em;
[data-date] {
aspect-ratio: 1;
background-color: var(--bs-success-border-subtle);
@extend .h-100, .overflow-hidden;
border-radius: 25%;
div {
background-color: var(--bs-success-text-emphasis);
@extend .h-100;
}
&:empty {
@extend .bg-body-secondary, .bg-opacity-75;
}
&:hover {
border: solid 1px #8884;
}
}
}

.tooltip.wide {
--bs-tooltip-max-width: none;
}
60 changes: 43 additions & 17 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,53 @@ def show
if @user && (@user.visible? || current_user&.administrator?)
@title = @user.display_name

@heatmap_data = Rails.cache.fetch("heatmap_data_with_ids_user_#{@user.id}", :expires_in => 1.day) do
@heatmap_data = Rails.cache.fetch("heatmap_data_of_user_#{@user.id}", :expires_in => 1.day) do
one_year_ago = 1.year.ago.beginning_of_day
today = Time.zone.now.end_of_day

Changeset
.where(:user_id => @user.id)
.where(:created_at => one_year_ago..today)
.where(:num_changes => 1..)
.group("date_trunc('day', created_at)")
.select("date_trunc('day', created_at) AS date, SUM(num_changes) AS total_changes, MAX(id) AS max_id")
.order("date")
.map do |changeset|
{
:date => changeset.date.to_date.to_s,
:total_changes => changeset.total_changes.to_i,
:max_id => changeset.max_id
}
end
end
mapped = Changeset
.where(:user_id => @user.id)
.where(:created_at => one_year_ago..today)
.where(:num_changes => 1..)
.group("date_trunc('day', created_at)")
.select("date_trunc('day', created_at) AS date, SUM(num_changes) AS total_changes, MAX(id) AS max_id")
.order("date")
.map do |changeset|
{
:date => changeset.date.to_date,
:total_changes => changeset.total_changes.to_i,
:max_id => changeset.max_id
}
end

indexed = mapped.index_by { |entry| entry[:date] }

# Pad the start by one week to ensure the heatmap can start on the first day of the week
all_days = ((1.year.ago - 1.week).beginning_of_day.to_date..today.to_date).map do |date|
indexed[date] || { :date => date, :total_changes => 0 }
end

# Get unique months with repeating months and count into the next year with numbers over 12
month_offset = 0
months = ((1.year.ago - 2.weeks).beginning_of_day.to_date..(today + 1.week).to_date)
.map(&:month)
.chunk_while { |before, after| before == after }
.map(&:first)
.map do |month|
month_offset += 12 if month == 1
month + month_offset
end

@changes_count = @heatmap_data.sum { |entry| entry[:total_changes] }
total = mapped.sum { |entry| entry[:total_changes] }
max_per_day = mapped.map { |entry| entry[:total_changes] }.max

{
:days => all_days,
:months => months,
:total => total,
:max_per_day => max_per_day
}
end
else
render_unknown_user params[:display_name]
end
Expand Down
25 changes: 25 additions & 0 deletions app/views/users/_heatmap.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<h2 class="text-body-secondary fs-5 mt-4">
<%= t("users.show.contributions", :count => data[:total]) %>
</h2>
<div class="row">
<div class="col overflow-auto">
<div class="heatmap d-none align-items-center mb-1 text-center" data-max-per-day="<%= data[:max_per_day] %>">
<!-- Months -->
<% data[:months].each do |month| %>
<span class="mb-1 mx-n2" data-month="<%= month %>"><%= t("date.abbr_month_names")[((month - 1) % 12) + 1] %></span>
<% end %>
<!-- Days -->
<% (0..6).each do |day| %>
<span class="me-1 my-n1" data-weekday="<%= day %>"><%= t("date.abbr_day_names")[day] %></span>
<% end %>
<!-- Heatmap -->
<% data[:days].each do |day| %>
<% if day[:total_changes] == 0 %>
<span data-date="<%= day[:date] %>"></span>
<% else %>
<a href="<%= user_history_path @user, :before => day[:max_id] + 1 %>" data-date="<%= day[:date] %>" data-count="<%= day[:total_changes] %>"><div></div></a>
<% end %>
<% end %>
</div>
</div>
</div>
27 changes: 2 additions & 25 deletions app/views/users/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<% content_for :head do %>
<%= stylesheet_link_tag "cal-heatmap/dist/cal-heatmap" %>
<%= javascript_include_tag "heatmap" %>
<% end %>
<% content_for :heading do %>
Expand Down Expand Up @@ -247,30 +246,8 @@

<div class="richtext text-break"><%= @user.description.to_html %></div>

<% if @heatmap_data.present? %>
<h2 class="text-body-secondary fs-5">
<%= t(".contributions", :count => @changes_count) %>
</h2>
<div class="row">
<div class="col overflow-auto">
<div class="heatmap-wrapper d-flex align-items-start">
<!-- Labels -->
<ul class="list-unstyled d-flex flex-column justify-content-between ch-domain-text mb-0 mt-4">
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[1] %></li>
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[3] %></li>
<li>&nbsp;</li>
<li><%= t("date.abbr_day_names")[5] %></li>
<li>&nbsp;</li>
</ul>
<!-- Heatmap -->
<div id="cal-heatmap" class="ms-2"
data-heatmap="<%= @heatmap_data.to_json %>" data-display-name="<%= @user.display_name %>">
</div>
</div>
</div>
</div>
<% if @heatmap_data[:total].positive? %>
<%= render :partial => "heatmap", :locals => { :data => @heatmap_data } %>
<% end %>

<% if current_user and @user.id == current_user.id %>
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@

resources :notes, :path => "note", :id => /\d+/, :only => [:show, :new]

get "/user/:display_name/history" => "changesets#index"
get "/user/:display_name/history" => "changesets#index", :as => :user_history
get "/user/:display_name/history/feed" => "changesets#feed", :defaults => { :format => :atom }
get "/user/:display_name/notes" => "notes#index", :as => :user_notes
get "/history/friends" => "changesets#index", :friends => true, :as => "friend_changesets", :defaults => { :format => :html }
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"name": "openstreetmap",
"private": true,
"dependencies": {
"cal-heatmap": "^4.2.4",
"i18n-js": "^4.5.1",
"jquery-simulate": "^1.0.2",
"js-cookie": "^3.0.0",
Expand Down
Loading
Loading