<commit_before>var fs = require('fs');

function reloadCustomAvatars() {
	var path = require('path');
	var newCustomAvatars = {};
	fs.readdirSync('./config/avatars').forEach(function (file) {
		var ext = path.extname(file);
		if (ext !== '.png' && ext !== '.gif')
			return;

		var user = toId(path.basename(file, ext));
		newCustomAvatars[user] = file;
		delete Config.customavatars[user];
	});

	// Make sure the manually entered avatars exist
	for (var a in Config.customavatars)
		if (typeof Config.customavatars[a] === 'number')
			newCustomAvatars[a] = Config.customavatars[a];
		else
			fs.exists('./config/avatars/' + Config.customavatars[a], function (user, file, isExists) {
				if (isExists)
					Config.customavatars[user] = file;
			}.bind(null, a, Config.customavatars[a]));

	Config.customavatars = newCustomAvatars;
}
reloadCustomAvatars();

if (Config.watchConfig) {
	fs.watchFile('./config/config.js', function (curr, prev) {
		if (curr.mtime <= prev.mtime) return;
		reloadCustomAvatars();
	});
}

const script = function () {
/*
	FILENAME=`mktemp`
	function cleanup {
		rm -f $FILENAME
	}
	trap cleanup EXIT

	set -xe

	timeout 10 wget "$1" -nv -O $FILENAME

	FRAMES=`identify $FILENAME | wc -l`
	if [ $FRAMES -gt 1 ]; then
		EXT=".gif"
	else
		EXT=".png"
	fi

	timeout 10 convert $FILENAME -layers TrimBounds -coalesce -adaptive-resize 80x80\> -background transparent -gravity center -extent 80x80 "$2$EXT"
*/
}.toString().match(/[^]*\/\*([^]*)\*\//)[1];

var pendingAdds = {};

exports.commands = {
	sca: 'customavatar',
	customavatars: 'customavatar',
	customavatar: function (target, room, user) {
		var parts = target.split(',');
		var cmd = parts[0].trim().toLowerCase();

		if (cmd in {'':1, show:1, view:1, display:1}) {
			var message = "";
			for (var a in Config.customavatars)
				message += "" + Tools.escapeHTML(a) + ": " + Tools.escapeHTML(Config.customavatars[a]) + "
";
			return this.sendReplyBox(message);
		}

		if (!this.can('ban')) return false;

		switch (cmd) {
			case 'set':
				var userid = toId(parts[1]);
				var targetUser = Users.getExact(userid);
				var avatar = parts.slice(2).join(',').trim();
				if (!this.can('ban') && userid !== user.userid) return false;

				if (!userid) return this.sendReply("You didn't specify a user.");
				if (Config.customavatars[userid]) return this.sendReply(userid + " already has a custom avatar.");

				var hash = require('crypto').createHash('sha512').update(userid + '\u0000' + avatar).digest('hex').slice(0, 8);
				pendingAdds[hash] = {userid: userid, avatar: avatar};
				parts[1] = hash;

				if (!targetUser) {
					this.sendReply("Warning: " + userid + " is not online.");
					this.sendReply("If you want to continue, use: /customavatar forceset, " + hash);
					return;
				}

				/* falls through */
			case 'forceset':
				if (user.avatarCooldown && !this.can('pban')) {
					var milliseconds = (Date.now() - user.avatarCooldown);
					var seconds = ((milliseconds / 1000) % 60);
					var minutes = ((seconds / 60) % 60);
					var remainingTime = Math.round(seconds - (5 * 60));
					if (((Date.now() - user.avatarCooldown) <= 5 * 60 * 1000)) return this.sendReply("You must wait " + (remainingTime - remainingTime * 2) + " seconds before setting another avatar.");
				}
				user.avatarCooldown = Date.now();

				var hash = parts[1].trim();
				if (!pendingAdds[hash]) return this.sendReply("Invalid hash.");

				var userid = pendingAdds[hash].userid;
				var avatar = pendingAdds[hash].avatar;
				delete pendingAdds[hash];

				require('child_process').execFile('bash', ['-c', script, '-', avatar, './config/avatars/' + userid], function (e, out, err) {
					if (e) {
						this.sendReply(userid + "'s custom avatar failed to be set. Script output:");
						(out + err).split('\n').forEach(this.sendReply.bind(this));
						return;
					}

					reloadCustomAvatars();

					var targetUser = Users.getExact(userid);
					if (targetUser) targetUser.avatar = Config.customavatars[userid];

					this.sendReply(userid + "'s custom avatar has been set.");
					//Users.messageSeniorStaff(userid+' has received a custom avatar from '+user.name);
					Rooms.rooms.staff.add(userid+' has received a custom avatar from '+user.name);
					room.update();
				}.bind(this));
				break;

			case 'remove':
			case 'delete':
				var userid = toId(parts[1]);
				if (!this.can('ban') && userid !== user.userid) return false;
				if (!Config.customavatars[userid]) return this.sendReply(userid + " does not have a custom avatar.");

				if (Config.customavatars[userid].toString().split('.').slice(0, -1).join('.') !== userid)
					return this.sendReply(userid + "'s custom avatar (" + Config.customavatars[userid] + ") cannot be removed with this script.");

				var targetUser = Users.getExact(userid);
				if (targetUser) targetUser.avatar = 1;

				fs.unlink('./config/avatars/' + Config.customavatars[userid], function (e) {
					if (e) return this.sendReply(userid + "'s custom avatar (" + Config.customavatars[userid] + ") could not be removed: " + e.toString());

					delete Config.customavatars[userid];
					this.sendReply(userid + "'s custom avatar removed successfully");
				}.bind(this));
				break;

			default:
				return this.sendReply("Invalid command. Valid commands are `/customavatar set, user, avatar` and `/customavatar delete, user`.");
		}
	}
};
<commit_msg>Revert customavatar permissions<commit_after>var fs = require('fs');

function reloadCustomAvatars() {
	var path = require('path');
	var newCustomAvatars = {};
	fs.readdirSync('./config/avatars').forEach(function (file) {
		var ext = path.extname(file);
		if (ext !== '.png' && ext !== '.gif')
			return;

		var user = toId(path.basename(file, ext));
		newCustomAvatars[user] = file;
		delete Config.customavatars[user];
	});

	// Make sure the manually entered avatars exist
	for (var a in Config.customavatars)
		if (typeof Config.customavatars[a] === 'number')
			newCustomAvatars[a] = Config.customavatars[a];
		else
			fs.exists('./config/avatars/' + Config.customavatars[a], function (user, file, isExists) {
				if (isExists)
					Config.customavatars[user] = file;
			}.bind(null, a, Config.customavatars[a]));

	Config.customavatars = newCustomAvatars;
}
reloadCustomAvatars();

if (Config.watchConfig) {
	fs.watchFile('./config/config.js', function (curr, prev) {
		if (curr.mtime <= prev.mtime) return;
		reloadCustomAvatars();
	});
}

const script = function () {
/*
	FILENAME=`mktemp`
	function cleanup {
		rm -f $FILENAME
	}
	trap cleanup EXIT

	set -xe

	timeout 10 wget "$1" -nv -O $FILENAME

	FRAMES=`identify $FILENAME | wc -l`
	if [ $FRAMES -gt 1 ]; then
		EXT=".gif"
	else
		EXT=".png"
	fi

	timeout 10 convert $FILENAME -layers TrimBounds -coalesce -adaptive-resize 80x80\> -background transparent -gravity center -extent 80x80 "$2$EXT"
*/
}.toString().match(/[^]*\/\*([^]*)\*\//)[1];

var pendingAdds = {};

exports.commands = {
	sca: 'customavatar',
	customavatars: 'customavatar',
	customavatar: function (target, room, user) {
		var parts = target.split(',');
		var cmd = parts[0].trim().toLowerCase();

		if (cmd in {'':1, show:1, view:1, display:1}) {
			var message = "";
			for (var a in Config.customavatars)
				message += "" + Tools.escapeHTML(a) + ": " + Tools.escapeHTML(Config.customavatars[a]) + "
";
			return this.sendReplyBox(message);
		}

		if (!this.can('pban') && !Gold.hasBadge(toId(user.name),'vip')) return false;

		switch (cmd) {
			case 'set':
				var userid = toId(parts[1]);
				var targetUser = Users.getExact(userid);
				var avatar = parts.slice(2).join(',').trim();
				if (!this.can('pban') && Gold.hasBadge(toId(user.name),'vip') && userid !== user.userid) return false;

				if (!userid) return this.sendReply("You didn't specify a user.");
				if (Config.customavatars[userid]) return this.sendReply(userid + " already has a custom avatar.");

				var hash = require('crypto').createHash('sha512').update(userid + '\u0000' + avatar).digest('hex').slice(0, 8);
				pendingAdds[hash] = {userid: userid, avatar: avatar};
				parts[1] = hash;

				if (!targetUser) {
					this.sendReply("Warning: " + userid + " is not online.");
					this.sendReply("If you want to continue, use: /customavatar forceset, " + hash);
					return;
				}

				/* falls through */
			case 'forceset':
				if (user.avatarCooldown && !this.can('pban')) {
					var milliseconds = (Date.now() - user.avatarCooldown);
					var seconds = ((milliseconds / 1000) % 60);
					var minutes = ((seconds / 60) % 60);
					var remainingTime = Math.round(seconds - (5 * 60));
					if (((Date.now() - user.avatarCooldown) <= 5 * 60 * 1000)) return this.sendReply("You must wait " + (remainingTime - remainingTime * 2) + " seconds before setting another avatar.");
				}
				user.avatarCooldown = Date.now();

				var hash = parts[1].trim();
				if (!pendingAdds[hash]) return this.sendReply("Invalid hash.");

				var userid = pendingAdds[hash].userid;
				var avatar = pendingAdds[hash].avatar;
				delete pendingAdds[hash];

				require('child_process').execFile('bash', ['-c', script, '-', avatar, './config/avatars/' + userid], function (e, out, err) {
					if (e) {
						this.sendReply(userid + "'s custom avatar failed to be set. Script output:");
						(out + err).split('\n').forEach(this.sendReply.bind(this));
						return;
					}

					reloadCustomAvatars();

					var targetUser = Users.getExact(userid);
					if (targetUser) targetUser.avatar = Config.customavatars[userid];

					this.sendReply(userid + "'s custom avatar has been set.");
					//Users.messageSeniorStaff(userid+' has received a custom avatar from '+user.name);
					Rooms.rooms.staff.add(userid+' has received a custom avatar from '+user.name);
					room.update();
				}.bind(this));
				break;

			case 'remove':
			case 'delete':
				var userid = toId(parts[1]);
				if (!this.can('pban') && Gold.hasBadge(toId(user.name),'vip') && userid !== user.userid) return false;
				if (!Config.customavatars[userid]) return this.sendReply(userid + " does not have a custom avatar.");

				if (Config.customavatars[userid].toString().split('.').slice(0, -1).join('.') !== userid)
					return this.sendReply(userid + "'s custom avatar (" + Config.customavatars[userid] + ") cannot be removed with this script.");

				var targetUser = Users.getExact(userid);
				if (targetUser) targetUser.avatar = 1;

				fs.unlink('./config/avatars/' + Config.customavatars[userid], function (e) {
					if (e) return this.sendReply(userid + "'s custom avatar (" + Config.customavatars[userid] + ") could not be removed: " + e.toString());

					delete Config.customavatars[userid];
					this.sendReply(userid + "'s custom avatar removed successfully");
				}.bind(this));
				break;

			default:
				return this.sendReply("Invalid command. Valid commands are `/customavatar set, user, avatar` and `/customavatar delete, user`.");
		}
	}
};
<|endoftext|>


<commit_before>/*
Copyright (c) 2010 Jonas Nockert and individual contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

(function( $ ) {

$.fn.taggit = function( options ) {

    /*
    Updates the suggestion highlighting based on the tags input field.

    */
    var check_suggestions = function( input_field,
                                      tag_suggestions_selector ) {
        var tags_text = input_field.val();
        var tags = $.fn.taggit.parse_tags( tags_text );

        /* Add suggested tags as well as marking them as used or
           not used based on the tags input field */
        $( tag_suggestions_selector ).each(function( index ) {
            if ( $.inArray( $( this ).text(), tags ) >= 0 ) {
                if ( ! $( this ).hasClass( "taggit-tag-used" ) ) {
                    $( this ).addClass( "taggit-tag-used" );
                }
            } else {
                if ( $( this ).hasClass( "taggit-tag-used" ) ) {
                    $( this ).removeClass( "taggit-tag-used" );
                }
            }
        });
    };

    /*
    Keyup handler.

    */
    var keyup = function( e ) {
        var input_field = $( this );

        input_field.stopTime( "taggit-check-suggestions" );
        input_field.oneTime( 300, "taggit-check-suggestions", function() {
            check_suggestions( input_field, e.data.tag_suggestions_selector );
        });
    };

    /*
    Main JQuery extension. It's chaining but should not be expected
    to give proper results if called with a selector matching multiple
    elements.

    */
    return this.each(function() {
        var input_field = $( this );
        var tag_suggestions_selector = options.tag_selector;

        input_field.taggit_options = options;

        if ( input_field && tag_suggestions_selector ) {
            var tags_text = input_field.val();
            var tags = $.fn.taggit.parse_tags( tags_text );

            // bind click on suggestion tags
            $( tag_suggestions_selector ).click(function() {
                var tag = $( this );
                var tag_text = tag.text();
                var tags_text = input_field.val();
                var tags = $.fn.taggit.parse_tags( tags_text );

                var index = $.inArray( tag_text, tags );
                if ( index < 0 ) {
                    tag.addClass( "taggit-tag-used" );
                    tags.push( tag_text );
                } else {
                    tag.removeClass( "taggit-tag-used" );
                    tags.splice( index, 1 );
                }
                input_field.val( $.fn.taggit.edit_string_for_tags( tags ) );

                /* Handle duplicate suggestion tags. E.g. when listing
                   both tag suggestions from user and community. Clicking
                   a tag should highlight it in both lists. */
                check_suggestions( input_field, tag_suggestions_selector );
            });
        }

        var eventdata = { tag_suggestions_selector:
                          tag_suggestions_selector };
        $( this ).keyup( eventdata, keyup );

        // Handle input field initial value
        check_suggestions( input_field, tag_suggestions_selector );
    });
};

/*
Removes duplicate entries from an array.

*/
$.fn.taggit.uniqify_array = function( a ) {
    var k = [];
    var rval = [];

    if ( ! k.contains ) {
        k.contains = function( e ) {
            for ( var i = 0; i < this.length; i++ ) {
                if( this[i] == e ) {
                    return true;
                }
            }
            return false;
        };
    }
    for ( var i = 0; i < a.length; i++ ) {
        if( ! k.contains( a[i] ) ) {
            rval.push( a[i] );
            k.push( a[i] );
        }
    }
    return rval;
};

/*
Splits a string into individual tags. Returns an array of trimmed,
non-empty tags.

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.split_strip = function( tags_string, delimiter ) {
    if ( delimiter === undefined ) {
        delimiter = ",";
    }

    var tags = [];
    var split_tags = tags_string.split( delimiter );
    for ( var i = 0; i < split_tags.length; i++ ) {
        var tag = $.trim( split_tags[i] );
        if ( tag.length > 0 ) {
            tags.push( tag );
        }
    }
    return tags;
};

/*
Parses a tag string, comma- or space-separated. Tags containing commas
need to be double quoted.

Returns a sorted list of unique tag names.

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.parse_tags = function( tagstring ) {
    tagstring = $.trim( tagstring );

    if ( tagstring.length <= 0 ) {
        return [];
    }

    // No commas or quotes?
    if ( ! ( /[,\"]/.test( tagstring ) ) ) {
        words = tagstring.split( " " );

        // remove duplicate tags
        words = $.fn.taggit.uniqify_array( words );

        // Sort tags
        return words.sort();
    }

    var words = [];
    var buffer = [];
    /* Defer splitting of non-quoted sections until we know if there are
       any unquoted commas. */
    var to_be_split = [];
    var saw_loose_comma = false;
    var open_quote = false;

    try {
        var i = 0;
        var c;

        while ( true ) {
            if ( i >= tagstring.length ) {
                throw "stopiteration";
            }
            c = tagstring[i++];
            if ( c == "\"" ) {
                var trimmed_buffer = $.trim( buffer.join( "" ) );
                if ( trimmed_buffer.length > 0 ) {
                    to_be_split.push( trimmed_buffer );
                    buffer = [];
                }
                // Find the matching quote
                open_quote = true;
                if ( i >= tagstring.length ) {
                    throw "stopiteration";
                }
                c = tagstring[i++];
                while ( c != "\"" ) {
                    buffer.push( c );
                    if ( i >= tagstring.length ) {
                        throw "stopiteration";
                    }
                    c = tagstring[i++];
                }
                trimmed_buffer = $.trim( buffer.join( "" ) );
                if ( trimmed_buffer.length > 0 ) {
                    var trimmed_word = $.trim( trimmed_buffer );
                    if ( trimmed_word.length > 0 ) {
                        words.push( trimmed_word );
                    }
                    buffer = [];
                }
                open_quote = false;
            } else {
                if ( ! saw_loose_comma && c === "," ) {
                    saw_loose_comma = true;
                }
                buffer.push( c );
            }
        }
    } catch( e ) {
        if ( e != "stopiteration" ) {
            throw e;
        }

        /* If we were parsing an open quote which was never closed treat
           the buffer as unquoted. */
        if ( buffer.length > 0 ) {
            if ( open_quote && $.inArray( ",", buffer ) >= 0 ) {
                saw_loose_comma = true;
            }
            to_be_split.push( buffer.join( "" ) );
        }
    }

    if ( to_be_split.length > 0 ) {
        var delimiter;

        if ( saw_loose_comma ) {
            delimiter = ",";
        } else {
            delimiter = " ";
        }
        for ( var chunk in to_be_split ) {
            var split_words = $.fn.taggit.split_strip( to_be_split[chunk],
                                                       delimiter );
            for ( var word in split_words ) {
                words.push( split_words[word] );
            }
        }
    }

    // remove duplicate tags
    words = $.fn.taggit.uniqify_array( words );

    // Sort tags
    return words.sort();
};

/*
Given an array of tags, e.g. produced by parse_tags, returns a
comma-separated string representation that, if sent to parse_tags
would yield the same results as we started with (albeit perhaps
sorted differently).

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.edit_string_for_tags = function( tags ) {
    var names = [];

    for ( var tag in tags ) {
        var name = tags[tag];
        if ( name.indexOf( "," ) >= 0 || name.indexOf( " " ) >= 0 ) {
            names.push( "\"" + name + "\"" );
        } else {
            names.push( name );
        }
    }
    names.sort();
    return names.join( ", " );
};

})( jQuery );
<commit_msg>Updated jquery-taggit<commit_after>/*
Copyright (c) 2010 Jonas Nockert and individual contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

(function( $ ) {

$.fn.taggit = function( options ) {

    /*
    Updates the suggestion highlighting based on the tags input field.

    */
    var check_suggestions = function( input_field,
                                      tag_suggestions_selector ) {
        var tags_text = input_field.val();
        var tags = $.fn.taggit.parse_tags( tags_text );

        /* Add suggested tags as well as marking them as used or
           not used based on the tags input field */
        $( tag_suggestions_selector ).each(function( index ) {
            if ( $.inArray( $( this ).text(), tags ) >= 0 ) {
                if ( ! $( this ).hasClass( "taggit-tag-used" ) ) {
                    $( this ).addClass( "taggit-tag-used" );
                }
            } else {
                if ( $( this ).hasClass( "taggit-tag-used" ) ) {
                    $( this ).removeClass( "taggit-tag-used" );
                }
            }
        });
    };

    /*
    Keyup handler.

    */
    var keyup = function( e ) {
        var input_field = $( this );

        input_field.stopTime( "taggit-check-suggestions" );
        input_field.oneTime( 300, "taggit-check-suggestions", function() {
            check_suggestions( input_field, e.data.tag_suggestions_selector );
        });
    };

    /*
    Main JQuery extension. It's chaining but should not be expected
    to give proper results if called with a selector matching multiple
    elements.

    */
    return this.each(function() {
        var input_field = $( this );
        var tag_suggestions_selector = options.tag_selector;

        input_field.taggit_options = options;

        if ( input_field && tag_suggestions_selector ) {
            var tags_text = input_field.val();
            var tags = $.fn.taggit.parse_tags( tags_text );

            // bind click on suggestion tags
            $( tag_suggestions_selector ).click(function() {
                var tag = $( this );
                var tag_text = tag.text();
                var tags_text = input_field.val();
                var tags = $.fn.taggit.parse_tags( tags_text );

                var index = $.inArray( tag_text, tags );
                if ( index < 0 ) {
                    tag.addClass( "taggit-tag-used" );
                    tags.push( tag_text );
                } else {
                    tag.removeClass( "taggit-tag-used" );
                    tags.splice( index, 1 );
                }
                input_field.val( $.fn.taggit.edit_string_for_tags( tags ) );

                /* Handle duplicate suggestion tags. E.g. when listing
                   both tag suggestions from user and community. Clicking
                   a tag should highlight it in both lists. */
                check_suggestions( input_field, tag_suggestions_selector );
            });
        }

        var eventdata = { tag_suggestions_selector:
                          tag_suggestions_selector };
        $( this ).keyup( eventdata, keyup );

        // Handle input field initial value
        check_suggestions( input_field, tag_suggestions_selector );
    });
};

/*
Removes duplicate entries from an array.

*/
$.fn.taggit.uniqify_array = function( a ) {
    var u = [];

    for ( var i = 0; i < a.length; i++ ) {
        var val = a[i];
        if ( $.inArray(val, u) === -1 ) {
            u.push(val);
        }
    }
    return u;
};

/*
Splits a string into individual tags. Returns an array of trimmed,
non-empty tags.

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.split_strip = function( tags_string, delimiter ) {
    if ( delimiter === undefined ) {
        delimiter = ",";
    }

    var tags = [];
    var split_tags = tags_string.split( delimiter );
    for ( var i = 0; i < split_tags.length; i++ ) {
        var tag = $.trim( split_tags[i] );
        if ( tag.length > 0 ) {
            tags.push( tag );
        }
    }
    return tags;
};

/*
Parses a tag string, comma- or space-separated. Tags containing commas
need to be double quoted.

Returns a sorted list of unique tag names.

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.parse_tags = function( tagstring ) {
    tagstring = $.trim( tagstring );

    if ( tagstring.length <= 0 ) {
        return [];
    }

    // No commas or quotes?
    if ( ! ( /[,\"]/.test( tagstring ) ) ) {
        words = tagstring.split( " " );

        // remove duplicate tags
        words = $.fn.taggit.uniqify_array( words );

        // Sort tags
        return words.sort();
    }

    var words = [];
    var buffer = [];
    /* Defer splitting of non-quoted sections until we know if there are
       any unquoted commas. */
    var to_be_split = [];
    var saw_loose_comma = false;
    var open_quote = false;

    try {
        var i = 0;
        var c;

        while ( true ) {
            if ( i >= tagstring.length ) {
                throw "stopiteration";
            }
            c = tagstring.charAt(i++);
            if ( c == "\"" ) {
                var trimmed_buffer = $.trim( buffer.join( "" ) );
                if ( trimmed_buffer.length > 0 ) {
                    to_be_split.push( trimmed_buffer );
                    buffer = [];
                }
                // Find the matching quote
                open_quote = true;
                if ( i >= tagstring.length ) {
                    throw "stopiteration";
                }
                c = tagstring.charAt(i++);
                while ( c != "\"" ) {
                    buffer.push( c );
                    if ( i >= tagstring.length ) {
                        throw "stopiteration";
                    }
                    c = tagstring.charAt(i++);
                }
                trimmed_buffer = $.trim( buffer.join( "" ) );
                if ( trimmed_buffer.length > 0 ) {
                    var trimmed_word = $.trim( trimmed_buffer );
                    if ( trimmed_word.length > 0 ) {
                        words.push( trimmed_word );
                    }
                    buffer = [];
                }
                open_quote = false;
            } else {
                if ( ! saw_loose_comma && c === "," ) {
                    saw_loose_comma = true;
                }
                buffer.push( c );
            }
        }
    } catch( e ) {
        if ( e != "stopiteration" ) {
            throw e;
        }

        /* If we were parsing an open quote which was never closed treat
           the buffer as unquoted. */
        if ( buffer.length > 0 ) {
            if ( open_quote && $.inArray( ",", buffer ) >= 0 ) {
                saw_loose_comma = true;
            }
            to_be_split.push( buffer.join( "" ) );
        }
    }

    if ( to_be_split.length > 0 ) {
        var delimiter;

        if ( saw_loose_comma ) {
            delimiter = ",";
        } else {
            delimiter = " ";
        }
        for ( var chunk in to_be_split ) {
            var split_words = $.fn.taggit.split_strip( to_be_split[chunk],
                                                       delimiter );
            for ( var word in split_words ) {
                words.push( split_words[word] );
            }
        }
    }

    // remove duplicate tags
    words = $.fn.taggit.uniqify_array( words );

    // Sort tags
    return words.sort();
};

/*
Given an array of tags, e.g. produced by parse_tags, returns a
comma-separated string representation that, if sent to parse_tags
would yield the same results as we started with (albeit perhaps
sorted differently).

Based on Alex Gaynor's django-taggit:
https://github.com/alex/django-taggit/
which in turn was ported from Jonathan Buchanan's django-tagging:
http://django-tagging.googlecode.com/

*/
$.fn.taggit.edit_string_for_tags = function( tags ) {
    var names = [];

    for ( var tag in tags ) {
        var name = tags[tag];
        if ( name.indexOf( "," ) >= 0 || name.indexOf( " " ) >= 0 ) {
            names.push( "\"" + name + "\"" );
        } else {
            names.push( name );
        }
    }
    names.sort();
    return names.join( ", " );
};

})( jQuery );
<|endoftext|>


<commit_before>( function( $ ) {

	/**
	 * Toggles an element attribute value.
	 *
	 * @since 0.5.1
	 *
	 * @param  {Number} index Element index.
	 * @param  {Bool}   val   Current attribute value.
	 * @return {Bool}         The updated attribute value.
	 */
	function toggleAttr( index, val ) {
		return 'true' !== val;
	}

	$.fn.collapsible = function( options ) {
		return this.each( ( index, element ) => {
			const $el      = $( element );
			const $title   = $el.find( options.titleSelector );
			const $content = $el.find( options.contentSelector );

			$title.on( 'click', event => {
				event.preventDefault();

				$content.slideToggle();
			} );
		} );
	};

	/**
	 * Sets up click handlers for a toggle button that hides / shows menu items
	 * for the current nav menu.
	 *
	 * @since 0.5.1
	 *
	 * @param  {Object} options       Plugin options.
	 * @param  {String} options.button A jQuery selector that targets the toggle button.
	 * @param  {String} options.menu   A jQuery selector that targets the menu container.
	 * @param  {String} options.items  A jQuery selector that targets the menu items.
	 * @return {Object}
	 */
	$.fn.collapsibleNav = function( options = {} ) {
		return this.each( ( index, element ) => {

			const defaults = {
				button: '.nav-toggle',
				menu:   '.menu',
				items:  '.menu__item',
			};

			options = $.extend( {}, defaults, options );

			console.log( options );

			const $nav    = $( element );
			const $button = $nav.find( options.button );
			const $menu   = $nav.find( options.menu );

			// Make sure we have a toggle button and a nav menu to toggle.
			if ( ! $button.length || ! $menu.length ) {
				return;
			}

			// Hide / show the nav menu when the toggle button is clicked.
			if ( $button.length ) {
				$button.on( 'click', event => {
					event.preventDefault();

					$nav.attr( 'aria-expanded', toggleAttr );
					$menu.slideToggle();
				} );
			}

			// Bail if we don't have a menu item selector.
			if ( ! options.items ) {
				return;
			}

			// Toggle sub-menus when the parent menu item is clicked.
			$menu.on( 'click', options.items, event => {
				event.stopPropagation();

				const $target  = $( event.target );
				const $subMenu = $target.children( '.sub-menu' );

				if ( $subMenu.length ) {

					let menuHeight = $menu.outerHeight();

					if ( 'true' === $target.attr( 'aria-expanded' ) ) {
						menuHeight -= $subMenu.outerHeight();
						$target.attr( 'aria-expanded', 'false' );
					} else {
						$target.attr( 'aria-expanded', 'true' );
						menuHeight += $subMenu.outerHeight();
					}

					$menu.css( 'max-height', menuHeight );
				}
			} );
		} );
	};

	/**
	 * Slide up transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideUp = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			// Trigger the slide up transition.
			$el.css( 'max-height', '0' )
				.removeClass( 'is-expanded' )
				.attr( 'aria-expanded', 'false' );
		} );
	};

	/**
	 * Slide down transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideDown = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			// Temporarily make the element visible to get the size.
			$el.css( 'max-height', 'none' );
			const height = $el.outerHeight();

			console.log( height );

			// Hide the element again.
			$el.css( 'max-height', '0' );

			// Trigger the slide down transition.
			const timeout = 1;

			setTimeout( () => {
				$el.css( 'max-height', height )
					.addClass( 'is-expanded' )
					.attr( 'aria-expanded', 'true' );
			}, timeout );
		} );
	};

	/**
	 * Toggle slide transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideToggle = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			if ( $el.hasClass( 'is-expanded' ) ) {
				$el.slideUp();
			} else {
				$el.slideDown();
			}
		} );
	};
} )( window.jQuery );
<commit_msg>BLRBT-12: Remove calls to `console.log()`.<commit_after>( function( $ ) {

	/**
	 * Toggles an element attribute value.
	 *
	 * @since 0.5.1
	 *
	 * @param  {Number} index Element index.
	 * @param  {Bool}   val   Current attribute value.
	 * @return {Bool}         The updated attribute value.
	 */
	function toggleAttr( index, val ) {
		return 'true' !== val;
	}

	$.fn.collapsible = function( options ) {
		return this.each( ( index, element ) => {
			const $el      = $( element );
			const $title   = $el.find( options.titleSelector );
			const $content = $el.find( options.contentSelector );

			$title.on( 'click', event => {
				event.preventDefault();

				$content.slideToggle();
			} );
		} );
	};

	/**
	 * Sets up click handlers for a toggle button that hides / shows menu items
	 * for the current nav menu.
	 *
	 * @since 0.5.1
	 *
	 * @param  {Object} options       Plugin options.
	 * @param  {String} options.button A jQuery selector that targets the toggle button.
	 * @param  {String} options.menu   A jQuery selector that targets the menu container.
	 * @param  {String} options.items  A jQuery selector that targets the menu items.
	 * @return {Object}
	 */
	$.fn.collapsibleNav = function( options = {} ) {
		return this.each( ( index, element ) => {

			const defaults = {
				button: '.nav-toggle',
				menu:   '.menu',
				items:  '.menu__item',
			};

			options = $.extend( {}, defaults, options );

			const $nav    = $( element );
			const $button = $nav.find( options.button );
			const $menu   = $nav.find( options.menu );

			// Make sure we have a toggle button and a nav menu to toggle.
			if ( ! $button.length || ! $menu.length ) {
				return;
			}

			// Hide / show the nav menu when the toggle button is clicked.
			if ( $button.length ) {
				$button.on( 'click', event => {
					event.preventDefault();

					$nav.attr( 'aria-expanded', toggleAttr );
					$menu.slideToggle();
				} );
			}

			// Bail if we don't have a menu item selector.
			if ( ! options.items ) {
				return;
			}

			// Toggle sub-menus when the parent menu item is clicked.
			$menu.on( 'click', options.items, event => {
				event.stopPropagation();

				const $target  = $( event.target );
				const $subMenu = $target.children( '.sub-menu' );

				if ( $subMenu.length ) {

					let menuHeight = $menu.outerHeight();

					if ( 'true' === $target.attr( 'aria-expanded' ) ) {
						menuHeight -= $subMenu.outerHeight();
						$target.attr( 'aria-expanded', 'false' );
					} else {
						$target.attr( 'aria-expanded', 'true' );
						menuHeight += $subMenu.outerHeight();
					}

					$menu.css( 'max-height', menuHeight );
				}
			} );
		} );
	};

	/**
	 * Slide up transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideUp = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			// Trigger the slide up transition.
			$el.css( 'max-height', '0' )
				.removeClass( 'is-expanded' )
				.attr( 'aria-expanded', 'false' );
		} );
	};

	/**
	 * Slide down transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideDown = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			// Temporarily make the element visible to get the size.
			$el.css( 'max-height', 'none' );
			const height = $el.outerHeight();

			// Hide the element again.
			$el.css( 'max-height', '0' );

			// Trigger the slide down transition.
			const timeout = 1;

			setTimeout( () => {
				$el.css( 'max-height', height )
					.addClass( 'is-expanded' )
					.attr( 'aria-expanded', 'true' );
			}, timeout );
		} );
	};

	/**
	 * Toggle slide transition.
	 *
	 * @since 0.5.1
	 *
	 * @return {Object}
	 */
	$.fn.slideToggle = function() {
		return this.each( ( index, element ) => {
			const $el = $( element );

			if ( $el.hasClass( 'is-expanded' ) ) {
				$el.slideUp();
			} else {
				$el.slideDown();
			}
		} );
	};
} )( window.jQuery );
<|endoftext|>


<commit_before>/*
    Given a set of search parameters, do a call to the Yelp API
    and return the resulting Food Picker JS object.
*/
var fpObj = function(limit, radius, lat, long_) {
    var poo = 0;
    var data = {limit: limit,
                radius: radius,
                lat: lat,
                'long' : long_};

    $.getJSON("http://localhost:5000/request",
        data, function() {
            console.log('hello');
            // poo = result;
            // console.log(result);
        });
    // return poo;
};

/*
    Given a Food Picker JS object, generate a binary decision tree
    that contains the questions being used by Food Picker.
*/
var questionTree = function(fpObj) {
    return 0;
};

/*
    Given a chunk of a Food Picker JS object, create one node of
    the binary decision tree.
*/
var questionNode = function(candidates) {

    var totalCount = candidates.length;

    if totalCount == 0 {
        return null
    }

    var table = {};
    for (var i = 0; i < totalCount; i++) {
        updateTable(table, candidates[i]);
    }

    var categoryList = [];
    var minDiff = totalCount;
    for (var key in table) {
        var diff = Math.abs(table[key] - totalCount / 2);
        if diff < minDiff {
            minDiff = diff;
            categoryList = [key]
        }
        if diff == minDiff {
            categoryList.push(key)
        }
    }

    var rand = (Math.random() * categoryList.length) | 0;
    var category = categoryList[rand];

};

fpObj(10, 10, 37.77493, -122.419415);

    var yes = [];
    var no = [];
    for (var j = 0; j < totalCount; j++) {
        if containsCategory(category, candidates[j]) {
            yes.push(candidates[j]);
        } else {
            no.push(candidates[j]);
        }
    }

    return {
        category: category,
        yes: yes,
        no: no,
        candidates: candidates
    }
};

var updateTable = function(table, candidate) {
    for (var i = 0; i < candidate.categories.length; i++) {
        var categoryName = candidate.categories[i][0];
        if (table.hasOwnProperty(categoryName)) {
            table[categoryName] += 1;
        } else {
            table[categoryName] = 1;
        }
    }
};

var containsCategory = function(questionCategory, candidate) {
    for (var i = 0; i < candidate.categories.length; i++) {
        var categoryName = candidate.categories[i][0];
        if (categoryName == questionCategory) {
            return true
        }
    }

    return false;
};
<commit_msg>api json object returns in function fpObj and also fixed some merge errors<commit_after>/*
    Given a set of search parameters, do a call to the Yelp API
    and return the resulting Food Picker JS object.
* @return object containing query information
*/
var fpObj = function(limit, radius, lat, long_) {
    var poo = 0;
    var data = {limit: limit,
                radius: radius,
                lat: lat,
                'long' : long_};

    $.getJSON("http://localhost:5000/request",
        data, function(result) {
            console.log('hello');
            poo = result;
            console.log(result);
        });
    return poo;
};

/*
    Given a Food Picker JS object, generate a binary decision tree
    that contains the questions being used by Food Picker.
*/
var questionTree = function(fpObj) {
    return 0;
};

var updateTable = function(table, candidate) {
    for (var i = 0; i < candidate.categories.length; i++) {
        var categoryName = candidate.categories[i][0];
        if (table.hasOwnProperty(categoryName)) {
            table[categoryName] += 1;
        } else {
            table[categoryName] = 1;
        }
    }
};

var containsCategory = function(questionCategory, candidate) {
    for (var i = 0; i < candidate.categories.length; i++) {
        var categoryName = candidate.categories[i][0];
        if (categoryName == questionCategory) {
            return true
        }
    }

    return false;
};

/*
    Given a chunk of a Food Picker JS object, create one node of
    the binary decision tree.
*/
var questionNode = function(candidates) {

    var totalCount = candidates.length;

    if (totalCount == 0) {
        return null
    }

    var table = {};
    for (var i = 0; i < totalCount; i++) {
        updateTable(table, candidates[i]);
    }

    var categoryList = [];
    var minDiff = totalCount;
    for (var key in table) {
        var diff = Math.abs(table[key] - totalCount / 2);
        if (diff < minDiff) {
            minDiff = diff;
            categoryList = [key]
        }
        if (diff == minDiff) {
            categoryList.push(key)
        }
    }

    var rand = (Math.random() * categoryList.length) | 0;
    var category = categoryList[rand];

    var yes = [];
    var no = [];
    for (var j = 0; j < totalCount; j++) {
        if (containsCategory(category, candidates[j])) {
            yes.push(candidates[j]);
        } else {
            no.push(candidates[j]);
        }
    }

    return {
        category: category,
        yes: yes,
        no: no,
        candidates: candidates
    };

};
fpObj(10, 10, 37.77493, -122.419415);

<|endoftext|>


<commit_before>/** Represents a single project in the work carousel */
import Parallax3D from '../parallax';

export class CarouselTag {
  constructor(elem) {
    this.elem = elem;
    this.name = elem.textContent;
    this.carousel = undefined; // Will be set when added to a carousel

    this.elem.addEventListener('click', () => this.select());
  }

  select() { if (this.carousel) this.carousel.filter(this); }
}

export class CarouselCard {
  constructor(elem, tagNames) {
    this.elem = elem;
    this.elem.classList.add('carousel-card');
    // Move elem into a wrapper div
    this.wrapper = document.createElement('div');
    this.wrapper.className = 'carousel-card-wrapper';
    this.elem.parentNode.insertBefore(this.wrapper, this.elem);
    this.wrapper.appendChild(this.elem);

    this.tagNames = tagNames || elem.dataset.tags.split(',').map(t => t.trim());
    this.titleElem = elem.getElementsByTagName('h1')[0];

    this.carousel = undefined; // Will be set when added to a carousel
    this.transitionTime = this.baseTransitionTime = 0.5;
    this.parallax = new Parallax3D(this.elem);

    this.elem.addEventListener('click', () => {
      if (this.carousel) {
        const i1 = this.carousel.cards.indexOf(this);
        const i2 = this.carousel.adjustIndex(i1);
        if (this.carousel.position !== i2) this.centerSelf();
        else this.toggleExpand();
      } else {
        this.toggleExpand();
      }
    });
    this.wrapper.addEventListener('scroll', () => this.scrollHandler());

    // Title manipulation interface

    const _this2 = this;
    /**
     * Interface for updating styles on the title.
     * Examples:
     *   this.title.translate = '-10px'
     *   this.title.opacity = 0.5
     */
    this.title = {
      _elem: _this2.titleElem,
      display() { this._elem.className = 'in-initial'; },
      fadeIn() { this._elem.className = 'fade-in'; },
      fadeOut() { this._elem.className = 'fade-out'; },
      fadeInLeft() { this._elem.className = 'in-from-left'; },
      fadeInRight() { this._elem.className = 'in-from-right'; },
      fadeOutLeft() { this._elem.className = 'out-to-left'; },
      fadeOutRight() { this._elem.className = 'out-to-right'; },
      fadeOutTop() { this._elem.className = 'out-to-top'; },
      moveDown() { this._elem.className = 'down'; },
      moveUp() { this._elem.className = 'up'; },
    };
  }

  /**
   * Update the card wrapper's CSS in order to reflect changes that were made to its JS
   * representation.
   */
  _applyTransform() {
    this.wrapper.style.transform = [
      'translateX(-50%)',
      `translateX(${this._translate})`,
    ].join(' ');
  }

  // Translation

  get translate() { return this._translate; }
  set translate(val) { this._translate = val; this._applyTransform(); }

  // Transition time

  get transitionTime() { return this._transitionTime; }
  set transitionTime(secs) {
    this._transitionTime = secs;
    this.wrapper.style.transition = `transform ${secs}s`;
  }

  setEnabled(enable = true) {
    const totalTime = this.transitionTime * 1000;
    const startTime = new Date();

    // Recursively adjust using setAnimationFrame until transitionTime is reached
    function step(parallax) {
      const timeElapsed = new Date() - startTime;
      // Still going
      if (timeElapsed < totalTime) {
        const progress = (timeElapsed / totalTime);
        if (enable) parallax.rotmax = progress * 10;
        else parallax.rotmax = (1 - progress) * 10;
        parallax.rotate();
        requestAnimationFrame(() => step(parallax));
      } else {
        parallax.rotmax = enable ? 10 : 0;
      }
    }
    step(this.parallax);
  }
  disable() { this.setEnabled(false); }
  enable() { this.setEnabled(true); }

  // Move the carousel so that this card is in the center
  centerSelf() {
    const thisIndex = this.carousel.cards.indexOf(this);
    this.carousel.position = this.carousel.adjustIndex(thisIndex);
  }

  // Bigger!
  expand() {
    // Prevents incorrect result when expanding and collapsing rapidly. Ensures that a pre-existing
    // timeout won't remove the expanded-x class after it's been added by this method.
    clearInterval(this._expansionTimeout);

    this.elem.classList.add('expanded-x');
    this.title.moveDown();
    this.disable();
    this._expansionTimeout = setTimeout(() => {
      this.elem.classList.add('expanded-y');
      this._expansionTimeout = setTimeout(() => { this.elem.style.maxHeight = 'none'; this.elem.classList.remove('expanded-y'); }, 500);
      document.getElementById('email').classList.add('hidden');
      window.nav.disableScrollBinding = true;
    }, 500);

    this.expanded = true;
    if (this.carousel) this.carousel.expanded(this);
  }
  collapse() {
    clearInterval(this._expansionTimeout);

    this.elem.style.maxHeight = '';
    this.elem.classList.add('expanded-y');
    setTimeout(() => this.elem.classList.remove('expanded-y'), 10);
    document.getElementById('email').classList.remove('hidden');
    window.nav.disableScrollBinding = false;

    this._expansionTimeout = setTimeout(() => {
      this.elem.classList.remove('expanded-x');
      this.title.moveUp();
      this.enable();
    }, 500);

    this.expanded = false;
    if (this.carousel) this.carousel.collapsed(this);
  }
  toggleExpand() {
    this[this.expanded ? 'collapse' : 'expand']();
  }

  fade() { this.elem.style.opacity = 0.5; }
  opacify() { this.elem.style.opacity = 1; }

  scrollHandler() {
    const transitionDistance = window.innerHeight / 10;
    const progress = Math.min(this.wrapper.scrollTop / transitionDistance, 1);

    const nav = document.getElementById('navigation');
    const tags = document.querySelector('.tags');
    nav.style.opacity = 0.5 * (1 - progress);
    tags.style.opacity = 1 - progress;

    // Turn off pointer events when halfway faded
    nav.style.pointerEvents = tags.style.pointerEvents = progress < 0.5 ? '' : 'none';
  }
}

/** Collects and orchestrates multiple CarouselCards */
export class Carousel {
  constructor(cards, tags) {
    this.tags = tags;
    this.tags.forEach((t) => { t.carousel = this; });
    this.cards = cards;
    this.cards.forEach((c) => { c.carousel = this; });
    this.hiddenIndices = [];

    this._position = 0;
    this.cards[this._position].title.display();
    // Trigger immediate layout of cards
    this.position = this._position;

    window.addEventListener('keydown', (e) => {
      // Carousel left/right with arrow keys, but only if carousel is expanded.
      // Carousel is expanded when the parent is #work-wrapper, the parent is .screen if it's not
      const projectsContainer = document.getElementsByClassName('projects-container')[0];
      if (projectsContainer.parentNode.id === 'work-wrapper') {
        if (e.key === 'ArrowRight') this.right();
        if (e.key === 'ArrowLeft') this.left();
      }
    });
  }

  // Transition time logic

  get baseTransitionTime() {
    const answer = this.cards[0].baseTransitionTime;
    // Warn if not every card has the same baseTransitionTime
    if (!this.cards.every(card => card.baseTransitionTime === answer)) console.warn('Carousel.baseTransitionTime called but baseTransitionTime of cards is inconsistent');
    return answer;
  }
  get transitionTime() {
    const answer = this.cards[0].transitionTime;
    // Warn if not every card has the same transitionTime
    if (!this.cards.every(card => card.transitionTime === answer)) console.warn('Carousel.transitionTime called but transitionTime of cards is inconsistent');
    return answer;
  }
  set transitionTime(secs) {
    this.cards.forEach((c) => { c.transitionTime = secs; });
  }

  // Rearrange

  // Adjust an index based on the number of hidden cards that come before it
  adjustIndex(index) {
    return index - this.hiddenIndices.filter(i => i < index).length;
  }

  get position() { return this._position; }
  set position(pos) {
    (async () => {
      const delta = Math.abs(this._position - pos);

      // Adjust transition time based on how many cards we're jumping
      const btt = this.baseTransitionTime;
      this.transitionTime = ((0.5 * btt) * ((delta || 1) - 1)) + btt;

      // If any card is open, collapse it and wait
      if (delta > 0 && typeof this.expandedIndex !== 'undefined') {
        this.cards[this.expandedIndex].collapse();
        await new Promise(resolve => setTimeout(resolve, 1000));
      }

      // Apply certain changes to each card
      this.cards.forEach((card, index) => {
        const adj = i => this.adjustIndex(i);
        const cardPosition = adj(index) - pos;
        const oldPosition = cardPosition - (this._position - pos);
        // Calculate an offset based on position relative to any expanded card
        let cardOffset = 0;
        if (adj(index) > adj(this.expandedIndex)) cardOffset = 12.5;
        if (adj(index) < adj(this.expandedIndex)) cardOffset = -12.5;
        // Set translation
        const cardTranslation = `${(48 * cardPosition) + cardOffset}vw`;
        card.translate = cardTranslation;

        // Expand / disable cards based on whether they're centered
        if (cardPosition === 0 && !card.expanded) card.enable();
        else card.disable(); // Not center card

        // Apply animations to titles
        if (cardPosition === 0) { // Center card
          if (oldPosition < 0) card.title.fadeInLeft(); // coming from left
          if (oldPosition > 0) card.title.fadeInRight(); // coming from right
        } else if (oldPosition === 0) {
          if (cardPosition < 0) card.title.fadeOutLeft(); // going left
          if (cardPosition > 0) card.title.fadeOutRight(); // going right
        }
      });
      // Update internally-stored position tracker
      this._position = pos;
    })();
  }

  expanded(card) {
    this.expandedIndex = this.cards.indexOf(card);
    this.position = this.position; // Re-layout
    this.cards.forEach((c) => {
      if (c !== card) c.fade();
    });
  }
  collapsed() {
    this.expandedIndex = undefined;
    setTimeout(() => {
      this.position = this.position; // Re-layout
      this.cards.forEach(c => c.opacify());
    }, 500);
  }

  get minIndex() { return 0; } // eslint-disable-line class-methods-use-this
  get maxIndex() { return this.cards.length - this.hiddenIndices.length - 1; }

  left() {
    if (this.position > this.minIndex) this.position -= 1;
  }
  right() {
    if (this.position < this.maxIndex) this.position += 1;
  }

  async filter(tag) {
    const cardsToRemove = this.cards.filter(c => !c.tagNames.includes(tag.name));
    this.cards.forEach(c => c.elem.classList.remove('removed')); // Clean slate
    cardsToRemove.forEach(c => c.elem.classList.add('removed')); // Play removed animation on affected cards

    if (cardsToRemove.length) await new Promise(resolve => setTimeout(resolve, 500));

    this.hiddenIndices = cardsToRemove.map(c => this.cards.indexOf(c));
    this.position = this.position; // Re-layout
  }

  async clearFilter() {
    if (this.hiddenIndices.length) {
      this.hiddenIndices = [];
      this.position = this.position; // Re-layout
      await new Promise(resolve => setTimeout(resolve, 500));
      this.cards.forEach(c => c.elem.classList.remove('removed'));
    }
  }
}
<commit_msg>Replace awaiting timeouts with common delay function<commit_after>/** Represents a single project in the work carousel */
import Parallax3D from '../parallax';

function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

export class CarouselTag {
  constructor(elem) {
    this.elem = elem;
    this.name = elem.textContent;
    this.carousel = undefined; // Will be set when added to a carousel

    this.elem.addEventListener('click', () => this.select());
  }

  select() { if (this.carousel) this.carousel.filter(this); }
}

export class CarouselCard {
  constructor(elem, tagNames) {
    this.elem = elem;
    this.elem.classList.add('carousel-card');
    // Move elem into a wrapper div
    this.wrapper = document.createElement('div');
    this.wrapper.className = 'carousel-card-wrapper';
    this.elem.parentNode.insertBefore(this.wrapper, this.elem);
    this.wrapper.appendChild(this.elem);

    this.tagNames = tagNames || elem.dataset.tags.split(',').map(t => t.trim());
    this.titleElem = elem.getElementsByTagName('h1')[0];

    this.carousel = undefined; // Will be set when added to a carousel
    this.transitionTime = this.baseTransitionTime = 0.5;
    this.parallax = new Parallax3D(this.elem);

    this.elem.addEventListener('click', () => {
      if (this.carousel) {
        const i1 = this.carousel.cards.indexOf(this);
        const i2 = this.carousel.adjustIndex(i1);
        if (this.carousel.position !== i2) this.centerSelf();
        else this.toggleExpand();
      } else {
        this.toggleExpand();
      }
    });
    this.wrapper.addEventListener('scroll', () => this.scrollHandler());

    // Title manipulation interface

    const _this2 = this;
    /**
     * Interface for updating styles on the title.
     * Examples:
     *   this.title.translate = '-10px'
     *   this.title.opacity = 0.5
     */
    this.title = {
      _elem: _this2.titleElem,
      display() { this._elem.className = 'in-initial'; },
      fadeIn() { this._elem.className = 'fade-in'; },
      fadeOut() { this._elem.className = 'fade-out'; },
      fadeInLeft() { this._elem.className = 'in-from-left'; },
      fadeInRight() { this._elem.className = 'in-from-right'; },
      fadeOutLeft() { this._elem.className = 'out-to-left'; },
      fadeOutRight() { this._elem.className = 'out-to-right'; },
      fadeOutTop() { this._elem.className = 'out-to-top'; },
      moveDown() { this._elem.className = 'down'; },
      moveUp() { this._elem.className = 'up'; },
    };
  }

  /**
   * Update the card wrapper's CSS in order to reflect changes that were made to its JS
   * representation.
   */
  _applyTransform() {
    this.wrapper.style.transform = [
      'translateX(-50%)',
      `translateX(${this._translate})`,
    ].join(' ');
  }

  // Translation

  get translate() { return this._translate; }
  set translate(val) { this._translate = val; this._applyTransform(); }

  // Transition time

  get transitionTime() { return this._transitionTime; }
  set transitionTime(secs) {
    this._transitionTime = secs;
    this.wrapper.style.transition = `transform ${secs}s`;
  }

  setEnabled(enable = true) {
    const totalTime = this.transitionTime * 1000;
    const startTime = new Date();

    // Recursively adjust using setAnimationFrame until transitionTime is reached
    function step(parallax) {
      const timeElapsed = new Date() - startTime;
      // Still going
      if (timeElapsed < totalTime) {
        const progress = (timeElapsed / totalTime);
        if (enable) parallax.rotmax = progress * 10;
        else parallax.rotmax = (1 - progress) * 10;
        parallax.rotate();
        requestAnimationFrame(() => step(parallax));
      } else {
        parallax.rotmax = enable ? 10 : 0;
      }
    }
    step(this.parallax);
  }
  disable() { this.setEnabled(false); }
  enable() { this.setEnabled(true); }

  // Move the carousel so that this card is in the center
  centerSelf() {
    const thisIndex = this.carousel.cards.indexOf(this);
    this.carousel.position = this.carousel.adjustIndex(thisIndex);
  }

  // Bigger!
  expand() {
    // Prevents incorrect result when expanding and collapsing rapidly. Ensures that a pre-existing
    // timeout won't remove the expanded-x class after it's been added by this method.
    clearInterval(this._expansionTimeout);

    this.elem.classList.add('expanded-x');
    this.title.moveDown();
    this.disable();
    this._expansionTimeout = setTimeout(() => {
      this.elem.classList.add('expanded-y');
      this._expansionTimeout = setTimeout(() => { this.elem.style.maxHeight = 'none'; this.elem.classList.remove('expanded-y'); }, 500);
      document.getElementById('email').classList.add('hidden');
      window.nav.disableScrollBinding = true;
    }, 500);

    this.expanded = true;
    if (this.carousel) this.carousel.expanded(this);
  }
  collapse() {
    clearInterval(this._expansionTimeout);

    this.elem.style.maxHeight = '';
    this.elem.classList.add('expanded-y');
    setTimeout(() => this.elem.classList.remove('expanded-y'), 10);
    document.getElementById('email').classList.remove('hidden');
    window.nav.disableScrollBinding = false;

    this._expansionTimeout = setTimeout(() => {
      this.elem.classList.remove('expanded-x');
      this.title.moveUp();
      this.enable();
    }, 500);

    this.expanded = false;
    if (this.carousel) this.carousel.collapsed(this);
  }
  toggleExpand() {
    this[this.expanded ? 'collapse' : 'expand']();
  }

  fade() { this.elem.style.opacity = 0.5; }
  opacify() { this.elem.style.opacity = 1; }

  scrollHandler() {
    const transitionDistance = window.innerHeight / 10;
    const progress = Math.min(this.wrapper.scrollTop / transitionDistance, 1);

    const nav = document.getElementById('navigation');
    const tags = document.querySelector('.tags');
    nav.style.opacity = 0.5 * (1 - progress);
    tags.style.opacity = 1 - progress;

    // Turn off pointer events when halfway faded
    nav.style.pointerEvents = tags.style.pointerEvents = progress < 0.5 ? '' : 'none';
  }
}

/** Collects and orchestrates multiple CarouselCards */
export class Carousel {
  constructor(cards, tags) {
    this.tags = tags;
    this.tags.forEach((t) => { t.carousel = this; });
    this.cards = cards;
    this.cards.forEach((c) => { c.carousel = this; });
    this.hiddenIndices = [];

    this._position = 0;
    this.cards[this._position].title.display();
    // Trigger immediate layout of cards
    this.position = this._position;

    window.addEventListener('keydown', (e) => {
      // Carousel left/right with arrow keys, but only if carousel is expanded.
      // Carousel is expanded when the parent is #work-wrapper, the parent is .screen if it's not
      const projectsContainer = document.getElementsByClassName('projects-container')[0];
      if (projectsContainer.parentNode.id === 'work-wrapper') {
        if (e.key === 'ArrowRight') this.right();
        if (e.key === 'ArrowLeft') this.left();
      }
    });
  }

  // Transition time logic

  get baseTransitionTime() {
    const answer = this.cards[0].baseTransitionTime;
    // Warn if not every card has the same baseTransitionTime
    if (!this.cards.every(card => card.baseTransitionTime === answer)) console.warn('Carousel.baseTransitionTime called but baseTransitionTime of cards is inconsistent');
    return answer;
  }
  get transitionTime() {
    const answer = this.cards[0].transitionTime;
    // Warn if not every card has the same transitionTime
    if (!this.cards.every(card => card.transitionTime === answer)) console.warn('Carousel.transitionTime called but transitionTime of cards is inconsistent');
    return answer;
  }
  set transitionTime(secs) {
    this.cards.forEach((c) => { c.transitionTime = secs; });
  }

  // Rearrange

  // Adjust an index based on the number of hidden cards that come before it
  adjustIndex(index) {
    return index - this.hiddenIndices.filter(i => i < index).length;
  }

  get position() { return this._position; }
  set position(pos) {
    (async () => {
      const delta = Math.abs(this._position - pos);

      // Adjust transition time based on how many cards we're jumping
      const btt = this.baseTransitionTime;
      this.transitionTime = ((0.5 * btt) * ((delta || 1) - 1)) + btt;

      // If any card is open, collapse it and wait
      if (delta > 0 && typeof this.expandedIndex !== 'undefined') {
        this.cards[this.expandedIndex].collapse();
        await delay(1000);
      }

      // Apply certain changes to each card
      this.cards.forEach((card, index) => {
        const adj = i => this.adjustIndex(i);
        const cardPosition = adj(index) - pos;
        const oldPosition = cardPosition - (this._position - pos);
        // Calculate an offset based on position relative to any expanded card
        let cardOffset = 0;
        if (adj(index) > adj(this.expandedIndex)) cardOffset = 12.5;
        if (adj(index) < adj(this.expandedIndex)) cardOffset = -12.5;
        // Set translation
        const cardTranslation = `${(48 * cardPosition) + cardOffset}vw`;
        card.translate = cardTranslation;

        // Expand / disable cards based on whether they're centered
        if (cardPosition === 0 && !card.expanded) card.enable();
        else card.disable(); // Not center card

        // Apply animations to titles
        if (cardPosition === 0) { // Center card
          if (oldPosition < 0) card.title.fadeInLeft(); // coming from left
          if (oldPosition > 0) card.title.fadeInRight(); // coming from right
        } else if (oldPosition === 0) {
          if (cardPosition < 0) card.title.fadeOutLeft(); // going left
          if (cardPosition > 0) card.title.fadeOutRight(); // going right
        }
      });
      // Update internally-stored position tracker
      this._position = pos;
    })();
  }

  expanded(card) {
    this.expandedIndex = this.cards.indexOf(card);
    this.position = this.position; // Re-layout
    this.cards.forEach((c) => {
      if (c !== card) c.fade();
    });
  }
  collapsed() {
    this.expandedIndex = undefined;
    setTimeout(() => {
      this.position = this.position; // Re-layout
      this.cards.forEach(c => c.opacify());
    }, 500);
  }

  get minIndex() { return 0; } // eslint-disable-line class-methods-use-this
  get maxIndex() { return this.cards.length - this.hiddenIndices.length - 1; }

  left() {
    if (this.position > this.minIndex) this.position -= 1;
  }
  right() {
    if (this.position < this.maxIndex) this.position += 1;
  }

  async filter(tag) {
    const cardsToRemove = this.cards.filter(c => !c.tagNames.includes(tag.name));
    this.cards.forEach(c => c.elem.classList.remove('removed')); // Clean slate
    cardsToRemove.forEach(c => c.elem.classList.add('removed')); // Play removed animation on affected cards

    if (cardsToRemove.length) await delay(500);

    this.hiddenIndices = cardsToRemove.map(c => this.cards.indexOf(c));
    this.position = this.position; // Re-layout
  }

  async clearFilter() {
    if (this.hiddenIndices.length) {
      this.hiddenIndices = [];
      this.position = this.position; // Re-layout
      await delay(500);
      this.cards.forEach(c => c.elem.classList.remove('removed'));
    }
  }
}
<|endoftext|>


<commit_before>/**
 * Copyright (C) 2018-2021  The Software Heritage developers
 * See the AUTHORS file at the top-level directory of this distribution
 * License: GNU Affero General Public License version 3, or any later version
 * See top-level LICENSE file for more information
 */

// Creation of a stacked histogram with D3.js for software origin visits history
// Parameters description:
//  - container: selector for the div that will contain the histogram
//  - visitsData: raw swh origin visits data
//  - currentYear: the visits year to display by default
//  - yearClickCallback: callback when the user selects a year through the histogram

import {visitStatusColor} from './utils';

export async function createVisitsHistogram(container, visitsData, currentYear, yearClickCallback) {

  const d3 = await import(/* webpackChunkName: "d3" */ 'utils/d3');

  // remove previously created histogram and tooltip if any
  d3.select(container).select('svg').remove();
  d3.select('div.d3-tooltip').remove();

  // histogram size and margins
  let width = 1000;
  let height = 200;
  const margin = {top: 20, right: 80, bottom: 30, left: 50};

  // create responsive svg
  const svg = d3.select(container)
    .attr('style',
          'padding-bottom: ' + Math.ceil(height * 100 / width) + '%')
    .append('svg')
    .attr('viewBox', '0 0 ' + width + ' ' + height);

  // create tooltip div
  const tooltip = d3.select('body')
    .append('div')
    .attr('class', 'd3-tooltip')
    .style('opacity', 0);

  // update width and height without margins
  width = width - margin.left - margin.right;
  height = height - margin.top - margin.bottom;

  // create main svg group element
  const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

  // create x scale
  const x = d3.scaleTime().rangeRound([0, width]);

  // create y scale
  const y = d3.scaleLinear().range([height, 0]);

  // create ordinal colorscale mapping visit status
  const colors = d3.scaleOrdinal()
    .domain(Object.keys(visitStatusColor))
    .range(Object.values(visitStatusColor));

  // first swh crawls were made in 2015
  const startYear = 2015;
  // set latest display year as the current one
  const now = new Date();
  const endYear = now.getUTCFullYear() + 1;
  const monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))];

  // create months bins based on setup extent
  const monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]);
  // create years bins based on setup extent
  const yearBins = d3.timeYears(monthExtent[0], monthExtent[1]);

  // set x scale domain
  x.domain(d3.extent(monthBins));

  // use D3 histogram layout to create a function that will bin the visits by month
  const binByMonth = d3.histogram()
    .value(d => d.date)
    .domain(x.domain())
    .thresholds(monthBins);

  // use D3 nest function to group the visits by status
  const visitsByStatus = d3.groups(visitsData, d => d['status'])
    .sort((a, b) => d3.ascending(a[0], b[0]));

  // prepare data in order to be able to stack visit statuses by month
  const statuses = [];
  const histData = [];
  for (let i = 0; i < monthBins.length; ++i) {
    histData[i] = {};
  }
  visitsByStatus.forEach(entry => {
    statuses.push(entry[0]);
    const monthsData = binByMonth(entry[1]);
    for (let i = 0; i < monthsData.length; ++i) {
      histData[i]['x0'] = monthsData[i]['x0'];
      histData[i]['x1'] = monthsData[i]['x1'];
      histData[i][entry[0]] = monthsData[i];
    }
  });

  // create function to stack visits statuses by month
  const stacked = d3.stack()
    .keys(statuses)
    .value((d, key) => d[key].length);

  // compute the maximum amount of visits by month
  const yMax = d3.max(histData, d => {
    let total = 0;
    for (let i = 0; i < statuses.length; ++i) {
      total += d[statuses[i]].length;
    }
    return total;
  });

  // set y scale domain
  y.domain([0, yMax]);

  // compute ticks values for the y axis
  const step = 5;
  const yTickValues = [];
  for (let i = 0; i <= yMax / step; ++i) {
    yTickValues.push(i * step);
  }
  if (yTickValues.length === 0) {
    for (let i = 0; i <= yMax; ++i) {
      yTickValues.push(i);
    }
  } else if (yMax % step !== 0) {
    yTickValues.push(yMax);
  }

  // add histogram background grid
  g.append('g')
    .attr('class', 'grid')
    .call(d3.axisLeft(y)
      .tickValues(yTickValues)
      .tickSize(-width)
      .tickFormat(''));

  // create one fill only rectangle by displayed year
  // each rectangle will be made visible when hovering the mouse over a year range
  // user will then be able to select a year by clicking in the rectangle

  g.append('g')
    .selectAll('rect')
    .data(yearBins)
    .enter().append('rect')
    .attr('class', d => 'year' + d.getUTCFullYear())
    .attr('fill', 'red')
    .attr('fill-opacity', d => d.getUTCFullYear() === currentYear ? 0.3 : 0)
    .attr('stroke', 'none')
    .attr('x', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      return x(date);
    })
    .attr('y', 0)
    .attr('height', height)
    .attr('width', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return yearWidth;
    })
    // mouse event callbacks used to show rectangle years
    // when hovering the mouse over the histograms
    .on('mouseover', (event, d) => {
      svg.selectAll('rect.year' + d.getUTCFullYear())
        .attr('fill-opacity', 0.5);
    })
    .on('mouseout', (event, d) => {
      svg.selectAll('rect.year' + d.getUTCFullYear())
        .attr('fill-opacity', 0);
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.3);
    })
    // callback to select a year after a mouse click
    // in a rectangle year
    .on('click', (event, d) => {
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'none');
      currentYear = d.getUTCFullYear();
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.5);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'black');
      yearClickCallback(currentYear);
    });

  // create the stacked histogram of visits
  g.append('g')
    .selectAll('g')
    .data(stacked(histData))
    .enter().append('g')
    .attr('fill', d => colors(d.key))
    .selectAll('rect')
    .data(d => d)
    .enter().append('rect')
    .attr('class', d => 'month' + d.data.x1.getMonth())
    .attr('x', d => x(d.data.x0))
    .attr('y', d => y(d[1]))
    .attr('height', d => y(d[0]) - y(d[1]))
    .attr('width', d => x(d.data.x1) - x(d.data.x0) - 1)
    // mouse event callbacks used to show rectangle years
    // but also to show tooltip when hovering the mouse
    // over the histogram bars
    .on('mouseover', (event, d) => {
      svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
        .attr('fill-opacity', 0.5);
      tooltip.transition()
        .duration(200)
        .style('opacity', 1);
      const ds = d.data.x1.toISOString().substr(0, 7).split('-');
      let tooltipText = '' + ds[1] + ' / ' + ds[0] + ':
';
      for (let i = 0; i < statuses.length; ++i) {
        const visitStatus = statuses[i];
        const nbVisits = d.data[visitStatus].length;
        if (nbVisits === 0) continue;
        tooltipText += nbVisits + ' ' + visitStatus + ' visits';
        if (i !== statuses.length - 1) tooltipText += '
';
      }
      tooltip.html(tooltipText)
        .style('left', event.pageX + 15 + 'px')
        .style('top', event.pageY + 'px');
    })
    .on('mouseout', (event, d) => {
      svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
        .attr('fill-opacity', 0);
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.3);
      tooltip.transition()
        .duration(500)
        .style('opacity', 0);
    })
    .on('mousemove', (event) => {
      tooltip.style('left', event.pageX + 15 + 'px')
        .style('top', event.pageY + 'px');
    })
    // callback to select a year after a mouse click
    // inside a histogram bar
    .on('click', (event, d) => {
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'none');
      currentYear = d.data.x1.getUTCFullYear();
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.5);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'black');
      yearClickCallback(currentYear);
    });

  // create one stroke only rectangle by displayed year
  // that will be displayed on top of the histogram when the user has selected a year
  g.append('g')
    .selectAll('rect')
    .data(yearBins)
    .enter().append('rect')
    .attr('class', d => 'yearoutline' + d.getUTCFullYear())
    .attr('fill', 'none')
    .attr('stroke', d => d.getUTCFullYear() === currentYear ? 'black' : 'none')
    .attr('x', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      return x(date);
    })
    .attr('y', 0)
    .attr('height', height)
    .attr('width', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return yearWidth;
    });

  // add x axis with a tick for every 1st day of each year
  const xAxis = g.append('g')
    .attr('class', 'axis')
    .attr('transform', 'translate(0,' + height + ')')
    .call(
      d3.axisBottom(x)
        .ticks(d3.timeYear.every(1))
        .tickFormat(d => d.getUTCFullYear())
    );

  // shift tick labels in order to display them at the middle
  // of each year range
  xAxis.selectAll('text')
    .attr('transform', d => {
      const year = d.getUTCFullYear();
      const date = new Date(Date.UTC(year, 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return 'translate(' + -yearWidth / 2 + ', 0)';
    });

  // add y axis for the number of visits
  g.append('g')
    .attr('class', 'axis')
    .call(d3.axisLeft(y).tickValues(yTickValues));

  // add legend for visit statuses
  const legendGroup = g.append('g')
    .attr('font-family', 'sans-serif')
    .attr('font-size', 10)
    .attr('text-anchor', 'end');

  legendGroup.append('text')
    .attr('x', width + margin.right - 5)
    .attr('y', 9.5)
    .attr('dy', '0.32em')
    .text('visit status:');

  const legend = legendGroup.selectAll('g')
    .data(statuses.slice().reverse())
    .enter().append('g')
    .attr('transform', (d, i) => 'translate(0,' + (i + 1) * 20 + ')');

  legend.append('rect')
    .attr('x', width + 2 * margin.right / 3)
    .attr('width', 19)
    .attr('height', 19)
    .attr('fill', colors);

  legend.append('text')
    .attr('x', width + 2 * margin.right / 3 - 5)
    .attr('y', 9.5)
    .attr('dy', '0.32em')
    .text(d => d);

  // add text label for the y axis
  g.append('text')
    .attr('transform', 'rotate(-90)')
    .attr('y', -margin.left)
    .attr('x', -(height / 2))
    .attr('dy', '1em')
    .style('text-anchor', 'middle')
    .text('Number of visits');
}
<commit_msg>assets/origin/visits-histogram: Improve axis ticks rendering<commit_after>/**
 * Copyright (C) 2018-2022  The Software Heritage developers
 * See the AUTHORS file at the top-level directory of this distribution
 * License: GNU Affero General Public License version 3, or any later version
 * See top-level LICENSE file for more information
 */

// Creation of a stacked histogram with D3.js for software origin visits history
// Parameters description:
//  - container: selector for the div that will contain the histogram
//  - visitsData: raw swh origin visits data
//  - currentYear: the visits year to display by default
//  - yearClickCallback: callback when the user selects a year through the histogram

import {visitStatusColor} from './utils';

export async function createVisitsHistogram(container, visitsData, currentYear, yearClickCallback) {

  const d3 = await import(/* webpackChunkName: "d3" */ 'utils/d3');

  // remove previously created histogram and tooltip if any
  d3.select(container).select('svg').remove();
  d3.select('div.d3-tooltip').remove();

  // histogram size and margins
  let width = 1000;
  let height = 200;
  const margin = {top: 20, right: 80, bottom: 30, left: 50};

  // create responsive svg
  const svg = d3.select(container)
    .attr('style',
          'padding-bottom: ' + Math.ceil(height * 100 / width) + '%')
    .append('svg')
    .attr('viewBox', '0 0 ' + width + ' ' + height);

  // create tooltip div
  const tooltip = d3.select('body')
    .append('div')
    .attr('class', 'd3-tooltip')
    .style('opacity', 0);

  // update width and height without margins
  width = width - margin.left - margin.right;
  height = height - margin.top - margin.bottom;

  // create main svg group element
  const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

  // create x scale
  const x = d3.scaleTime().rangeRound([0, width]);

  // create y scale
  const y = d3.scaleLinear().range([height, 0]);

  // create ordinal colorscale mapping visit status
  const colors = d3.scaleOrdinal()
    .domain(Object.keys(visitStatusColor))
    .range(Object.values(visitStatusColor));

  // first swh crawls were made in 2015
  const startYear = 2015;
  // set latest display year as the current one
  const now = new Date();
  const endYear = now.getUTCFullYear() + 1;
  const monthExtent = [new Date(Date.UTC(startYear, 0, 1)), new Date(Date.UTC(endYear, 0, 1))];

  // create months bins based on setup extent
  const monthBins = d3.timeMonths(d3.timeMonth.offset(monthExtent[0], -1), monthExtent[1]);
  // create years bins based on setup extent
  const yearBins = d3.timeYears(monthExtent[0], monthExtent[1]);

  // set x scale domain
  x.domain(d3.extent(monthBins));

  // use D3 histogram layout to create a function that will bin the visits by month
  const binByMonth = d3.histogram()
    .value(d => d.date)
    .domain(x.domain())
    .thresholds(monthBins);

  // use D3 nest function to group the visits by status
  const visitsByStatus = d3.groups(visitsData, d => d['status'])
    .sort((a, b) => d3.ascending(a[0], b[0]));

  // prepare data in order to be able to stack visit statuses by month
  const statuses = [];
  const histData = [];
  for (let i = 0; i < monthBins.length; ++i) {
    histData[i] = {};
  }
  visitsByStatus.forEach(entry => {
    statuses.push(entry[0]);
    const monthsData = binByMonth(entry[1]);
    for (let i = 0; i < monthsData.length; ++i) {
      histData[i]['x0'] = monthsData[i]['x0'];
      histData[i]['x1'] = monthsData[i]['x1'];
      histData[i][entry[0]] = monthsData[i];
    }
  });

  // create function to stack visits statuses by month
  const stacked = d3.stack()
    .keys(statuses)
    .value((d, key) => d[key].length);

  // compute the maximum amount of visits by month
  const yMax = d3.max(histData, d => {
    let total = 0;
    for (let i = 0; i < statuses.length; ++i) {
      total += d[statuses[i]].length;
    }
    return total;
  });

  // set y scale domain
  y.domain([0, yMax]);

  // compute ticks values for the y axis (at most 8 ticks)
  const step = Math.floor(yMax / 8) + 1;
  const yTickValues = [];
  for (let i = 0; i <= yMax / step; ++i) {
    yTickValues.push(i * step);
  }
  if (yTickValues.length === 0) {
    for (let i = 0; i <= yMax; ++i) {
      yTickValues.push(i);
    }
  } else if (yMax % step !== 0) {
    yTickValues.push(yMax);
  }

  // add histogram background grid
  g.append('g')
    .attr('class', 'grid')
    .call(d3.axisLeft(y)
      .tickValues(yTickValues)
      .tickSize(-width)
      .tickFormat(''));

  // create one fill only rectangle by displayed year
  // each rectangle will be made visible when hovering the mouse over a year range
  // user will then be able to select a year by clicking in the rectangle

  g.append('g')
    .selectAll('rect')
    .data(yearBins)
    .enter().append('rect')
    .attr('class', d => 'year' + d.getUTCFullYear())
    .attr('fill', 'red')
    .attr('fill-opacity', d => d.getUTCFullYear() === currentYear ? 0.3 : 0)
    .attr('stroke', 'none')
    .attr('x', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      return x(date);
    })
    .attr('y', 0)
    .attr('height', height)
    .attr('width', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return yearWidth;
    })
    // mouse event callbacks used to show rectangle years
    // when hovering the mouse over the histograms
    .on('mouseover', (event, d) => {
      svg.selectAll('rect.year' + d.getUTCFullYear())
        .attr('fill-opacity', 0.5);
    })
    .on('mouseout', (event, d) => {
      svg.selectAll('rect.year' + d.getUTCFullYear())
        .attr('fill-opacity', 0);
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.3);
    })
    // callback to select a year after a mouse click
    // in a rectangle year
    .on('click', (event, d) => {
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'none');
      currentYear = d.getUTCFullYear();
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.5);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'black');
      yearClickCallback(currentYear);
    });

  // create the stacked histogram of visits
  g.append('g')
    .selectAll('g')
    .data(stacked(histData))
    .enter().append('g')
    .attr('fill', d => colors(d.key))
    .selectAll('rect')
    .data(d => d)
    .enter().append('rect')
    .attr('class', d => 'month' + d.data.x1.getMonth())
    .attr('x', d => x(d.data.x0))
    .attr('y', d => y(d[1]))
    .attr('height', d => y(d[0]) - y(d[1]))
    .attr('width', d => x(d.data.x1) - x(d.data.x0) - 1)
    // mouse event callbacks used to show rectangle years
    // but also to show tooltip when hovering the mouse
    // over the histogram bars
    .on('mouseover', (event, d) => {
      svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
        .attr('fill-opacity', 0.5);
      tooltip.transition()
        .duration(200)
        .style('opacity', 1);
      const ds = d.data.x1.toISOString().substr(0, 7).split('-');
      let tooltipText = '' + ds[1] + ' / ' + ds[0] + ':
';
      for (let i = 0; i < statuses.length; ++i) {
        const visitStatus = statuses[i];
        const nbVisits = d.data[visitStatus].length;
        if (nbVisits === 0) continue;
        tooltipText += nbVisits + ' ' + visitStatus + ' visits';
        if (i !== statuses.length - 1) tooltipText += '
';
      }
      tooltip.html(tooltipText)
        .style('left', event.pageX + 15 + 'px')
        .style('top', event.pageY + 'px');
    })
    .on('mouseout', (event, d) => {
      svg.selectAll('rect.year' + d.data.x1.getUTCFullYear())
        .attr('fill-opacity', 0);
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.3);
      tooltip.transition()
        .duration(500)
        .style('opacity', 0);
    })
    .on('mousemove', (event) => {
      tooltip.style('left', event.pageX + 15 + 'px')
        .style('top', event.pageY + 'px');
    })
    // callback to select a year after a mouse click
    // inside a histogram bar
    .on('click', (event, d) => {
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'none');
      currentYear = d.data.x1.getUTCFullYear();
      svg.selectAll('rect.year' + currentYear)
        .attr('fill-opacity', 0.5);
      svg.selectAll('rect.yearoutline' + currentYear)
        .attr('stroke', 'black');
      yearClickCallback(currentYear);
    });

  // create one stroke only rectangle by displayed year
  // that will be displayed on top of the histogram when the user has selected a year
  g.append('g')
    .selectAll('rect')
    .data(yearBins)
    .enter().append('rect')
    .attr('class', d => 'yearoutline' + d.getUTCFullYear())
    .attr('fill', 'none')
    .attr('stroke', d => d.getUTCFullYear() === currentYear ? 'black' : 'none')
    .attr('x', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      return x(date);
    })
    .attr('y', 0)
    .attr('height', height)
    .attr('width', d => {
      const date = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return yearWidth;
    });

  // add x axis with a tick for every 1st day of each year
  const xAxis = g.append('g')
    .attr('class', 'axis')
    .attr('transform', 'translate(0,' + height + ')')
    .call(
      d3.axisBottom(x)
        .ticks(d3.timeYear.every(1))
        .tickFormat(d => {
          const year = d.getUTCFullYear();
          if (year >= startYear) {
            return year;
          } else {
            // filter out 2014 tick label
            return '';
          }
        })
    );

  // shift tick labels in order to display them at the middle
  // of each year range
  xAxis.selectAll('text')
    .attr('transform', d => {
      const year = d.getUTCFullYear();
      const date = new Date(Date.UTC(year, 0, 1));
      const yearWidth = x(d3.timeYear.offset(date, 1)) - x(date);
      return 'translate(' + -yearWidth / 2 + ', 0)';
    });

  // add y axis for the number of visits
  g.append('g')
    .attr('class', 'axis')
    .call(d3.axisLeft(y).tickValues(yTickValues));

  // add legend for visit statuses
  const legendGroup = g.append('g')
    .attr('font-family', 'sans-serif')
    .attr('font-size', 10)
    .attr('text-anchor', 'end');

  legendGroup.append('text')
    .attr('x', width + margin.right - 5)
    .attr('y', 9.5)
    .attr('dy', '0.32em')
    .text('visit status:');

  const legend = legendGroup.selectAll('g')
    .data(statuses.slice().reverse())
    .enter().append('g')
    .attr('transform', (d, i) => 'translate(0,' + (i + 1) * 20 + ')');

  legend.append('rect')
    .attr('x', width + 2 * margin.right / 3)
    .attr('width', 19)
    .attr('height', 19)
    .attr('fill', colors);

  legend.append('text')
    .attr('x', width + 2 * margin.right / 3 - 5)
    .attr('y', 9.5)
    .attr('dy', '0.32em')
    .text(d => d);

  // add text label for the y axis
  g.append('text')
    .attr('transform', 'rotate(-90)')
    .attr('y', -margin.left)
    .attr('x', -(height / 2))
    .attr('dy', '1em')
    .style('text-anchor', 'middle')
    .text('Number of visits');
}
<|endoftext|>