diff --git a/web/index.html b/web/index.html index 11424507..ea50d319 100644 --- a/web/index.html +++ b/web/index.html @@ -155,6 +155,7 @@

Commits Tree

+ diff --git a/web/public/js/graph/git/file-explorer.js b/web/public/js/graph/git/file-explorer.js index 31e91054..d4cc7f57 100644 --- a/web/public/js/graph/git/file-explorer.js +++ b/web/public/js/graph/git/file-explorer.js @@ -24,6 +24,8 @@ function renderCodeExplorer(freedom, data, elementId) { } }); + let some = calculateCodeLayout(data); + console.log(some); let freedom_nest = d3.group(freedom_year, d => d.region_simple) let data_nested = {key: "freedom_nest", values: freedom_nest} diff --git a/web/public/js/plugins/code-layout.js b/web/public/js/plugins/code-layout.js new file mode 100644 index 00000000..e1ddc658 --- /dev/null +++ b/web/public/js/plugins/code-layout.js @@ -0,0 +1,316 @@ +// MIT License +// +// Copyright (c) 2020 Korny Sietsma +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +const debug = false; + +function computeCirclingPolygon(points, radius) { + const increment = (2 * Math.PI) / points; + const circlingPolygon = []; + + for (let a = 0, i = 0; i < points; i++, a += increment) { + circlingPolygon.push([radius * Math.cos(a), radius * Math.sin(a)]); + } + + return circlingPolygon; +} + +function flareWeightLoc(d) { + if (d.data === undefined) return 0; + if (d.data.loc === undefined) return 0; + return d.data.loc.code; +} + +function pruneWeightlessNodes(hierarchy) { + if (hierarchy.children !== undefined) { + // eslint-disable-next-line no-param-reassign + hierarchy.children = hierarchy.children.filter((node) => node.value > 0); + hierarchy.children.forEach((child) => pruneWeightlessNodes(child)); + } +} + +function addPaths(pathSoFar, node) { + let path; + if (pathSoFar === null) { + path = ''; // not 'flare' - could use '/' or null - but this is nicer for output + } else { + if (pathSoFar === '') { + path = node.name; + } else { + path = `${pathSoFar}/${node.name}`; + } + } + const children = node.children + ? node.children.map((n) => addPaths(path, n)) + : undefined; + return { + name: node.name, + path, + children: children, + layout: node.layout, + value: node.value, + data: node.data, + }; +} + +function calculate_values(node) { + if (node.children) { + for (const n of node.children) { + calculate_values(n); + } + const tot = node.children.map((n) => n.value).reduce((a, b) => a + b, 0); + node.value = tot; + } else { + node.value = flareWeightLoc(node); + } +} + +function calculateVoronoi( + nameSoFar, + node, + clipPolygon, + center, + goodenough, + depth +) { + const name = nameSoFar ? `${nameSoFar}/${node.name}` : node.name; + node.layout = { + polygon: clipPolygon, + center, + algorithm: 'voronoi', + }; + + if (!node.children) { + return; + } + if (depth < 3) { + console.warn(`calculating voronoi for ${name}`); + } else if (depth === 3) { + console.warn(`calculating voronoi for ${name} and descendants`); + } + if (debug) { + console.warn( + `calculating voronoi for ${name} with ${node.children.length} children and a clip polygon with ${clipPolygon.length} vertices` + ); + } + + const MAX_SIMULATION_COUNT = 200; // we re-run the whole simulation this many times if it fails + const MAX_ITERATION_COUNT = 500; // this is how many times a particular simulation iterates + const MIN_WEIGHT_RATIO = 0.005; // maybe this should be a parameter? Too high, we iterate a lot. Too low, sizes are not proportional to lines of code. + let simulationCount = 0; + let simulationLoopEnded = false; + let bestConvergenceRatio = 1.0; + let bestPolygons = undefined; + while (!simulationLoopEnded) { + try { + var simulation = d3.voronoiMapSimulation(node.children) + .maxIterationCount(MAX_ITERATION_COUNT) + .minWeightRatio(MIN_WEIGHT_RATIO) + .weight((d) => d.value) + .clip(clipPolygon) + .stop(); + + var state = simulation.state(); + + let tickCount = 0; + let warningTime = Date.now(); + while (!state.ended) { + tickCount += 1; + const now = Date.now(); + if (now - warningTime > 10000) { + // every 10 seconds + warningTime = now; + console.warn( + `slow voronoi processing of ${name} with ${node.children.length} children, tick count: ${tickCount}` + ); + } + simulation.tick(); + state = simulation.state(); + } + if (tickCount === MAX_ITERATION_COUNT) { + if (state.convergenceRatio < bestConvergenceRatio) { + if (debug) { + console.warn( + 'best iteration result so far', + simulationCount, + state.convergenceRatio + ); + } + bestConvergenceRatio = state.convergenceRatio; + bestPolygons = [...state.polygons]; + } + + if (simulationCount < MAX_SIMULATION_COUNT) { + simulationCount = simulationCount + 1; + + console.warn( + `processing ${name} with ${node.children.length} children - Exceeded tick count ${tickCount} - retrying from scratch, try ${simulationCount}` + ); + } else { + console.error('Too many meta retries - stopping'); + simulationLoopEnded = true; + if (!goodenough) { + throw Error("Too many retries, can't provide good simulation"); + } else { + console.warn('returning good-enough result', bestConvergenceRatio); + } + } + } else { + if (bestPolygons) { + console.warn( + 'successful converging layout, using real ratio not best-so-far: ', + state.convergenceRatio + ); + bestPolygons = undefined; + bestConvergenceRatio = state.convergenceRatio; + } + simulationLoopEnded = true; + } + } catch (e) { + // re-try from scratch but only after predictable exceptions + console.warn('caught e', e.message); + if (!(e instanceof Error) && !(e instanceof TypeError)) { + console.error('not Error or TypeError'); + throw e; + } + if ( + e.message === 'handleOverweighted1 is looping too much' || + e.message === + 'at least 1 site has no area, which is not supposed to arise' + ) { + simulationCount = simulationCount + 1; + if (simulationCount < MAX_SIMULATION_COUNT) { + console.warn( + `caught ${e.message}, retrying from scratch`, + simulationCount + ); + } else { + console.error( + `caught ${e.message}, too many errors!`, + simulationCount + ); + simulationLoopEnded = true; + if (!goodenough) { + throw Error("Too many retries, can't provide good simulation"); + } else { + console.warn('returning good-enough result', bestConvergenceRatio); + } + } + } else { + console.error( + `unhandled exception ${e.name}:${e.message} - rethrowing` + ); + throw e; + } + } + } + var polygons = state.polygons; + if (bestPolygons) { + console.error( + 'No good layout found - using best convergence ratio', + bestConvergenceRatio + ); + polygons = bestPolygons; + } else { + if (debug) { + console.warn( + 'Successful layout - best convergence ratio', + state.convergenceRatio + ); + } + } + + for (const polygon of polygons) { + const pdata = polygon.map((d) => d); + calculateVoronoi( + name, + polygon.site.originalObject.data.originalData, + pdata, + [polygon.site.x, polygon.site.y], + goodenough, + depth + 1 + ); + } +} + +function calculateCodeLayout(input) { + return codeLayout(input, 128, false); +} + +function codeLayout(input, points, circles) { + const parsedData = input + const width = 1024; + + console.warn('getting values recursively'); + calculate_values(parsedData); + console.warn('pruning empty nodes'); + pruneWeightlessNodes(parsedData); + + // top level clip shape + if (circles) { + // area = pi r^2 so r = sqrt(area/pi) or just use sqrt(area) for simplicity + const children = parsedData.children.map((child) => { + return { r: Math.sqrt(child.value), originalObject: child }; + }); + d3.packSiblings(children); + // top level layout + const enclosingCirle = d3.packEnclose(children); + const { x, y, r } = enclosingCirle; + // TODO: offset by x/y + parsedData.layout = { + polygon: computeCirclingPolygon(points, r), + center: [0, 0], + width: r * 2, + height: r * 2, + algorithm: 'circlePack', + }; + + for (const child of children) { + const clipPolygon = computeCirclingPolygon( + points, + child.r + ).map(([x, y]) => [x + child.x, y + child.y]); + const center = [child.x, child.y]; + + calculateVoronoi( + child.originalObject.name, + child.originalObject, + clipPolygon, + center, + true, + 1 + ); + child.originalObject.layout.width = child.r; + child.originalObject.layout.height = child.r; + } + } else { + const clipPolygon = computeCirclingPolygon(points, width / 2); + const center = [0, 0]; + + calculateVoronoi(null, parsedData, clipPolygon, center, true, 0); + + parsedData.layout.width = width; + parsedData.layout.height = width; + } + + const results = addPaths(null, parsedData); + + return results; +}