├── .gitignore ├── README.md ├── app.py ├── db_generation ├── __init__.py ├── git_database.py └── git_log_format.py ├── generate_db.py ├── img ├── example_image.png └── example_image.svg ├── requirements.txt ├── server ├── __init__.py ├── database_to_JSON.py └── file_tree.py ├── sql ├── filesChangeMostByAuthor.sql ├── filesChangeMostByCommit.sql ├── rankAuthorsByLinesChanged.sql ├── rankAuthorsByLinesChangedInPath.sql ├── rankAuthorsByNumCommits.sql ├── rankCommitsByLinesChanged.sql ├── rankCommitsByLinesChangedForFile.sql ├── rankFilesByLinesChanged.sql └── rankFilesByNumCommits.sql ├── static ├── javascript │ ├── overlay.js │ ├── sidebar.js │ ├── treemap.js │ ├── treemap.ts │ └── treemap_style.js └── stylesheets │ ├── index.css │ ├── overlay.css │ ├── sidebar.css │ └── treemap.css └── templates ├── index.html └── treemap.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *_lastcommit.txt 3 | scratchpad.sql 4 | __pycache__/ 5 | *.json 6 | *.log 7 | repos/* 8 | Lib/ 9 | Scripts/ 10 | pyvenv.cfg 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git-Heat-Map 2 | 3 | ![Map showing the files in cpython that Guido van Rossum changed the most](img/example_image.png) 4 | *Map showing the files in cpython that Guido van Rossum changed the most; 5 | full SVG image available in repo* 6 | 7 | ## Now with file extension based highlighting 8 | ## Now with submodule support 9 | ## Website now available 10 | 11 | A version of this program is now available for use at [heatmap.jonathanforsythe.co.uk](https://heatmap.jonathanforsythe.co.uk) 12 | 13 | ## Basic use guide 14 | 15 | * Generate database with `python generate_db.py {path_to_repo_dir}` 16 | * Create virtual environment with `python -m venv .` and install required modules with `pip install -r requirements.txt` 17 | * Run web server with `python app.py` or `flask run` (`flask run --host=` to run on that ip address, with `0.0.0.0` being used for all addresses on that machine) 18 | * Connect on `127.0.0.1:5000` 19 | * Available repos will be displayed, select the one you want to view 20 | * Add emails, commits, filenames, and date ranges you want to highlight 21 | * The "browse" buttons allow the user to see a list of valid values 22 | * Alternatively valid [sqlite](https://www.sqlite.org/lang_expr.html#:~:text=The%20LIKE%20operator%20does%20a,more%20characters%20in%20the%20string.) patterns can be passed in 23 | * Clicking on any of these entries will cause the query to exclude results matching that entry 24 | * By default highlight hue is determined by file extensions but this can be manually overridden 25 | * Options affecting performance are levels of text to render, and minimum size of boxes rendered 26 | * Press submit query to update which files are highlighted 27 | * Press refresh to update highlighting hue and redraw based on window size 28 | * Click on directories to zoom in, and the back button in the sidebar to zoom out 29 | 30 | ## Project Structure 31 | 32 | This project consists of two parts: 33 | 34 | 1. Git log -> database 35 | 2. Database -> treemap 36 | 37 | ### Git log -> database 38 | 39 | Scans through an entire git history using `git log`, and creates a database using three tables: 40 | * *Files*, which just keeps track of filenames 41 | * *Commits*, which stores commit hash, author, committer 42 | * *CommitFile*, which stores an instance of a certain file being changed by a certain commit, and tracks how many lines were added/removed by that commit 43 | * *Author*, which stores an author name and email 44 | * *CommitAuthor*, which links commits and Author in order to support coauthors on commits 45 | 46 | Using these we can keep track of which files/commits changed the repository the most, which in itself can provide useful insight 47 | 48 | ### Database -> treemap 49 | 50 | Taking the database above, uses an SQL query to generate a JSON object with the following structure: 51 | ``` 52 | directory: 53 | "name": 54 | "val": 55 | "children": [, ...] 56 | 57 | file: 58 | "name": 59 | "val": 60 | ``` 61 | then uses this to generate an inline svg image representing a [treemap](https://en.wikipedia.org/wiki/Treemapping "Wikipedia: Treemapping") of the file system, with the size of each rectangle being the `val` described above. 62 | 63 | Then generates a second JSON object in a similar manner to above, but filtering for the things we want (only certain emails, date ranges, etc), then uses this to highlight the rectangles in varying intensity based on the `val`s returned eg highlighting the files changed most by a certain author. 64 | 65 | ## Performance 66 | These speeds were attained on my personal computer. 67 | ### Database generation 68 | 69 | | Repo | Number of commits | Git log time | Git log size | Database time | Database size | **Total time** | 70 | | --- | --- | --- | --- | --- | --- | --- | 71 | | [linux](https://github.com/torvalds/linux) | 1,154,884 | 60 minutes | 444MB | 462.618 seconds | 733MB | **68 minutes** | 72 | | [cpython](https://github.com/python/cpython) | 115,874 | 4.6 minutes | 44.6MB | 36.607 seconds | 74.3MB | **5.2 minutes** | 73 | 74 | Time taken seems to scale linearly, going through approximately 300 commits/second, or requiring 0.0033 seconds/commit. 75 | Database size also scales linearly, with approximately 2600 commits/MB, or requiring 384 B/commit. 76 | 77 | ### Querying database and displaying treemap 78 | 79 | For this test I filtered each repo by its most prominent authors: 80 | 81 | | Repo | Author filter | Drawing treemap time | Highlighting treemap time | 82 | | --- | --- | --- | --- | 83 | | linux | torvalds@linux-foundation.org | 19.7 s | 54.3 s | 84 | | cpython | guido@python.org | 842 ms | 1238 ms | 85 | 86 | These times are with `minimum size drawn = 0`, on very large repositories, so the performance is not completely unreasonable. This does not include the time for the browser to actually render the svg, which can take longer. 87 | 88 | ## Wanted features 89 | 90 | ### Faster database generation 91 | Currently done using git log which can take a very long time for large repos. Will look into any other ways of getting needed information on files. 92 | 93 | ### Multiple filters per query 94 | Currently the user can submit only a single query for the highlighting. Ideally they could have a separate filter dictating which boxes to draw in the first place, and possibly multiple filters that could result in multiple colour highlighting on the same image. 95 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import functools 3 | import sqlite3 4 | import functools 5 | 6 | from server import database_to_JSON 7 | 8 | from flask import Flask, render_template, request, abort, make_response 9 | 10 | app = Flask(__name__) 11 | app.static_folder = "static" 12 | 13 | project_dir = pathlib.Path(__file__).parent 14 | repos_dir = project_dir / "repos" 15 | query_dir = project_dir / "sql" 16 | 17 | def top_level_db_list(): 18 | return (d.stem for d in repos_dir.iterdir() if d.is_dir()) 19 | 20 | def db_path_list(): 21 | return (db.parent for db in repos_dir.rglob("*.db")) 22 | 23 | def query_list(): 24 | return (d.stem for d in query_dir.iterdir()) 25 | 26 | def valid_db_check(func): 27 | @functools.wraps(func) 28 | def wrapper(*args, **kwargs): 29 | name = kwargs["name"] 30 | poss_path = repos_dir / pathlib.Path(name) 31 | if poss_path in db_path_list(): 32 | return func(*args, **kwargs) 33 | abort(404) 34 | return wrapper 35 | 36 | def valid_query_check(func): 37 | @functools.wraps(func) 38 | def wrapper(*args, **kwargs): 39 | query = kwargs["query"] 40 | if query in query_list(): 41 | return func(*args, **kwargs) 42 | abort(404) 43 | return wrapper 44 | 45 | def cache_on_db_change(func): 46 | @functools.wraps(func) 47 | def wrapper(*args, **kwargs): 48 | name = kwargs["name"] 49 | this_repo_dir = repos_dir / pathlib.Path(name) 50 | db_path = (this_repo_dir / this_repo_dir.stem).with_suffix(".db") 51 | server_time = db_path.stat().st_mtime 52 | client_time = request.if_modified_since 53 | if client_time != None and client_time.timestamp()+1 >= server_time: 54 | return make_response("", 304) 55 | rv = make_response(func(*args, **kwargs)) 56 | rv.headers["cache-control"] = "no-cache" 57 | rv.last_modified = db_path.stat().st_mtime 58 | return rv 59 | return wrapper 60 | 61 | @app.route("/") 62 | def index_page(): 63 | return render_template("index.html", db_list=sorted(top_level_db_list())) 64 | 65 | @app.route("/") 66 | @valid_db_check 67 | def treemap_page(name): 68 | return render_template("treemap.html", name=name) 69 | 70 | @app.route("//filetree.json") 71 | @cache_on_db_change 72 | @valid_db_check 73 | def filetree_json(name): 74 | if request.args.keys(): 75 | valid_keys = ("email_include", "email_exclude", "commit_include", "commit_exclude", "filename_include", "filename_exclude", "datetime_include", "datetime_exclude") 76 | params = {key: tuple(request.args.getlist(key)) for key in valid_keys if key in request.args.keys()} 77 | params_frozen = frozenset(params.items()) 78 | return get_json_with_params(name, params_frozen) 79 | 80 | # If no parameters are given, use specially cached result 81 | this_repo_dir = repos_dir / pathlib.Path(name) 82 | db_path = (this_repo_dir / this_repo_dir.stem).with_suffix(".db") 83 | json_path = this_repo_dir / "filetree.json" 84 | if json_path.is_file(): 85 | database_change_time = db_path.stat().st_mtime 86 | json_change_time = json_path.stat().st_mtime 87 | if not database_change_time > json_change_time: 88 | # Use cached result only if it exists and is newer than the database 89 | with open(json_path, "rb") as f: 90 | return f.read().decode(errors="replace") 91 | 92 | with open(json_path, "wb") as f: 93 | json = database_to_JSON.get_json_from_db(db_path) 94 | f.write(json.encode(errors="replace")) 95 | return json 96 | 97 | 98 | @app.route("//highlight.json") 99 | @cache_on_db_change 100 | @valid_db_check 101 | def highight_json(name): 102 | valid_keys = ("email_include", "email_exclude", "commit_include", "commit_exclude", "filename_include", "filename_exclude", "datetime_include", "datetime_exclude") 103 | params = {key: tuple(request.args.getlist(key)) for key in valid_keys if key in request.args.keys()} 104 | params_frozen = frozenset(params.items()) 105 | return get_json_with_params(name, params_frozen) 106 | 107 | @app.route("//.gitmodules") 108 | @cache_on_db_change 109 | @valid_db_check 110 | def submodule_list(name): 111 | this_repo_dir = repos_dir / pathlib.Path(name) 112 | gitmodules_path = this_repo_dir / ".gitmodules" 113 | if not gitmodules_path.is_file(): 114 | return [] 115 | with open(this_repo_dir / ".gitmodules") as f: 116 | return f.read().splitlines() 117 | 118 | @app.route("//query/") 119 | @cache_on_db_change 120 | @valid_db_check 121 | @valid_query_check 122 | def sql_query(name, query): 123 | this_repo_dir = repos_dir / pathlib.Path(name) 124 | db_path = (this_repo_dir / this_repo_dir.stem).with_suffix(".db") 125 | query_path = (project_dir / "sql" / query).with_suffix(".sql") 126 | with open(query_path) as f: 127 | query_text = f.read() 128 | con = sqlite3.connect(db_path) 129 | cur = con.cursor() 130 | cur.execute(query_text, tuple(request.args.keys())) 131 | out = [[i[0] for i in cur.description]] + cur.fetchall() 132 | return out 133 | 134 | @functools.lru_cache(maxsize=100) 135 | def get_json_with_params(name, params): 136 | this_repo_dir = repos_dir / pathlib.Path(name) 137 | db_path = (this_repo_dir / this_repo_dir.stem).with_suffix(".db") 138 | params_dict = {a[0]: a[1] for a in params} 139 | query, sql_params = database_to_JSON.get_filtered_query(params_dict) 140 | return database_to_JSON.get_json_from_db(db_path, query, sql_params) 141 | 142 | if __name__ == "__main__": 143 | app.run() 144 | -------------------------------------------------------------------------------- /db_generation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmforsythe/Git-Heat-Map/49b717454ecca5b8ed0cbe4e81fa4a340c0fdfed/db_generation/__init__.py -------------------------------------------------------------------------------- /db_generation/git_database.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import re 3 | 4 | # Start of commit 5 | COMMIT_START_SYMBOL = chr(30) 6 | # Unit separator 7 | COMMIT_SPLIT_SYMBOL = chr(31) 8 | 9 | select_file_sql = """ 10 | ( 11 | SELECT files.fileID 12 | FROM files 13 | WHERE files.filePath = ? 14 | ) 15 | """ 16 | 17 | insert_commit_sql = """ 18 | INSERT INTO 19 | commits(hash, authorDate, committerName, committerEmail, committerDate) 20 | VALUES(?, ?, ?, ?, ?) 21 | """ 22 | 23 | insert_file_sql = """ 24 | INSERT OR IGNORE INTO 25 | files(filePath) 26 | VALUES(?) 27 | """ 28 | 29 | insert_commitFile_sql = f""" 30 | INSERT INTO 31 | commitFile(hash, fileID, linesAdded, linesRemoved) 32 | VALUES(?, {select_file_sql}, ?, ?) 33 | """ 34 | 35 | insert_author_sql = """ 36 | INSERT OR IGNORE INTO 37 | author(authorEmail, authorName) 38 | VALUES(?, ?) 39 | """ 40 | 41 | insert_commitAuthor_sql = f""" 42 | INSERT OR IGNORE INTO 43 | commitAuthor(hash, authorEmail) 44 | VALUES(?, ?) 45 | """ 46 | 47 | update_file_sql = """ 48 | UPDATE OR IGNORE files 49 | SET filePath = ? 50 | WHERE filePath = ? 51 | """ 52 | 53 | delete_commitFile_sql = f""" 54 | DELETE FROM commitFile 55 | WHERE commitFile.fileID = {select_file_sql} 56 | """ 57 | 58 | delete_file_sql = """ 59 | DELETE FROM files 60 | WHERE files.filePath = ? 61 | """ 62 | 63 | nullify_file_sql = """ 64 | UPDATE FILES 65 | SET filePath = NULL 66 | WHERE filePath = ? 67 | """ 68 | 69 | regex_numstat_z = re.compile(r"([\-\d]+)\t([\-\d]+)\t(?:\0([^\0]+)\0([^\0]+)|([^\0]+))\0") 70 | 71 | def db_connection(database_path): 72 | con = sqlite3.connect(database_path) 73 | return con 74 | 75 | def create_tables(cur): 76 | cur.execute(""" 77 | CREATE TABLE if not exists commits( 78 | hash character(40) NOT NULL PRIMARY KEY, 79 | authorDate text NOT NULL, 80 | committerName text NOT NULL, 81 | committerEmail text NOT NULL, 82 | committerDate text NOT NULL 83 | ) 84 | """) 85 | 86 | cur.execute(""" 87 | CREATE TABLE if not exists files( 88 | fileID integer NOT NULL PRIMARY KEY, 89 | filePath text UNIQUE 90 | ) 91 | """) 92 | 93 | cur.execute(""" 94 | CREATE TABLE if not exists commitFile( 95 | hash character(40), 96 | fileID text, 97 | linesAdded integer, 98 | linesRemoved integer, 99 | FOREIGN KEY (hash) REFERENCES commits (hash), 100 | FOREIGN KEY (fileID) REFERENCES files (fileID), 101 | PRIMARY KEY (hash, fileID) 102 | ) 103 | """) 104 | 105 | cur.execute(""" 106 | CREATE TABLE if not exists author( 107 | authorEmail text NOT NULL PRIMARY KEY, 108 | authorName text NOT NULL 109 | ) 110 | """) 111 | 112 | cur.execute(""" 113 | CREATE TABLE if not exists commitAuthor( 114 | hash character(40), 115 | authorEmail text, 116 | FOREIGN KEY (hash) REFERENCES commits (hash), 117 | FOREIGN KEY (authorEmail) REFERENCES author (authorEmail), 118 | PRIMARY KEY (hash, authorEmail) 119 | ) 120 | """) 121 | 122 | def commit_create(cur, fields): 123 | cur.execute(insert_commit_sql, 124 | tuple(fields[i] for i in 125 | ("hash", "authorDate", 126 | "committerName", "committerEmail", "committerDate") 127 | )) 128 | 129 | def author_create(cur, email, name): 130 | cur.execute(insert_author_sql, (email, name)) 131 | 132 | def commitAuthor_create(cur, hash, email): 133 | cur.execute(insert_commitAuthor_sql, (hash, email)) 134 | 135 | def handle_commit(cur, commit_lines): 136 | if len(commit_lines) <= 1: 137 | return 138 | encoding = commit_lines[0].split(COMMIT_SPLIT_SYMBOL.encode())[-2].decode() 139 | if encoding == "": 140 | encoding = "utf-8" 141 | 142 | keys = ("hash", "authorName", "authorEmail", "authorDate", "committerName", "committerEmail", "committerDate") 143 | first_line_sep = commit_lines[0][1:].decode(encoding, errors="replace").split(COMMIT_SPLIT_SYMBOL) 144 | fields = {keys[i] : first_line_sep[i] 145 | for i in range(len(keys))} 146 | try: 147 | commit_create(cur, fields) 148 | except sqlite3.IntegrityError: 149 | return fields["hash"] 150 | author_create(cur, fields["authorEmail"], fields["authorName"]) 151 | commitAuthor_create(cur, fields["hash"], fields["authorEmail"]) 152 | for i in range(len(keys), len(first_line_sep)-2): 153 | # Some repos (such as the bitcoin and godot repos) don't do co author trailers 154 | # in the usual way, so need to remove newlines (done in git_log_format) and 155 | # match for all authors on one line 156 | for name, email in re.findall(r"(.*?) <(.*?)> ?", first_line_sep[i]): 157 | author_create(cur, email, name) 158 | commitAuthor_create(cur, fields["hash"], email) 159 | 160 | numstat_line = commit_lines[1].decode(encoding) 161 | matches = regex_numstat_z.findall(numstat_line) 162 | # First commit in --compact-summary is on the end of previous line 163 | first_secondary_line = numstat_line.split("\0")[-1] 164 | commit_lines.insert(2, first_secondary_line.encode(encoding)) 165 | for i in range(len(matches)): 166 | try: 167 | handle_match(cur, matches[i], commit_lines[2+i].decode(encoding), fields) 168 | except: 169 | print(matches[i], commit_lines[2+i].decode(encoding)) 170 | raise 171 | return fields["hash"] 172 | 173 | def file_create(cur, file_path): 174 | cur.execute(insert_file_sql, (file_path,)) 175 | 176 | def file_rename(cur, old_name, new_name): 177 | cur.execute(update_file_sql, (new_name, old_name)) 178 | 179 | def file_delete(cur, file_path): 180 | cur.execute(nullify_file_sql, (file_path,)) 181 | 182 | def commitFile_create(cur, fields, file_path, added, removed): 183 | cur.execute(insert_commitFile_sql, (fields["hash"], file_path, added, removed)) 184 | 185 | def handle_match(cur, match, secondary_line, fields): 186 | p, n = secondary_line.split("|") 187 | second_path = p.strip() 188 | 189 | if match[4]: 190 | file_path = match[4] 191 | elif match[2] and match[3]: 192 | file_rename(cur, match[2], match[3]) 193 | file_path = match[3] 194 | 195 | if re.match(r"(.*)\(new.{0,3}\)$", second_path): 196 | file_create(cur, file_path) 197 | 198 | if "-" in match[:1]: 199 | added = 0 200 | removed = 0 201 | else: 202 | added = int(match[0]) 203 | removed = int(match[1]) 204 | second_total = int(n.split()[0]) 205 | assert(added+removed == second_total) 206 | 207 | commitFile_create(cur, fields, file_path, added, removed) 208 | 209 | if re.match(r"(.*)\(gone\)$", second_path): 210 | file_delete(cur, file_path) 211 | 212 | def get_next_line(log_output): 213 | return log_output.readline() 214 | 215 | def create_indices(cur): 216 | cur.execute("CREATE INDEX if not exists commitFileID ON commitFile (fileID)") 217 | cur.execute("CREATE INDEX if not exists commitAuthorEmail ON commitAuthor (authorEmail)") 218 | -------------------------------------------------------------------------------- /db_generation/git_log_format.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | def get_log_process(path, last_commit=None): 5 | SEPARATOR="%x1F" 6 | GIT_COMMIT_FLAG="%x1E" 7 | command = [ 8 | "git", 9 | "-C", 10 | path, 11 | "log", 12 | f"--pretty=format:\"%n{GIT_COMMIT_FLAG}%H{SEPARATOR}%aN{SEPARATOR}%aE{SEPARATOR}%aI{SEPARATOR}%cN{SEPARATOR}%cE{SEPARATOR}%cI{SEPARATOR}%(trailers:key=Co-authored-by,valueonly=true,separator={SEPARATOR},unfold=true){SEPARATOR}{SEPARATOR}%e\"", 13 | "--reverse", 14 | "--use-mailmap", 15 | "--numstat", 16 | "-z", 17 | "--compact-summary", 18 | "--stat-width=1024", 19 | "--stat-name-width=1024", 20 | "--stat-graph-width=1" 21 | ] 22 | if last_commit: 23 | command.insert(4, f"{last_commit}..HEAD") 24 | return subprocess.Popen(command, stdout=subprocess.PIPE) 25 | 26 | def main(): 27 | argc = len(sys.argv) 28 | if argc <= 1: 29 | print("No repo supplied") 30 | return 31 | 32 | last_commit = None if argc < 3 else sys.argv[2] 33 | log_process = get_log_process(sys.argv[1], last_commit) 34 | while line := log_process.stdout.readline(): 35 | print(line) 36 | continue 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /generate_db.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import subprocess 4 | import pathlib 5 | 6 | from db_generation import git_database, git_log_format 7 | 8 | def generate_db(log_output, database_path): 9 | con = git_database.db_connection(database_path) 10 | cur = con.cursor() 11 | 12 | git_database.create_tables(cur) 13 | 14 | lines = [] 15 | 16 | while line := git_database.get_next_line(log_output): 17 | if chr(line[0]).encode() == git_database.COMMIT_START_SYMBOL.encode(): 18 | last_commit = git_database.handle_commit(cur, lines) 19 | lines = [line] 20 | else: 21 | lines.append(line) 22 | last_commit = git_database.handle_commit(cur, lines) 23 | 24 | git_database.create_indices(cur) 25 | 26 | con.commit() 27 | con.close() 28 | 29 | return last_commit 30 | 31 | def get_submodules(source_path): 32 | l = subprocess.Popen(f"git -C {source_path}" + r" config -z --file .gitmodules --get-regexp submodule\..*\.path", stdout=subprocess.PIPE).stdout.read().split(b"\0") 33 | return [pathlib.Path(os.fsdecode(i.splitlines()[1])) for i in l if len(i)] 34 | 35 | def generate_recursive(source_path, source_path_parent, dest_dir_parent): 36 | print(source_path) 37 | repo_name = source_path.stem 38 | 39 | dest_dir = dest_dir_parent / source_path.relative_to(source_path_parent) 40 | 41 | dest_dir.mkdir(parents=True, exist_ok=True) 42 | database_path = (dest_dir / repo_name).with_suffix(".db") 43 | 44 | last_commit_file = dest_dir / "lastcommit.txt" 45 | if last_commit_file.is_file(): 46 | with open(last_commit_file, "r") as f: 47 | last_commit = f.read() 48 | else: 49 | last_commit = None 50 | 51 | log_process = git_log_format.get_log_process(source_path, last_commit) 52 | 53 | log_output = log_process.stdout 54 | 55 | last_commit = generate_db(log_output, database_path) 56 | 57 | if last_commit != None: 58 | with open(last_commit_file, "w") as f: 59 | f.write(last_commit) 60 | 61 | print(f"Database generated at \"{database_path.absolute()}\"") 62 | 63 | submodule_paths = get_submodules(source_path) 64 | for p in submodule_paths: 65 | generate_recursive(source_path / p, source_path, dest_dir) 66 | 67 | submodules_file = dest_dir / ".gitmodules" 68 | with open(submodules_file, "w") as f: 69 | f.writelines("\n".join(p.as_posix() for p in submodule_paths)) 70 | 71 | def main(): 72 | argc = len(sys.argv) 73 | if argc < 2: 74 | print("No repo supplied") 75 | return 76 | 77 | repos_dir = pathlib.Path(__file__).parent / "repos" 78 | repos_dir.mkdir(exist_ok=True) 79 | 80 | source_path = pathlib.Path(sys.argv[1]) 81 | 82 | generate_recursive(source_path, source_path.parent, repos_dir) 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /img/example_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmforsythe/Git-Heat-Map/49b717454ecca5b8ed0cbe4e81fa4a340c0fdfed/img/example_image.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.2 2 | Werkzeug==2.3.8 3 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmforsythe/Git-Heat-Map/49b717454ecca5b8ed0cbe4e81fa4a340c0fdfed/server/__init__.py -------------------------------------------------------------------------------- /server/database_to_JSON.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | from . import file_tree 4 | 5 | def get_filtered_query(filter): 6 | base_query = """ 7 | SELECT files.filePath, SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) 8 | FROM ( 9 | SELECT DISTINCT commitAuthor.hash 10 | FROM commitAuthor 11 | WHERE WHERE_INNER_LINE 12 | ) commitsFiltered 13 | JOIN commitFile ON commitsFiltered.hash = commitFile.hash 14 | JOIN files ON commitFile.fileID = files.fileID JOIN_LINE 15 | WHERE WHERE_LINE 16 | GROUP BY files.filePath 17 | """ 18 | 19 | params = [] 20 | joins = set() 21 | wheres = ["files.filePath NOTNULL"] 22 | wheres_inner = [] 23 | 24 | valid_field = lambda key: key in filter and isinstance(filter[key], tuple) and len(filter[key]) > 0 25 | 26 | if valid_field("email_include"): 27 | wheres_inner.append("(" + " OR ".join([f"commitAuthor.authorEmail LIKE ?" for email in filter["email_include"]]) + ")") 28 | params.extend(filter["email_include"]) 29 | 30 | if valid_field("email_exclude"): 31 | wheres_inner.append("(" + " AND ".join([f"commitAuthor.authorEmail NOT LIKE ?" for email in filter["email_exclude"]]) + ")") 32 | params.extend(filter["email_exclude"]) 33 | 34 | if valid_field("commit_include"): 35 | wheres.append("(" + " OR ".join([f"commitFile.hash LIKE ?" for hash in filter["commit_include"]]) + ")") 36 | params.extend(filter["commit_include"]) 37 | 38 | if valid_field("commit_exclude"): 39 | wheres.append("(" + " AND ".join([f"commitFile.hash NOT LIKE ?" for hash in filter["commit_exclude"]]) + ")") 40 | params.extend(filter["commit_exclude"]) 41 | 42 | if valid_field("filename_include"): 43 | wheres.append("(" + " OR ".join([f"files.filePath LIKE ?" for filename in filter["filename_include"]]) + ")") 44 | params.extend(filter["filename_include"]) 45 | 46 | if valid_field("filename_exclude"): 47 | wheres.append("(" + " AND ".join([f"files.filePath NOT LIKE ?" for filename in filter["filename_exclude"]]) + ")") 48 | params.extend(filter["filename_exclude"]) 49 | 50 | if valid_field("datetime_include"): 51 | joins.add("JOIN commits on commitFile.hash = commits.hash") 52 | wheres.append("(" + " OR ".join([f"commits.authorDate BETWEEN ? AND ?" for date_range in filter["datetime_include"]]) + ")") 53 | date_expand = [d for r in filter["datetime_include"] for d in r.split(" ")] 54 | params.extend(date_expand) 55 | 56 | if valid_field("datetime_exclude"): 57 | joins.add("JOIN commits on commitFile.hash = commits.hash") 58 | wheres.append("(" + " AND ".join([f"commits.authorDate NOT BETWEEN ? AND ?" for date_range in filter["datetime_exclude"]]) + ")") 59 | date_expand = [d for r in filter["datetime_exclude"] for d in r.split(" ")] 60 | params.extend(date_expand) 61 | 62 | if len(wheres_inner) == 0: 63 | wheres_inner = ["1"] 64 | query = base_query.replace("JOIN_LINE", " ".join(joins)).replace("WHERE_LINE", " AND ".join(wheres)).replace("WHERE_INNER_LINE", " AND ".join(wheres_inner)) 65 | return query, tuple(params) 66 | 67 | get_files_SQL = """ 68 | SELECT files.filePath, SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) 69 | FROM files 70 | JOIN commitFile on files.fileID = commitFile.fileID 71 | WHERE files.filePath NOTNULL 72 | GROUP BY files.filePath 73 | """ 74 | 75 | def get_json_from_db(database_name, query=get_files_SQL, params=tuple()): 76 | con = sqlite3.connect(database_name) 77 | cur = con.cursor() 78 | 79 | file_dict = {} 80 | 81 | cur.execute(query, params) 82 | while line := cur.fetchone(): 83 | file_dict[line[0]] = line[1] 84 | 85 | con.close() 86 | 87 | rootDir = file_tree.Directory("") 88 | for key in file_dict: 89 | cur_dir = rootDir 90 | path = key.split("/") 91 | for component in path[:-1]: 92 | if component not in cur_dir.children: 93 | cur_dir.add_child(file_tree.Directory(component)) 94 | cur_dir = cur_dir.children[component] 95 | cur_dir.add_child(file_tree.File(path[-1], file_dict[key])) 96 | 97 | rootDir.update_val() 98 | return rootDir.get_json(0) 99 | 100 | if __name__ == "__main__": 101 | import sys 102 | # Needed to stop encoding errors 103 | sys.stdin.reconfigure(encoding='utf-8') 104 | sys.stdout.reconfigure(encoding='utf-8') 105 | if len(sys.argv) > 1: 106 | print(get_json_from_db(sys.argv[1])) 107 | -------------------------------------------------------------------------------- /server/file_tree.py: -------------------------------------------------------------------------------- 1 | SEPARATOR = " " 2 | 3 | class Directory: 4 | def __init__(self, name): 5 | self.children = {} 6 | self.val = None 7 | self.name = name 8 | 9 | def update_val(self): 10 | self.val = sum((self.children[c].get_val() for c in self.children)) 11 | 12 | def get_val(self): 13 | if self.val == None: 14 | self.update_val() 15 | return self.val 16 | 17 | def add_child(self, c): 18 | self.children[c.name] = c 19 | 20 | def tree_print(self, level): 21 | print(SEPARATOR*level + self.name, self.val) 22 | for c in self.children: 23 | self.children[c].tree_print(level+1) 24 | 25 | def get_json(self, level): 26 | return "\n".join([SEPARATOR*level + "{" + f"\"name\": \"{self.name}\", \"val\": {self.val}, \"children\": [", 27 | ",\n".join([self.children[c].get_json(level+2) for c in self.children]), 28 | SEPARATOR*level + "]}"]) 29 | 30 | class File: 31 | def __init__(self, name, val): 32 | self.name = name 33 | self.val = val 34 | 35 | def get_val(self): 36 | return self.val 37 | 38 | def tree_print(self, level): 39 | print(SEPARATOR*level + self.name, self.val) 40 | 41 | def get_json(self, level): 42 | return SEPARATOR*level + f"{{\"name\": \"{self.name}\", \"val\": {self.val}}}" 43 | -------------------------------------------------------------------------------- /sql/filesChangeMostByAuthor.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) AS "Total changes", 3 | files.filePath AS "File path" 4 | FROM commitFile 5 | JOIN files ON files.fileID = commitFile.fileID 6 | JOIN commitAuthor ON commitFile.hash = commitAuthor.hash 7 | WHERE files.filePath NOTNULL AND commitAuthor.authorEmail LIKE ? 8 | GROUP BY files.filePath 9 | ORDER BY Rank ASC 10 | -------------------------------------------------------------------------------- /sql/filesChangeMostByCommit.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) AS "Total changes", 3 | files.filePath AS "File path" 4 | FROM commitFile 5 | JOIN files ON files.fileID = commitFile.fileID 6 | WHERE files.filePath NOTNULL AND commitFile.hash LIKE ? 7 | GROUP BY files.filePath 8 | ORDER BY Rank ASC 9 | -------------------------------------------------------------------------------- /sql/rankAuthorsByLinesChanged.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) AS "Total changes", 3 | author.authorEmail AS "Author email" 4 | FROM author 5 | JOIN commitAuthor ON author.authorEmail = commitAuthor.authorEmail 6 | JOIN commitFile ON commitAuthor.hash = commitFile.hash 7 | GROUP BY author.authorEmail 8 | ORDER BY Rank ASC 9 | -------------------------------------------------------------------------------- /sql/rankAuthorsByLinesChangedInPath.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded+commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded+commitFile.linesRemoved) AS "Total changes", 3 | commitAuthor.authorEmail AS "Author email" 4 | FROM commitAuthor 5 | JOIN commitFile ON commitAuthor.hash = commitFile.hash 6 | JOIN files ON commitFile.fileID = files.fileID 7 | WHERE files.filePath LIKE ? 8 | GROUP BY commitAuthor.authorEmail 9 | ORDER BY Rank ASC 10 | -------------------------------------------------------------------------------- /sql/rankAuthorsByNumCommits.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY COUNT(*) DESC) AS "Rank", 2 | COUNT(*) AS "Number of commits", 3 | authorEmail AS "Author email" 4 | FROM commitAuthor 5 | GROUP BY authorEmail 6 | ORDER BY Rank ASC 7 | -------------------------------------------------------------------------------- /sql/rankCommitsByLinesChanged.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) AS "Total changes", 3 | commits.hash AS "Commit hash" 4 | FROM commits 5 | JOIN commitFile ON commits.hash = commitFile.hash 6 | GROUP BY commits.hash 7 | ORDER BY Rank ASC 8 | -------------------------------------------------------------------------------- /sql/rankCommitsByLinesChangedForFile.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY commitFile.linesAdded+commitFile.linesRemoved DESC) AS "Rank", 2 | commitFile.linesAdded+commitFile.linesRemoved AS "Total changes", 3 | commits.hash AS "Commit hash" 4 | FROM commits 5 | JOIN commitFile ON commits.hash = commitFile.hash 6 | JOIN files ON commitFile.fileID = files.fileID 7 | WHERE files.filePath = ? 8 | GROUP BY commits.hash 9 | ORDER BY Rank ASC 10 | -------------------------------------------------------------------------------- /sql/rankFilesByLinesChanged.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) DESC) AS "Rank", 2 | SUM(commitFile.linesAdded)+SUM(commitFile.linesRemoved) AS "Total changes", 3 | files.filePath AS "File path" 4 | FROM commitFile 5 | JOIN files ON files.fileID = commitFile.fileID 6 | WHERE files.filePath NOTNULL 7 | GROUP BY files.fileID 8 | ORDER BY Rank ASC 9 | -------------------------------------------------------------------------------- /sql/rankFilesByNumCommits.sql: -------------------------------------------------------------------------------- 1 | SELECT RANK() OVER (ORDER BY COUNT(*) DESC) AS "Rank", COUNT(*), files.filePath AS "File path" 2 | FROM commitFile 3 | JOIN files ON files.fileID = commitFile.fileID 4 | WHERE files.filePath NOTNULL 5 | GROUP BY files.filePath 6 | ORDER BY Rank ASC 7 | -------------------------------------------------------------------------------- /static/javascript/overlay.js: -------------------------------------------------------------------------------- 1 | function open_overlay() { 2 | const overlay = document.getElementById('overlay') 3 | const info_box = document.getElementById('info_box') 4 | 5 | overlay.style.display = 'block' 6 | info_box.style.display = 'block' 7 | } 8 | 9 | function close_overlay() { 10 | const overlay = document.getElementById('overlay') 11 | const info_box = document.getElementById('info_box') 12 | 13 | overlay.style.display = 'none' 14 | info_box.style.display = 'none' 15 | } 16 | 17 | function set_info_content(...child_elements) { 18 | const info_box = document.getElementById('info_box') 19 | info_box.replaceChildren(...child_elements) 20 | } 21 | 22 | async function get_author_stats(author_email) { 23 | return fetch(`/${DATABASE_NAME}/query/filesChangeMostByAuthor?` 24 | + new URLSearchParams(author_email) 25 | ) 26 | } 27 | 28 | async function get_commit_stats(author_email) { 29 | return fetch(`/${DATABASE_NAME}/query/filesChangeMostByCommit?` 30 | + new URLSearchParams(author_email) 31 | ) 32 | } 33 | 34 | async function get_file_stats(path) { 35 | return fetch(`/${DATABASE_NAME}/query/rankAuthorsByLinesChangedInPath?` 36 | + new URLSearchParams(path) 37 | ) 38 | } 39 | 40 | async function get_file_commits(path) { 41 | return fetch(`/${DATABASE_NAME}/query/rankCommitsByLinesChangedForFile?` 42 | + new URLSearchParams(path) 43 | ) 44 | } 45 | 46 | async function get_all_authors() { 47 | return fetch(`/${DATABASE_NAME}/query/rankAuthorsByLinesChanged`) 48 | } 49 | 50 | async function get_all_commits() { 51 | return fetch(`/${DATABASE_NAME}/query/rankCommitsByLinesChanged`) 52 | } 53 | 54 | async function get_all_files() { 55 | return fetch(`/${DATABASE_NAME}/query/rankFilesByLinesChanged`) 56 | } 57 | 58 | const FIELD_TO_FUNCTION = new Map([ 59 | ["Author email", update_info_box_with_author_stats], 60 | ["Commit hash", update_info_box_with_commit_stats], 61 | ["File path", update_info_box_with_file_stats] 62 | ]) 63 | 64 | const FIELD_TO_FILTER = new Map([ 65 | ["Author email", "email_filter"], 66 | ["Commit hash", "commit_filter"], 67 | ["File path", "filename_filter"] 68 | ]) 69 | 70 | function sql_response_to_table(r) { 71 | let column_func_map = new Map() 72 | let column_filter_list_map = new Map() 73 | const m = r.length 74 | if (!m) return 75 | const table = document.createElement("table") 76 | const thead = document.createElement("thead") 77 | const tr_head = document.createElement("tr") 78 | r[0].forEach((column_name, index) => { 79 | if (FIELD_TO_FUNCTION.has(column_name)) { 80 | column_func_map.set(index, column_name) 81 | } 82 | if (FIELD_TO_FILTER.has(column_name)) { 83 | const filter = document.getElementById(FIELD_TO_FILTER.get(column_name)) 84 | if (filter) { 85 | const filter_list = filter.querySelector(".item_list") 86 | if (filter_list) column_filter_list_map.set(index, filter_list) 87 | } 88 | } 89 | const td = document.createElement("td") 90 | const text = document.createTextNode(column_name) 91 | td.appendChild(text) 92 | tr_head.appendChild(td) 93 | }) 94 | thead.appendChild(tr_head) 95 | table.appendChild(thead) 96 | const tbody = document.createElement("tbody") 97 | r.forEach((row, index) => { 98 | if (index == 0) return 99 | const tr = document.createElement("tr") 100 | row.forEach((val, column) => { 101 | const td = document.createElement("td") 102 | const text = document.createTextNode(val) 103 | if (column_func_map.has(column)) { 104 | const button = document.createElement("button") 105 | button.classList.add("link_button") 106 | button.value = val 107 | button.onclick = () => { 108 | FIELD_TO_FUNCTION.get(column_func_map.get(column))(button.value) 109 | } 110 | td.appendChild(button) 111 | button.appendChild(text) 112 | } else { 113 | td.appendChild(text) 114 | } 115 | if (column_filter_list_map.has(column)) { 116 | const button = document.createElement("button") 117 | button.classList.add("add_button") 118 | button.value = val 119 | button.onclick = () => { 120 | column_filter_list_map.get(column).appendChild(make_list_item(button.value)) 121 | } 122 | td.appendChild(button) 123 | button.appendChild(document.createTextNode("+")) 124 | } 125 | tr.appendChild(td) 126 | }) 127 | tbody.appendChild(tr) 128 | }) 129 | table.appendChild(tbody) 130 | table.classList.add("info_table") 131 | return table 132 | } 133 | 134 | function update_info_box_downloading() { 135 | set_info_content(document.createTextNode("Fetching data...")) 136 | } 137 | 138 | function update_info_box_rendering() { 139 | set_info_content(document.createTextNode("Rendering data...")) 140 | } 141 | 142 | async function update_info_box_with_author_stats(author_email) { 143 | const h1 = document.createElement("h1") 144 | h1.appendChild(document.createTextNode("Files changed most by author")) 145 | const h2 = document.createElement("h2") 146 | h2.appendChild(document.createTextNode(author_email)) 147 | update_info_box_downloading() 148 | const response = await get_author_stats(author_email) 149 | update_info_box_rendering() 150 | const table = sql_response_to_table(await response.json()) 151 | set_info_content(h1, h2, table) 152 | } 153 | 154 | async function update_info_box_with_commit_stats(hash) { 155 | const h1 = document.createElement("h1") 156 | h1.appendChild(document.createTextNode("Files changed in commit")) 157 | const h2 = document.createElement("h2") 158 | h2.appendChild(document.createTextNode(hash)) 159 | update_info_box_downloading() 160 | const response = await get_commit_stats(hash) 161 | update_info_box_rendering() 162 | const table = sql_response_to_table(await response.json()) 163 | set_info_content(h1, h2, table) 164 | } 165 | 166 | async function update_info_box_with_file_stats(path) { 167 | const h1 = document.createElement("h1") 168 | h1.appendChild(document.createTextNode("Author emails with most changes to")) 169 | const h2 = document.createElement("h2") 170 | h2.appendChild(document.createTextNode(path)) 171 | update_info_box_downloading() 172 | const response = await get_file_stats(path) 173 | update_info_box_rendering() 174 | const table = sql_response_to_table(await response.json()) 175 | set_info_content(h1, h2, table) 176 | } 177 | 178 | async function update_info_box_all_authors() { 179 | const h1 = document.createElement("h1") 180 | h1.appendChild(document.createTextNode("Author emails sorted by most changes")) 181 | update_info_box_downloading() 182 | const response = await get_all_authors() 183 | update_info_box_rendering() 184 | const table = sql_response_to_table(await response.json()) 185 | set_info_content(h1, table) 186 | } 187 | 188 | async function browse_authors() { 189 | update_info_box_all_authors() 190 | open_overlay() 191 | } 192 | 193 | async function update_info_box_all_commits() { 194 | const h1 = document.createElement("h1") 195 | h1.appendChild(document.createTextNode("Commits sorted by most changes")) 196 | update_info_box_downloading() 197 | const response = await get_all_commits() 198 | update_info_box_rendering() 199 | const table = sql_response_to_table(await response.json()) 200 | set_info_content(h1, table) 201 | } 202 | 203 | async function browse_commits() { 204 | update_info_box_all_commits() 205 | open_overlay() 206 | } 207 | 208 | async function update_info_box_all_files() { 209 | const h1 = document.createElement("h1") 210 | h1.appendChild(document.createTextNode("Files sorted by most changes")) 211 | update_info_box_downloading() 212 | const response = await get_all_files() 213 | update_info_box_rendering() 214 | const table = sql_response_to_table(await response.json()) 215 | set_info_content(h1, table) 216 | } 217 | 218 | async function browse_files() { 219 | update_info_box_all_files() 220 | open_overlay() 221 | } 222 | 223 | function update_info_box_extension_colours() { 224 | const h1 = document.createElement("h1") 225 | h1.appendChild(document.createTextNode("File extension colouring")) 226 | 227 | const sorted_list = [...EXTENSION_MAP.entries()].sort((a,b) => a[1]-b[1]) 228 | const extensions = sorted_list.map(([key, value]) => { 229 | if (EXTENSION_AREA.has(key) && EXTENSION_NUM_FILES.has(key)) 230 | return [key, value, EXTENSION_AREA.get(key), EXTENSION_NUM_FILES.get(key)] 231 | }).filter(a => a !== undefined) 232 | 233 | const table = document.createElement("table") 234 | const thead = document.createElement("thead") 235 | const tr_head = document.createElement("tr") 236 | const head_td_extension = document.createElement("td") 237 | head_td_extension.appendChild(document.createTextNode("Extension")) 238 | const head_td_colour = document.createElement("td") 239 | head_td_colour.appendChild(document.createTextNode("Colour")) 240 | const head_td_lines_changed = document.createElement("td") 241 | head_td_lines_changed.appendChild(document.createTextNode("Lines changed")) 242 | const head_td_num_files = document.createElement("td") 243 | head_td_num_files.appendChild(document.createTextNode("Number of files")) 244 | tr_head.replaceChildren(head_td_extension, head_td_colour, head_td_lines_changed, head_td_num_files) 245 | thead.appendChild(tr_head) 246 | table.appendChild(thead) 247 | 248 | const tbody = document.createElement("tbody") 249 | extensions.forEach(arr => { 250 | const tr = document.createElement("tr") 251 | const td_extension = document.createElement("td") 252 | td_extension.appendChild(document.createTextNode(arr[0] ? arr[0] : "")) 253 | const td_colour = document.createElement("td") 254 | if (arr[1]) td_colour.style.backgroundColor = `hsl(${arr[1]},100%,50%)` 255 | const td_lines_changed = document.createElement("td") 256 | td_lines_changed.appendChild(document.createTextNode(arr[2])) 257 | const td_num_files= document.createElement("td") 258 | td_num_files.appendChild(document.createTextNode(arr[3])) 259 | tr.replaceChildren(td_extension, td_colour, td_lines_changed, td_num_files) 260 | tbody.appendChild(tr) 261 | }) 262 | table.appendChild(tbody) 263 | 264 | set_info_content(h1, table) 265 | } 266 | 267 | function show_colours() { 268 | update_info_box_extension_colours() 269 | open_overlay() 270 | } 271 | -------------------------------------------------------------------------------- /static/javascript/sidebar.js: -------------------------------------------------------------------------------- 1 | function make_list_item(text) { 2 | let el = document.createElement("div") 3 | el.classList.add("list_item") 4 | let text_el = document.createElement("div", ) 5 | el.appendChild(text_el) 6 | text_el.classList.add("list_item_text") 7 | text_el.innerText = text 8 | let close_button = document.createElement("button") 9 | el.appendChild(close_button) 10 | close_button.innerText = "x"; 11 | close_button.width = "1em"; 12 | close_button.classList.add("close_button") 13 | close_button.onclick = () => { 14 | el.parentElement.removeChild(el) 15 | } 16 | let filetree_button = document.createElement("button") 17 | el.appendChild(filetree_button) 18 | filetree_button.innerText = "!" 19 | filetree_button.width = "1em" 20 | filetree_button.classList.add("filetree_button") 21 | filetree_button.onclick = (event) => { 22 | event.stopPropagation() 23 | el.classList.toggle("filetree") 24 | } 25 | filetree_button.title = "Toggle filetree filtering\nIf enabled controls boxes being displayed at all instead of just colouring" 26 | el.onclick = () => { 27 | el.classList.toggle("item_negated") 28 | } 29 | el.title = "Click to toggle inclusion/exclusion" 30 | return el 31 | } 32 | 33 | function filter_entry_setup(filter_id) { 34 | let filter = document.getElementById(filter_id) 35 | let filter_entry = filter.querySelector(".text_entry") 36 | let filter_submit = filter.querySelector(".text_submit") 37 | let filter_list = filter.querySelector(".item_list") 38 | if (filter_entry && filter_submit) { 39 | filter_submit.onclick = () => { 40 | if (filter_entry.value != "") { 41 | filter_list.appendChild(make_list_item(filter_entry.value)) 42 | } 43 | } 44 | } 45 | } 46 | 47 | function date_entry_setup(filter_id) { 48 | let filter = document.getElementById(filter_id) 49 | let [filter_entry_start, filter_entry_end] = filter.querySelectorAll(".datetime_entry") 50 | let filter_submit = filter.querySelector(".date_submit") 51 | let filter_list = filter.querySelector(".item_list") 52 | if (filter_entry_start && filter_entry_end && filter_submit) { 53 | filter_submit.onclick = () => { 54 | if (filter_entry_start.value != "" && filter_entry_end.value != "") { 55 | filter_list.appendChild(make_list_item(filter_entry_start.value + " " + filter_entry_end.value)) 56 | } 57 | } 58 | } 59 | } 60 | 61 | function get_include_exclude(filter_name, filter_id, filetree) { 62 | let filter = document.getElementById(filter_id) 63 | let filter_list = filter.querySelector(".item_list") 64 | let children = filter_list.querySelectorAll(".list_item") 65 | let include = [] 66 | let exclude = [] 67 | children.forEach((c) => { 68 | if (filetree != c.classList.contains("filetree")) return 69 | if (c.classList.contains("item_negated")) { 70 | exclude.push(c.querySelector(".list_item_text").innerText) 71 | } else { 72 | include.push(c.querySelector(".list_item_text").innerText) 73 | } 74 | }) 75 | const include_name = filter_name+"_include" 76 | const exclude_name = filter_name+"_exclude" 77 | out = {} 78 | out[include_name] = include 79 | out[exclude_name] = exclude 80 | return out 81 | } 82 | 83 | function submodule_tree_list_generator(tree, parent_path) { 84 | let child_lists = tree.submodules.map((child_tree) => submodule_tree_list_generator(child_tree, tree.path)) 85 | let li = document.createElement("li") 86 | let label = document.createElement("label") 87 | let input = document.createElement("input") 88 | input.type = "checkbox" 89 | input.checked = tree.enabled 90 | input.addEventListener(("change"), (event) => { 91 | tree.enabled = input.checked 92 | }) 93 | let text = document.createTextNode(tree.path.slice(parent_path.length)) 94 | label.appendChild(input) 95 | label.appendChild(text) 96 | li.appendChild(label) 97 | if (child_lists.length > 0) { 98 | let ul = document.createElement("ul") 99 | child_lists.forEach((c) => ul.appendChild(c)) 100 | li.appendChild(ul) 101 | } 102 | return li 103 | } 104 | 105 | function submodule_tree_setup() { 106 | let el = document.getElementById("submodule_tree") 107 | if (SUBMODULE_TREE.submodules.length > 0) { 108 | let ul = document.createElement("ul") 109 | SUBMODULE_TREE.submodules.forEach((submodule) => { 110 | ul.appendChild(submodule_tree_list_generator(submodule, "")) 111 | }) 112 | el.appendChild(ul) 113 | el.classList.remove("hidden") 114 | } 115 | } 116 | 117 | function text_depth_setup() { 118 | let el = document.getElementById("text_depth_number") 119 | el.addEventListener("input", (event) => { 120 | update_styles(document.getElementById("treemap_root_svg"), el.value) 121 | }) 122 | } 123 | 124 | function size_picker_setup() { 125 | let el = document.getElementById("size_picker_number") 126 | el.value = MIN_AREA 127 | el.addEventListener("input", (event) => { 128 | MIN_AREA = el.value 129 | MIN_AREA_USER_SET = true 130 | }) 131 | } 132 | 133 | function filetype_highlighting_setup() { 134 | const filetype_highlighting_control = document.getElementById("filetype_highlight_control") 135 | if (filetype_highlighting_control) { 136 | filetype_highlighting_control.checked = USER_DEFINED_HUE 137 | filetype_highlighting_control.onchange = () => { 138 | USER_DEFINED_HUE = filetype_highlighting_control.checked 139 | } 140 | } 141 | } 142 | 143 | function color_picker_setup() { 144 | let el = document.getElementById("sidebar_color_picker") 145 | let input_number = el.querySelector(".color_picker_number") 146 | let input_range = el.querySelector(".color_picker_range") 147 | let display = el.querySelector(".color_display") 148 | display.style["background-color"] = `hsl(${input_number.value},100%,50%)` 149 | input_number.addEventListener("input", (event) => { 150 | display.style["background-color"] = `hsl(${event.target.value},100%,50%)` 151 | input_range.value = event.target.value 152 | }) 153 | input_range.addEventListener("input", (event) => { 154 | display.style["background-color"] = `hsl(${event.target.value},100%,50%)` 155 | input_number.value = event.target.value 156 | }) 157 | } 158 | 159 | function get_hue() { 160 | let el = document.getElementById("sidebar_color_picker") 161 | let input_number = el.querySelector(".color_picker_number") 162 | return input_number.value 163 | } 164 | 165 | function get_query_object() { 166 | const query_list = [["email", "email_filter"], ["commit", "commit_filter"], ["filename", "filename_filter"], ["datetime", "datetime_filter"]] 167 | let query_filetree = {} 168 | let query_highlight = {} 169 | query_list.forEach((q) => { 170 | query_filetree = {...query_filetree, ...get_include_exclude(...q, true)} 171 | query_highlight = {...query_highlight, ...get_include_exclude(...q, false)} 172 | }) 173 | return {filetree: query_filetree, highlight: query_highlight} 174 | } 175 | 176 | function submit_query_setup() { 177 | let submit_button = document.getElementById("submit_query") 178 | if (submit_button) { 179 | submit_button.onclick = () => { 180 | const query = get_query_object() 181 | save_query(query) 182 | display_filetree_with_params(query.filetree, query.highlight, get_hue()) 183 | } 184 | } 185 | } 186 | 187 | function fraction_highlighting_setup() { 188 | const fraction_highlighting_control = document.getElementById("highlight_control") 189 | if (fraction_highlighting_control) { 190 | fraction_highlighting_control.onchange = () => { 191 | FRACTION_HIGHLIGHTING = fraction_highlighting_control.checked 192 | } 193 | } 194 | } 195 | 196 | function refresh_button_setup() { 197 | let refresh_button = document.getElementById("refresh_button") 198 | if (refresh_button) { 199 | refresh_button.onclick = () => { 200 | path = back_stack.slice(-1) 201 | if (path == null) path = "" 202 | display_filetree_path(filetree_obj_global, highlighting_obj_global, path, get_hue()) 203 | } 204 | } 205 | } 206 | 207 | function back_button_setup() { 208 | let back_button = document.getElementById("back_button") 209 | if (back_button) { 210 | back_button.onclick = () => { 211 | path = back_stack.pop() 212 | if (path == null) path = "" 213 | display_filetree_path(filetree_obj_global, highlighting_obj_global, path, get_hue()) 214 | } 215 | } 216 | } 217 | 218 | function export_svg_setup() { 219 | const el = document.getElementById("save_button") 220 | const button = el.querySelector("input") 221 | button.onclick = () => { 222 | const link = document.createElement("a") 223 | const svg_data = document.getElementById("treemap_root_svg").outerHTML 224 | const blob = new Blob([svg_data], {type:"image/svg+xml;charset=utf-8"}) 225 | const download_url = URL.createObjectURL(blob) 226 | link.href = download_url 227 | link.download = `${DATABASE_NAME}.svg` 228 | link.click() 229 | } 230 | } 231 | 232 | function save_query(query) { 233 | localStorage.setItem(document.title + "_stored_query_filetree", JSON.stringify(query.filetree)) 234 | localStorage.setItem(document.title + "_stored_query_highlight", JSON.stringify(query.highlight)) 235 | } 236 | 237 | function load_query() { 238 | const query_list = [["email", "email_filter"], ["commit", "commit_filter"], ["filename", "filename_filter"], ["datetime", "datetime_filter"]] 239 | function get_part(part_name, filetree) { 240 | const query = JSON.parse(localStorage.getItem(`${document.title}_stored_query_${part_name}`)) 241 | if (!query) return 242 | query_list.forEach((q) => { 243 | const name = q[0] 244 | const filter_id = q[1] 245 | const filter = document.getElementById(filter_id) 246 | const filter_list = filter.querySelector(".item_list") 247 | if (name+"_include" in query) { 248 | query[name+"_include"].forEach((val) => { 249 | if (val && val != "") { 250 | filter_list.appendChild(make_list_item(val)) 251 | if (filetree) filter_list.lastChild.classList.toggle("filetree") 252 | } 253 | }) 254 | } 255 | if (name+"_exclude" in query) { 256 | query[name+"_exclude"].forEach((val) => { 257 | if (val && val != "") { 258 | filter_list.appendChild(make_list_item(val)).classList.toggle("item_negated") 259 | if (filetree) filter_list.lastChild.classList.toggle("filetree") 260 | } 261 | }) 262 | } 263 | }) 264 | } 265 | get_part("filetree",true) 266 | get_part("highlight") 267 | } 268 | 269 | function main() { 270 | filter_entry_setup("email_filter") 271 | filter_entry_setup("commit_filter") 272 | filter_entry_setup("filename_filter") 273 | date_entry_setup("datetime_filter") 274 | submodule_tree_setup() 275 | text_depth_setup() 276 | size_picker_setup() 277 | filetype_highlighting_setup() 278 | color_picker_setup() 279 | submit_query_setup() 280 | fraction_highlighting_setup() 281 | refresh_button_setup() 282 | back_button_setup() 283 | export_svg_setup() 284 | load_query() 285 | } 286 | 287 | main() 288 | -------------------------------------------------------------------------------- /static/javascript/treemap.js: -------------------------------------------------------------------------------- 1 | // Request file with with given parameters 2 | function loadFile(file_path, params_obj) { 3 | let result = null 4 | let xmlhttp = new XMLHttpRequest() 5 | xmlhttp.open("GET", path_and_params_to_url(file_path, params_obj), false) 6 | xmlhttp.send() 7 | if (xmlhttp.status==200) { 8 | result = xmlhttp.responseText 9 | } 10 | return result 11 | } 12 | 13 | function fetch_with_params(file_path, params_obj) { 14 | return fetch(path_and_params_to_url(file_path, params_obj)).then((response) => { 15 | if (response.ok) { 16 | return response.json() 17 | } 18 | return null 19 | }) 20 | } 21 | 22 | function path_and_params_to_url(file_path, params_obj) { 23 | const search_params_str = params_to_url_params(params_obj).toString() 24 | return file_path + (search_params_str != "" ? `?${search_params_str}` : "") 25 | } 26 | 27 | function params_to_url_params(params_obj) { 28 | const search_params = new URLSearchParams() 29 | for (const key in params_obj) { 30 | params_obj[key].forEach((value) => search_params.append(key, value)) 31 | } 32 | return search_params 33 | } 34 | 35 | function sort_by_val(j) { 36 | if ("children" in j) { 37 | j.children.forEach(sort_by_val) 38 | j.children.sort((a,b) => b.val-a.val) 39 | } 40 | } 41 | 42 | // Part of the squarified treemap algorithm 43 | // Given a list of (area) values and a width that they need to fit into, return 44 | // the worst aspect ratio of any of these boxes when placed in a line 45 | function worst(R, w) { 46 | const s = R.reduce((acc,x) => acc+x, 0) 47 | if (s == 0 || w == 0) return -Infinity 48 | let m = 0 49 | const ws2 = (w**2)/(s**2) 50 | for (let i=0; i= height ? height : width 69 | let row = [] 70 | let cur_worst = Infinity 71 | for (let i=0; i c.val), size) 73 | if (cur_worst >= possible_worst) { 74 | row.push(children[i]) 75 | cur_worst = possible_worst 76 | } 77 | else break 78 | } 79 | const i = row.length 80 | children_out = children_out.concat(handle_row(row, x, y, width, height, parent_path, level, SVG_ROOT)) 81 | 82 | let area = row.reduce((acc, c) => acc+c.val, 0) 83 | let size_used = area / size 84 | if (width >= height) { 85 | x = x + size_used 86 | width = width - size_used 87 | } else { 88 | y = y + size_used 89 | height = height - size_used 90 | } 91 | 92 | const tmp = children.slice(i) 93 | if (tmp && tmp.length != 0) { 94 | children_out = children_out.concat(squarify(x, y, width, height, children.slice(i), parent_path, level, SVG_ROOT)) 95 | } 96 | return children_out 97 | } 98 | 99 | // Given a rectangular canvas and a list of items that will be displayed in one row, 100 | // produce objects for these items and their children 101 | function handle_row(row, x, y, width, height, parent_path, level, SVG_ROOT) { 102 | let row_area = row.reduce((acc, cur) => acc+cur.val, 0) 103 | let out = [] 104 | row.forEach((val, index, array) => { 105 | let box_area = val.val 106 | if (width >= height) { 107 | const row_width = height != 0 ? row_area / height : 0 108 | const box_height = row_width != 0 ? box_area / row_width : 0 109 | let el = {"text": val.name, "area": box_area, "x": x, "y": y, "width": row_width, "height": box_height, "parent": parent_path, "level": level} 110 | if ("submodule" in val && val.submodule == true) el.submodule = true 111 | if (NEST && "children" in val) el.children = squarify(x, y, row_width, box_height, val.children, `${parent_path}/${val.name}`, level+1, SVG_ROOT) 112 | out.push(el) 113 | y += box_height 114 | } else { 115 | const row_height = width != 0 ? row_area / width : 0 116 | const box_width = row_height != 0 ? box_area / row_height : 0 117 | let el = {"text": val.name, "area": box_area, "x": x, "y": y, "width": box_width, "height": row_height, "parent": parent_path, "level": level} 118 | if ("submodule" in val && val.submodule == true) el.submodule = true 119 | if (NEST && "children" in val) el.children = squarify(x, y, box_width, row_height, val.children, `${parent_path}/${val.name}`, level+1, SVG_ROOT) 120 | out.push(el) 121 | x += box_width 122 | } 123 | }) 124 | MAX_DEPTH = Math.max(MAX_DEPTH, level+1) 125 | return out 126 | } 127 | 128 | // Turns our object into an svg element 129 | function get_box_text_element(obj) { 130 | const is_leaf = !("children" in obj) 131 | const is_submodule = "submodule" in obj && obj.submodule == true 132 | 133 | let element = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 134 | let box = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 135 | let text = document.createElementNS('http://www.w3.org/2000/svg', 'text') 136 | let title = document.createElementNS('http://www.w3.org/2000/svg', 'title') 137 | 138 | element.setAttribute("x", `${obj.x}`) 139 | element.setAttribute("y", `${obj.y}`) 140 | element.setAttribute("width", `${obj.width}`) 141 | element.setAttribute("height", `${obj.height}`) 142 | element.classList.add(`svg_level_${obj.level}`) 143 | if (is_leaf) element.classList.add("svg_leaf") 144 | if (is_submodule) element.classList.add("svg_submodule") 145 | const path = `${obj.parent}/${obj.text}` 146 | element.setAttribute("id", `svg_path_${path}`) 147 | 148 | box.classList.add("svg_box") 149 | box.setAttribute("fill", `url(#Gradient${obj.level})`) 150 | box.setAttribute("fill-opacity", "20%") 151 | 152 | const txt = document.createTextNode(obj.text) 153 | text.appendChild(txt) 154 | text.classList.add("svg_text") 155 | text.setAttribute("x", "50%") 156 | text.setAttribute("y", "50%") 157 | text.setAttribute("dominant-baseline", "middle") 158 | text.setAttribute("text-anchor", "middle") 159 | let font_size = Math.min(1.5*obj.width/obj.text.length, 1*obj.height) 160 | text.setAttribute("font-size", `${font_size}`) 161 | text.setAttribute("stroke-width", `${font_size/80}`) 162 | 163 | const title_txt = document.createTextNode(`${obj.area}\n${path}`) 164 | title.appendChild(title_txt) 165 | 166 | if (obj.level == 0) { 167 | if (!is_leaf) element.onclick = () => { 168 | back_stack.push(obj.parent) 169 | display_filetree_path(filetree_obj_global, highlighting_obj_global, path, get_hue()) 170 | } 171 | else element.onclick = () => { 172 | update_info_box_with_file_stats(path.slice(1)) 173 | open_overlay() 174 | } 175 | element.onmouseover = () => box.classList.add("svg_box_selected") 176 | element.onmouseout = () => box.classList.remove("svg_box_selected") 177 | } 178 | 179 | element.appendChild(box) 180 | element.appendChild(text) 181 | element.appendChild(title) 182 | 183 | if (obj.area < Math.max(0, MIN_AREA)) { 184 | element.classList.add("is_not_visible") 185 | } 186 | 187 | return element 188 | } 189 | 190 | function fraction_to_saturation_and_lightness(fraction) { 191 | const saturation_max = 90 192 | const saturation_min = 40 193 | const lightness_min = 50 194 | const lightness_max = 90 195 | return [(saturation_max-saturation_min)*fraction+saturation_min, (lightness_min-lightness_max)*fraction+lightness_max] 196 | } 197 | 198 | function delete_children(node) { 199 | node.querySelectorAll("svg").forEach((child) => node.removeChild(child)) 200 | node.querySelectorAll(".svg_background").forEach((child) => node.removeChild(child)) 201 | } 202 | 203 | function get_child_from_path(obj, path) { 204 | if (path[0] == "/") path = path.slice(1) 205 | if (path == "") return obj 206 | const index = path.indexOf("/") 207 | if (index == -1) { 208 | desired_child = obj.children.filter((child) => child.name == path) 209 | if (desired_child.length == 1) { 210 | return desired_child[0] 211 | } 212 | } else { 213 | desired_child = obj.children.filter((child) => child.name == path.slice(0,index)) 214 | if (desired_child.length == 1) { 215 | return get_child_from_path(desired_child[0], path.slice(index+1)) 216 | } 217 | } 218 | return {} 219 | } 220 | 221 | function insert_subtree(parent, to_insert, path) { 222 | let cur_child_val = 0 223 | let new_child_val = 0 224 | if (path[0] == "/") path = path.slice(1) 225 | if (path == "") { 226 | parent = to_insert 227 | } else { 228 | const index = path.indexOf("/") 229 | if (index == -1) { 230 | let poss_children = parent.children.filter((child) => child.name == path) 231 | if (poss_children.length == 0) { 232 | const tmp = {"val": 0} 233 | poss_children.push(tmp) 234 | parent.children.push(tmp) 235 | } 236 | if (poss_children.length == 1) { 237 | cur_child_val = poss_children[0].val 238 | new_child_val = to_insert.val 239 | poss_children[0].name = path 240 | poss_children[0].val = to_insert.val 241 | poss_children[0].children = to_insert.children 242 | poss_children[0].submodule = true 243 | } 244 | } else { 245 | const poss_children = parent.children.filter((child) => child.name == path.slice(0,index)) 246 | if (poss_children.length == 1) { 247 | cur_child_val = poss_children[0].val 248 | new_child_val = insert_subtree(poss_children[0], to_insert, path.slice(index+1)) 249 | } 250 | } 251 | } 252 | parent.val += new_child_val - cur_child_val 253 | sort_by_val(parent) 254 | return parent.val 255 | } 256 | 257 | function get_extension(filename) { 258 | const n = filename.length 259 | const i = filename.indexOf(".") 260 | if (i == -1 || i == n) return null 261 | return filename.split(".").pop() 262 | } 263 | 264 | let EXTENSION_MAP = new Map() 265 | let EXTENSION_AREA = new Map() 266 | let EXTENSION_NUM_FILES = new Map() 267 | 268 | function extension_hue(extension) { 269 | if (!EXTENSION_MAP.has(extension)) { 270 | if (extension === null || extension === undefined) { 271 | EXTENSION_MAP.set(extension, null) 272 | } else { 273 | const bytes = Uint8Array.from(extension.split("").map(c => c.charCodeAt(0))) 274 | let hue = 0 275 | bytes.forEach((b, index) => { 276 | hue += ((((b%26)+7)%26)+1) * (360/27) / (27**index) 277 | }) 278 | EXTENSION_MAP.set(extension, hue) 279 | } 280 | } 281 | return EXTENSION_MAP.get(extension) 282 | } 283 | 284 | USER_DEFINED_HUE = false 285 | 286 | // Given an object in the style generated by handle_row, draw the boxes as necessary 287 | function draw_tree(obj_tree, SVG_ROOT) { 288 | // Draw children first so parent directory draws on top and so is clickable 289 | if (obj_tree && "children" in obj_tree) obj_tree.children.forEach((child) => draw_tree(child, SVG_ROOT)) 290 | 291 | // Connect object model to actual displayed elements 292 | obj_tree.SVG_ELEMENT = get_box_text_element(obj_tree) 293 | 294 | // Separate function so that we can update element colour dynamically 295 | obj_tree.update_highlight = () => { 296 | if (obj_tree.SVG_ELEMENT.querySelector(".svg_box_highlight") === null) { 297 | const box_highlight = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 298 | box_highlight.classList.add("svg_box_highlight") 299 | box_highlight.setAttribute("fill", "none") 300 | box_highlight.setAttribute("fill-opacity", "100%") 301 | obj_tree.SVG_ELEMENT.insertBefore(box_highlight, obj_tree.SVG_ELEMENT.querySelector(".svg_box")) 302 | } 303 | const rect = obj_tree.SVG_ELEMENT.querySelector(".svg_box_highlight") 304 | const hue_to_use = USER_DEFINED_HUE ? "hue_user" : "hue_filetype" 305 | if (hue_to_use in obj_tree && "fraction" in obj_tree && rect) { 306 | [saturation, lightness] = fraction_to_saturation_and_lightness(obj_tree.fraction) 307 | rect.style["fill"] = `hsl(${obj_tree[hue_to_use]},${saturation}%,${lightness}%)` 308 | rect.style["fill-opacity"] = "100%" 309 | } 310 | } 311 | 312 | obj_tree.highlight = (hue, fraction) => { 313 | obj_tree.hue_user = hue 314 | obj_tree.fraction = fraction 315 | obj_tree.update_highlight() 316 | } 317 | 318 | obj_tree.filetype_highlight = () => { 319 | if (!obj_tree || "children" in obj_tree) return 320 | const extension = get_extension(obj_tree.text) 321 | const hue = extension_hue(extension) 322 | 323 | if (!EXTENSION_AREA.has(extension)) EXTENSION_AREA.set(extension, 0) 324 | EXTENSION_AREA.set(extension, EXTENSION_AREA.get(extension) + obj_tree.area) 325 | 326 | if (!EXTENSION_NUM_FILES.has(extension)) EXTENSION_NUM_FILES.set(extension, 0) 327 | EXTENSION_NUM_FILES.set(extension, EXTENSION_NUM_FILES.get(extension) + 1) 328 | 329 | if (hue === null || hue === undefined) return 330 | obj_tree.hue_filetype = hue 331 | obj_tree.update_highlight() 332 | } 333 | 334 | // Modifies text that appears when hovering over element 335 | obj_tree.set_title = (text) => { 336 | const alt_text = obj_tree.SVG_ELEMENT.querySelector("title") 337 | if (alt_text) { 338 | alt_text.textContent = alt_text.textContent.concat(`\n${text}`) 339 | } 340 | } 341 | SVG_ROOT.appendChild(obj_tree.SVG_ELEMENT) 342 | } 343 | 344 | // Get a list of all highlighted objects so we can more easily modify them 345 | function get_objs_to_highlight(obj_tree, highlighting_obj) { 346 | let out = [] 347 | if ("children" in highlighting_obj) highlighting_obj.children.forEach((child) => { 348 | if (!"children" in obj_tree) { 349 | console.error(`Searching for ${child.name} in`, obj_tree) 350 | } 351 | obj_tree_child = obj_tree.children.find((child2) => child2.text == child.name) 352 | if (obj_tree_child) out = out.concat(get_objs_to_highlight(obj_tree_child, child)) 353 | }) 354 | else if (highlighting_obj.val > 0) { 355 | obj_tree.highlight_value = highlighting_obj.val 356 | out.push(obj_tree) 357 | } 358 | return out 359 | } 360 | 361 | function get_all_objs(obj_tree) { 362 | let out = [] 363 | if ("children" in obj_tree) { 364 | obj_tree.children.forEach((child) => { 365 | out = out.concat(get_all_objs(child)) 366 | }) 367 | } 368 | else { 369 | out.push(obj_tree) 370 | } 371 | return out 372 | } 373 | 374 | function set_alt_text(obj_tree, highlighting_obj) { 375 | if ("children" in highlighting_obj) highlighting_obj.children.forEach((child) => { 376 | if (!"children" in obj_tree) { 377 | console.error(`Searching for ${child.name} in`, obj_tree) 378 | } 379 | const obj_to_set_text = obj_tree.children.find((child2) => child2.text == child.name) 380 | if (obj_to_set_text == undefined) { 381 | console.error(`Could not find ${child.name} in`, obj_tree) 382 | return 383 | } 384 | set_alt_text(obj_to_set_text, child) 385 | }) 386 | obj_tree.set_title(highlighting_obj.val) 387 | } 388 | 389 | // Highlight based on what fraction of a files changes are covered by the given filter 390 | // If false will highlight based on total changes to that file in the given filter 391 | FRACTION_HIGHLIGHTING = true 392 | 393 | function display_filetree(filetree_obj, highlighting_obj, SVG_ROOT, x, y, aspect_ratio, cur_path, hue) { 394 | delete_children(SVG_ROOT) 395 | const area = filetree_obj.val 396 | const width = Math.sqrt(area*aspect_ratio) 397 | const height = area / width 398 | 399 | if (!MIN_AREA_USER_SET) { 400 | // Currently disabling automatic min area 401 | // MIN_AREA = Math.floor(area / 5000) 402 | document.getElementById("size_picker_number").value = MIN_AREA 403 | } 404 | 405 | SVG_ROOT.setAttribute("viewBox", `0 0 ${width} ${height}`) 406 | const background_svg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 407 | background_svg.classList.add("svg_background") 408 | SVG_ROOT.appendChild(background_svg) 409 | 410 | let obj_tree = "children" in filetree_obj ? squarify(x,y,width,height,filetree_obj.children, cur_path, 0, SVG_ROOT) : handle_row([filetree_obj], x, y, width, height, cur_path, 0, SVG_ROOT) 411 | obj_tree.forEach((val) => draw_tree(val, SVG_ROOT)) 412 | 413 | EXTENSION_MAP.clear() 414 | EXTENSION_AREA.clear() 415 | EXTENSION_NUM_FILES.clear() 416 | const all_objs = get_all_objs({"children": obj_tree}) 417 | all_objs.forEach(obj => obj.filetype_highlight()) 418 | 419 | let objs_to_highlight = get_objs_to_highlight({"children": obj_tree}, highlighting_obj) 420 | if (Array.isArray(objs_to_highlight) && objs_to_highlight.length > 0) { 421 | const get_val = (obj) => obj.highlight_value 422 | const get_frac = (obj) => obj.highlight_value / obj.area 423 | const highlight_func = FRACTION_HIGHLIGHTING ? get_frac : get_val 424 | const max_val = objs_to_highlight.reduce((prev, cur) => Math.max(prev, highlight_func(cur)), -Infinity) 425 | const min_val = objs_to_highlight.reduce((prev, cur) => Math.min(prev, highlight_func(cur)), Infinity) 426 | // We want to scale using a log curve where f(max_val) = 1 and f(min_val) = 0 427 | // This works with log_{max_val+1-min_val}(x+1-min_val) 428 | if (max_val > min_val) objs_to_highlight.forEach((obj) => obj.highlight(hue, Math.log(highlight_func(obj) + 1 - min_val) / Math.log(max_val + 1 - min_val))) 429 | else if (min_val > 0) objs_to_highlight.forEach((obj) => obj.highlight(hue, 1)) 430 | set_alt_text({"children": obj_tree, "set_title": () => {}}, highlighting_obj) 431 | } 432 | } 433 | 434 | function display_filetree_path(filetree_obj, highlighting_obj, path, hue) { 435 | MAX_DEPTH = 0 436 | const [SVG_ROOT, x, y, aspect_ratio] = get_drawing_params() 437 | display_filetree(get_child_from_path(filetree_obj, path), get_child_from_path(highlighting_obj, path), SVG_ROOT, x, y, aspect_ratio, path, hue) 438 | } 439 | 440 | function get_drawing_params() { 441 | const SVG_ROOT = document.getElementById("treemap_root_svg") 442 | const vw = Math.max(SVG_ROOT.clientWidth || 0, SVG_ROOT.innerWidth || 0) 443 | const vh = Math.max(SVG_ROOT.clientHeight || 0, SVG_ROOT.innerHeight || 0) 444 | const aspect_ratio = vw/vh 445 | const x = 0 446 | const y = 0 447 | return [SVG_ROOT, x, y, aspect_ratio] 448 | } 449 | 450 | async function display_filetree_with_params(filetree_params, highlight_params, hue) { 451 | let filetree_promise = fetch_with_params(`/${DATABASE_NAME}/filetree.json`, filetree_params) 452 | filetree_obj_global = await filetree_promise 453 | await populate_submodules(SUBMODULE_TREE) 454 | sort_by_val(filetree_obj_global) 455 | if (highlight_params != null) { 456 | let highlight_promise = fetch_with_params(`/${DATABASE_NAME}/highlight.json`, highlight_params) 457 | highlighting_obj_global = await highlight_promise 458 | highlight_submodules(SUBMODULE_TREE, highlight_params) 459 | } else { 460 | highlighting_obj_global = filetree_obj_global 461 | } 462 | back_stack = [] 463 | display_filetree_path(filetree_obj_global, highlighting_obj_global, "", hue) 464 | } 465 | 466 | function get_submodule_names(submoudle_path) { 467 | return JSON.parse(loadFile(`/${DATABASE_NAME}${submoudle_path}/.gitmodules`)) 468 | } 469 | 470 | function get_submodule_tree(submoudle_path) { 471 | let children = get_submodule_names(submoudle_path) 472 | return { 473 | path: submoudle_path, 474 | submodules: children.map((child_name) => 475 | get_submodule_tree(`${submoudle_path}/${child_name}`) 476 | ), 477 | enabled: true 478 | } 479 | } 480 | 481 | async function populate_submodules(tree) { 482 | if (tree.enabled) return Promise.all(tree.submodules.map(async (submodule) => { 483 | if (!submodule.enabled) return 484 | const filetree_path = `/${DATABASE_NAME}${submodule.path}/filetree.json` 485 | const filetree = await fetch_with_params(filetree_path) 486 | insert_subtree(filetree_obj_global, filetree, submodule.path) 487 | return populate_submodules(submodule) 488 | })) 489 | } 490 | 491 | function highlight_submodules(tree, highlight_params) { 492 | if (tree.enabled) tree.submodules.forEach((submodule) => { 493 | if (!submodule.enabled) return 494 | const highlight_path = `/${DATABASE_NAME}${submodule.path}/highlight.json` 495 | const highlight = JSON.parse(loadFile(highlight_path, highlight_params)) 496 | insert_subtree(highlighting_obj_global, highlight, submodule.path) 497 | highlight_submodules(submodule, highlight_params) 498 | }) 499 | } 500 | 501 | async function main() { 502 | await display_filetree_with_params({}, null, "", 0) 503 | update_styles(document.getElementById("treemap_root_svg"), 1) 504 | update_defs(document.getElementById("treemap_root_svg"), MAX_DEPTH) 505 | } 506 | 507 | let filetree_obj_global = {} 508 | let highlighting_obj_global = {"name": "/", "val": 0, "children": []} 509 | let SUBMODULE_TREE = get_submodule_tree("") 510 | let back_stack = [] 511 | let MAX_DEPTH = 0 512 | 513 | main() 514 | -------------------------------------------------------------------------------- /static/javascript/treemap.ts: -------------------------------------------------------------------------------- 1 | // Request file with with given parameters 2 | function loadFile(file_path, params_obj) { 3 | let result = null 4 | let xmlhttp = new XMLHttpRequest() 5 | xmlhttp.open("GET", path_and_params_to_url(file_path, params_obj), false) 6 | xmlhttp.send() 7 | if (xmlhttp.status==200) { 8 | result = xmlhttp.responseText 9 | } 10 | return result 11 | } 12 | 13 | function fetch_with_params(file_path, params_obj) { 14 | return fetch(path_and_params_to_url(file_path, params_obj)).then((response) => { 15 | if (response.ok) { 16 | return response.json() 17 | } 18 | return null 19 | }) 20 | } 21 | 22 | function path_and_params_to_url(file_path, params_obj) { 23 | const search_params_str = params_to_url_params(params_obj).toString() 24 | return file_path + (search_params_str != "" ? `?${search_params_str}` : "") 25 | } 26 | 27 | function params_to_url_params(params_obj) { 28 | const search_params = new URLSearchParams() 29 | for (const key in params_obj) { 30 | params_obj[key].forEach((value) => search_params.append(key, value)) 31 | } 32 | return search_params 33 | } 34 | 35 | function sort_by_val(j) { 36 | if ("children" in j) { 37 | j.children.forEach(sort_by_val) 38 | j.children.sort((a,b) => b.val-a.val) 39 | } 40 | } 41 | 42 | // Part of the squarified treemap algorithm 43 | // Given a list of (area) values and a width that they need to fit into, return 44 | // the worst aspect ratio of any of these boxes when placed in a line 45 | function worst(R, w) { 46 | const s = R.reduce((acc,x) => acc+x, 0) 47 | if (s == 0 || w == 0) return -Infinity 48 | let m = 0 49 | const ws2 = (w**2)/(s**2) 50 | for (let i=0; i= height ? height : width 69 | let row = [] 70 | let cur_worst = Infinity 71 | for (let i=0; i c.val), size) 73 | if (cur_worst >= possible_worst) { 74 | row.push(children[i]) 75 | cur_worst = possible_worst 76 | } 77 | else break 78 | } 79 | const i = row.length 80 | children_out = children_out.concat(handle_row(row, x, y, width, height, parent_path, level, SVG_ROOT)) 81 | 82 | let area = row.reduce((acc, c) => acc+c.val, 0) 83 | let size_used = area / size 84 | if (width >= height) { 85 | x = x + size_used 86 | width = width - size_used 87 | } else { 88 | y = y + size_used 89 | height = height - size_used 90 | } 91 | 92 | const tmp = children.slice(i) 93 | if (tmp && tmp.length != 0) { 94 | children_out = children_out.concat(squarify(x, y, width, height, children.slice(i), parent_path, level, SVG_ROOT)) 95 | } 96 | return children_out 97 | } 98 | 99 | // Given a rectangular canvas and a list of items that will be displayed in one row, 100 | // produce objects for these items and their children 101 | function handle_row(row, x, y, width, height, parent_path, level, SVG_ROOT) { 102 | let row_area = row.reduce((acc, cur) => acc+cur.val, 0) 103 | let out = [] 104 | row.forEach((val, index, array) => { 105 | let box_area = val.val 106 | if (width >= height) { 107 | const row_width = height != 0 ? row_area / height : 0 108 | const box_height = row_width != 0 ? box_area / row_width : 0 109 | let el = {"text": val.name, "area": box_area, "x": x, "y": y, "width": row_width, "height": box_height, "parent": parent_path, "level": level} 110 | if ("submodule" in val && val.submodule == true) el.submodule = true 111 | if (NEST && "children" in val) el.children = squarify(x, y, row_width, box_height, val.children, `${parent_path}/${val.name}`, level+1, SVG_ROOT) 112 | out.push(el) 113 | y += box_height 114 | } else { 115 | const row_height = width != 0 ? row_area / width : 0 116 | const box_width = row_height != 0 ? box_area / row_height : 0 117 | let el = {"text": val.name, "area": box_area, "x": x, "y": y, "width": box_width, "height": row_height, "parent": parent_path, "level": level} 118 | if ("submodule" in val && val.submodule == true) el.submodule = true 119 | if (NEST && "children" in val) el.children = squarify(x, y, box_width, row_height, val.children, `${parent_path}/${val.name}`, level+1, SVG_ROOT) 120 | out.push(el) 121 | x += box_width 122 | } 123 | }) 124 | MAX_DEPTH = Math.max(MAX_DEPTH, level+1) 125 | return out 126 | } 127 | 128 | // Turns our object into an svg element 129 | function get_box_text_element(obj) { 130 | const is_leaf = !("children" in obj) 131 | const is_submodule = "submodule" in obj && obj.submodule == true 132 | 133 | let element = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 134 | let box = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 135 | let text = document.createElementNS('http://www.w3.org/2000/svg', 'text') 136 | let title = document.createElementNS('http://www.w3.org/2000/svg', 'title') 137 | 138 | element.setAttribute("x", `${obj.x}`) 139 | element.setAttribute("y", `${obj.y}`) 140 | element.setAttribute("width", `${obj.width}`) 141 | element.setAttribute("height", `${obj.height}`) 142 | element.classList.add(`svg_level_${obj.level}`) 143 | if (is_leaf) element.classList.add("svg_leaf") 144 | if (is_submodule) element.classList.add("svg_submodule") 145 | const path = `${obj.parent}/${obj.text}` 146 | element.setAttribute("id", `svg_path_${path}`) 147 | 148 | box.classList.add("svg_box") 149 | box.setAttribute("fill", `url(#Gradient${obj.level})`) 150 | box.setAttribute("fill-opacity", "20%") 151 | 152 | const txt = document.createTextNode(obj.text) 153 | text.appendChild(txt) 154 | text.classList.add("svg_text") 155 | text.setAttribute("x", "50%") 156 | text.setAttribute("y", "50%") 157 | text.setAttribute("dominant-baseline", "middle") 158 | text.setAttribute("text-anchor", "middle") 159 | let font_size = Math.min(1.5*obj.width/obj.text.length, 1*obj.height) 160 | text.setAttribute("font-size", `${font_size}`) 161 | text.setAttribute("stroke-width", `${font_size/80}`) 162 | 163 | const title_txt = document.createTextNode(`${obj.area}\n${path}`) 164 | title.appendChild(title_txt) 165 | 166 | if (obj.level == 0) { 167 | if (!is_leaf) element.onclick = () => { 168 | back_stack.push(obj.parent) 169 | display_filetree_path(filetree_obj_global, highlighting_obj_global, path, get_hue()) 170 | } 171 | else element.onclick = () => { 172 | update_info_box_with_file_stats(path.slice(1)) 173 | open_overlay() 174 | } 175 | element.onmouseover = () => box.classList.add("svg_box_selected") 176 | element.onmouseout = () => box.classList.remove("svg_box_selected") 177 | } 178 | 179 | element.appendChild(box) 180 | element.appendChild(text) 181 | element.appendChild(title) 182 | 183 | if (obj.area < Math.max(0, MIN_AREA)) { 184 | element.classList.add("is_not_visible") 185 | } 186 | 187 | return element 188 | } 189 | 190 | function fraction_to_saturation_and_lightness(fraction) { 191 | const saturation_max = 90 192 | const saturation_min = 40 193 | const lightness_min = 50 194 | const lightness_max = 90 195 | return [(saturation_max-saturation_min)*fraction+saturation_min, (lightness_min-lightness_max)*fraction+lightness_max] 196 | } 197 | 198 | function delete_children(node) { 199 | node.querySelectorAll("svg").forEach((child) => node.removeChild(child)) 200 | node.querySelectorAll(".svg_background").forEach((child) => node.removeChild(child)) 201 | } 202 | 203 | function get_child_from_path(obj, path) { 204 | if (path[0] == "/") path = path.slice(1) 205 | if (path == "") return obj 206 | const index = path.indexOf("/") 207 | if (index == -1) { 208 | desired_child = obj.children.filter((child) => child.name == path) 209 | if (desired_child.length == 1) { 210 | return desired_child[0] 211 | } 212 | } else { 213 | desired_child = obj.children.filter((child) => child.name == path.slice(0,index)) 214 | if (desired_child.length == 1) { 215 | return get_child_from_path(desired_child[0], path.slice(index+1)) 216 | } 217 | } 218 | return {} 219 | } 220 | 221 | function insert_subtree(parent, to_insert, path) { 222 | let cur_child_val = 0 223 | let new_child_val = 0 224 | if (path[0] == "/") path = path.slice(1) 225 | if (path == "") { 226 | parent = to_insert 227 | } else { 228 | const index = path.indexOf("/") 229 | if (index == -1) { 230 | let poss_children = parent.children.filter((child) => child.name == path) 231 | if (poss_children.length == 0) { 232 | const tmp = {"val": 0} 233 | poss_children.push(tmp) 234 | parent.children.push(tmp) 235 | } 236 | if (poss_children.length == 1) { 237 | cur_child_val = poss_children[0].val 238 | new_child_val = to_insert.val 239 | poss_children[0].name = path 240 | poss_children[0].val = to_insert.val 241 | poss_children[0].children = to_insert.children 242 | poss_children[0].submodule = true 243 | } 244 | } else { 245 | const poss_children = parent.children.filter((child) => child.name == path.slice(0,index)) 246 | if (poss_children.length == 1) { 247 | cur_child_val = poss_children[0].val 248 | new_child_val = insert_subtree(poss_children[0], to_insert, path.slice(index+1)) 249 | } 250 | } 251 | } 252 | parent.val += new_child_val - cur_child_val 253 | sort_by_val(parent) 254 | return parent.val 255 | } 256 | 257 | function get_extension(filename) { 258 | const n = filename.length 259 | const i = filename.indexOf(".") 260 | if (i == -1 || i == n) return null 261 | return filename.split(".").pop() 262 | } 263 | 264 | let EXTENSION_MAP = new Map() 265 | let EXTENSION_AREA = new Map() 266 | let EXTENSION_NUM_FILES = new Map() 267 | 268 | function extension_hue(extension) { 269 | if (!EXTENSION_MAP.has(extension)) { 270 | if (extension === null || extension === undefined) { 271 | EXTENSION_MAP.set(extension, null) 272 | } else { 273 | const bytes = Uint8Array.from(extension.split("").map(c => c.charCodeAt(0))) 274 | let hue = 0 275 | bytes.forEach((b, index) => { 276 | hue += ((((b%26)+7)%26)+1) * (360/27) / (27**index) 277 | }) 278 | EXTENSION_MAP.set(extension, hue) 279 | } 280 | } 281 | return EXTENSION_MAP.get(extension) 282 | } 283 | 284 | USER_DEFINED_HUE = false 285 | 286 | // Given an object in the style generated by handle_row, draw the boxes as necessary 287 | function draw_tree(obj_tree, SVG_ROOT) { 288 | // Draw children first so parent directory draws on top and so is clickable 289 | if (obj_tree && "children" in obj_tree) obj_tree.children.forEach((child) => draw_tree(child, SVG_ROOT)) 290 | 291 | // Connect object model to actual displayed elements 292 | obj_tree.SVG_ELEMENT = get_box_text_element(obj_tree) 293 | 294 | // Separate function so that we can update element colour dynamically 295 | obj_tree.update_highlight = () => { 296 | if (obj_tree.SVG_ELEMENT.querySelector(".svg_box_highlight") === null) { 297 | const box_highlight = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 298 | box_highlight.classList.add("svg_box_highlight") 299 | box_highlight.setAttribute("fill", "none") 300 | box_highlight.setAttribute("fill-opacity", "100%") 301 | obj_tree.SVG_ELEMENT.insertBefore(box_highlight, obj_tree.SVG_ELEMENT.querySelector(".svg_box")) 302 | } 303 | const rect = obj_tree.SVG_ELEMENT.querySelector(".svg_box_highlight") 304 | const hue_to_use = USER_DEFINED_HUE ? "hue_user" : "hue_filetype" 305 | if (hue_to_use in obj_tree && "fraction" in obj_tree && rect) { 306 | [saturation, lightness] = fraction_to_saturation_and_lightness(obj_tree.fraction) 307 | rect.style["fill"] = `hsl(${obj_tree[hue_to_use]},${saturation}%,${lightness}%)` 308 | rect.style["fill-opacity"] = "100%" 309 | } 310 | } 311 | 312 | obj_tree.highlight = (hue, fraction) => { 313 | obj_tree.hue_user = hue 314 | obj_tree.fraction = fraction 315 | obj_tree.update_highlight() 316 | } 317 | 318 | obj_tree.filetype_highlight = () => { 319 | if (!obj_tree || "children" in obj_tree) return 320 | const extension = get_extension(obj_tree.text) 321 | const hue = extension_hue(extension) 322 | 323 | if (!EXTENSION_AREA.has(extension)) EXTENSION_AREA.set(extension, 0) 324 | EXTENSION_AREA.set(extension, EXTENSION_AREA.get(extension) + obj_tree.area) 325 | 326 | if (!EXTENSION_NUM_FILES.has(extension)) EXTENSION_NUM_FILES.set(extension, 0) 327 | EXTENSION_NUM_FILES.set(extension, EXTENSION_NUM_FILES.get(extension) + 1) 328 | 329 | if (hue === null || hue === undefined) return 330 | obj_tree.hue_filetype = hue 331 | obj_tree.update_highlight() 332 | } 333 | 334 | // Modifies text that appears when hovering over element 335 | obj_tree.set_title = (text) => { 336 | const alt_text = obj_tree.SVG_ELEMENT.querySelector("title") 337 | if (alt_text) { 338 | alt_text.textContent = alt_text.textContent.concat(`\n${text}`) 339 | } 340 | } 341 | SVG_ROOT.appendChild(obj_tree.SVG_ELEMENT) 342 | } 343 | 344 | // Get a list of all highlighted objects so we can more easily modify them 345 | function get_objs_to_highlight(obj_tree, highlighting_obj) { 346 | let out = [] 347 | if ("children" in highlighting_obj) highlighting_obj.children.forEach((child) => { 348 | if (!"children" in obj_tree) { 349 | console.error(`Searching for ${child.name} in`, obj_tree) 350 | } 351 | obj_tree_child = obj_tree.children.find((child2) => child2.text == child.name) 352 | if (obj_tree_child) out = out.concat(get_objs_to_highlight(obj_tree_child, child)) 353 | }) 354 | else if (highlighting_obj.val > 0) { 355 | obj_tree.highlight_value = highlighting_obj.val 356 | out.push(obj_tree) 357 | } 358 | return out 359 | } 360 | 361 | function get_all_objs(obj_tree) { 362 | let out = [] 363 | if ("children" in obj_tree) { 364 | obj_tree.children.forEach((child) => { 365 | out = out.concat(get_all_objs(child)) 366 | }) 367 | } 368 | else { 369 | out.push(obj_tree) 370 | } 371 | return out 372 | } 373 | 374 | function set_alt_text(obj_tree, highlighting_obj) { 375 | if ("children" in highlighting_obj) highlighting_obj.children.forEach((child) => { 376 | if (!"children" in obj_tree) { 377 | console.error(`Searching for ${child.name} in`, obj_tree) 378 | } 379 | const obj_to_set_text = obj_tree.children.find((child2) => child2.text == child.name) 380 | if (obj_to_set_text == undefined) { 381 | console.error(`Could not find ${child.name} in`, obj_tree) 382 | return 383 | } 384 | set_alt_text(obj_to_set_text, child) 385 | }) 386 | obj_tree.set_title(highlighting_obj.val) 387 | } 388 | 389 | // Highlight based on what fraction of a files changes are covered by the given filter 390 | // If false will highlight based on total changes to that file in the given filter 391 | FRACTION_HIGHLIGHTING = true 392 | 393 | function display_filetree(filetree_obj, highlighting_obj, SVG_ROOT, x, y, aspect_ratio, cur_path, hue) { 394 | delete_children(SVG_ROOT) 395 | const area = filetree_obj.val 396 | const width = Math.sqrt(area*aspect_ratio) 397 | const height = area / width 398 | 399 | if (!MIN_AREA_USER_SET) { 400 | // Currently disabling automatic min area 401 | // MIN_AREA = Math.floor(area / 5000) 402 | document.getElementById("size_picker_number").value = MIN_AREA 403 | } 404 | 405 | SVG_ROOT.setAttribute("viewBox", `0 0 ${width} ${height}`) 406 | const background_svg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 407 | background_svg.classList.add("svg_background") 408 | SVG_ROOT.appendChild(background_svg) 409 | 410 | let obj_tree = "children" in filetree_obj ? squarify(x,y,width,height,filetree_obj.children, cur_path, 0, SVG_ROOT) : handle_row([filetree_obj], x, y, width, height, cur_path, 0, SVG_ROOT) 411 | obj_tree.forEach((val) => draw_tree(val, SVG_ROOT)) 412 | 413 | EXTENSION_MAP.clear() 414 | EXTENSION_AREA.clear() 415 | EXTENSION_NUM_FILES.clear() 416 | const all_objs = get_all_objs({"children": obj_tree}) 417 | all_objs.forEach(obj => obj.filetype_highlight()) 418 | 419 | let objs_to_highlight = get_objs_to_highlight({"children": obj_tree}, highlighting_obj) 420 | if (Array.isArray(objs_to_highlight) && objs_to_highlight.length > 0) { 421 | const get_val = (obj) => obj.highlight_value 422 | const get_frac = (obj) => obj.highlight_value / obj.area 423 | const highlight_func = FRACTION_HIGHLIGHTING ? get_frac : get_val 424 | const max_val = objs_to_highlight.reduce((prev, cur) => Math.max(prev, highlight_func(cur)), -Infinity) 425 | const min_val = objs_to_highlight.reduce((prev, cur) => Math.min(prev, highlight_func(cur)), Infinity) 426 | // We want to scale using a log curve where f(max_val) = 1 and f(min_val) = 0 427 | // This works with log_{max_val+1-min_val}(x+1-min_val) 428 | if (max_val > min_val) objs_to_highlight.forEach((obj) => obj.highlight(hue, Math.log(highlight_func(obj) + 1 - min_val) / Math.log(max_val + 1 - min_val))) 429 | else if (min_val > 0) objs_to_highlight.forEach((obj) => obj.highlight(hue, 1)) 430 | set_alt_text({"children": obj_tree, "set_title": () => {}}, highlighting_obj) 431 | } 432 | } 433 | 434 | function display_filetree_path(filetree_obj, highlighting_obj, path, hue) { 435 | MAX_DEPTH = 0 436 | const [SVG_ROOT, x, y, aspect_ratio] = get_drawing_params() 437 | display_filetree(get_child_from_path(filetree_obj, path), get_child_from_path(highlighting_obj, path), SVG_ROOT, x, y, aspect_ratio, path, hue) 438 | } 439 | 440 | function get_drawing_params() { 441 | const SVG_ROOT = document.getElementById("treemap_root_svg") 442 | const vw = Math.max(SVG_ROOT.clientWidth || 0, SVG_ROOT.innerWidth || 0) 443 | const vh = Math.max(SVG_ROOT.clientHeight || 0, SVG_ROOT.innerHeight || 0) 444 | const aspect_ratio = vw/vh 445 | const x = 0 446 | const y = 0 447 | return [SVG_ROOT, x, y, aspect_ratio] 448 | } 449 | 450 | async function display_filetree_with_params(filetree_params, highlight_params, hue) { 451 | let filetree_promise = fetch_with_params(`/${DATABASE_NAME}/filetree.json`, filetree_params) 452 | filetree_obj_global = await filetree_promise 453 | await populate_submodules(SUBMODULE_TREE) 454 | sort_by_val(filetree_obj_global) 455 | if (highlight_params != null) { 456 | let highlight_promise = fetch_with_params(`/${DATABASE_NAME}/highlight.json`, highlight_params) 457 | highlighting_obj_global = await highlight_promise 458 | highlight_submodules(SUBMODULE_TREE, highlight_params) 459 | } else { 460 | highlighting_obj_global = filetree_obj_global 461 | } 462 | back_stack = [] 463 | display_filetree_path(filetree_obj_global, highlighting_obj_global, "", hue) 464 | } 465 | 466 | function get_submodule_names(submoudle_path) { 467 | return JSON.parse(loadFile(`/${DATABASE_NAME}${submoudle_path}/.gitmodules`)) 468 | } 469 | 470 | function get_submodule_tree(submoudle_path) { 471 | let children = get_submodule_names(submoudle_path) 472 | return { 473 | path: submoudle_path, 474 | submodules: children.map((child_name) => 475 | get_submodule_tree(`${submoudle_path}/${child_name}`) 476 | ), 477 | enabled: true 478 | } 479 | } 480 | 481 | async function populate_submodules(tree) { 482 | if (tree.enabled) return Promise.all(tree.submodules.map(async (submodule) => { 483 | if (!submodule.enabled) return 484 | const filetree_path = `/${DATABASE_NAME}${submodule.path}/filetree.json` 485 | const filetree = await fetch_with_params(filetree_path) 486 | insert_subtree(filetree_obj_global, filetree, submodule.path) 487 | return populate_submodules(submodule) 488 | })) 489 | } 490 | 491 | function highlight_submodules(tree, highlight_params) { 492 | if (tree.enabled) tree.submodules.forEach((submodule) => { 493 | if (!submodule.enabled) return 494 | const highlight_path = `/${DATABASE_NAME}${submodule.path}/highlight.json` 495 | const highlight = JSON.parse(loadFile(highlight_path, highlight_params)) 496 | insert_subtree(highlighting_obj_global, highlight, submodule.path) 497 | highlight_submodules(submodule, highlight_params) 498 | }) 499 | } 500 | 501 | async function main() { 502 | await display_filetree_with_params({}, null, "", 0) 503 | update_styles(document.getElementById("treemap_root_svg"), 1) 504 | update_defs(document.getElementById("treemap_root_svg"), MAX_DEPTH) 505 | } 506 | 507 | let filetree_obj_global = {} 508 | let highlighting_obj_global = {"name": "/", "val": 0, "children": []} 509 | let SUBMODULE_TREE = get_submodule_tree("") 510 | let back_stack = [] 511 | let MAX_DEPTH = 0 512 | 513 | main() 514 | -------------------------------------------------------------------------------- /static/javascript/treemap_style.js: -------------------------------------------------------------------------------- 1 | const SVG_STYLE = ` 2 | .svg_background { 3 | width: 100%; 4 | height: 100%; 5 | fill: #ffffff; 6 | } 7 | 8 | .svg_box { 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | .svg_box_selected { 14 | stroke: blue; 15 | stroke-width: 2%; 16 | } 17 | 18 | .svg_leaf .svg_box_selected { 19 | stroke: lightblue; 20 | } 21 | 22 | .svg_submodule .svg_box_selected { 23 | stroke: orange; 24 | } 25 | 26 | .svg_text { 27 | visibility: hidden; 28 | font-family: monospace; 29 | } 30 | 31 | .svg_box_highlight { 32 | width: 100%; 33 | height: 100%; 34 | } 35 | 36 | .is_not_visible { 37 | visibility: hidden; 38 | } 39 | ` 40 | 41 | function delete_styles(node) { 42 | cur_style_element = node.querySelector("style") 43 | if (cur_style_element) { 44 | node.removeChild(cur_style_element) 45 | } 46 | } 47 | 48 | function update_styles(node, text_depth) { 49 | delete_styles(node) 50 | const style = document.createElement("style") 51 | 52 | let text_rule = "" 53 | 54 | for (let i=0; i 2 | 3 | 4 | Git Heat Map 5 | 6 | 7 | 8 |

Git Heat Map

9 |
10 |

Repos

11 | 16 |
17 | 20 | 21 | -------------------------------------------------------------------------------- /templates/treemap.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ name }} 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | --------------------------------------------------------------------------------