Skip to content

Instantly share code, notes, and snippets.

@dbose
Last active June 18, 2025 23:18
Show Gist options
  • Save dbose/b53add5d0a07648463e7f728e43a8336 to your computer and use it in GitHub Desktop.
Save dbose/b53add5d0a07648463e7f728e43a8336 to your computer and use it in GitHub Desktop.
swim-lane-in-dbt-docs
const $ = require('jquery');
const _ = require('underscore');
const graphlib = require('graphlib');
const selectorGraph = require('./selector_graph');
const colorValidation = require('./validate_node_color');
angular
.module('dbt')
.factory('graph', [
'$state', '$window', '$q', 'selectorService', 'project', 'locationService',
function($state, $window, $q, selectorService, projectService, locationService) {
var graph_options = {
vertical: {
userPanningEnabled: false,
boxSelectionEnabled: false,
maxZoom: 1.5,
},
horizontal: {
userPanningEnabled: true,
boxSelectionEnabled: false,
maxZoom: 1,
minZoom: 0.05,
}
}
// SWIM-LANES: Priority order for categories (filters and orders swim-lanes)
const priorityOrder = {
'sources': 0,
'validated': 10,
'integration': 20,
'staging': 30,
'datamart': 40,
'reporting': 50,
'access': 60
};
// SWIM-LANES: Extract category from node name prefix first, then path
function getSwimLaneCategory(node) {
var category = null;
// First try prefix-based categorization (name-based)
if (node.name || node.label) {
var nodeName = node.name || node.label || '';
// Map common prefixes to categories
var prefixMap = {
'seed_': 'sources',
'src_': 'sources',
'source_': 'sources',
'raw_': 'sources',
'stg_': 'staging',
'staging_': 'staging',
'val_': 'validated',
'validated_': 'validated',
'int_': 'integration',
'intermediate_': 'integration',
'integration_': 'integration',
'dm_': 'datamart',
'mart_': 'datamart',
'datamart_': 'datamart',
'rpt_': 'reporting',
'report_': 'reporting',
'reporting_': 'reporting',
'acc_': 'access',
'access_': 'access'
};
// Check for matching prefixes
for (var prefix in prefixMap) {
if (nodeName.toLowerCase().startsWith(prefix)) {
category = prefixMap[prefix];
break;
}
}
}
// If prefix-based didn't work, try path-based categorization as fallback
if (!category && (node.original_file_path || node.path)) {
var path = node.original_file_path || node.path || '';
var pathParts = path.split('/');
if (pathParts.length >= 2 && pathParts[0] === 'models') {
category = pathParts[1];
} else if (pathParts.length >= 1 && pathParts[0]) {
category = pathParts[0];
}
}
// Only return categories that are in priorityOrder
return (category && priorityOrder.hasOwnProperty(category)) ? category : null;
}
// SWIM-LANES: Color mapping for different categories
function getCategoryColor(category) {
var colors = {
'sources': '#6b7280', // Gray
'validated': '#3b82f6', // Blue
'integration': '#10b981', // Emerald
'staging': '#f59e0b', // Amber
'datamart': '#8b5cf6', // Violet
'reporting': '#06b6d4', // Cyan
'access': '#64748b' // Slate
};
return colors[category] || '#9ca3af'; // Default gray for unknown
}
function getCategoryBorderColor(category) {
var colors = {
'sources': '#4b5563', // Darker gray
'validated': '#1d4ed8', // Darker blue
'integration': '#059669', // Darker emerald
'staging': '#d97706', // Darker amber
'datamart': '#7c3aed', // Darker violet
'reporting': '#0891b2', // Darker cyan
'access': '#475569' // Darker slate
};
return colors[category] || '#6b7280'; // Default darker gray for unknown
}
var layouts = {
none: {
name: 'null',
},
left_right: {
name: 'dagre',
rankDir: 'LR',
rankSep: 200,
edgeSep: 30,
nodeSep: 50,
},
top_down: {
name: 'preset',
positions: function(node) {
var primary_node_id = $state.params.unique_id;
if (!primary_node_id) {
return {x: 0, y: 0};
}
var dag = service.graph.pristine.dag;
var parents = _.sortBy(selectorGraph.ancestorNodes(dag, primary_node_id, 1));
var children = _.sortBy(selectorGraph.descendentNodes(dag, primary_node_id, 1));
var is_parent = _.partial(_.includes, parents);
var is_child = _.partial(_.includes, children);
var parent_subgraph = dag.filterNodes(is_parent)
var child_subgraph = dag.filterNodes(is_child)
var parent_nodes = graphlib.alg.topsort(parent_subgraph);
var child_nodes = graphlib.alg.topsort(child_subgraph).reverse();
return getNodeVertPosition(primary_node_id, parent_nodes, child_nodes, node.data('id'))
}
},
}
var service = {
loading: true,
loaded: $q.defer(),
graph_element: null,
orientation: 'sidebar',
expanded: false,
graph: {
options: graph_options.vertical,
pristine: {
nodes: {},
edges: {},
dag: null
},
elements: [],
layout: layouts.none,
style: [
// SWIM-LANES: Swim-lane header styling (bigger labels)
{
selector: '.swimlane-header',
style: {
'background-color': '#374151',
'border-color': '#6b7280',
'border-width': 2,
'font-size': '18px', // Bigger font
'font-weight': 'bold',
'color': '#ffffff',
'shape': 'roundrectangle',
'width': 'label',
'height': 'label',
'padding': '12px', // More padding for bigger labels
'content': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'selectable': false,
'grabbable': false,
'z-index': 10
}
},
// SWIM-LANES: Vertical boundary lines (dashed effect using small rectangles)
{
selector: '.swimlane-boundary',
style: {
'width': 3,
'height': 20, // Height of each dash
'background-color': '#94a3b8',
'background-opacity': 0.7,
'shape': 'rectangle',
'border-width': 0,
'selectable': false,
'grabbable': false,
'z-index': -1
}
},
{
selector: 'edge.vertical',
style: {
'curve-style': 'unbundled-bezier',
'target-arrow-shape': 'triangle-backcurve',
'target-arrow-color': '#027599',
'arrow-scale': 1.5,
'line-color': '#027599',
'width': 3,
'target-distance-from-node': '5px',
'source-endpoint': '0% 50%',
'target-endpoint': '0deg',
}
},
{
selector: 'edge.horizontal',
style: {
'curve-style': 'unbundled-bezier',
'target-arrow-shape': 'triangle-backcurve',
'target-arrow-color': '#006f8a',
'arrow-scale': 1.5,
'target-distance-from-node': '10px',
'source-distance-from-node': '5px',
'line-color': '#006f8a',
'width': 3,
'source-endpoint': '50% 0%',
'target-endpoint': '270deg'
}
},
{
selector: 'edge[selected=1]',
style: {
'line-color': '#bd6bb6',
'target-arrow-color': '#bd6bb6',
'z-index': 1, // draw on top of non-selected nodes
}
},
{
selector: 'node[display="none"]',
style: {
display: 'none'
}
},
{
selector: 'node.vertical',
style: {
'text-margin-x': '5px',
'background-color': '#0094b3',
'border-color': '#0094b3',
'font-size': '16px',
'shape': 'ellipse',
'color': '#fff',
'width': '5px',
'height': '5px',
'padding': '5px',
'content': 'data(label)',
'font-weight': 300,
'text-valign': 'center',
'text-halign': 'right',
}
},
{
selector: 'node.horizontal',
style: {
// SWIM-LANES: Enhanced with category colors (with fallback for non-priority categories)
'background-color': function(ele) {
var category = getSwimLaneCategory(ele.data());
return category ? getCategoryColor(category) : '#0094b3'; // Default blue for non-priority categories
},
'border-color': function(ele) {
var category = getSwimLaneCategory(ele.data());
return category ? getCategoryBorderColor(category) : '#0094b3'; // Default blue for non-priority categories
},
'border-width': 3,
'font-size': '24px',
'shape': 'roundrectangle',
'color': '#fff',
'width': 'label',
'height': 'label',
'padding': '12px',
'content': 'data(label)',
'font-weight': 300,
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
'text-valign': 'center',
'text-halign': 'center',
'ghost': 'yes',
'ghost-offset-x': '2px',
'ghost-offset-y': '4px',
'ghost-opacity': 0.5,
'text-outline-color': '#000',
'text-outline-width': '1px',
'text-outline-opacity': 0.2
}
},
{
selector: 'node[resource_type="source"]',
style: {
'background-color': '#5fb825',
'border-color': '#5fb825',
}
},
{
selector: 'node[resource_type="exposure"]',
style: {
'background-color': '#ff694b',
'border-color': '#ff694b',
}
},
{
selector: 'node[resource_type="metric"]',
style: {
'background-color': '#ff5688',
'border-color': '#ff5688',
}
},
{
selector: 'node[resource_type="semantic_model"]',
style: {
'background-color': '#ffa8c2',
'border-color': '#ffa8c2',
}
},
{
selector: 'node[resource_type="saved_query"]',
style: {
'background-color': '#ff7f50',
'border-color': '#ff7f50',
}
},
{
selector: 'node[language="python"]',
style: {
'background-color': '#6a5acd',
'border-color': '#6a5acd',
}
},
{
selector: 'node[node_color]',
style: {
'background-color': 'data(node_color)',
'border-color': 'data(node_color)',
}
},
{
selector: 'node[selected=1]',
style: {
'background-color': '#bd6bb6',
'border-color': '#bd6bb6',
}
},
{
selector: 'node.horizontal[selected=1]',
style: {
'background-color': '#88447d',
'border-color': '#88447d',
}
},
{
selector: 'node.horizontal.dirty',
style: {
'background-color': '#919599',
'border-color': '#919599',
}
},
{
selector: 'node[hidden=1]',
style: {
'background-color': '#919599',
'border-color': '#919599',
'background-opacity': 0.5,
}
},
{
selector: 'node[access="private"]',
style: {
'background-opacity': 0.2,
'border-width': 2,
'ghost': 'no',
}
},
],
ready: function(e) {
console.log("graph ready");
},
}
}
service.setGraphReady = function(graph_element) {
service.loading = false;
service.loaded.resolve();
service.graph_element = graph_element;
// SWIM-LANES: Organize nodes into swim-lanes after graph is ready
if (graph_element && service.orientation === 'fullscreen') {
setTimeout(function() {
service.organizeIntoSwimLanes();
}, 100); // Small delay to ensure layout is complete
}
}
// SWIM-LANES: Function to organize nodes into vertical swim-lanes (like JIRA columns)
service.organizeIntoSwimLanes = function() {
if (!service.graph_element) return;
var cy = service.graph_element;
var allNodes = cy.nodes();
var regularNodes = allNodes.not('.swimlane-header');
// Remove any existing swim-lane headers and boundaries
cy.remove('.swimlane-header');
cy.remove('.swimlane-boundary');
// Group nodes by category
var nodesByCategory = {};
var categories = [];
regularNodes.forEach(function(node) {
var category = getSwimLaneCategory(node.data());
if (category) {
if (!nodesByCategory[category]) {
nodesByCategory[category] = [];
categories.push(category);
}
nodesByCategory[category].push(node);
}
});
if (categories.length === 0) return; // No categorized nodes
// Sort categories by priority (left to right: lowest priority number = leftmost)
categories.sort(function(a, b) {
var priorityA = priorityOrder[a] !== undefined ? priorityOrder[a] : 999;
var priorityB = priorityOrder[b] !== undefined ? priorityOrder[b] : 999;
return priorityA - priorityB; // This ensures sources (0) comes before staging (30)
});
// Get current graph bounds
var bounds = regularNodes.boundingBox();
var laneWidth = bounds.w / categories.length;
var laneSpacing = 30; // Spacing between swim-lanes
// Position nodes into vertical swim-lanes and add headers
_.each(categories, function(category, index) {
var nodesInLane = nodesByCategory[category];
var laneLeftX = bounds.x1 + (index * laneWidth);
var laneRightX = laneLeftX + laneWidth;
var laneCenterX = laneLeftX + (laneWidth / 2);
var usableWidth = laneWidth - (2 * laneSpacing);
// Sort nodes by their Y position to maintain top-bottom flow within swim-lane
nodesInLane.sort(function(a, b) {
return a.position('y') - b.position('y');
});
// Compact vertical distribution within the swim-lane
_.each(nodesInLane, function(node, nodeIndex) {
var currentPos = node.position();
var newX;
if (nodesInLane.length === 1) {
// Single node: center it in the lane
newX = laneCenterX;
} else {
// Compact distribution: use minimal spacing to keep nodes close together
var maxNodesPerRow = Math.ceil(Math.sqrt(nodesInLane.length)); // Square-ish arrangement
var nodeWidth = 120; // Approximate node width + spacing
var totalWidth = Math.min(maxNodesPerRow * nodeWidth, usableWidth);
var startX = laneCenterX - (totalWidth / 2);
var row = Math.floor(nodeIndex / maxNodesPerRow);
var col = nodeIndex % maxNodesPerRow;
var nodesInThisRow = Math.min(maxNodesPerRow, nodesInLane.length - (row * maxNodesPerRow));
if (nodesInThisRow === 1) {
newX = laneCenterX; // Center single nodes
} else {
var rowSpacing = totalWidth / (nodesInThisRow - 1);
newX = startX + (col * rowSpacing);
}
}
node.position({
x: newX, // Compact horizontal distribution within swim-lane
y: currentPos.y // Keep Y position from Dagre (minimal change)
});
});
// Add swim-lane header at the top of the column
cy.add({
group: 'nodes',
data: {
id: 'swimlane-header-' + category,
label: category.charAt(0).toUpperCase() + category.slice(1)
},
position: {
x: laneCenterX,
y: bounds.y1 - 100 // Position higher to accommodate bigger labels
},
classes: 'swimlane-header'
});
});
// Add vertical boundary lines between swim-lanes (dashed effect)
_.each(categories, function(category, index) {
if (index > 0) { // Don't add boundary before first swim-lane
var laneLeftX = bounds.x1 + (index * laneWidth);
var lineHeight = bounds.h;
var dashLength = 20;
var gapLength = 15;
var totalDashUnit = dashLength + gapLength;
var numDashes = Math.ceil(lineHeight / totalDashUnit);
// Create dashed line using multiple small rectangles
for (var dashIndex = 0; dashIndex < numDashes; dashIndex++) {
var dashY = bounds.y1 + (dashIndex * totalDashUnit) + (dashLength / 2);
cy.add({
group: 'nodes',
data: {
id: 'swimlane-boundary-' + index + '-' + dashIndex,
label: ''
},
position: {
x: laneLeftX,
y: dashY
},
classes: 'swimlane-boundary'
});
}
}
});
// Make swim-lane headers and boundaries non-interactive
cy.nodes('.swimlane-header').ungrabify();
cy.nodes('.swimlane-boundary').ungrabify();
}
service.ready = function(cb) {
service.loaded.promise.then(function() {
cb(service);
});
}
function getNodeVertPosition(primary_node, parents, children, this_node) {
var num_nodes = 1 + Math.max(parents.length, children.length);
var scale_x = 100 / num_nodes;
var scale_y = 100;
var config;
if (primary_node == this_node) {
return {x: 0, y: 0}
} else if (_.includes(parents, this_node)) {
config = {set: parents, index: _.indexOf(parents, this_node), factor: -1, type:'parent'}
} else if (_.includes(children, this_node)) {
config = {set: children, index: _.indexOf(children, this_node), factor: 1, type: 'child'}
} else {
return {x: 0, y: 0}
}
var size = config.set.length;
if (config.type == 'parent') {
var res = {
x: (0 + config.index) * scale_x,
y: -(2 * scale_y) - (size - config.index - 1) * scale_y
}
} else {
var res = {
x: (0 + config.index) * scale_x,
y: (2 * scale_y) + (size - config.index - 1) * scale_y
}
}
return res
}
function setNodes(node_ids, highlight, classes) {
var nodes = _.map(node_ids, function(id) { return service.graph.pristine.nodes[id] });
var edges = [];
_.flatten(_.each(node_ids, function(id) {
var node_edges = service.graph.pristine.edges[id]
_.each(node_edges, function(edge) {
if (_.includes(node_ids, edge.data.target) && _.includes(node_ids, edge.data.source)) {
edges.push(edge);
}
});
}));
var elements = _.compact(nodes).concat(_.compact(edges));
_.each(service.graph.elements, function(el) {
el.data['display'] = 'none';
el.data['selected'] = 0;
el.data['hidden'] = 0;
el.classes = classes;
});
_.each(elements, function(el) {
el.data['display'] = 'element';
el.classes = classes;
if (highlight && _.includes(highlight, el.data.unique_id)) {
el.data['selected'] = 1;
}
// models can be hidden if docs.show === false
if (! ( _.get(el,['data', 'docs','show'],true)) ) {
el.data['hidden'] = 1;
}
// models can be shown in a different color if docs.node_color is set
// we also validate that the color is either a valid hex color or a valid color name
var color_config = _.get(el, ['data', 'docs', 'node_color'])
if (color_config && colorValidation.isValidColor(color_config)) {
el.data['node_color'] = color_config;
}
});
service.graph.elements = _.filter(elements, function(e) { return e.data.display == 'element'});
return node_ids;
}
service.manifest = {};
service.packages = [];
service.selected_node = null;
service.getCanvasHeight = function() {
return ($window.innerHeight * 0.8) + "px"
}
projectService.ready(function(project) {
service.manifest = project;
service.packages = _.uniq(_.map(service.manifest.nodes, 'package_name'));
_.each(_.filter(service.manifest.nodes, function(node) {
// operation needs to be a graph type so that the parent/child map can be resolved even though we won't be displaying it
var is_graph_type = _.includes(['model', 'seed', 'source', 'snapshot', 'analysis', 'exposure', 'metric', 'semantic_model', 'operation', 'saved_query'], node.resource_type);
var is_singular_test = node.resource_type === 'test' && !node.hasOwnProperty('test_metadata');
var is_unit_test = node.resource_type === 'unit_test';
return is_graph_type || is_singular_test || is_unit_test;
}), function(node) {
var node_obj = {
group: "nodes",
data: _.assign(node, {
parent: node.package_name,
id: node.unique_id,
is_group: 'false'
})
}
service.graph.pristine.nodes[node.unique_id] = node_obj;
});
_.each(service.manifest.parent_map, function(parents, child) {
_.each(parents, function(parent) {
var parent_node = service.manifest.nodes[parent];
var child_node = service.manifest.nodes[child];
if (!_.includes(['model', 'source', 'seed', 'snapshot', 'metric', 'semantic_model', 'saved_query'], parent_node.resource_type)) {
return;
} else if (child_node.resource_type == 'test' && child_node.hasOwnProperty('test_metadata')) {
return;
}
var unique_id = parent_node.unique_id + "|" + child_node.unique_id;
var edge = {
group: "edges",
data: {
source: parent_node.unique_id,
target: child_node.unique_id,
unique_id: unique_id,
}
};
var edge_id = child_node.unique_id;
if (!service.graph.pristine.edges[edge_id]) {
service.graph.pristine.edges[edge_id] = [];
}
service.graph.pristine.edges[edge_id].push(edge);
})
});
var dag = new graphlib.Graph({directed: true});
_.each(service.graph.pristine.nodes, function(node) {
dag.setNode(node.data.unique_id, node.data.name);
});
_.each(service.graph.pristine.edges, function(edges) {
_.each(edges, function(edge) {
dag.setEdge(edge.data.source, edge.data.target);
});
});
service.graph.pristine.dag = dag;
service.graph.elements = _.flatten(_.values(service.graph.pristine.nodes).concat(_.values(service.graph.pristine.edges)));
setNodes(dag.nodes())
});
function updateGraphWithSelector(selected_spec, classes, should_highlight) {
var dag = service.graph.pristine.dag;
if (!dag) return;
// good: "+source:quickbooks.invoices+"
var pristine = service.graph.pristine.nodes;
var selected = selectorService.selectNodes(dag, pristine, selected_spec);
var highlight_nodes = should_highlight ? selected.matched : [];
return setNodes(selected.selected, highlight_nodes, classes);
}
service.hideGraph = function() {
service.orientation = 'sidebar';
service.expanded = false;
}
service.showVerticalGraph = function(node_name, force_expand) {
service.orientation = 'sidebar'
if (force_expand) {
service.expanded = true;
}
var selected_spec = _.assign({}, selectorService.options, {
include: "+" + node_name + "+",
exclude: '',
hops: 1
});
var nodes = updateGraphWithSelector(selected_spec, 'vertical', true);
service.graph.layout = layouts.top_down;
service.graph.options = graph_options.vertical;
return nodes;
}
service.showFullGraph = function(node_name) {
service.orientation = 'fullscreen'
service.expanded = true;
var selected_spec = _.assign({}, selectorService.options);
if (node_name) {
selected_spec.include = "+" + node_name + "+";
selected_spec.exclude = "";
} else {
selected_spec.include = "";
selected_spec.exclude = "";
}
var nodes = updateGraphWithSelector(selected_spec, 'horizontal', true);
service.graph.layout = layouts.left_right;
service.graph.options = graph_options.horizontal;
// SWIM-LANES: Organize into swim-lanes after layout
if (service.graph_element) {
setTimeout(function() {
service.organizeIntoSwimLanes();
}, 200); // Delay to ensure Dagre layout is complete
}
// update url with selection
locationService.setState(selected_spec);
return nodes;
}
service.updateGraph = function(selected_spec) {
service.orientation = 'fullscreen'
service.expanded = true;
var nodes = updateGraphWithSelector(selected_spec, 'horizontal', false);
service.graph.layout = layouts.left_right;
service.graph.options = graph_options.horizontal;
// SWIM-LANES: Organize into swim-lanes after layout
if (service.graph_element) {
setTimeout(function() {
service.organizeIntoSwimLanes();
}, 200); // Delay to ensure Dagre layout is complete
}
// update url with selection
locationService.setState(selected_spec);
return nodes;
}
service.deselectNodes = function() {
if (service.orientation != 'fullscreen') {
return;
}
var g = service.graph_element;
g.elements().data('selected', 0);
}
service.selectNode = function(node_id) {
if (service.orientation != 'fullscreen') {
return;
}
var node = service.graph.pristine.nodes[node_id];
// get all edges that pass through this node
var dag = service.graph.pristine.dag;
var parents = _.indexBy(selectorGraph.ancestorNodes(dag, node_id));
var children = _.indexBy(selectorGraph.descendentNodes(dag, node_id));
parents[node_id] = node_id;
children[node_id] = node_id;
var g = service.graph_element;
_.each(service.graph.elements, function(el) {
var graph_el = g.$id(el.data.id);
if (parents[el.data.source] && parents[el.data.target]) {
graph_el.data('selected', 1);
} else if (children[el.data.source] && children[el.data.target]) {
graph_el.data('selected', 1);
} else if (el.data.unique_id == node_id) {
graph_el.data('selected', 1);
} else {
graph_el.data('selected', 0);
}
});
}
service.markDirty = function(node_ids) {
service.markAllClean();
_.each(node_ids, function(node_id) {
service.graph_element.$id(node_id).addClass('dirty');
})
}
service.markAllClean = function() {
if (service.graph_element) {
service.graph_element.elements().removeClass('dirty');
}
}
return service;
}]);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment