diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 85b5cb8604..02dad72de2 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -7,6 +7,7 @@ //= link_tree ../opensearch .xml //= link_directory ../stylesheets .css + //= link_tree ../../../vendor/assets/iD/iD/img //= link_directory ../../../vendor/assets/iD/iD/data .json //= link_directory ../../../vendor/assets/iD/iD/locales .json @@ -14,6 +15,10 @@ //= link_directory ../../../vendor/assets/iD/iD/mapillary-js .js //= link_directory ../../../vendor/assets/iD/iD/pannellum .js //= link_directory ../../../vendor/assets/iD/iD/pannellum .css +//= link_directory ../../../vendor/assets/d3 .js +//= link_directory ../../../vendor/assets/cal-heatmap .js +//= link_directory ../../../vendor/assets/cal-heatmap .css + //= link_tree ../../../vendor/assets/leaflet .png diff --git a/app/assets/javascripts/heatmap.js b/app/assets/javascripts/heatmap.js new file mode 100644 index 0000000000..547e6fa215 --- /dev/null +++ b/app/assets/javascripts/heatmap.js @@ -0,0 +1,134 @@ +//= require d3 +//= require cal-heatmap +//= require calendar-label +//= require popper +//= require tooltip + + +/* global CalHeatmap, CalendarLabel, Tooltip */ +document.addEventListener("DOMContentLoaded", () => { + const heatmapElement = document.querySelector("#cal-heatmap"); + + if (!heatmapElement) { + console.warn("Heatmap element not found in the DOM."); + return; + } + + const heatmapData = heatmapElement.dataset.heatmap ? JSON.parse(heatmapElement.dataset.heatmap) : []; + const colorScheme = heatmapElement.dataset.siteColorScheme || "auto"; + const locale = $("head").data().locale; + const weekdays = getLocalizedWeekdays(locale); + const rangeColors = ["#14432a", "#166b34", "#37a446", "#4dd05a"]; + const startDate = new Date(Date.now() - (365 * 24 * 60 * 60 * 1000)); + + const theme = getThemeFromColorScheme(colorScheme); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + let cal = new CalHeatmap(); + renderHeatmap(theme); + + if (colorScheme === "auto") { + mediaQuery.addEventListener("change", (e) => { + const newTheme = e.matches ? "dark" : "light"; + renderHeatmap(newTheme); + }); + } + + function renderHeatmap(currentTheme) { + cal.destroy(); + cal = new CalHeatmap(); + cal.paint({ + itemSelector: "#cal-heatmap", + theme: currentTheme, + domain: { + type: "month", + gutter: 4, + label: { + text: (timestamp) => getMonthNameFromTranslations(locale, new Date(timestamp).getMonth()), + 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: "threshold", + range: currentTheme === "dark" ? rangeColors : rangeColors.reverse(), + domain: [10, 20, 30, 40] + } + } + }, [ + [CalendarLabel, { + position: "left", + key: "left", + text: () => weekdays, + textAlign: "end", + width: 30, + padding: [23, 10, 0, 0] + }], + [Tooltip, { + text: (date, value) => getTooltipText(date, value, locale) + }] + ]); + } +}); + +function getThemeFromColorScheme(colorScheme) { + if (colorScheme === "auto") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return colorScheme; +} + +function getLocalizedWeekdays(locale) { + const translations = I18n.translations[locale] || I18n.translations.en; + const date = translations && translations.date; + const abbrDayNames = date && date.abbr_day_names; + + return (abbrDayNames || []).map((day, index) => + index % 2 === 0 ? "" : day + ); +} + +function getMonthNameFromTranslations(locale, monthIndex) { + const translations = I18n.translations[locale] || I18n.translations.en; + const date = translations && translations.date; + const abbrMonthNames = date && date.abbr_month_names; + + const months = abbrMonthNames || []; + return months[monthIndex + 1] || ""; +} + +function getTooltipText(date, value, locale) { + const jsDate = new Date(date); + const translations = I18n.translations[locale] || I18n.translations.en; + const dateTranslations = translations && translations.date; + const monthNames = dateTranslations && dateTranslations.month_names; + + const months = monthNames || []; + const monthName = months[jsDate.getMonth() + 1] || `${jsDate.getMonth + 1}.`; + const day = jsDate.getDate(); + const year = jsDate.getFullYear(); + + const localizedDate = `${day}. ${monthName} ${year}.`; + return value > 0 ? + I18n.t("javascripts.heatmap.tooltip.contributions", { count: value, date: localizedDate }) : + I18n.t("javascripts.heatmap.tooltip.no_contributions", { date: localizedDate }); +} diff --git a/app/assets/stylesheets/heatmap.css b/app/assets/stylesheets/heatmap.css new file mode 100644 index 0000000000..0352c79d99 --- /dev/null +++ b/app/assets/stylesheets/heatmap.css @@ -0,0 +1,3 @@ +/* + *= require cal-heatmap + */ diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 63a83ad1da..08264503e3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -53,6 +53,23 @@ def show if @user && (@user.visible? || current_user&.administrator?) @title = @user.display_name + + one_year_ago = 1.year.ago + @heatmap_data = Rails.cache.fetch("heatmap_data_user_#{@user.id}", :expires_in => 1.day) do + Changeset + .where(:user_id => @user.id) + .where(:created_at => one_year_ago..) + .where(:num_changes => 1..) + .group("date_trunc('day', created_at)") + .select("date_trunc('day', created_at) AS date, SUM(num_changes) AS total_changes") + .order("date") + .map do |changeset| + { + :date => changeset.date.to_date.to_s, + :total_changes => changeset.total_changes.to_i + } + end + end else render_unknown_user params[:display_name] end diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index d479b1d56d..84faf51f5a 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,3 +1,7 @@ +<% content_for :head do %> + <%= stylesheet_link_tag "heatmap" %> + <%= javascript_include_tag "heatmap" %> +<% end %> <% content_for :heading do %>
0?d[i-1]:l,v.x1=i
0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e =0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o >1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),n t.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r >>1;f[g]1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(n9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i