Skip to main content
The Geographic Map uses Leaflet to display your network data spatially, showing directional arcs between origin and destination coordinates with customizable styling, interactive legends, and a unique cost mode that factors distance into connection importance.

Overview

Key features of the geographic visualization:
  • Leaflet-based interactive map with pan/zoom
  • Dashed arcs connecting origins to destinations
  • Node shapes distinguish origins (circles), destinations (squares), and bidirectional nodes (diamonds)
  • Color-by field for grouping nodes by any column
  • Cost mode where arc width = distance × weight
  • Interactive legends for filtering by color groups
  • Detailed popups showing referrals by origin/destination with percentages
The map requires latitude/longitude columns in your dataset. You can provide coordinates for origins, destinations, or both.

When to Use

Use the Geographic Map when:
  • Your data contains spatial information (lat/lng coordinates)
  • You want to understand geographic distribution of referrals
  • Distance matters—see how far connections travel
  • You need to identify regional clusters or patterns
  • You want to compare cost (distance × weight) vs. raw weight
Nodes without coordinates won’t appear on the map. Check the statistics line to see how many links are georeferenced.

Setup Requirements

Coordinate Columns

In the header configuration, specify lat/lng columns:
// State variables - app.js:14-17
originLatCol: '',   // Latitude for origin nodes
originLngCol: '',   // Longitude for origin nodes
destLatCol: '',     // Latitude for destination nodes
destLngCol: '',     // Longitude for destination nodes
Provide all 4 columns for complete spatial accuracy

Coordinate Extraction

The system extracts coordinates once when columns are set:
// Extraction logic - app.js:1619-1676
function extractNodeCoordinates() {
    const hasOriginCoords = appState.originLatCol && appState.originLngCol;
    const hasDestCoords = appState.destLatCol && appState.destLngCol;
    
    appState.nodeCoordinates.clear();
    appState.nodesWithOriginCoords = new Set();
    appState.nodesWithDestCoords = new Set();
    
    appState.rows.forEach(row => {
        const originNode = String(row[appState.originCol] ?? '').trim();
        const destNode = String(row[appState.destCol] ?? '').trim();
        
        // Extract origin coordinates
        if (hasOriginCoords && originNode) {
            const originLat = parseFloat(row[appState.originLatCol]);
            const originLng = parseFloat(row[appState.originLngCol]);
            if (!isNaN(originLat) && !isNaN(originLng)) {
                appState.nodesWithOriginCoords.add(originNode);
                appState.nodeCoordinates.set(originNode, { lat: originLat, lng: originLng });
            }
        }
        
        // Extract destination coordinates
        if (hasDestCoords && destNode) {
            const destLat = parseFloat(row[appState.destLatCol]);
            const destLng = parseFloat(row[appState.destLngCol]);
            if (!isNaN(destLat) && !isNaN(destLng)) {
                appState.nodesWithDestCoords.add(destNode);
                appState.nodeCoordinates.set(destNode, { lat: destLat, lng: destLng });
            }
        }
    });
}

Key Features

Cost Mode

Cost mode changes arc width from raw weight to distance × weight:
// Cost calculation - app.js:1890-1912
function haversineDist(lat1, lng1, lat2, lng2) {
    const R = 6371; // Earth radius in km
    const toRad = v => v * Math.PI / 180;
    const dLat = toRad(lat2 - lat1);
    const dLng = toRad(lng2 - lng1);
    const a = Math.sin(dLat / 2) ** 2 +
              Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
    return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

const useCost = appState.mapCostMode;
const edgeMetric = new Map();
filteredEdges.forEach(edge => {
    const from = appState.nodeCoordinates.get(edge.source);
    const to = appState.nodeCoordinates.get(edge.target);
    let metric = edge.value || 1;
    if (useCost && from && to) {
        const dist = haversineDist(from.lat, from.lng, to.lat, to.lng);
        metric = dist * (edge.value || 1);  // Distance × weight
    }
    edgeMetric.set(`${edge.source}|${edge.target}`, metric);
});
In cost mode, a high-weight short-distance connection may have less visual prominence than a low-weight long-distance connection, reflecting the true “cost” of the relationship.
1

Toggle cost mode

Click the Cost button in the toolbar (turns blue when active)
2

Arc widths update

Arcs now reflect distance × weight, not just weight
3

Node sizes update

Node sizes also incorporate cost metric
4

Popups show cost

Hover popups display “Cost (dist×wt)” column

Arc Width Scaling

// Width calculation - app.js:1963-1965
const lineWidth = useCost
    ? 2 + (metric / maxMetric) * 38      // Cost mode: 2-40px
    : 4 + (weight / maxWeight) * 36;     // Normal mode: 4-40px
Arcs have an invisible hitbox (minimum 18px) for easy clicking, even when visually thin.

Node Shapes by Role

Node shapes indicate network role:
// Shape logic - app.js:1991-2061
const isOrigin = originNodes.has(nodeName);   // Sends referrals
const isDest = destNodes.has(nodeName);       // Receives referrals

if (isOrigin && isDest) {
    // Diamond: both origin and destination
    const svg = `<svg>...<polygon points="diamond"/>...</svg>`;
    marker = L.marker([lat, lng], { icon: L.divIcon({ html: svg }) });
} else if (isDest) {
    // Square: destination only
    const svg = `<svg>...<rect/>...</svg>`;
    marker = L.marker([lat, lng], { icon: L.divIcon({ html: svg }) });
} else {
    // Circle: origin only
    marker = L.circleMarker([lat, lng], { radius, fillColor: color });
}

Circle

Origin nodesSend referrals out

Square

Destination nodesReceive referrals in

Diamond

Both directionsAct as origin AND destination

Color-By Field

Group nodes by any column in your dataset:
// Color-by logic - app.js:1815-1843
const colorCol = appState.mapColorCol;
let nodeColorMap = null;
let colorGroups = [];

if (colorCol) {
    nodeColorMap = new Map();
    
    // Assign color group to each node
    appState.rows.forEach(row => {
        const originNode = String(row[appState.originCol] ?? '').trim();
        const destNode = String(row[appState.destCol] ?? '').trim();
        const val = String(row[colorCol] ?? '').trim();
        if (originNode && !nodeColorMap.has(originNode)) nodeColorMap.set(originNode, val);
        if (destNode && !nodeColorMap.has(destNode)) nodeColorMap.set(destNode, val);
    });
    
    // Build color scale
    colorGroups = Array.from(new Set(nodeColorMap.values())).sort();
    const groupColorScale = d3.scaleOrdinal()
        .domain(colorGroups)
        .range(colorPalette);
    
    colorScale = (nodeName) => groupColorScale(nodeColorMap.get(nodeName) || '');
}
Default: Each node gets a unique color from d3.schemeCategory10

Interactive Color Legend

When a color-by field is set, an interactive legend appears:
// Legend rendering - app.js:2089-2133
if (colorCol && colorGroups.length > 0) {
    const legend = L.control({ position: 'bottomright' });
    legend.onAdd = function () {
        const div = L.DomUtil.create('div', 'map-color-legend');
        
        // Title with clear button if filtered
        const titleRow = `<span>${colorCol}</span>` + 
            (legendFilter ? '<span class="map-legend-clear">✕ clear</span>' : '');
        
        // Group rows
        colorGroups.forEach(g => {
            const isActive = legendFilter === g;
            const opacity = legendFilter && !isActive ? '0.35' : '1';
            const row = `<div onclick="filterByGroup('${g}')" style="opacity:${opacity}">
                <span style="background:${color}"></span>
                <span>${g || '(empty)'}</span>
            </div>`;
        });
        
        return div;
    };
    legend.addTo(mapInstance);
}
1

Click a group

Map filters to show only nodes and edges related to that group
2

Active group highlights

Selected group shown in bold, others dimmed (35% opacity)
3

Click clear

”✕ clear” button appears when filtered—click to restore all data
4

Click again to deselect

Clicking the active group also clears the filter

Legend Filter Behavior

When a legend group is selected:
// Legend filter - app.js:1857-1878
const legendFilter = appState.mapLegendFilter;
if (legendFilter && nodeColorMap) {
    // Nodes in the selected group
    const groupNodes = new Set(
        Array.from(nodeColorMap.entries())
            .filter(([n, g]) => g === legendFilter)
            .map(([n]) => n)
    );
    
    // Show only edges whose TARGET belongs to the group
    filteredEdges = filteredEdges.filter(e => groupNodes.has(e.target));
    
    // Origins connected to this group (shown even if not in group)
    const connectedOrigins = new Set(filteredEdges.map(e => e.source));
    
    // Force all shown nodes to the group's color
    const forcedColor = dummyScale(legendFilter);
    colorScale = (nodeName) => {
        if (groupNodes.has(nodeName) || connectedOrigins.has(nodeName)) return forcedColor;
        return originalColorScale(nodeName);
    };
}
Legend filtering shows the “network feeding into” the selected destination group, including origins that aren’t part of the group.

Detailed Popups

Node popups show comprehensive connection details:
// Popup generation - app.js:1995-2037
let popupHtml = '';
const outEdges = outEdgesBySource.get(nodeName);
const inEdges = inEdgesByTarget.get(nodeName);

// Outgoing section (if origin)
if (outEdges && outEdges.length > 0) {
    const sorted = outEdges.slice().sort((a, b) => b.value - a.value);
    const totalSent = sorted.reduce((s, e) => s + e.value, 0);
    const totalCostSent = sorted.reduce((s, e) => s + edgeMetric.get(...), 0);
    
    popupHtml += `<div><strong>${nodeName}</strong><br/>
        Total sent: ${totalSent} · Cost sent: ${totalCostSent}</div>
        <table>
            <tr><th>Dest</th><th>Weight</th><th>Cost</th><th>%</th></tr>
            ${sorted.map(e => `<tr><td>${e.target}</td><td>${e.value}</td><td>${cost}</td><td>${pct}%</td></tr>`)}
        </table>`;
}

// Incoming section (if destination)
if (inEdges && inEdges.length > 0) {
    // Similar table for incoming edges
}
Shows: origin → destination, referrals, distance, cost (if cost mode)

Interactive Controls

Origin Filter

Show only arcs from selected origin

Destination Filter

Show only arcs to selected destination

Color by

Group nodes by any column in your dataset

Cost Mode

Toggle between weight and distance×weight for arc width

Map Navigation

  • Drag: Pan the map
  • Mouse wheel: Zoom in/out
  • Auto-fit: On first render, map automatically fits all markers with padding
  • Reuse view: When filters change, map preserves your zoom/pan position
// Map initialization - app.js:1789-1812
if (mapInstance) {
    // Reuse existing map to preserve view
    mapInstance.eachLayer(layer => {
        if (!(layer instanceof L.TileLayer)) mapInstance.removeLayer(layer);
    });
} else {
    // First render: create map and fit bounds
    mapInstance = L.map(container);
    const bounds = L.latLngBounds(coordsArray.map(c => [c.lat, c.lng]));
    mapInstance.fitBounds(bounds, { padding: [30, 30] });
    
    // CartoDB light basemap
    L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
        attribution: '© OpenStreetMap contributors © CARTO'
    }).addTo(mapInstance);
}

Statistics Display

The toolbar and panel show georeferencing statistics:
// Statistics - app.js:2069-2087
const totalEdges = appState.aggregatedEdges.length;
const displayedNodes = markers.length;
const displayedLinks = edgesDrawn;
const missingCoords = totalEdges - displayedLinks;

const statsText = `Georeferenced: ${displayedNodes} nodes · ${displayedLinks}/${totalEdges} links` +
    (missingCoords > 0 ? ` (${missingCoords} without coordinates)` : '');
If you see many links without coordinates, check that your lat/lng columns are correctly configured and contain valid numeric values.

Best Practices

  • Ensure lat/lng columns contain decimal degree values (e.g., 40.7128, -74.0060)
  • Remove or handle missing coordinates before uploading
  • If nodes appear in multiple rows with different coords, only the first coord encountered is used
  • Use origin/dest filters to declutter dense maps
  • Enable cost mode to emphasize long-distance connections
  • Use color-by + legend filter to focus on specific groups
  • Zoom in on regions of interest before screenshotting
  • Large circles/squares = high-traffic nodes
  • Thick arcs = strong connections (or high cost in cost mode)
  • Geographic clusters = regional referral patterns
  • Long arcs = cross-region relationships
  • Diamonds = nodes acting as intermediaries
  • Reveals “expensive” relationships (high distance × weight)
  • Useful for logistics/supply chain optimization
  • Highlights when local connections might be underutilized
  • Compare normal vs. cost mode to see distance impact

Tile Layer

The map uses CartoDB’s light basemap:
// Basemap - app.js:1806-1810
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
    attribution: '© OpenStreetMap contributors © CARTO',
    subdomains: 'abcd',
    maxZoom: 20
}).addTo(mapInstance);
This provides a clean, neutral background that doesn’t compete with your data.

Performance Considerations

  • Map reuse: The map instance is preserved when filters change, avoiding re-initialization
  • Invisible hitboxes: Arcs have wide invisible overlays (18px) for easy clicking
  • Layer management: Only data layers are redrawn; tile layer persists
  • Lazy rendering: Arcs only drawn if both endpoints have appropriate coordinates
// Draw condition - app.js:1953-1959
if (fromCoords && toCoords && 
    srcHasOwnCoords.has(edge.source) && 
    tgtHasOwnCoords.has(edge.target)) {
    // Draw arc
} else {
    // Skip this edge
}

Build docs developers (and LLMs) love