From e8f3c74d0df826cd865dd4617f89e40ad1fc0ba8 Mon Sep 17 00:00:00 2001 From: aboydnw Date: Tue, 10 Mar 2026 23:26:19 +0000 Subject: [PATCH 1/4] feat: add organization nickname config plumbing Expose an optional organization nickname from TOML through the build output and runtime config so UI copy can use a short org label consistently. Made-with: Cursor --- config.toml | 1 + js/chart.js | 1485 ++++++++++++++++++++++++++ public/data/config.json | 1 + python/contributor_network/cli.py | 1 + python/contributor_network/config.py | 2 + 5 files changed, 1490 insertions(+) create mode 100644 js/chart.js diff --git a/config.toml b/config.toml index 5a709f8..85d1b9f 100644 --- a/config.toml +++ b/config.toml @@ -2,6 +2,7 @@ title = "The Development Seed Contributor Network" author = "Pete Gadomski" description = "An interactive visualization of contributors to Development Seed code and their connections to other repositories" organization_name = "Development Seed" +organization_nickname = "DevSeed" repositories = [ "developmentseed/titiler", "developmentseed/lonboard", diff --git a/js/chart.js b/js/chart.js new file mode 100644 index 0000000..e7cf7c3 --- /dev/null +++ b/js/chart.js @@ -0,0 +1,1485 @@ +// This file was copied-and-modified from +// https://github.com/nbremer/ORCA/blob/77745774d9d189818ab1ba27e07979897434abf9/top-contributor-network/createORCAVisual.js, +// and is licensed under the same (MPL). +// +// Development Seed Modifications: +// - Updated color scheme to DevSeed brand (Grenadier orange, Aquamarine blue) +// - Removed the central "team" pseudo-node entirely (force simulation finds natural equilibrium) +// - Contributors positioned in fixed ring around viewport center +// - Added null safety checks for hover/click interactions +// - Added boundary checking to prevent hover outside visualization area +// - Added mouseleave handler to properly clean up hover state +// - Refactored to use modular components (Phase 1 & 2 data expansion) +// +///////////////////////////////////////////////////////////////////// +/////////////// Visualization designed & developed by /////////////// +/////////////////////////// Nadieh Bremer /////////////////////////// +///////////////////////// VisualCinnamon.com //////////////////////// +///////////////////////////////////////////////////////////////////// + +// ============================================================ +// Modular Imports (loaded via Vite bundler) +import { COLORS, FONTS, SIZES } from './config/theme.js'; +import { + isValidNode, + isValidLink, + getLinkNodeId, + resolveLinkReferences +} from './utils/validation.js'; +import { + createRepoRadiusScale, + createOwnerRadiusScale, + createContributorRadiusScale, + createLinkDistanceScale, + createLinkWidthScale +} from './config/scales.js'; +import { + setFont, + setRepoFont, + setCentralRepoFont, + setOwnerFont, + setContributorFont, + renderText, + getLines, + splitString, + drawTextAlongArc +} from './render/text.js'; + +// ============================================================ +import { + renderStatsLine, + renderLanguages, + renderCommunityMetrics, + renderLicense, + renderArchivedBadge, + REPO_CARD_CONFIG +} from './render/repoCard.js'; +import { + runOwnerSimulation, + runContributorSimulation, + runCollaborationSimulation +} from './simulations/index.js'; +import { + createFilterState, + addOrganization, + removeOrganization, + clearFilters, + hasOrganization, + hasActiveFilters, + setMetricFilter +} from './state/filterState.js'; +import { prepareData } from './data/prepare.js'; +import { positionContributorNodes } from './layout/positioning.js'; +import { draw as drawVisualization } from './render/draw.js'; +import { + createInteractionState, + setHovered, + clearHover, + setClicked, + clearClick, + clearAll, + setDelaunay, + clearDelaunay +} from './state/interactionState.js'; +import { findNode as findNodeAtPosition } from './interaction/findNode.js'; +import { setupHover as setupHoverInteraction } from './interaction/hover.js'; +import { setupClick as setupClickInteraction } from './interaction/click.js'; +import { + setupZoom as setupZoomModule, + applyZoomTransform, + shouldSuppressClick, + transformMouseCoordinates +} from './interaction/zoom.js'; +import { + drawCircle, + drawCircleArc, + drawLine, + drawNode, + drawNodeArc, + drawHoverRing, + timeRangeArc, + drawHatchPattern, + drawLink +} from './render/shapes.js'; +import { drawTooltip as drawTooltipModule } from './render/tooltip.js'; +import { drawNodeLabel } from './render/labels.js'; +import { LAYOUT } from './config/theme.js'; + +// Extract commonly used constants for convenience +const DEFAULT_SIZE = LAYOUT.defaultSize; +import { handleResize, sizeCanvas, calculateScaleFactor } from './layout/resize.js'; + +// ============================================================ +// Main Visualization +// ============================================================ +const createContributorNetworkVisual = ( + container, + contributor_padding, + masterContributorsList, + displayNameMap, + orgNickname = 'DevSeed', +) => { + ///////////////////////////////////////////////////////////////// + ///////////////////// CONSTANTS & VARIABLES ///////////////////// + ///////////////////////////////////////////////////////////////// + + const PI = Math.PI; + const TAU = PI * 2; + + let round = Math.round; + let cos = Math.cos; + let sin = Math.sin; + let min = Math.min; + let max = Math.max; + let sqrt = Math.sqrt; + + ///////////////////////////////////////////////////////////////// + // Filter State Management + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/state/filterState.js + let activeFilters = createFilterState(); + + // Master contributor list (passed from template) + // Used for: validating contributors exist, future username-based filtering + // - masterContributors: { username: { ...contributor data } } + // - displayNameToUsername: { "Display Name": "github_username" } + let masterContributors = masterContributorsList; + let displayNameToUsername = displayNameMap; + + /** + * Check if a contributor display name is in the master list + * Useful for filtering to only "official" DeVSeed employees or verifying data integrity + * @param {string} displayName - The display name to check + * @returns {boolean} - True if valid or validation impossible, false if invalid + */ + function isValidContributor(displayName) { + // If maps are missing (e.g. during initial loads or legacy mode), default to true + if (!displayNameToUsername || !masterContributors) return true; + + // Check if name maps to a username, and that username exists in master list + const username = displayNameToUsername[displayName]; + return username && masterContributors[username]; + } + + // Preserve original data to allow filtering + let originalContributors; + let originalRepos; + let originalLinks; + + // Track visible data after filtering + let visibleRepos; + let visibleLinks; + let visibleContributors; + + // Datasets + let contributors; + let repos; + let nodes = [], + nodes_central; + let links; + + ///////////////////////////////////////////////////////////////// + // Interaction State Management + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/state/interactionState.js + let interactionState = createInteractionState(); + + // Convenience references to Delaunay data + // These are kept in sync with interactionState and delaunayData object + let delaunay; + let nodes_delaunay; + + // Helper to sync local variables with delaunayData + function syncDelaunayVars(delaunayData) { + delaunay = delaunayData.delaunay; + nodes_delaunay = delaunayData.nodesDelaunay; + } + + ///////////////////////////////////////////////////////////////// + // Zoom State Management + ///////////////////////////////////////////////////////////////// + // Zoom state object (will be mutated by setupZoom) + const zoomState = {}; + const ZOOM_CLICK_SUPPRESS_MS = 150; + + // Visual Settings - Based on SF = 1 + // Layout constants imported from src/js/config/theme.js + const CENTRAL_RADIUS = LAYOUT.centralRadius; // The radius of the central repository node (reduced for less prominence) + let RADIUS_CONTRIBUTOR; // The eventual radius along which the contributor nodes are placed + let CONTRIBUTOR_RING_WIDTH; + + const INNER_RADIUS_FACTOR = LAYOUT.innerRadiusFactor; // The factor of the RADIUS_CONTRIBUTOR outside of which the inner repos are not allowed to go in the force simulation + const MAX_CONTRIBUTOR_WIDTH = LAYOUT.maxContributorWidth; // The maximum width (at SF = 1) of the contributor name before it gets wrapped + const CONTRIBUTOR_PADDING = contributor_padding; // The padding between the contributor nodes around the circle (at SF = 1) + + ///////////////////////////////////////////////////////////////// + ///////////////////////////// Colors //////////////////////////// + ///////////////////////////////////////////////////////////////// + /* + * DevSeed Brand Colors + * Imported from src/js/config/theme.js + * Kept as local constants for compatibility + */ + const COLOR_BACKGROUND = COLORS.background; + + // Was purple, now Grenadier for accent rings + const COLOR_PURPLE = COLORS.grenadier; + + const COLOR_REPO_MAIN = COLORS.repoMain; // Grenadier (signature orange) + const COLOR_REPO = COLORS.repo; // Aquamarine (secondary blue) + const COLOR_OWNER = COLORS.owner; // Grenadier + const COLOR_CONTRIBUTOR = COLORS.contributor; // Lighter aquamarine + + const COLOR_LINK = COLORS.link; + const COLOR_TEXT = COLORS.text; // Base dark gray + + ///////////////////////////////////////////////////////////////// + //////////////////////// Validation Helpers ///////////////////// + ///////////////////////////////////////////////////////////////// + // Imported from src/js/utils/validation.js + // isValidNode, isValidLink, getLinkNodeId are now imported + + + ///////////////////////////////////////////////////////////////// + ///////////////////////// Create Canvas ///////////////////////// + ///////////////////////////////////////////////////////////////// + + // Create the three canvases and add them to the container + const canvas = document.createElement("canvas"); + canvas.id = "canvas"; + const context = canvas.getContext("2d"); + + const canvas_click = document.createElement("canvas"); + canvas_click.id = "canvas-click"; + const context_click = canvas_click.getContext("2d"); + + const canvas_hover = document.createElement("canvas"); + canvas_hover.id = "canvas-hover"; + const context_hover = canvas_hover.getContext("2d"); + + container.appendChild(canvas); + container.appendChild(canvas_click); + container.appendChild(canvas_hover); + + // Set some important stylings of each canvas + container.style.position = "relative"; + container.style["background-color"] = COLOR_BACKGROUND; + + styleCanvas(canvas); + styleCanvas(canvas_hover); + styleCanvas(canvas_click); + + styleBackgroundCanvas(canvas); + styleBackgroundCanvas(canvas_click); + + // canvas_click is positioned by styleBackgroundCanvas, but needs pointer events + canvas_click.style.pointerEvents = "auto"; + canvas_click.style.zIndex = "1"; + + canvas_hover.style.position = "absolute"; + canvas_hover.style.top = "0"; + canvas_hover.style.left = "0"; + canvas_hover.style.zIndex = "2"; + canvas_hover.style.pointerEvents = "auto"; // Hover canvas needs pointer events for hover to work + + function styleCanvas(canvas) { + canvas.style.display = "block"; + canvas.style.margin = "0"; + } // function styleCanvas + + function styleBackgroundCanvas(canvas) { + canvas.style.position = "absolute"; + canvas.style.top = "0"; + canvas.style.left = "0"; + canvas.style.pointerEvents = "none"; + canvas.style.zIndex = "0"; + canvas.style.transition = "opacity 200ms ease-in"; + } // function styleBackgroundCanvas + + ///////////////////////////////////////////////////////////////// + /////////////////////////// Set Sizes /////////////////////////// + ///////////////////////////////////////////////////////////////// + + //Sizes + // DEFAULT_SIZE extracted from LAYOUT.defaultSize in theme.js + let WIDTH = DEFAULT_SIZE; + let HEIGHT = DEFAULT_SIZE; + let width = DEFAULT_SIZE; + let height = DEFAULT_SIZE; + let SF, PIXEL_RATIO; + + ///////////////////////////////////////////////////////////////// + //////////////////////// Create Functions /////////////////////// + ///////////////////////////////////////////////////////////////// + + let parseDate = d3.timeParse("%Y-%m-%dT%H:%M:%SZ"); + let parseDateUnix = d3.timeParse("%s"); + let formatDate = d3.timeFormat("%b %Y"); + let formatDateExact = d3.timeFormat("%b %d, %Y"); + let formatDigit = d3.format(",.2s"); + // let formatDigit = d3.format(",.2r") + + /* D3 Scales - using factories from src/js/config/scales.js */ + const scale_repo_radius = createRepoRadiusScale(d3); + const scale_owner_radius = createOwnerRadiusScale(d3); + + // Based on the number of commits to the central repo + const scale_contributor_radius = createContributorRadiusScale(d3); + + const scale_link_distance = createLinkDistanceScale(d3); + + const scale_link_width = createLinkWidthScale(d3); + // .clamp(true) + + ///////////////////////////////////////////////////////////////// + //////////////////////// Draw the Visual //////////////////////// + ///////////////////////////////////////////////////////////////// + + function chart(values) { + ///////////////////////////////////////////////////////////// + ////////////////////// Data Preparation ///////////////////// + ///////////////////////////////////////////////////////////// + // Preserve original data for filtering + originalContributors = JSON.parse(JSON.stringify(values[0])); + originalRepos = JSON.parse(JSON.stringify(values[1])); + originalLinks = JSON.parse(JSON.stringify(values[2])); + + // Initialize filters to show all + applyFilters(); + + // Prepare data using extracted module + const prepared = prepareData( + { + contributors, + repos, + links + }, + { + d3, + COLOR_CONTRIBUTOR, + COLOR_REPO, + COLOR_OWNER, + MAX_CONTRIBUTOR_WIDTH, + context, + isValidContributor, + setContributorFont, + getLines + }, + { + scale_repo_radius, + scale_owner_radius, + scale_contributor_radius, + scale_link_width + } + ); + + // Validate prepareData return structure + if (!prepared || typeof prepared !== 'object') { + throw new Error('prepareData returned invalid result: expected object'); + } + if (!Array.isArray(prepared.nodes) || prepared.nodes.length === 0) { + throw new Error('prepareData returned invalid nodes: expected non-empty array'); + } + if (!Array.isArray(prepared.links)) { + throw new Error('prepareData returned invalid links: expected array'); + } + + // Update local variables from prepared data + nodes = prepared.nodes; + nodes_central = prepared.nodes_central; + links = prepared.links; + // console.log("Data prepared") + + ///////////////////////////////////////////////////////////// + /////////////// Run Force Simulation per Owner ////////////// + ///////////////////////////////////////////////////////////// + // Run a force simulation for per owner for all the repos that have the same "owner" + // Like a little cloud of repos around them + runOwnerSimulation(nodes, links, d3, getLinkNodeId, sqrt, max, min); + // console.log("Contributor mini force simulation done") + + ///////////////////////////////////////////////////////////// + //////////// Run Force Simulation per Contributor /////////// + ///////////////////////////////////////////////////////////// + // Run a force simulation for per contributor for all the repos that are not shared between other contributors + // Like a little cloud of repos around them + runContributorSimulation(nodes, links, d3, getLinkNodeId, sqrt, max); + // console.log("Owner mini force simulation done") + + ///////////////////////////////////////////////////////////// + ///////////////// Position Contributor Nodes //////////////// + ///////////////////////////////////////////////////////////// + // Place the contributor nodes in a circle around viewport center (0, 0) + // Taking into account the max_radius of single-degree repos around them + const positioningResult = positionContributorNodes( + { nodes, contributors }, + { CONTRIBUTOR_PADDING } + ); + RADIUS_CONTRIBUTOR = positioningResult.RADIUS_CONTRIBUTOR; + CONTRIBUTOR_RING_WIDTH = positioningResult.CONTRIBUTOR_RING_WIDTH; + // console.log("Contributor nodes positioned") + + ///////////////////////////////////////////////////////////// + /////////// Run Force Simulation for Shared Repos /////////// + ///////////////////////////////////////////////////////////// + // Run a force simulation to position the repos that are shared between contributors + nodes_central = runCollaborationSimulation( + nodes, + links, + d3, + getLinkNodeId, + sqrt, + max, + { + context, + scale_link_distance, + RADIUS_CONTRIBUTOR, + INNER_RADIUS_FACTOR + } + ); + // console.log("Central force simulation done") + + ///////////////////////////////////////////////////////////// + ////////////// Resolve String References in Links /////////// + ///////////////////////////////////////////////////////////// + // After all force simulations, ensure ALL links have source/target + // as node objects (not string IDs). Some links may not pass through + // any simulation, leaving their references as strings. + links = resolveLinkReferences(links, nodes); + + ///////////////////////////////////////////////////////////// + ///////////// Set the Sizes and Draw the Visual ///////////// + ///////////////////////////////////////////////////////////// + chart.resize(); + + ///////////////////////////////////////////////////////////// + ////////////////////// Setup Interactions //////////////////// + ///////////////////////////////////////////////////////////// + // Setup interactions AFTER resize so they have correct WIDTH/HEIGHT/SF values + setupHover(); + setupClick(); + } // function chart + + ///////////////////////////////////////////////////////////////// + /////////////////////// Zoom Helpers //////////////////////////// + ///////////////////////////////////////////////////////////////// + // Note: applyZoomTransform is imported from src/js/interaction/zoom.js + // This redrawAll function uses the modular approach with interactionState + + // Redraw all canvas layers (main, hover, click) + function redrawAll() { + draw(); + if (interactionState.CLICK_ACTIVE && interactionState.CLICKED_NODE) { + context_click.clearRect(0, 0, WIDTH, HEIGHT); + context_click.save(); + applyZoomTransform(context_click, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); + drawHoverState(context_click, interactionState.CLICKED_NODE, false); + context_click.restore(); + } else { + context_click.clearRect(0, 0, WIDTH, HEIGHT); + } + if (interactionState.HOVER_ACTIVE && interactionState.HOVERED_NODE) { + context_hover.clearRect(0, 0, WIDTH, HEIGHT); + context_hover.save(); + applyZoomTransform(context_hover, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); + drawHoverState(context_hover, interactionState.HOVERED_NODE); + context_hover.restore(); + } else { + context_hover.clearRect(0, 0, WIDTH, HEIGHT); + } + } // function redrawAll + + ///////////////////////////////////////////////////////////////// + //////////////////////// Draw the visual //////////////////////// + ///////////////////////////////////////////////////////////////// + + // Draw the visual - extracted to src/js/render/draw.js + function draw() { + // IMPORTANT: Background clearing is intentionally handled here, NOT in draw.js + // Clear must happen BEFORE zoom transform is applied - otherwise only the + // transformed (zoomed/panned) area gets cleared, causing ghost images. + // See src/js/render/draw.js for the corresponding NOTE comment. + context.fillStyle = COLOR_BACKGROUND; + context.fillRect(0, 0, WIDTH, HEIGHT); + + // Apply zoom transform before drawing + context.save(); + applyZoomTransform(context, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); + + drawVisualization( + context, + { nodes, links, nodes_central }, + { WIDTH, HEIGHT, SF, COLOR_BACKGROUND, RADIUS_CONTRIBUTOR, CONTRIBUTOR_RING_WIDTH }, + { + drawLink: drawLinkWrapper, + drawNodeArc: drawNodeArcWrapper, + drawNode: drawNodeWrapper, + drawNodeLabel: drawNodeLabelWrapper + } + ); + + context.restore(); + } // function draw + + ///////////////////////////////////////////////////////////////// + //////////////////////// Resize the chart /////////////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/layout/resize.js + chart.resize = () => { + // Debug: Log resize call + console.log('chart.resize() called', { width, height, nodesCount: nodes.length }); + + const canvases = { + canvas, + canvas_click, + canvas_hover + }; + const contexts = { + context, + context_click, + context_hover + }; + const config = { + width, + height, + DEFAULT_SIZE, + RADIUS_CONTRIBUTOR, + CONTRIBUTOR_RING_WIDTH, + round + }; + const state = { + WIDTH, + HEIGHT, + PIXEL_RATIO, + SF, + nodes_delaunay, + delaunay + }; + const data = { + nodes + }; + + // Update local variables from state object BEFORE calling handleResize + // so that draw() uses the correct values + WIDTH = state.WIDTH; + HEIGHT = state.HEIGHT; + PIXEL_RATIO = state.PIXEL_RATIO; + SF = state.SF; + + // Create a wrapper for draw() that updates local variables first + const drawWithUpdatedState = () => { + // Update local variables from state object before drawing + WIDTH = state.WIDTH; + HEIGHT = state.HEIGHT; + PIXEL_RATIO = state.PIXEL_RATIO; + SF = state.SF; + nodes_delaunay = state.nodes_delaunay; + delaunay = state.delaunay; + // Now draw with updated values + draw(); + }; + + handleResize( + canvases, + contexts, + config, + state, + data, + { + d3, + setDelaunay, + interactionState, + draw: drawWithUpdatedState + } + ); + + // Update local variables from state object after resize (in case they changed) + WIDTH = state.WIDTH; + HEIGHT = state.HEIGHT; + PIXEL_RATIO = state.PIXEL_RATIO; + SF = state.SF; + nodes_delaunay = state.nodes_delaunay; + delaunay = state.delaunay; + + // Debug: Log after resize + console.log('chart.resize() completed', { WIDTH, HEIGHT, SF, nodesCount: nodes.length }); + }; //function resize + + ///////////////////////////////////////////////////////////////// + /////////////////// Data Preparation Functions ////////////////// + ///////////////////////////////////////////////////////////////// + + //////////////// Apply filters to the data //////////////// + // NOTE: Pure filter logic has been extracted to src/js/data/filter.js + // This function handles integration with the visualization's mutable state. + // For new features (e.g., blog charts), import { applyFilters } from './data/filter.js' + function applyFilters() { + // Guard against uninitialized data + if (!originalRepos || !originalLinks || !originalContributors) { + console.error("applyFilters(): Original data not initialized"); + return; + } + + // Start with pristine DEEP COPY of all repos (not shallow .slice()) + // Critical: prepareData() mutates objects (adds/deletes properties), + // so we must clone to avoid corrupting originalRepos on subsequent rebuilds + visibleRepos = JSON.parse(JSON.stringify(originalRepos)); + + // If organizations are selected, filter to those organizations + if (activeFilters.organizations.length > 0) { + visibleRepos = visibleRepos.filter((repo) => { + const owner = repo.repo.substring(0, repo.repo.indexOf("/")); + return hasOrganization(activeFilters, owner); + }); + } + + // Apply minimum stars filter + if (activeFilters.starsMin !== null) { + visibleRepos = visibleRepos.filter( + (repo) => +repo.repo_stars >= activeFilters.starsMin + ); + } + + // Apply minimum forks filter + if (activeFilters.forksMin !== null) { + visibleRepos = visibleRepos.filter( + (repo) => +repo.repo_forks >= activeFilters.forksMin + ); + } + + // Get visible repo names for quick lookup + const visibleRepoNames = new Set(visibleRepos.map((r) => r.repo)); + + // Filter links to DEEP COPY (filter first, then clone) + // Links are also mutated in prepareData() (source/target set, author_name deleted) + visibleLinks = originalLinks + .filter((link) => visibleRepoNames.has(link.repo)) + .map((link) => JSON.parse(JSON.stringify(link))); + + // Build set of visible contributor display names from visible links + const visibleDisplayNames = new Set( + visibleLinks.map((link) => link.author_name), + ); + + // Filter contributors to DEEP COPY + // Contributors are mutated in prepareData() (contributor_name_top deleted, etc.) + visibleContributors = originalContributors + .filter((contributor) => visibleDisplayNames.has(contributor.author_name)) + .map((c) => JSON.parse(JSON.stringify(c))); + + // Build set of visible contributor names for link filtering + const visibleContributorNames = new Set( + visibleContributors.map((c) => c.author_name), + ); + + // Re-filter links to only those where the contributor is also visible + visibleLinks = visibleLinks.filter((link) => { + return visibleContributorNames.has(link.author_name); + }); + + // Update the working arrays that prepareData() uses + contributors = visibleContributors; + repos = visibleRepos; + links = visibleLinks; + + // Debug: Log filtering results (enable via localStorage) + if (localStorage.getItem('debug-contributor-network') === 'true') { + console.debug('=== APPLY FILTERS ==='); + console.debug(`Org filters: ${activeFilters.organizations.join(", ") || "none"}`); + console.debug(`Stars min: ${activeFilters.starsMin ?? "none"}, Forks min: ${activeFilters.forksMin ?? "none"}`); + console.debug(`Data before: ${originalContributors.length} contributors, ${originalRepos.length} repos, ${originalLinks.length} links`); + console.debug(`Data after: ${visibleContributors.length} contributors, ${visibleRepos.length} repos, ${visibleLinks.length} links`); + console.debug('Visible repos:', visibleRepos.map(r => r.repo)); + console.debug('Visible contributors:', visibleContributors.map(c => c.author_name)); + } + } + + //////////////// Prepare the data for the visual //////////////// + // Extracted to src/js/data/prepare.js + + + ///////////////////////////////////////////////////////////////// + ///////////////// Force Simulation | Per Owner ////////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/simulations/ownerSimulation.js + + ///////////////////////////////////////////////////////////////// + /////////////// Force Simulation | Per Contributor ////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/simulations/contributorSimulation.js + + // Place the contributor nodes in a circle around the central repo + // Taking into account the max_radius of single-degree repos around them + // Extracted to src/js/layout/positioning.js + + ///////////////////////////////////////////////////////////////// + ///////////// Force Simulation | Collaboration Repos //////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/simulations/collaborationSimulation.js + + + ///////////////////////////////////////////////////////////////// + ///////////////////// Node Drawing Functions //////////////////// + ///////////////////////////////////////////////////////////////// + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function drawNodeWrapper(context, SF, d) { + const config = { COLOR_BACKGROUND, max }; + drawNode(context, SF, d, config, interactionState); + } + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function drawNodeArcWrapper(context, SF, d) { + drawNodeArc(context, SF, d, interactionState, COLOR_CONTRIBUTOR, d3, null); + } + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function drawHoverRingWrapper(context, d) { + drawHoverRing(context, d, SF, null); + } + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function timeRangeArcWrapper(context, SF, d, repo, link, COL = COLOR_REPO_MAIN) { + timeRangeArc(context, SF, d, repo, link, COL, d3, null); + } + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function drawHatchPatternWrapper(context, radius, angle, d) { + drawHatchPattern(context, radius, angle, SF, d.color, sin); + } + + // Extracted to src/js/render/shapes.js + // drawCircle is now imported directly + + ///////////////////////////////////////////////////////////////// + ///////////////////// Line Drawing Functions //////////////////// + ///////////////////////////////////////////////////////////////// + + // Extracted to src/js/render/shapes.js + // Wrapper to adapt old signature to new module signature + function drawLinkWrapper(context, SF, l) { + const config = { COLOR_LINK }; + drawLink(context, SF, l, config, interactionState, calculateLinkGradient, calculateEdgeCenters, scale_link_width); + } + + // Extracted to src/js/render/shapes.js + // drawLine and drawCircleArc are now imported directly + + ///////////////////// Calculate Line Centers //////////////////// + function calculateEdgeCenters(l, size = 2, sign = true) { + //Find a good radius + l.r = + sqrt(sq(l.target.x - l.source.x) + sq(l.target.y - l.source.y)) * size; //Can run from > 0.5 + //Find center of the arc function + let centers = findCenters( + l.r, + { x: l.source.x, y: l.source.y }, + { x: l.target.x, y: l.target.y }, + ); + l.sign = sign; + l.center = l.sign ? centers.c2 : centers.c1; + + /////////////// Calculate center for curved edges /////////////// + //https://stackoverflow.com/questions/26030023 + //http://jsbin.com/jutidigepeta/3/edit?html,js,output + function findCenters(r, p1, p2) { + // pm is middle point of (p1, p2) + let pm = { x: 0.5 * (p1.x + p2.x), y: 0.5 * (p1.y + p2.y) }; + // compute leading vector of the perpendicular to p1 p2 == C1C2 line + let perpABdx = -(p2.y - p1.y); + let perpABdy = p2.x - p1.x; + // normalize vector + let norm = sqrt(sq(perpABdx) + sq(perpABdy)); + perpABdx /= norm; + perpABdy /= norm; + // compute distance from pm to p1 + let dpmp1 = sqrt(sq(pm.x - p1.x) + sq(pm.y - p1.y)); + // sin of the angle between { circle center, middle , p1 } + let sin = dpmp1 / r; + // is such a circle possible ? + if (sin < -1 || sin > 1) return null; // no, return null + // yes, compute the two centers + let cos = sqrt(1 - sq(sin)); // build cos out of sin + let d = r * cos; + let res1 = { x: pm.x + perpABdx * d, y: pm.y + perpABdy * d }; + let res2 = { x: pm.x - perpABdx * d, y: pm.y - perpABdy * d }; + return { c1: res1, c2: res2 }; + } //function findCenters + } //function calculateEdgeCenters + + ///////////////// Create gradients for the links //////////////// + function calculateLinkGradient(context, l) { + // l.gradient = context.createLinearGradient(l.source.x, l.source.y, l.target.x, l.target.y) + // l.gradient.addColorStop(0, l.source.color) + // l.gradient.addColorStop(1, l.target.color) + + // The opacity of the links depends on the number of links + const scale_alpha = d3 + .scaleLinear() + .domain([300, 800]) + .range([0.5, 0.2]) + .clamp(true); + + // Incorporate opacity into gradient + let alpha; + if (interactionState.hoverActive) alpha = l.target.special_type ? 0.3 : 0.7; + else alpha = l.target.special_type ? 0.15 : scale_alpha(links.length); + + // Scale down opacity for links converging on high-degree owner nodes + // to prevent overlapping links from compounding into an opaque mass + if (l.target.type === "owner" && l.target.degree > 5) { + const scale_density = d3.scaleLinear() + .domain([5, 15, 40]) + .range([1, 0.5, 0.25]) + .clamp(true); + alpha *= scale_density(l.target.degree); + } + + createGradient(l, alpha); + + function createGradient(l, alpha) { + let col; + let color_rgb_source; + let color_rgb_target; + + col = d3.rgb(l.source.color); + color_rgb_source = + "rgba(" + col.r + "," + col.g + "," + col.b + "," + alpha + ")"; + col = d3.rgb(l.target.color); + color_rgb_target = + "rgba(" + col.r + "," + col.g + "," + col.b + "," + alpha + ")"; + + // Guard against non-finite coordinates (NaN, Infinity, -Infinity) + // This prevents "createLinearGradient: non-finite" errors during filtering + if ( + l.source && l.target && + typeof l.source.x === 'number' && typeof l.source.y === 'number' && + typeof l.target.x === 'number' && typeof l.target.y === 'number' && + isFinite(l.source.x) && isFinite(l.source.y) && + isFinite(l.target.x) && isFinite(l.target.y) + ) { + try { + l.gradient = context.createLinearGradient( + l.source.x * SF, + l.source.y * SF, + l.target.x * SF, + l.target.y * SF, + ); + + // Distance between source and target + let dist = sqrt( + sq(l.target.x - l.source.x) + sq(l.target.y - l.source.y), + ); + // What percentage is the source's radius of the total distance + let perc = l.source.r / dist; + // Let the starting color be at perc, so it starts changing color right outside the radius of the source node + l.gradient.addColorStop(perc, color_rgb_source); + l.gradient.addColorStop(1, color_rgb_target); + } catch (e) { + // If gradient creation fails for any reason, fall back to solid color + if (localStorage.getItem('debug-contributor-network') === 'true') { + console.warn('Gradient creation error:', e, { link: l, sf: SF }); + } + l.gradient = COLOR_LINK; + } + } else { + // Gradient can't be created - invalid coordinates + if (localStorage.getItem('debug-contributor-network') === 'true') { + console.warn('Invalid coordinates for gradient', { + sourceX: l.source?.x, + sourceY: l.source?.y, + targetX: l.target?.x, + targetY: l.target?.y + }); + } + l.gradient = COLOR_LINK; + } + } //function createGradient + } //function calculateLinkGradient + + ///////////////////////////////////////////////////////////////// + //////////////////////// Hover Functions //////////////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/interaction/hover.js + function setupHover() { + const config = { + PIXEL_RATIO, + WIDTH, + HEIGHT, + SF, + RADIUS_CONTRIBUTOR, + CONTRIBUTOR_RING_WIDTH, + sqrt + }; + // Create delaunayData object that will be kept in sync + const delaunayData = { + get delaunay() { return delaunay; }, + set delaunay(val) { delaunay = val; }, + get nodesDelaunay() { return nodes_delaunay; }, + set nodesDelaunay(val) { nodes_delaunay = val; } + }; + + setupHoverInteraction({ + d3, + canvasSelector: "#canvas-hover", + config, + delaunayData, + interactionState, + canvas, + contextHover: context_hover, + setHovered, + clearHover, + drawHoverState, + zoomState + }); + } // function setupHover + + // Draw the hovered node and its links and neighbors and a tooltip + function drawHoverState(context, d, DO_TOOLTIP = true) { + // Note: Zoom transform should already be applied by the caller (in redrawAll) + // This function assumes the context is already transformed + // Draw the hover canvas + context.save(); + + ///////////////////////////////////////////////// + // Get all the connected links (if not done before) + if (d.neighbor_links === undefined) { + d.neighbor_links = links.filter( + (l) => { + return l.source.id === d.id || l.target.id === d.id; + } + ); + } // if + + // Get all the connected nodes (if not done before) + if (d.neighbors === undefined) { + d.neighbors = nodes.filter((n) => { + return links.find( + (l) => { + return (l.source.id === d.id && l.target.id === n.id) || + (l.target.id === d.id && l.source.id === n.id); + } + ); + }); + + // If any of these neighbors are "owner" nodes, find what the original repo was from that owner that the contributor was connected to + // OR + // If this node is a repo and any of these neighbors are "owner" nodes, find what original contributor was connected to this repo + if (d.type === "contributor" || d.type === "repo") { + d.neighbors.forEach((n) => { + if (n && n.type === "owner" && d.data && d.data.links_original) { + // Go through all of the original links and see if this owner is in there + d.data.links_original.forEach((l) => { + if (l.owner === n.id) { + let node, link; + if (d.type === "contributor") { + // Find the repo node + node = nodes.find((r) => r.id === l.repo); + // Skip if node doesn't exist (repo not in visualization) + if (!node) return; + // Also find the link between the repo and owner and add this to the neighbor_links + link = links.find( + (l) => l.source.id === n.id && l.target.id === node.id, + ); + } else if (d.type === "repo") { + // Find the contributor node + node = nodes.find((r) => r.id === l.contributor_name); + // Skip if node doesn't exist (contributor not in visualization) + if (!node) return; + // Also find the link between the contributor and owner and add this to the neighbor_links + link = links.find( + (l) => l.source.id === node.id && l.target.id === n.id, + ); + } // else if + + // Add it to the neighbors (only if node exists) + if (node) { + d.neighbors.push(node); + if (link) d.neighbor_links.push(link); + } + } // if + }); // forEach + } // if + }); // forEach + } // if + } // if + + ///////////////////////////////////////////////// + // Draw all the links to this node (with null safety) + if (d.neighbor_links) { + d.neighbor_links.forEach((l) => { + if (l && l.source && l.target) drawLinkWrapper(context, SF, l); + }); // forEach + } + + // Draw all the connected nodes (with null safety) + if (d.neighbors) { + d.neighbors.forEach((n) => { if (n) drawNodeArcWrapper(context, SF, n); }); + d.neighbors.forEach((n) => { if (n) drawNodeWrapper(context, SF, n); }); + // Draw all the labels of the "central" connected nodes + d.neighbors.forEach((n) => { + if (n && n.node_central) drawNodeLabelWrapper(context, n); + }); // forEach + } + + ///////////////////////////////////////////////// + // Draw the hovered node + drawNodeWrapper(context, SF, d); + // Show a ring around the hovered node + drawHoverRingWrapper(context, d); + + ///////////////////////////////////////////////// + // Show its label + if (d.node_central && d.type === "contributor") drawNodeLabelWrapper(context, d); + + ///////////////////////////////////////////////// + // Create a tooltip with more info + if (DO_TOOLTIP) drawTooltipWrapper(context, d); + + context.restore(); + } // function drawHoverState + + ///////////////////////////////////////////////////////////////// + //////////////////////// Click Functions //////////////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/interaction/click.js + function setupClick() { + const config = { + PIXEL_RATIO, + WIDTH, + HEIGHT, + SF, + RADIUS_CONTRIBUTOR, + CONTRIBUTOR_RING_WIDTH, + sqrt + }; + // Create delaunayData object that will be kept in sync + const delaunayData = { + get delaunay() { return delaunay; }, + set delaunay(val) { delaunay = val; }, + get nodesDelaunay() { return nodes_delaunay; }, + set nodesDelaunay(val) { nodes_delaunay = val; } + }; + + setupClickInteraction({ + d3, + canvasSelector: "#canvas-hover", // Use hover canvas for clicks too since it's on top + config, + delaunayData, + interactionState, + canvas, + contextClick: context_click, + contextHover: context_hover, + nodes, + setClicked, + clearClick, + clearHover, + setDelaunay, + drawHoverState, + zoomState, + ZOOM_CLICK_SUPPRESS_MS + }); + } // function setupClick + + ///////////////////////////////////////////////////////////////// + //////////////////////// Zoom Functions ///////////////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/interaction/zoom.js + function setupZoom() { + setupZoomModule({ + d3, + canvasSelector: "#canvas-hover", + state: zoomState, + redrawAll, + ZOOM_CLICK_SUPPRESS_MS + }); + } // function setupZoom + + ///////////////////////////////////////////////////////////////// + ///////////////// General Interaction Functions ///////////////// + ///////////////////////////////////////////////////////////////// + // Extracted to src/js/interaction/findNode.js + // Note: findNode is now imported and used directly in hover.js and click.js + + // Draw the tooltip above the node + // Extracted to src/js/render/tooltip.js + // Wrapper to adapt old signature to new module signature + function drawTooltipWrapper(context, d) { + const config = { + SF, + COLOR_BACKGROUND, + COLOR_TEXT, + COLOR_CONTRIBUTOR, + COLOR_REPO, + COLOR_OWNER, + min, + orgNickname, + }; + drawTooltipModule(context, d, config, interactionState, null, formatDate, formatDateExact, formatDigit); + } + + // Keep old function name for compatibility - delegate to wrapper + function drawTooltip(context, d) { + return drawTooltipWrapper(context, d); + } + + // Wrapper to adapt old signature to new module signature + function drawNodeLabelWrapper(context, d, DO_CENTRAL_OUTSIDE = false) { + const config = { + SF, + COLOR_TEXT, + COLOR_BACKGROUND, + COLOR_REPO_MAIN, + PI + }; + drawNodeLabel(context, d, config, null, DO_CENTRAL_OUTSIDE); + } + + // ============================================================================= + // NOTE: The following functions have been extracted to modules: + // - drawTooltip → src/js/render/tooltip.js + // - drawNodeLabel → src/js/render/labels.js + // - text/font functions → src/js/render/text.js + // + // The wrapper functions above (drawTooltipWrapper, drawNodeLabelWrapper) + // import and call these modular implementations. + // ============================================================================= + + ///////////////////////////////////////////////////////////////// + ///////////////////////// Test Functions //////////////////////// + ///////////////////////////////////////////////////////////////// + + // TEST - Draw a (scaled wrong) version of the delaunay triangles + function testDelaunay(delaunay, context) { + context.save(); + context.translate(WIDTH / 2, HEIGHT / 2); + context.beginPath(); + delaunay.render(context); + context.strokeStyle = "silver"; + context.lineWidth = 1 * SF; + context.stroke(); + context.restore(); + } // function testDelaunay + + // TEST - Draw a stroked rectangle around the bbox of the nodes + function drawBbox(context, nodes) { + context.strokeStyle = "red"; + context.lineWidth = 1; + nodes + .filter((d) => d.bbox) + .forEach((d) => { + context.strokeRect( + d.x * SF + d.bbox[0][0] * SF, + d.y * SF + d.bbox[0][1] * SF, + (d.bbox[1][0] - d.bbox[0][0]) * SF, + (d.bbox[1][1] - d.bbox[0][1]) * SF, + ); + }); // forEach + } // function drawBbox + + ///////////////////////////////////////////////////////////////// + //////////////////////// Helper Functions /////////////////////// + ///////////////////////////////////////////////////////////////// + + function mod(x, n) { + return ((x % n) + n) % n; + } + + function sq(x) { + return x * x; + } + + function isInteger(value) { + return /^\d+$/.test(value); + } + + ///////////////////////////////////////////////////////////////// + /////////////////////// Accessor functions ////////////////////// + ///////////////////////////////////////////////////////////////// + + chart.width = function (value) { + if (!arguments.length) return width; + width = value; + return chart; + }; // chart.width + + chart.height = function (value) { + if (!arguments.length) return height; + height = value; + return chart; + }; // chart.height + + // Note: chart.repository() accessor removed - no longer have a central repository + + ///////////////////////////////////////////////////////////////// + ////////////////////// Filtering Functions ////////////////////// + ///////////////////////////////////////////////////////////////// + + chart.rebuild = function () { + // Reset visualization state completely + nodes = []; + links = []; + nodes_central = []; // Reset derived array for central nodes + + // Reset interaction state + clearAll(interactionState); + clearDelaunay(interactionState); + + // Reset spatial data structures (will be rebuilt in resize()) + nodes_delaunay = []; + delaunay = null; + + // Apply current filters + applyFilters(); + + // Re-run the full initialization pipeline + const prepared = prepareData( + { + contributors, + repos, + links + }, + { + d3, + COLOR_CONTRIBUTOR, + COLOR_REPO, + COLOR_OWNER, + MAX_CONTRIBUTOR_WIDTH, + context, + isValidContributor, + setContributorFont, + getLines + }, + { + scale_repo_radius, + scale_owner_radius, + scale_contributor_radius, + scale_link_width + } + ); + + // Validate prepareData return structure + if (!prepared || typeof prepared !== 'object') { + console.error('rebuild: prepareData returned invalid result'); + return chart; + } + if (!Array.isArray(prepared.nodes) || prepared.nodes.length === 0) { + console.error('rebuild: prepareData returned empty nodes - filters may be too restrictive'); + return chart; + } + + // Update local variables from prepared data + nodes = prepared.nodes; + nodes_central = prepared.nodes_central; + links = prepared.links; + + runOwnerSimulation(nodes, links, d3, getLinkNodeId, sqrt, max, min); + runContributorSimulation(nodes, links, d3, getLinkNodeId, sqrt, max); + const positioningResult = positionContributorNodes( + { nodes, contributors }, + { CONTRIBUTOR_PADDING } + ); + RADIUS_CONTRIBUTOR = positioningResult.RADIUS_CONTRIBUTOR; + CONTRIBUTOR_RING_WIDTH = positioningResult.CONTRIBUTOR_RING_WIDTH; + + // Pre-compute SF now that RADIUS_CONTRIBUTOR is known. + // This ensures SF is consistent before any downstream code references it. + // resize() will re-derive the same value, but setting it early prevents + // any intermediate code from seeing a stale SF. + SF = calculateScaleFactor(WIDTH, DEFAULT_SIZE, RADIUS_CONTRIBUTOR, CONTRIBUTOR_RING_WIDTH); + + nodes_central = runCollaborationSimulation( + nodes, + links, + d3, + getLinkNodeId, + sqrt, + max, + { + context, + scale_link_distance, + RADIUS_CONTRIBUTOR, + INNER_RADIUS_FACTOR + } + ); + // Resolve any remaining string references in links + links = resolveLinkReferences(links, nodes); + + // Position any nodes that didn't get positioned by force simulations + // Critical for filtered data where force simulations may not include all nodes + const unpositionedNodes = nodes.filter(n => + n.x === 0 && n.y === 0 + ); + + if (unpositionedNodes.length > 0) { + // Get total counts by type for proper distribution + const contributorCount = nodes.filter(n => n.type === 'contributor').length; + const repoCount = nodes.filter(n => n.type === 'repo').length; + + let contributorIdx = 0; + let repoIdx = 0; + + unpositionedNodes.forEach(node => { + if (node.type === 'contributor') { + // Distribute contributors evenly around outer circle + const angle = (contributorIdx / Math.max(1, contributorCount)) * Math.PI * 2; + const radius = 250; // Outer ring for contributors + node.x = Math.cos(angle) * radius; + node.y = Math.sin(angle) * radius; + // Ensure radius property is set for gradient calculations + if (!node.r) node.r = 6; + contributorIdx++; + } else if (node.type === 'repo') { + // Distribute repos around middle zone + const angle = (repoIdx / Math.max(1, repoCount)) * Math.PI * 2; + const radius = 150 + Math.random() * 50; + node.x = Math.cos(angle) * radius; + node.y = Math.sin(angle) * radius; + // Ensure radius property is set for gradient calculations + if (!node.r) node.r = 8; + repoIdx++; + } else if (node.type === 'owner') { + // Owners stay near center + const angle = (repoIdx / 5) * Math.PI * 2; + const radius = 50; + node.x = Math.cos(angle) * radius; + node.y = Math.sin(angle) * radius; + // Ensure radius property is set for gradient calculations + if (!node.r) node.r = 15; + } + }); + + if (localStorage.getItem('debug-contributor-network') === 'true') { + console.debug(`Positioned ${unpositionedNodes.length} unpositioned nodes in rings by type`); + } + } + + // Ensure all nodes in links have valid, finite coordinates + links.forEach(link => { + if (link.source && link.target) { + // Check/fix source coordinates + if (typeof link.source.x !== 'number' || typeof link.source.y !== 'number' || + !isFinite(link.source.x) || !isFinite(link.source.y)) { + link.source.x = 0; + link.source.y = 0; + } + // Check/fix target coordinates + if (typeof link.target.x !== 'number' || typeof link.target.y !== 'number' || + !isFinite(link.target.x) || !isFinite(link.target.y)) { + link.target.x = 0; + link.target.y = 0; + } + } + }); + + // Calculate SF early so it's finalized before interaction handlers capture it. + // positionContributorNodes() determines RADIUS_CONTRIBUTOR and CONTRIBUTOR_RING_WIDTH, + // which resize() uses to compute SF. By calling resize() first, we ensure setupHover() + // and setupClick() capture the final SF value — preventing the hit-detection offset bug + // that occurred when SF changed between setupHover() and resize(). + // This matches the order in the initial chart() function (resize before interactions). + chart.resize(); + + // Re-setup interaction handlers AFTER resize so they have correct WIDTH/HEIGHT/SF values + setupHover(); + setupClick(); + + return chart; + }; + + /** + * Updates the active filters and rebuilds the chart + * @param {string} organizationName - Organization to filter by + * @param {boolean} enabled - Whether to enable or disable this filter + * @returns {Object} - The chart instance + */ + chart.setFilter = function (organizationName, enabled) { + if (enabled) { + addOrganization(activeFilters, organizationName); + } else { + removeOrganization(activeFilters, organizationName); + } + + chart.rebuild(); + return chart; + }; + + /** + * Updates a metric-based repo filter and rebuilds the chart + * @param {string} metric - Metric name ('starsMin' or 'forksMin') + * @param {number|null} value - Minimum threshold value, or null to clear + * @returns {Object} - The chart instance + */ + chart.setRepoFilter = function (metric, value) { + setMetricFilter(activeFilters, metric, value); + chart.rebuild(); + return chart; + }; + + chart.getActiveFilters = function () { + return { ...activeFilters }; + }; + + /** + * DEBUG: Get internal nodes array for diagnostic purposes + */ + chart.getNodes = function () { + return nodes; + }; + + /** + * DEBUG: Get internal links array for diagnostic purposes + */ + chart.getLinks = function () { + return links; + }; + + /** + * DEBUG: Get debug state snapshot + */ + chart.getDebugState = function () { + const validLinks = links.filter(l => + l.source && l.target && + typeof l.source === 'object' && typeof l.target === 'object' + ); + + // Count links that have valid coordinates for drawing + const drawableLinks = validLinks.filter(l => + l.source && l.target && + typeof l.source.x === 'number' && typeof l.source.y === 'number' && + typeof l.target.x === 'number' && typeof l.target.y === 'number' && + isFinite(l.source.x) && isFinite(l.source.y) && + isFinite(l.target.x) && isFinite(l.target.y) + ); + + return { + nodesCount: nodes.length, + linksCount: links.length, + nodeTypes: nodes.reduce((acc, n) => { + acc[n.type] = (acc[n.type] || 0) + 1; + return acc; + }, {}), + validLinks: validLinks.length, + linksToBeDrawn: drawnLinks.length, + linksWithValidCoordinates: drawableLinks.length, + activeFilters: { ...activeFilters } + }; + }; + + return chart; +}; // function createContributorNetworkVisual + +// ============================================================ +// Exports (for ES module bundling) +// ============================================================ +export { createContributorNetworkVisual }; + +// Also expose globally for non-module usage during transition +if (typeof window !== 'undefined') { + window.createContributorNetworkVisual = createContributorNetworkVisual; +} diff --git a/public/data/config.json b/public/data/config.json index aeaf1d9..99f1f4d 100644 --- a/public/data/config.json +++ b/public/data/config.json @@ -3,6 +3,7 @@ "author": "Pete Gadomski", "description": "An interactive visualization of contributors to Development Seed code and their connections to other repositories", "organization_name": "Development Seed", + "organization_nickname": "DevSeed", "contributor_padding": 20, "contributors": { "AMSCamacho": "Angela Camacho", diff --git a/python/contributor_network/cli.py b/python/contributor_network/cli.py index 54c951b..3548652 100644 --- a/python/contributor_network/cli.py +++ b/python/contributor_network/cli.py @@ -149,6 +149,7 @@ def build( "author": config.author, "description": config.description, "organization_name": config.organization_name, + "organization_nickname": config.organization_nickname, "contributor_padding": config.contributor_padding, "contributors": config.all_contributors, } diff --git a/python/contributor_network/config.py b/python/contributor_network/config.py index 2d5b7e6..d8070b6 100644 --- a/python/contributor_network/config.py +++ b/python/contributor_network/config.py @@ -21,6 +21,7 @@ class Config(BaseModel): author: Author attribution description: Description shown on the page organization_name: Name of the organization (e.g., "Development Seed") + organization_nickname: Short name used in tooltips (e.g., "DevSeed") repositories: List of GitHub repos to track (format: "owner/repo") contributors: Nested dict of contributor categories, each mapping GitHub username to display name @@ -31,6 +32,7 @@ class Config(BaseModel): author: str description: str organization_name: str + organization_nickname: str = "" repositories: list[str] contributors: dict[ str, dict[str, str] From ce2662708f37f2a3ae9553643f0c87e01569d2f6 Mon Sep 17 00:00:00 2001 From: aboydnw Date: Tue, 10 Mar 2026 23:26:23 +0000 Subject: [PATCH 2/4] feat: enrich repo and contributor info card metrics Add richer tooltip/card context with repo and commit totals, org-aware community labeling, and supporting prepared data fields to make card content more informative during exploration. Made-with: Cursor --- src/chart.ts | 4 +++- src/data/prepare.ts | 2 ++ src/main.ts | 5 ++++- src/render/repoCard.ts | 20 ++++++++++++++++++-- src/render/tooltip.ts | 42 +++++++++++++++++++++++++++++++++++++----- src/types.ts | 3 +++ 6 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/chart.ts b/src/chart.ts index 7700013..e430ec3 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -108,7 +108,8 @@ export const createContributorNetworkVisual = ( container: HTMLElement, contributor_padding: number, masterContributorsList: Record, - displayNameMap: Record + displayNameMap: Record, + orgNickname: string = 'DevSeed', ): ChartFunction => { const PI = Math.PI; const TAU = PI * 2; @@ -887,6 +888,7 @@ export const createContributorNetworkVisual = ( COLOR_REPO, COLOR_OWNER, min, + orgNickname, }; drawTooltipModule( ctx, diff --git a/src/data/prepare.ts b/src/data/prepare.ts index f26c4ce..15b99d4 100644 --- a/src/data/prepare.ts +++ b/src/data/prepare.ts @@ -325,6 +325,8 @@ export function prepareData( contributors.find((r) => r.contributor_name === l.contributor_name), ) .filter((c): c is ContributorData => c !== undefined); + d.totalCommits = d.repo_total_commits ? +d.repo_total_commits : undefined; + d.orgCommits = d3.sum(d.links_original, (l) => l.commit_count); }); let owners: OwnerData[] = []; diff --git a/src/main.ts b/src/main.ts index f174521..9a1dfd5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { createContributorNetworkVisual } from "./chart"; interface Config { organization_name?: string; + organization_nickname?: string; contributor_padding?: number; contributors?: Record; title?: string; @@ -18,6 +19,7 @@ if (!configResponse.ok) { const config: Config = await configResponse.json(); const organizationName = config.organization_name || "Development Seed"; +const orgNickname = config.organization_nickname || organizationName; const contributor_padding = config.contributor_padding || 20; const masterContributors: Record = config.contributors || {}; @@ -51,7 +53,8 @@ const contributorNetworkVisual = createContributorNetworkVisual( container, contributor_padding, masterContributors, - displayNameToUsername + displayNameToUsername, + orgNickname, ); contributorNetworkVisual.width(size).height(size); diff --git a/src/render/repoCard.ts b/src/render/repoCard.ts index 00ca810..bbd6b30 100644 --- a/src/render/repoCard.ts +++ b/src/render/repoCard.ts @@ -31,6 +31,8 @@ export interface RepoCardData { devseedContributors?: number; externalContributors?: number; communityRatio?: number; + totalCommits?: number; + orgCommits?: number; license?: string | null; archived?: boolean; [key: string]: unknown; @@ -279,8 +281,10 @@ export function renderCommunityMetrics( x: number, y: number, SF: number, + orgNickname?: string, ): number { const config = REPO_CARD_CONFIG; + const org = orgNickname ?? 'DevSeed'; if (!data.totalContributors || data.totalContributors === 0) { return y; @@ -302,19 +306,31 @@ export function renderCommunityMetrics( renderText( context, - `${total} contributors (${devseed} DevSeed, ${external} community)`, + `${total} contributors (${devseed} ${org}, ${external} community)`, x * SF, y * SF, 1.25 * SF, ); + if (data.totalCommits && data.totalCommits > 0) { + y += config.valueFontSize * config.lineHeight; + const orgPct = Math.round((data.orgCommits || 0) / data.totalCommits * 100); + renderText( + context, + `${data.totalCommits.toLocaleString()} total commits (${orgPct}% from ${org})`, + x * SF, + y * SF, + 1.25 * SF, + ); + } + if (devseed === 1 && total > 0) { y += config.valueFontSize * config.lineHeight; context.globalAlpha = config.warningOpacity; setFont(context, config.valueFontSize * SF, 400, 'italic'); renderText( context, - '⚠ Single DevSeed maintainer', + `⚠ Single ${org} maintainer`, x * SF, y * SF, 1.25 * SF, diff --git a/src/render/tooltip.ts b/src/render/tooltip.ts index c7ba761..94a3405 100644 --- a/src/render/tooltip.ts +++ b/src/render/tooltip.ts @@ -27,6 +27,7 @@ interface TooltipConfig { COLOR_REPO: string; COLOR_OWNER: string; min: (...values: number[]) => number; + orgNickname?: string; } /** @@ -81,6 +82,9 @@ function calculateRepoTooltipHeight( if (data.totalContributors && data.totalContributors > 0) { y += config.sectionSpacing; y += config.valueFontSize * config.lineHeight + 4; + if (data.totalCommits && data.totalCommits > 0) { + y += config.valueFontSize * config.lineHeight; + } if (data.devseedContributors === 1 && data.totalContributors > 0) { y += config.valueFontSize * config.lineHeight; y += config.valueFontSize * config.lineHeight; @@ -131,7 +135,9 @@ function calculateRepoTooltipWidth( formatDate: (value: Date) => string, formatDateExact: (value: Date) => string, formatDigit: (value: number) => string, + orgNickname?: string, ): number { + const org = orgNickname ?? 'DevSeed'; const config = REPO_CARD_CONFIG; let maxWidth = 0; @@ -195,13 +201,22 @@ function calculateRepoTooltipWidth( const external = data.externalContributors || 0; width = context.measureText( - `${total} contributors (${devseed} DevSeed, ${external} community)`, + `${total} contributors (${devseed} ${org}, ${external} community)`, ).width * 1.25; if (width > maxWidth) maxWidth = width; + if (data.totalCommits && data.totalCommits > 0) { + const orgPct = Math.round((data.orgCommits || 0) / data.totalCommits * 100); + width = + context.measureText( + `${data.totalCommits.toLocaleString()} total commits (${orgPct}% from ${org})`, + ).width * 1.25; + if (width > maxWidth) maxWidth = width; + } + if (devseed === 1 && total > 0) { width = - context.measureText('⚠ Single DevSeed maintainer').width * 1.25; + context.measureText(`⚠ Single ${org} maintainer`).width * 1.25; if (width > maxWidth) maxWidth = width; } } @@ -282,7 +297,7 @@ export function drawTooltip( formatDateExact: (value: Date) => string, formatDigit: (value: number) => string, ): void { - const { SF, COLOR_BACKGROUND, COLOR_TEXT, COLOR_CONTRIBUTOR, COLOR_REPO, COLOR_OWNER } = + const { SF, COLOR_BACKGROUND, COLOR_TEXT, COLOR_CONTRIBUTOR, COLOR_REPO, COLOR_OWNER, orgNickname } = config; let line_height = 1.2; @@ -298,7 +313,7 @@ export function drawTooltip( let H: number, W: number; if (d.type === 'contributor') { - H = 100; + H = 125; W = 320; } else if (d.type === 'owner') { H = 155; @@ -313,6 +328,7 @@ export function drawTooltip( formatDate, formatDateExact, formatDigit, + orgNickname, ); } else { H = 116; @@ -365,6 +381,12 @@ export function drawTooltip( text = nodeData.contributor_name ?? nodeData.author_name; let tW = context.measureText(text).width * 1.25; if (tW + 40 * SF > W * SF) W = tW / SF + 40; + const repoCount = (nodeData.links_original as LinkData[] | undefined)?.length ?? 0; + const totalCommits = (nodeData.total_commits as number) || 0; + setFont(context, 14 * SF, 400, 'normal'); + const statsText = `${repoCount} ${repoCount === 1 ? 'repo' : 'repos'} · ${totalCommits.toLocaleString()} commits`; + tW = context.measureText(statsText).width * 1.25; + if (tW + 40 * SF > W * SF) W = tW / SF + 40; } let H_OFFSET = d.y < 0 ? 20 : -H - 20; @@ -415,6 +437,16 @@ export function drawTooltip( setFont(context, font_size * SF, 700, 'normal'); text = nodeData.contributor_name ?? nodeData.author_name; renderText(context, text, xLeft * SF, y * SF, 1.25 * SF); + + y += 26; + const contribRepoCount = (nodeData.links_original as LinkData[] | undefined)?.length ?? 0; + const contribTotalCommits = (nodeData.total_commits as number) || 0; + font_size = 14; + context.globalAlpha = 0.6; + setFont(context, font_size * SF, 400, 'normal'); + text = `${contribRepoCount} ${contribRepoCount === 1 ? 'repo' : 'repos'} · ${contribTotalCommits.toLocaleString()} commits`; + renderText(context, text, xLeft * SF, y * SF, 1.25 * SF); + context.globalAlpha = 1; } else if (d.type === 'owner') { font_size = 22; setFont(context, font_size * SF, 700, 'normal'); @@ -509,7 +541,7 @@ export function drawTooltip( if (nodeData.totalContributors && nodeData.totalContributors > 0) { if (y > statsY) drawSectionDivider(context, y + 4, W, x, SF); } - y = renderCommunityMetrics(context, repoData, xLeft, y, SF); + y = renderCommunityMetrics(context, repoData, xLeft, y, SF, orgNickname); if (nodeData.license) { if (y > statsY) drawSectionDivider(context, y + 4, W, x, SF); diff --git a/src/types.ts b/src/types.ts index 3734590..4f77d5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,8 @@ export interface RepoData { devseedContributors: number; externalContributors: number; communityRatio: number; + totalCommits?: number; + orgCommits?: number; createdAt: Date; updatedAt: Date; languages: string[]; @@ -46,6 +48,7 @@ export interface RepoData { repo_has_discussions?: string | boolean; repo_archived?: string | boolean; repo_total_contributors?: string; + repo_total_commits?: string; repo_devseed_contributors?: string; repo_external_contributors?: string; repo_community_ratio?: string; From eb24b2423d6d530f85171486bc4c8115c1795a28 Mon Sep 17 00:00:00 2001 From: aboydnw Date: Wed, 11 Mar 2026 22:07:32 +0000 Subject: [PATCH 3/4] remove erroneous file --- js/chart.js | 1485 --------------------------------------------------- 1 file changed, 1485 deletions(-) delete mode 100644 js/chart.js diff --git a/js/chart.js b/js/chart.js deleted file mode 100644 index e7cf7c3..0000000 --- a/js/chart.js +++ /dev/null @@ -1,1485 +0,0 @@ -// This file was copied-and-modified from -// https://github.com/nbremer/ORCA/blob/77745774d9d189818ab1ba27e07979897434abf9/top-contributor-network/createORCAVisual.js, -// and is licensed under the same (MPL). -// -// Development Seed Modifications: -// - Updated color scheme to DevSeed brand (Grenadier orange, Aquamarine blue) -// - Removed the central "team" pseudo-node entirely (force simulation finds natural equilibrium) -// - Contributors positioned in fixed ring around viewport center -// - Added null safety checks for hover/click interactions -// - Added boundary checking to prevent hover outside visualization area -// - Added mouseleave handler to properly clean up hover state -// - Refactored to use modular components (Phase 1 & 2 data expansion) -// -///////////////////////////////////////////////////////////////////// -/////////////// Visualization designed & developed by /////////////// -/////////////////////////// Nadieh Bremer /////////////////////////// -///////////////////////// VisualCinnamon.com //////////////////////// -///////////////////////////////////////////////////////////////////// - -// ============================================================ -// Modular Imports (loaded via Vite bundler) -import { COLORS, FONTS, SIZES } from './config/theme.js'; -import { - isValidNode, - isValidLink, - getLinkNodeId, - resolveLinkReferences -} from './utils/validation.js'; -import { - createRepoRadiusScale, - createOwnerRadiusScale, - createContributorRadiusScale, - createLinkDistanceScale, - createLinkWidthScale -} from './config/scales.js'; -import { - setFont, - setRepoFont, - setCentralRepoFont, - setOwnerFont, - setContributorFont, - renderText, - getLines, - splitString, - drawTextAlongArc -} from './render/text.js'; - -// ============================================================ -import { - renderStatsLine, - renderLanguages, - renderCommunityMetrics, - renderLicense, - renderArchivedBadge, - REPO_CARD_CONFIG -} from './render/repoCard.js'; -import { - runOwnerSimulation, - runContributorSimulation, - runCollaborationSimulation -} from './simulations/index.js'; -import { - createFilterState, - addOrganization, - removeOrganization, - clearFilters, - hasOrganization, - hasActiveFilters, - setMetricFilter -} from './state/filterState.js'; -import { prepareData } from './data/prepare.js'; -import { positionContributorNodes } from './layout/positioning.js'; -import { draw as drawVisualization } from './render/draw.js'; -import { - createInteractionState, - setHovered, - clearHover, - setClicked, - clearClick, - clearAll, - setDelaunay, - clearDelaunay -} from './state/interactionState.js'; -import { findNode as findNodeAtPosition } from './interaction/findNode.js'; -import { setupHover as setupHoverInteraction } from './interaction/hover.js'; -import { setupClick as setupClickInteraction } from './interaction/click.js'; -import { - setupZoom as setupZoomModule, - applyZoomTransform, - shouldSuppressClick, - transformMouseCoordinates -} from './interaction/zoom.js'; -import { - drawCircle, - drawCircleArc, - drawLine, - drawNode, - drawNodeArc, - drawHoverRing, - timeRangeArc, - drawHatchPattern, - drawLink -} from './render/shapes.js'; -import { drawTooltip as drawTooltipModule } from './render/tooltip.js'; -import { drawNodeLabel } from './render/labels.js'; -import { LAYOUT } from './config/theme.js'; - -// Extract commonly used constants for convenience -const DEFAULT_SIZE = LAYOUT.defaultSize; -import { handleResize, sizeCanvas, calculateScaleFactor } from './layout/resize.js'; - -// ============================================================ -// Main Visualization -// ============================================================ -const createContributorNetworkVisual = ( - container, - contributor_padding, - masterContributorsList, - displayNameMap, - orgNickname = 'DevSeed', -) => { - ///////////////////////////////////////////////////////////////// - ///////////////////// CONSTANTS & VARIABLES ///////////////////// - ///////////////////////////////////////////////////////////////// - - const PI = Math.PI; - const TAU = PI * 2; - - let round = Math.round; - let cos = Math.cos; - let sin = Math.sin; - let min = Math.min; - let max = Math.max; - let sqrt = Math.sqrt; - - ///////////////////////////////////////////////////////////////// - // Filter State Management - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/state/filterState.js - let activeFilters = createFilterState(); - - // Master contributor list (passed from template) - // Used for: validating contributors exist, future username-based filtering - // - masterContributors: { username: { ...contributor data } } - // - displayNameToUsername: { "Display Name": "github_username" } - let masterContributors = masterContributorsList; - let displayNameToUsername = displayNameMap; - - /** - * Check if a contributor display name is in the master list - * Useful for filtering to only "official" DeVSeed employees or verifying data integrity - * @param {string} displayName - The display name to check - * @returns {boolean} - True if valid or validation impossible, false if invalid - */ - function isValidContributor(displayName) { - // If maps are missing (e.g. during initial loads or legacy mode), default to true - if (!displayNameToUsername || !masterContributors) return true; - - // Check if name maps to a username, and that username exists in master list - const username = displayNameToUsername[displayName]; - return username && masterContributors[username]; - } - - // Preserve original data to allow filtering - let originalContributors; - let originalRepos; - let originalLinks; - - // Track visible data after filtering - let visibleRepos; - let visibleLinks; - let visibleContributors; - - // Datasets - let contributors; - let repos; - let nodes = [], - nodes_central; - let links; - - ///////////////////////////////////////////////////////////////// - // Interaction State Management - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/state/interactionState.js - let interactionState = createInteractionState(); - - // Convenience references to Delaunay data - // These are kept in sync with interactionState and delaunayData object - let delaunay; - let nodes_delaunay; - - // Helper to sync local variables with delaunayData - function syncDelaunayVars(delaunayData) { - delaunay = delaunayData.delaunay; - nodes_delaunay = delaunayData.nodesDelaunay; - } - - ///////////////////////////////////////////////////////////////// - // Zoom State Management - ///////////////////////////////////////////////////////////////// - // Zoom state object (will be mutated by setupZoom) - const zoomState = {}; - const ZOOM_CLICK_SUPPRESS_MS = 150; - - // Visual Settings - Based on SF = 1 - // Layout constants imported from src/js/config/theme.js - const CENTRAL_RADIUS = LAYOUT.centralRadius; // The radius of the central repository node (reduced for less prominence) - let RADIUS_CONTRIBUTOR; // The eventual radius along which the contributor nodes are placed - let CONTRIBUTOR_RING_WIDTH; - - const INNER_RADIUS_FACTOR = LAYOUT.innerRadiusFactor; // The factor of the RADIUS_CONTRIBUTOR outside of which the inner repos are not allowed to go in the force simulation - const MAX_CONTRIBUTOR_WIDTH = LAYOUT.maxContributorWidth; // The maximum width (at SF = 1) of the contributor name before it gets wrapped - const CONTRIBUTOR_PADDING = contributor_padding; // The padding between the contributor nodes around the circle (at SF = 1) - - ///////////////////////////////////////////////////////////////// - ///////////////////////////// Colors //////////////////////////// - ///////////////////////////////////////////////////////////////// - /* - * DevSeed Brand Colors - * Imported from src/js/config/theme.js - * Kept as local constants for compatibility - */ - const COLOR_BACKGROUND = COLORS.background; - - // Was purple, now Grenadier for accent rings - const COLOR_PURPLE = COLORS.grenadier; - - const COLOR_REPO_MAIN = COLORS.repoMain; // Grenadier (signature orange) - const COLOR_REPO = COLORS.repo; // Aquamarine (secondary blue) - const COLOR_OWNER = COLORS.owner; // Grenadier - const COLOR_CONTRIBUTOR = COLORS.contributor; // Lighter aquamarine - - const COLOR_LINK = COLORS.link; - const COLOR_TEXT = COLORS.text; // Base dark gray - - ///////////////////////////////////////////////////////////////// - //////////////////////// Validation Helpers ///////////////////// - ///////////////////////////////////////////////////////////////// - // Imported from src/js/utils/validation.js - // isValidNode, isValidLink, getLinkNodeId are now imported - - - ///////////////////////////////////////////////////////////////// - ///////////////////////// Create Canvas ///////////////////////// - ///////////////////////////////////////////////////////////////// - - // Create the three canvases and add them to the container - const canvas = document.createElement("canvas"); - canvas.id = "canvas"; - const context = canvas.getContext("2d"); - - const canvas_click = document.createElement("canvas"); - canvas_click.id = "canvas-click"; - const context_click = canvas_click.getContext("2d"); - - const canvas_hover = document.createElement("canvas"); - canvas_hover.id = "canvas-hover"; - const context_hover = canvas_hover.getContext("2d"); - - container.appendChild(canvas); - container.appendChild(canvas_click); - container.appendChild(canvas_hover); - - // Set some important stylings of each canvas - container.style.position = "relative"; - container.style["background-color"] = COLOR_BACKGROUND; - - styleCanvas(canvas); - styleCanvas(canvas_hover); - styleCanvas(canvas_click); - - styleBackgroundCanvas(canvas); - styleBackgroundCanvas(canvas_click); - - // canvas_click is positioned by styleBackgroundCanvas, but needs pointer events - canvas_click.style.pointerEvents = "auto"; - canvas_click.style.zIndex = "1"; - - canvas_hover.style.position = "absolute"; - canvas_hover.style.top = "0"; - canvas_hover.style.left = "0"; - canvas_hover.style.zIndex = "2"; - canvas_hover.style.pointerEvents = "auto"; // Hover canvas needs pointer events for hover to work - - function styleCanvas(canvas) { - canvas.style.display = "block"; - canvas.style.margin = "0"; - } // function styleCanvas - - function styleBackgroundCanvas(canvas) { - canvas.style.position = "absolute"; - canvas.style.top = "0"; - canvas.style.left = "0"; - canvas.style.pointerEvents = "none"; - canvas.style.zIndex = "0"; - canvas.style.transition = "opacity 200ms ease-in"; - } // function styleBackgroundCanvas - - ///////////////////////////////////////////////////////////////// - /////////////////////////// Set Sizes /////////////////////////// - ///////////////////////////////////////////////////////////////// - - //Sizes - // DEFAULT_SIZE extracted from LAYOUT.defaultSize in theme.js - let WIDTH = DEFAULT_SIZE; - let HEIGHT = DEFAULT_SIZE; - let width = DEFAULT_SIZE; - let height = DEFAULT_SIZE; - let SF, PIXEL_RATIO; - - ///////////////////////////////////////////////////////////////// - //////////////////////// Create Functions /////////////////////// - ///////////////////////////////////////////////////////////////// - - let parseDate = d3.timeParse("%Y-%m-%dT%H:%M:%SZ"); - let parseDateUnix = d3.timeParse("%s"); - let formatDate = d3.timeFormat("%b %Y"); - let formatDateExact = d3.timeFormat("%b %d, %Y"); - let formatDigit = d3.format(",.2s"); - // let formatDigit = d3.format(",.2r") - - /* D3 Scales - using factories from src/js/config/scales.js */ - const scale_repo_radius = createRepoRadiusScale(d3); - const scale_owner_radius = createOwnerRadiusScale(d3); - - // Based on the number of commits to the central repo - const scale_contributor_radius = createContributorRadiusScale(d3); - - const scale_link_distance = createLinkDistanceScale(d3); - - const scale_link_width = createLinkWidthScale(d3); - // .clamp(true) - - ///////////////////////////////////////////////////////////////// - //////////////////////// Draw the Visual //////////////////////// - ///////////////////////////////////////////////////////////////// - - function chart(values) { - ///////////////////////////////////////////////////////////// - ////////////////////// Data Preparation ///////////////////// - ///////////////////////////////////////////////////////////// - // Preserve original data for filtering - originalContributors = JSON.parse(JSON.stringify(values[0])); - originalRepos = JSON.parse(JSON.stringify(values[1])); - originalLinks = JSON.parse(JSON.stringify(values[2])); - - // Initialize filters to show all - applyFilters(); - - // Prepare data using extracted module - const prepared = prepareData( - { - contributors, - repos, - links - }, - { - d3, - COLOR_CONTRIBUTOR, - COLOR_REPO, - COLOR_OWNER, - MAX_CONTRIBUTOR_WIDTH, - context, - isValidContributor, - setContributorFont, - getLines - }, - { - scale_repo_radius, - scale_owner_radius, - scale_contributor_radius, - scale_link_width - } - ); - - // Validate prepareData return structure - if (!prepared || typeof prepared !== 'object') { - throw new Error('prepareData returned invalid result: expected object'); - } - if (!Array.isArray(prepared.nodes) || prepared.nodes.length === 0) { - throw new Error('prepareData returned invalid nodes: expected non-empty array'); - } - if (!Array.isArray(prepared.links)) { - throw new Error('prepareData returned invalid links: expected array'); - } - - // Update local variables from prepared data - nodes = prepared.nodes; - nodes_central = prepared.nodes_central; - links = prepared.links; - // console.log("Data prepared") - - ///////////////////////////////////////////////////////////// - /////////////// Run Force Simulation per Owner ////////////// - ///////////////////////////////////////////////////////////// - // Run a force simulation for per owner for all the repos that have the same "owner" - // Like a little cloud of repos around them - runOwnerSimulation(nodes, links, d3, getLinkNodeId, sqrt, max, min); - // console.log("Contributor mini force simulation done") - - ///////////////////////////////////////////////////////////// - //////////// Run Force Simulation per Contributor /////////// - ///////////////////////////////////////////////////////////// - // Run a force simulation for per contributor for all the repos that are not shared between other contributors - // Like a little cloud of repos around them - runContributorSimulation(nodes, links, d3, getLinkNodeId, sqrt, max); - // console.log("Owner mini force simulation done") - - ///////////////////////////////////////////////////////////// - ///////////////// Position Contributor Nodes //////////////// - ///////////////////////////////////////////////////////////// - // Place the contributor nodes in a circle around viewport center (0, 0) - // Taking into account the max_radius of single-degree repos around them - const positioningResult = positionContributorNodes( - { nodes, contributors }, - { CONTRIBUTOR_PADDING } - ); - RADIUS_CONTRIBUTOR = positioningResult.RADIUS_CONTRIBUTOR; - CONTRIBUTOR_RING_WIDTH = positioningResult.CONTRIBUTOR_RING_WIDTH; - // console.log("Contributor nodes positioned") - - ///////////////////////////////////////////////////////////// - /////////// Run Force Simulation for Shared Repos /////////// - ///////////////////////////////////////////////////////////// - // Run a force simulation to position the repos that are shared between contributors - nodes_central = runCollaborationSimulation( - nodes, - links, - d3, - getLinkNodeId, - sqrt, - max, - { - context, - scale_link_distance, - RADIUS_CONTRIBUTOR, - INNER_RADIUS_FACTOR - } - ); - // console.log("Central force simulation done") - - ///////////////////////////////////////////////////////////// - ////////////// Resolve String References in Links /////////// - ///////////////////////////////////////////////////////////// - // After all force simulations, ensure ALL links have source/target - // as node objects (not string IDs). Some links may not pass through - // any simulation, leaving their references as strings. - links = resolveLinkReferences(links, nodes); - - ///////////////////////////////////////////////////////////// - ///////////// Set the Sizes and Draw the Visual ///////////// - ///////////////////////////////////////////////////////////// - chart.resize(); - - ///////////////////////////////////////////////////////////// - ////////////////////// Setup Interactions //////////////////// - ///////////////////////////////////////////////////////////// - // Setup interactions AFTER resize so they have correct WIDTH/HEIGHT/SF values - setupHover(); - setupClick(); - } // function chart - - ///////////////////////////////////////////////////////////////// - /////////////////////// Zoom Helpers //////////////////////////// - ///////////////////////////////////////////////////////////////// - // Note: applyZoomTransform is imported from src/js/interaction/zoom.js - // This redrawAll function uses the modular approach with interactionState - - // Redraw all canvas layers (main, hover, click) - function redrawAll() { - draw(); - if (interactionState.CLICK_ACTIVE && interactionState.CLICKED_NODE) { - context_click.clearRect(0, 0, WIDTH, HEIGHT); - context_click.save(); - applyZoomTransform(context_click, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); - drawHoverState(context_click, interactionState.CLICKED_NODE, false); - context_click.restore(); - } else { - context_click.clearRect(0, 0, WIDTH, HEIGHT); - } - if (interactionState.HOVER_ACTIVE && interactionState.HOVERED_NODE) { - context_hover.clearRect(0, 0, WIDTH, HEIGHT); - context_hover.save(); - applyZoomTransform(context_hover, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); - drawHoverState(context_hover, interactionState.HOVERED_NODE); - context_hover.restore(); - } else { - context_hover.clearRect(0, 0, WIDTH, HEIGHT); - } - } // function redrawAll - - ///////////////////////////////////////////////////////////////// - //////////////////////// Draw the visual //////////////////////// - ///////////////////////////////////////////////////////////////// - - // Draw the visual - extracted to src/js/render/draw.js - function draw() { - // IMPORTANT: Background clearing is intentionally handled here, NOT in draw.js - // Clear must happen BEFORE zoom transform is applied - otherwise only the - // transformed (zoomed/panned) area gets cleared, causing ghost images. - // See src/js/render/draw.js for the corresponding NOTE comment. - context.fillStyle = COLOR_BACKGROUND; - context.fillRect(0, 0, WIDTH, HEIGHT); - - // Apply zoom transform before drawing - context.save(); - applyZoomTransform(context, zoomState.zoomTransform || d3.zoomIdentity, PIXEL_RATIO, WIDTH, HEIGHT); - - drawVisualization( - context, - { nodes, links, nodes_central }, - { WIDTH, HEIGHT, SF, COLOR_BACKGROUND, RADIUS_CONTRIBUTOR, CONTRIBUTOR_RING_WIDTH }, - { - drawLink: drawLinkWrapper, - drawNodeArc: drawNodeArcWrapper, - drawNode: drawNodeWrapper, - drawNodeLabel: drawNodeLabelWrapper - } - ); - - context.restore(); - } // function draw - - ///////////////////////////////////////////////////////////////// - //////////////////////// Resize the chart /////////////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/layout/resize.js - chart.resize = () => { - // Debug: Log resize call - console.log('chart.resize() called', { width, height, nodesCount: nodes.length }); - - const canvases = { - canvas, - canvas_click, - canvas_hover - }; - const contexts = { - context, - context_click, - context_hover - }; - const config = { - width, - height, - DEFAULT_SIZE, - RADIUS_CONTRIBUTOR, - CONTRIBUTOR_RING_WIDTH, - round - }; - const state = { - WIDTH, - HEIGHT, - PIXEL_RATIO, - SF, - nodes_delaunay, - delaunay - }; - const data = { - nodes - }; - - // Update local variables from state object BEFORE calling handleResize - // so that draw() uses the correct values - WIDTH = state.WIDTH; - HEIGHT = state.HEIGHT; - PIXEL_RATIO = state.PIXEL_RATIO; - SF = state.SF; - - // Create a wrapper for draw() that updates local variables first - const drawWithUpdatedState = () => { - // Update local variables from state object before drawing - WIDTH = state.WIDTH; - HEIGHT = state.HEIGHT; - PIXEL_RATIO = state.PIXEL_RATIO; - SF = state.SF; - nodes_delaunay = state.nodes_delaunay; - delaunay = state.delaunay; - // Now draw with updated values - draw(); - }; - - handleResize( - canvases, - contexts, - config, - state, - data, - { - d3, - setDelaunay, - interactionState, - draw: drawWithUpdatedState - } - ); - - // Update local variables from state object after resize (in case they changed) - WIDTH = state.WIDTH; - HEIGHT = state.HEIGHT; - PIXEL_RATIO = state.PIXEL_RATIO; - SF = state.SF; - nodes_delaunay = state.nodes_delaunay; - delaunay = state.delaunay; - - // Debug: Log after resize - console.log('chart.resize() completed', { WIDTH, HEIGHT, SF, nodesCount: nodes.length }); - }; //function resize - - ///////////////////////////////////////////////////////////////// - /////////////////// Data Preparation Functions ////////////////// - ///////////////////////////////////////////////////////////////// - - //////////////// Apply filters to the data //////////////// - // NOTE: Pure filter logic has been extracted to src/js/data/filter.js - // This function handles integration with the visualization's mutable state. - // For new features (e.g., blog charts), import { applyFilters } from './data/filter.js' - function applyFilters() { - // Guard against uninitialized data - if (!originalRepos || !originalLinks || !originalContributors) { - console.error("applyFilters(): Original data not initialized"); - return; - } - - // Start with pristine DEEP COPY of all repos (not shallow .slice()) - // Critical: prepareData() mutates objects (adds/deletes properties), - // so we must clone to avoid corrupting originalRepos on subsequent rebuilds - visibleRepos = JSON.parse(JSON.stringify(originalRepos)); - - // If organizations are selected, filter to those organizations - if (activeFilters.organizations.length > 0) { - visibleRepos = visibleRepos.filter((repo) => { - const owner = repo.repo.substring(0, repo.repo.indexOf("/")); - return hasOrganization(activeFilters, owner); - }); - } - - // Apply minimum stars filter - if (activeFilters.starsMin !== null) { - visibleRepos = visibleRepos.filter( - (repo) => +repo.repo_stars >= activeFilters.starsMin - ); - } - - // Apply minimum forks filter - if (activeFilters.forksMin !== null) { - visibleRepos = visibleRepos.filter( - (repo) => +repo.repo_forks >= activeFilters.forksMin - ); - } - - // Get visible repo names for quick lookup - const visibleRepoNames = new Set(visibleRepos.map((r) => r.repo)); - - // Filter links to DEEP COPY (filter first, then clone) - // Links are also mutated in prepareData() (source/target set, author_name deleted) - visibleLinks = originalLinks - .filter((link) => visibleRepoNames.has(link.repo)) - .map((link) => JSON.parse(JSON.stringify(link))); - - // Build set of visible contributor display names from visible links - const visibleDisplayNames = new Set( - visibleLinks.map((link) => link.author_name), - ); - - // Filter contributors to DEEP COPY - // Contributors are mutated in prepareData() (contributor_name_top deleted, etc.) - visibleContributors = originalContributors - .filter((contributor) => visibleDisplayNames.has(contributor.author_name)) - .map((c) => JSON.parse(JSON.stringify(c))); - - // Build set of visible contributor names for link filtering - const visibleContributorNames = new Set( - visibleContributors.map((c) => c.author_name), - ); - - // Re-filter links to only those where the contributor is also visible - visibleLinks = visibleLinks.filter((link) => { - return visibleContributorNames.has(link.author_name); - }); - - // Update the working arrays that prepareData() uses - contributors = visibleContributors; - repos = visibleRepos; - links = visibleLinks; - - // Debug: Log filtering results (enable via localStorage) - if (localStorage.getItem('debug-contributor-network') === 'true') { - console.debug('=== APPLY FILTERS ==='); - console.debug(`Org filters: ${activeFilters.organizations.join(", ") || "none"}`); - console.debug(`Stars min: ${activeFilters.starsMin ?? "none"}, Forks min: ${activeFilters.forksMin ?? "none"}`); - console.debug(`Data before: ${originalContributors.length} contributors, ${originalRepos.length} repos, ${originalLinks.length} links`); - console.debug(`Data after: ${visibleContributors.length} contributors, ${visibleRepos.length} repos, ${visibleLinks.length} links`); - console.debug('Visible repos:', visibleRepos.map(r => r.repo)); - console.debug('Visible contributors:', visibleContributors.map(c => c.author_name)); - } - } - - //////////////// Prepare the data for the visual //////////////// - // Extracted to src/js/data/prepare.js - - - ///////////////////////////////////////////////////////////////// - ///////////////// Force Simulation | Per Owner ////////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/simulations/ownerSimulation.js - - ///////////////////////////////////////////////////////////////// - /////////////// Force Simulation | Per Contributor ////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/simulations/contributorSimulation.js - - // Place the contributor nodes in a circle around the central repo - // Taking into account the max_radius of single-degree repos around them - // Extracted to src/js/layout/positioning.js - - ///////////////////////////////////////////////////////////////// - ///////////// Force Simulation | Collaboration Repos //////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/simulations/collaborationSimulation.js - - - ///////////////////////////////////////////////////////////////// - ///////////////////// Node Drawing Functions //////////////////// - ///////////////////////////////////////////////////////////////// - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function drawNodeWrapper(context, SF, d) { - const config = { COLOR_BACKGROUND, max }; - drawNode(context, SF, d, config, interactionState); - } - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function drawNodeArcWrapper(context, SF, d) { - drawNodeArc(context, SF, d, interactionState, COLOR_CONTRIBUTOR, d3, null); - } - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function drawHoverRingWrapper(context, d) { - drawHoverRing(context, d, SF, null); - } - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function timeRangeArcWrapper(context, SF, d, repo, link, COL = COLOR_REPO_MAIN) { - timeRangeArc(context, SF, d, repo, link, COL, d3, null); - } - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function drawHatchPatternWrapper(context, radius, angle, d) { - drawHatchPattern(context, radius, angle, SF, d.color, sin); - } - - // Extracted to src/js/render/shapes.js - // drawCircle is now imported directly - - ///////////////////////////////////////////////////////////////// - ///////////////////// Line Drawing Functions //////////////////// - ///////////////////////////////////////////////////////////////// - - // Extracted to src/js/render/shapes.js - // Wrapper to adapt old signature to new module signature - function drawLinkWrapper(context, SF, l) { - const config = { COLOR_LINK }; - drawLink(context, SF, l, config, interactionState, calculateLinkGradient, calculateEdgeCenters, scale_link_width); - } - - // Extracted to src/js/render/shapes.js - // drawLine and drawCircleArc are now imported directly - - ///////////////////// Calculate Line Centers //////////////////// - function calculateEdgeCenters(l, size = 2, sign = true) { - //Find a good radius - l.r = - sqrt(sq(l.target.x - l.source.x) + sq(l.target.y - l.source.y)) * size; //Can run from > 0.5 - //Find center of the arc function - let centers = findCenters( - l.r, - { x: l.source.x, y: l.source.y }, - { x: l.target.x, y: l.target.y }, - ); - l.sign = sign; - l.center = l.sign ? centers.c2 : centers.c1; - - /////////////// Calculate center for curved edges /////////////// - //https://stackoverflow.com/questions/26030023 - //http://jsbin.com/jutidigepeta/3/edit?html,js,output - function findCenters(r, p1, p2) { - // pm is middle point of (p1, p2) - let pm = { x: 0.5 * (p1.x + p2.x), y: 0.5 * (p1.y + p2.y) }; - // compute leading vector of the perpendicular to p1 p2 == C1C2 line - let perpABdx = -(p2.y - p1.y); - let perpABdy = p2.x - p1.x; - // normalize vector - let norm = sqrt(sq(perpABdx) + sq(perpABdy)); - perpABdx /= norm; - perpABdy /= norm; - // compute distance from pm to p1 - let dpmp1 = sqrt(sq(pm.x - p1.x) + sq(pm.y - p1.y)); - // sin of the angle between { circle center, middle , p1 } - let sin = dpmp1 / r; - // is such a circle possible ? - if (sin < -1 || sin > 1) return null; // no, return null - // yes, compute the two centers - let cos = sqrt(1 - sq(sin)); // build cos out of sin - let d = r * cos; - let res1 = { x: pm.x + perpABdx * d, y: pm.y + perpABdy * d }; - let res2 = { x: pm.x - perpABdx * d, y: pm.y - perpABdy * d }; - return { c1: res1, c2: res2 }; - } //function findCenters - } //function calculateEdgeCenters - - ///////////////// Create gradients for the links //////////////// - function calculateLinkGradient(context, l) { - // l.gradient = context.createLinearGradient(l.source.x, l.source.y, l.target.x, l.target.y) - // l.gradient.addColorStop(0, l.source.color) - // l.gradient.addColorStop(1, l.target.color) - - // The opacity of the links depends on the number of links - const scale_alpha = d3 - .scaleLinear() - .domain([300, 800]) - .range([0.5, 0.2]) - .clamp(true); - - // Incorporate opacity into gradient - let alpha; - if (interactionState.hoverActive) alpha = l.target.special_type ? 0.3 : 0.7; - else alpha = l.target.special_type ? 0.15 : scale_alpha(links.length); - - // Scale down opacity for links converging on high-degree owner nodes - // to prevent overlapping links from compounding into an opaque mass - if (l.target.type === "owner" && l.target.degree > 5) { - const scale_density = d3.scaleLinear() - .domain([5, 15, 40]) - .range([1, 0.5, 0.25]) - .clamp(true); - alpha *= scale_density(l.target.degree); - } - - createGradient(l, alpha); - - function createGradient(l, alpha) { - let col; - let color_rgb_source; - let color_rgb_target; - - col = d3.rgb(l.source.color); - color_rgb_source = - "rgba(" + col.r + "," + col.g + "," + col.b + "," + alpha + ")"; - col = d3.rgb(l.target.color); - color_rgb_target = - "rgba(" + col.r + "," + col.g + "," + col.b + "," + alpha + ")"; - - // Guard against non-finite coordinates (NaN, Infinity, -Infinity) - // This prevents "createLinearGradient: non-finite" errors during filtering - if ( - l.source && l.target && - typeof l.source.x === 'number' && typeof l.source.y === 'number' && - typeof l.target.x === 'number' && typeof l.target.y === 'number' && - isFinite(l.source.x) && isFinite(l.source.y) && - isFinite(l.target.x) && isFinite(l.target.y) - ) { - try { - l.gradient = context.createLinearGradient( - l.source.x * SF, - l.source.y * SF, - l.target.x * SF, - l.target.y * SF, - ); - - // Distance between source and target - let dist = sqrt( - sq(l.target.x - l.source.x) + sq(l.target.y - l.source.y), - ); - // What percentage is the source's radius of the total distance - let perc = l.source.r / dist; - // Let the starting color be at perc, so it starts changing color right outside the radius of the source node - l.gradient.addColorStop(perc, color_rgb_source); - l.gradient.addColorStop(1, color_rgb_target); - } catch (e) { - // If gradient creation fails for any reason, fall back to solid color - if (localStorage.getItem('debug-contributor-network') === 'true') { - console.warn('Gradient creation error:', e, { link: l, sf: SF }); - } - l.gradient = COLOR_LINK; - } - } else { - // Gradient can't be created - invalid coordinates - if (localStorage.getItem('debug-contributor-network') === 'true') { - console.warn('Invalid coordinates for gradient', { - sourceX: l.source?.x, - sourceY: l.source?.y, - targetX: l.target?.x, - targetY: l.target?.y - }); - } - l.gradient = COLOR_LINK; - } - } //function createGradient - } //function calculateLinkGradient - - ///////////////////////////////////////////////////////////////// - //////////////////////// Hover Functions //////////////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/interaction/hover.js - function setupHover() { - const config = { - PIXEL_RATIO, - WIDTH, - HEIGHT, - SF, - RADIUS_CONTRIBUTOR, - CONTRIBUTOR_RING_WIDTH, - sqrt - }; - // Create delaunayData object that will be kept in sync - const delaunayData = { - get delaunay() { return delaunay; }, - set delaunay(val) { delaunay = val; }, - get nodesDelaunay() { return nodes_delaunay; }, - set nodesDelaunay(val) { nodes_delaunay = val; } - }; - - setupHoverInteraction({ - d3, - canvasSelector: "#canvas-hover", - config, - delaunayData, - interactionState, - canvas, - contextHover: context_hover, - setHovered, - clearHover, - drawHoverState, - zoomState - }); - } // function setupHover - - // Draw the hovered node and its links and neighbors and a tooltip - function drawHoverState(context, d, DO_TOOLTIP = true) { - // Note: Zoom transform should already be applied by the caller (in redrawAll) - // This function assumes the context is already transformed - // Draw the hover canvas - context.save(); - - ///////////////////////////////////////////////// - // Get all the connected links (if not done before) - if (d.neighbor_links === undefined) { - d.neighbor_links = links.filter( - (l) => { - return l.source.id === d.id || l.target.id === d.id; - } - ); - } // if - - // Get all the connected nodes (if not done before) - if (d.neighbors === undefined) { - d.neighbors = nodes.filter((n) => { - return links.find( - (l) => { - return (l.source.id === d.id && l.target.id === n.id) || - (l.target.id === d.id && l.source.id === n.id); - } - ); - }); - - // If any of these neighbors are "owner" nodes, find what the original repo was from that owner that the contributor was connected to - // OR - // If this node is a repo and any of these neighbors are "owner" nodes, find what original contributor was connected to this repo - if (d.type === "contributor" || d.type === "repo") { - d.neighbors.forEach((n) => { - if (n && n.type === "owner" && d.data && d.data.links_original) { - // Go through all of the original links and see if this owner is in there - d.data.links_original.forEach((l) => { - if (l.owner === n.id) { - let node, link; - if (d.type === "contributor") { - // Find the repo node - node = nodes.find((r) => r.id === l.repo); - // Skip if node doesn't exist (repo not in visualization) - if (!node) return; - // Also find the link between the repo and owner and add this to the neighbor_links - link = links.find( - (l) => l.source.id === n.id && l.target.id === node.id, - ); - } else if (d.type === "repo") { - // Find the contributor node - node = nodes.find((r) => r.id === l.contributor_name); - // Skip if node doesn't exist (contributor not in visualization) - if (!node) return; - // Also find the link between the contributor and owner and add this to the neighbor_links - link = links.find( - (l) => l.source.id === node.id && l.target.id === n.id, - ); - } // else if - - // Add it to the neighbors (only if node exists) - if (node) { - d.neighbors.push(node); - if (link) d.neighbor_links.push(link); - } - } // if - }); // forEach - } // if - }); // forEach - } // if - } // if - - ///////////////////////////////////////////////// - // Draw all the links to this node (with null safety) - if (d.neighbor_links) { - d.neighbor_links.forEach((l) => { - if (l && l.source && l.target) drawLinkWrapper(context, SF, l); - }); // forEach - } - - // Draw all the connected nodes (with null safety) - if (d.neighbors) { - d.neighbors.forEach((n) => { if (n) drawNodeArcWrapper(context, SF, n); }); - d.neighbors.forEach((n) => { if (n) drawNodeWrapper(context, SF, n); }); - // Draw all the labels of the "central" connected nodes - d.neighbors.forEach((n) => { - if (n && n.node_central) drawNodeLabelWrapper(context, n); - }); // forEach - } - - ///////////////////////////////////////////////// - // Draw the hovered node - drawNodeWrapper(context, SF, d); - // Show a ring around the hovered node - drawHoverRingWrapper(context, d); - - ///////////////////////////////////////////////// - // Show its label - if (d.node_central && d.type === "contributor") drawNodeLabelWrapper(context, d); - - ///////////////////////////////////////////////// - // Create a tooltip with more info - if (DO_TOOLTIP) drawTooltipWrapper(context, d); - - context.restore(); - } // function drawHoverState - - ///////////////////////////////////////////////////////////////// - //////////////////////// Click Functions //////////////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/interaction/click.js - function setupClick() { - const config = { - PIXEL_RATIO, - WIDTH, - HEIGHT, - SF, - RADIUS_CONTRIBUTOR, - CONTRIBUTOR_RING_WIDTH, - sqrt - }; - // Create delaunayData object that will be kept in sync - const delaunayData = { - get delaunay() { return delaunay; }, - set delaunay(val) { delaunay = val; }, - get nodesDelaunay() { return nodes_delaunay; }, - set nodesDelaunay(val) { nodes_delaunay = val; } - }; - - setupClickInteraction({ - d3, - canvasSelector: "#canvas-hover", // Use hover canvas for clicks too since it's on top - config, - delaunayData, - interactionState, - canvas, - contextClick: context_click, - contextHover: context_hover, - nodes, - setClicked, - clearClick, - clearHover, - setDelaunay, - drawHoverState, - zoomState, - ZOOM_CLICK_SUPPRESS_MS - }); - } // function setupClick - - ///////////////////////////////////////////////////////////////// - //////////////////////// Zoom Functions ///////////////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/interaction/zoom.js - function setupZoom() { - setupZoomModule({ - d3, - canvasSelector: "#canvas-hover", - state: zoomState, - redrawAll, - ZOOM_CLICK_SUPPRESS_MS - }); - } // function setupZoom - - ///////////////////////////////////////////////////////////////// - ///////////////// General Interaction Functions ///////////////// - ///////////////////////////////////////////////////////////////// - // Extracted to src/js/interaction/findNode.js - // Note: findNode is now imported and used directly in hover.js and click.js - - // Draw the tooltip above the node - // Extracted to src/js/render/tooltip.js - // Wrapper to adapt old signature to new module signature - function drawTooltipWrapper(context, d) { - const config = { - SF, - COLOR_BACKGROUND, - COLOR_TEXT, - COLOR_CONTRIBUTOR, - COLOR_REPO, - COLOR_OWNER, - min, - orgNickname, - }; - drawTooltipModule(context, d, config, interactionState, null, formatDate, formatDateExact, formatDigit); - } - - // Keep old function name for compatibility - delegate to wrapper - function drawTooltip(context, d) { - return drawTooltipWrapper(context, d); - } - - // Wrapper to adapt old signature to new module signature - function drawNodeLabelWrapper(context, d, DO_CENTRAL_OUTSIDE = false) { - const config = { - SF, - COLOR_TEXT, - COLOR_BACKGROUND, - COLOR_REPO_MAIN, - PI - }; - drawNodeLabel(context, d, config, null, DO_CENTRAL_OUTSIDE); - } - - // ============================================================================= - // NOTE: The following functions have been extracted to modules: - // - drawTooltip → src/js/render/tooltip.js - // - drawNodeLabel → src/js/render/labels.js - // - text/font functions → src/js/render/text.js - // - // The wrapper functions above (drawTooltipWrapper, drawNodeLabelWrapper) - // import and call these modular implementations. - // ============================================================================= - - ///////////////////////////////////////////////////////////////// - ///////////////////////// Test Functions //////////////////////// - ///////////////////////////////////////////////////////////////// - - // TEST - Draw a (scaled wrong) version of the delaunay triangles - function testDelaunay(delaunay, context) { - context.save(); - context.translate(WIDTH / 2, HEIGHT / 2); - context.beginPath(); - delaunay.render(context); - context.strokeStyle = "silver"; - context.lineWidth = 1 * SF; - context.stroke(); - context.restore(); - } // function testDelaunay - - // TEST - Draw a stroked rectangle around the bbox of the nodes - function drawBbox(context, nodes) { - context.strokeStyle = "red"; - context.lineWidth = 1; - nodes - .filter((d) => d.bbox) - .forEach((d) => { - context.strokeRect( - d.x * SF + d.bbox[0][0] * SF, - d.y * SF + d.bbox[0][1] * SF, - (d.bbox[1][0] - d.bbox[0][0]) * SF, - (d.bbox[1][1] - d.bbox[0][1]) * SF, - ); - }); // forEach - } // function drawBbox - - ///////////////////////////////////////////////////////////////// - //////////////////////// Helper Functions /////////////////////// - ///////////////////////////////////////////////////////////////// - - function mod(x, n) { - return ((x % n) + n) % n; - } - - function sq(x) { - return x * x; - } - - function isInteger(value) { - return /^\d+$/.test(value); - } - - ///////////////////////////////////////////////////////////////// - /////////////////////// Accessor functions ////////////////////// - ///////////////////////////////////////////////////////////////// - - chart.width = function (value) { - if (!arguments.length) return width; - width = value; - return chart; - }; // chart.width - - chart.height = function (value) { - if (!arguments.length) return height; - height = value; - return chart; - }; // chart.height - - // Note: chart.repository() accessor removed - no longer have a central repository - - ///////////////////////////////////////////////////////////////// - ////////////////////// Filtering Functions ////////////////////// - ///////////////////////////////////////////////////////////////// - - chart.rebuild = function () { - // Reset visualization state completely - nodes = []; - links = []; - nodes_central = []; // Reset derived array for central nodes - - // Reset interaction state - clearAll(interactionState); - clearDelaunay(interactionState); - - // Reset spatial data structures (will be rebuilt in resize()) - nodes_delaunay = []; - delaunay = null; - - // Apply current filters - applyFilters(); - - // Re-run the full initialization pipeline - const prepared = prepareData( - { - contributors, - repos, - links - }, - { - d3, - COLOR_CONTRIBUTOR, - COLOR_REPO, - COLOR_OWNER, - MAX_CONTRIBUTOR_WIDTH, - context, - isValidContributor, - setContributorFont, - getLines - }, - { - scale_repo_radius, - scale_owner_radius, - scale_contributor_radius, - scale_link_width - } - ); - - // Validate prepareData return structure - if (!prepared || typeof prepared !== 'object') { - console.error('rebuild: prepareData returned invalid result'); - return chart; - } - if (!Array.isArray(prepared.nodes) || prepared.nodes.length === 0) { - console.error('rebuild: prepareData returned empty nodes - filters may be too restrictive'); - return chart; - } - - // Update local variables from prepared data - nodes = prepared.nodes; - nodes_central = prepared.nodes_central; - links = prepared.links; - - runOwnerSimulation(nodes, links, d3, getLinkNodeId, sqrt, max, min); - runContributorSimulation(nodes, links, d3, getLinkNodeId, sqrt, max); - const positioningResult = positionContributorNodes( - { nodes, contributors }, - { CONTRIBUTOR_PADDING } - ); - RADIUS_CONTRIBUTOR = positioningResult.RADIUS_CONTRIBUTOR; - CONTRIBUTOR_RING_WIDTH = positioningResult.CONTRIBUTOR_RING_WIDTH; - - // Pre-compute SF now that RADIUS_CONTRIBUTOR is known. - // This ensures SF is consistent before any downstream code references it. - // resize() will re-derive the same value, but setting it early prevents - // any intermediate code from seeing a stale SF. - SF = calculateScaleFactor(WIDTH, DEFAULT_SIZE, RADIUS_CONTRIBUTOR, CONTRIBUTOR_RING_WIDTH); - - nodes_central = runCollaborationSimulation( - nodes, - links, - d3, - getLinkNodeId, - sqrt, - max, - { - context, - scale_link_distance, - RADIUS_CONTRIBUTOR, - INNER_RADIUS_FACTOR - } - ); - // Resolve any remaining string references in links - links = resolveLinkReferences(links, nodes); - - // Position any nodes that didn't get positioned by force simulations - // Critical for filtered data where force simulations may not include all nodes - const unpositionedNodes = nodes.filter(n => - n.x === 0 && n.y === 0 - ); - - if (unpositionedNodes.length > 0) { - // Get total counts by type for proper distribution - const contributorCount = nodes.filter(n => n.type === 'contributor').length; - const repoCount = nodes.filter(n => n.type === 'repo').length; - - let contributorIdx = 0; - let repoIdx = 0; - - unpositionedNodes.forEach(node => { - if (node.type === 'contributor') { - // Distribute contributors evenly around outer circle - const angle = (contributorIdx / Math.max(1, contributorCount)) * Math.PI * 2; - const radius = 250; // Outer ring for contributors - node.x = Math.cos(angle) * radius; - node.y = Math.sin(angle) * radius; - // Ensure radius property is set for gradient calculations - if (!node.r) node.r = 6; - contributorIdx++; - } else if (node.type === 'repo') { - // Distribute repos around middle zone - const angle = (repoIdx / Math.max(1, repoCount)) * Math.PI * 2; - const radius = 150 + Math.random() * 50; - node.x = Math.cos(angle) * radius; - node.y = Math.sin(angle) * radius; - // Ensure radius property is set for gradient calculations - if (!node.r) node.r = 8; - repoIdx++; - } else if (node.type === 'owner') { - // Owners stay near center - const angle = (repoIdx / 5) * Math.PI * 2; - const radius = 50; - node.x = Math.cos(angle) * radius; - node.y = Math.sin(angle) * radius; - // Ensure radius property is set for gradient calculations - if (!node.r) node.r = 15; - } - }); - - if (localStorage.getItem('debug-contributor-network') === 'true') { - console.debug(`Positioned ${unpositionedNodes.length} unpositioned nodes in rings by type`); - } - } - - // Ensure all nodes in links have valid, finite coordinates - links.forEach(link => { - if (link.source && link.target) { - // Check/fix source coordinates - if (typeof link.source.x !== 'number' || typeof link.source.y !== 'number' || - !isFinite(link.source.x) || !isFinite(link.source.y)) { - link.source.x = 0; - link.source.y = 0; - } - // Check/fix target coordinates - if (typeof link.target.x !== 'number' || typeof link.target.y !== 'number' || - !isFinite(link.target.x) || !isFinite(link.target.y)) { - link.target.x = 0; - link.target.y = 0; - } - } - }); - - // Calculate SF early so it's finalized before interaction handlers capture it. - // positionContributorNodes() determines RADIUS_CONTRIBUTOR and CONTRIBUTOR_RING_WIDTH, - // which resize() uses to compute SF. By calling resize() first, we ensure setupHover() - // and setupClick() capture the final SF value — preventing the hit-detection offset bug - // that occurred when SF changed between setupHover() and resize(). - // This matches the order in the initial chart() function (resize before interactions). - chart.resize(); - - // Re-setup interaction handlers AFTER resize so they have correct WIDTH/HEIGHT/SF values - setupHover(); - setupClick(); - - return chart; - }; - - /** - * Updates the active filters and rebuilds the chart - * @param {string} organizationName - Organization to filter by - * @param {boolean} enabled - Whether to enable or disable this filter - * @returns {Object} - The chart instance - */ - chart.setFilter = function (organizationName, enabled) { - if (enabled) { - addOrganization(activeFilters, organizationName); - } else { - removeOrganization(activeFilters, organizationName); - } - - chart.rebuild(); - return chart; - }; - - /** - * Updates a metric-based repo filter and rebuilds the chart - * @param {string} metric - Metric name ('starsMin' or 'forksMin') - * @param {number|null} value - Minimum threshold value, or null to clear - * @returns {Object} - The chart instance - */ - chart.setRepoFilter = function (metric, value) { - setMetricFilter(activeFilters, metric, value); - chart.rebuild(); - return chart; - }; - - chart.getActiveFilters = function () { - return { ...activeFilters }; - }; - - /** - * DEBUG: Get internal nodes array for diagnostic purposes - */ - chart.getNodes = function () { - return nodes; - }; - - /** - * DEBUG: Get internal links array for diagnostic purposes - */ - chart.getLinks = function () { - return links; - }; - - /** - * DEBUG: Get debug state snapshot - */ - chart.getDebugState = function () { - const validLinks = links.filter(l => - l.source && l.target && - typeof l.source === 'object' && typeof l.target === 'object' - ); - - // Count links that have valid coordinates for drawing - const drawableLinks = validLinks.filter(l => - l.source && l.target && - typeof l.source.x === 'number' && typeof l.source.y === 'number' && - typeof l.target.x === 'number' && typeof l.target.y === 'number' && - isFinite(l.source.x) && isFinite(l.source.y) && - isFinite(l.target.x) && isFinite(l.target.y) - ); - - return { - nodesCount: nodes.length, - linksCount: links.length, - nodeTypes: nodes.reduce((acc, n) => { - acc[n.type] = (acc[n.type] || 0) + 1; - return acc; - }, {}), - validLinks: validLinks.length, - linksToBeDrawn: drawnLinks.length, - linksWithValidCoordinates: drawableLinks.length, - activeFilters: { ...activeFilters } - }; - }; - - return chart; -}; // function createContributorNetworkVisual - -// ============================================================ -// Exports (for ES module bundling) -// ============================================================ -export { createContributorNetworkVisual }; - -// Also expose globally for non-module usage during transition -if (typeof window !== 'undefined') { - window.createContributorNetworkVisual = createContributorNetworkVisual; -} From 253a68fcf756f099e06596f85fc12b38d9aa1959 Mon Sep 17 00:00:00 2001 From: aboydnw Date: Wed, 11 Mar 2026 22:15:41 +0000 Subject: [PATCH 4/4] change devseed to org --- src/data/prepare.ts | 2 +- src/render/repoCard.ts | 8 ++++---- src/render/tooltip.ts | 8 ++++---- src/types.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/data/prepare.ts b/src/data/prepare.ts index 15b99d4..1f6ec32 100644 --- a/src/data/prepare.ts +++ b/src/data/prepare.ts @@ -187,7 +187,7 @@ export function prepareData( d.archived = d.repo_archived === "true" || d.repo_archived === true; d.totalContributors = +(d.repo_total_contributors ?? 0); - d.devseedContributors = +(d.repo_devseed_contributors ?? 0); + d.orgContributors = +(d.repo_devseed_contributors ?? 0); d.externalContributors = +(d.repo_external_contributors ?? 0); d.communityRatio = +(d.repo_community_ratio ?? 0); diff --git a/src/render/repoCard.ts b/src/render/repoCard.ts index bbd6b30..f804b3c 100644 --- a/src/render/repoCard.ts +++ b/src/render/repoCard.ts @@ -28,7 +28,7 @@ export interface RepoCardData { watchers?: number; languages?: string[]; totalContributors?: number; - devseedContributors?: number; + orgContributors?: number; externalContributors?: number; communityRatio?: number; totalCommits?: number; @@ -301,12 +301,12 @@ export function renderCommunityMetrics( setFont(context, config.valueFontSize * SF, 400, 'normal'); const total = data.totalContributors; - const devseed = data.devseedContributors || 0; + const orgContributors = data.orgContributors || 0; const external = data.externalContributors || 0; renderText( context, - `${total} contributors (${devseed} ${org}, ${external} community)`, + `${total} contributors (${orgContributors} ${org}, ${external} community)`, x * SF, y * SF, 1.25 * SF, @@ -324,7 +324,7 @@ export function renderCommunityMetrics( ); } - if (devseed === 1 && total > 0) { + if (orgContributors === 1 && total > 0) { y += config.valueFontSize * config.lineHeight; context.globalAlpha = config.warningOpacity; setFont(context, config.valueFontSize * SF, 400, 'italic'); diff --git a/src/render/tooltip.ts b/src/render/tooltip.ts index 94a3405..e022472 100644 --- a/src/render/tooltip.ts +++ b/src/render/tooltip.ts @@ -85,7 +85,7 @@ function calculateRepoTooltipHeight( if (data.totalCommits && data.totalCommits > 0) { y += config.valueFontSize * config.lineHeight; } - if (data.devseedContributors === 1 && data.totalContributors > 0) { + if (data.orgContributors === 1 && data.totalContributors > 0) { y += config.valueFontSize * config.lineHeight; y += config.valueFontSize * config.lineHeight; } else { @@ -197,11 +197,11 @@ function calculateRepoTooltipWidth( if (data.totalContributors && data.totalContributors > 0) { setFont(context, config.valueFontSize * SF, 400, 'normal'); const total = data.totalContributors; - const devseed = data.devseedContributors || 0; + const orgContributors = data.orgContributors || 0; const external = data.externalContributors || 0; width = context.measureText( - `${total} contributors (${devseed} ${org}, ${external} community)`, + `${total} contributors (${orgContributors} ${org}, ${external} community)`, ).width * 1.25; if (width > maxWidth) maxWidth = width; @@ -214,7 +214,7 @@ function calculateRepoTooltipWidth( if (width > maxWidth) maxWidth = width; } - if (devseed === 1 && total > 0) { + if (orgContributors === 1 && total > 0) { width = context.measureText(`⚠ Single ${org} maintainer`).width * 1.25; if (width > maxWidth) maxWidth = width; diff --git a/src/types.ts b/src/types.ts index 4f77d5e..2bd1487 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,7 @@ export interface RepoData { hasDiscussions: boolean; archived: boolean; totalContributors: number; - devseedContributors: number; + orgContributors: number; externalContributors: number; communityRatio: number; totalCommits?: number;