Skip to content

D3 Visualization Patterns

This guide covers the "D3 Rosetta" pattern used in the FCC React frontend, where React owns the DOM container and D3 renders inside it. It includes component recipes for the 7 built-in visualization types, color scheme reference, accessibility considerations, and testing strategies.


Table of Contents

  1. D3 Rosetta Pattern
  2. Component Template
  3. Shared Utilities: d3-utils.ts
  4. Component Recipes
  5. Color Schemes
  6. Accessibility
  7. Dark Mode Support
  8. Responsive Design
  9. Testing
  10. Related Documentation

D3 Rosetta Pattern

The FCC frontend follows the "D3 Rosetta" pattern, which cleanly separates React's responsibilities from D3's:

  • React manages the component lifecycle, props, state, and owns the SVG container element via useRef.
  • D3 handles all rendering inside the SVG element -- selections, scales, axes, transitions, and layout algorithms.
  • Data flows one way: React passes data down as props; D3 renders it in a useEffect that depends on those props.

This approach avoids the common pitfall of React and D3 fighting over DOM ownership.

Key rules

  1. React creates the <svg> element; D3 never calls document.createElement.
  2. D3 renders inside the SVG using d3.select(svgRef.current).
  3. On every data change, D3 clears and re-renders (or uses enter/update/exit).
  4. The useEffect cleanup function stops any running simulations or timers.

Module locations

All visualization components live under frontend/src/visualizations/:

frontend/src/visualizations/
  d3-utils.ts          -- shared color scales, tooltips, responsive SVG
  ForceGraph.tsx        -- force-directed persona interaction graph
  SankeyWorkflow.tsx    -- Sankey diagram for workflow flows
  VoronoiPersonaMap.tsx -- Voronoi tessellation persona map
  ChordDiagram.tsx      -- chord diagram for cross-references
  HyperEdgeGraph.tsx    -- hyperedge graph for multi-party interactions
  ProvenanceFlow.tsx    -- provenance tracking flow diagram
  HeatmapMatrix.tsx     -- heatmap matrix for dimension/score data
  NanoCubeExplorer.tsx  -- NanoCube data exploration visualization

Component Template

Every D3 visualization component follows this TypeScript template:

import { useRef, useEffect } from 'react';
import * as d3 from 'd3';

interface MyVizProps {
  data: DataItem[];
  width?: number;
  height?: number;
}

const MyViz: React.FC<MyVizProps> = ({
  data,
  width = 800,
  height = 600,
}) => {
  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    if (!svgRef.current || data.length === 0) return;

    // 1. Select and clear the SVG
    const svg = d3.select(svgRef.current);
    svg.selectAll('*').remove();

    // 2. Set up scales, layouts, etc.
    const xScale = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.value) ?? 0])
      .range([0, width]);

    // 3. Binddata and render
    svg.selectAll('rect')
      .data(data)
      .join('rect')
      .attr('x', 0)
      .attr('y', (_, i) => i * 25)
      .attr('width', d => xScale(d.value))
      .attr('height', 20)
      .attr('fill', '#1976D2');

    // 4. Cleanup (for simulations, timers, etc.)
    return () => {
      // Stop simulations, cancel timers
    };
  }, [data, width, height]);

  // 5. Empty state
  if (data.length === 0) {
    return (
      <div
        className="flex items-center justify-center bg-gray-50
                   dark:bg-gray-800 rounded-lg"
        style={{ width, height }}
        data-testid="my-viz-empty"
      >
        <p className="text-gray-400">No data available</p>
      </div>
    );
  }

  // 6. Render SVG container
  return (
    <div className="relative" data-testid="my-viz">
      <svg
        ref={svgRef}
        width={width}
        height={height}
        className="bg-white dark:bg-gray-900 rounded-lg
                   border border-gray-200 dark:border-gray-700"
        role="img"
        aria-label="My visualization description"
      />
    </div>
  );
};

export default MyViz;

Shared Utilities: d3-utils.ts

The frontend/src/visualizations/d3-utils.ts module provides four shared utilities used across all visualization components.

FCC_COLORS

A Record<string, string> mapping FCC phases and persona categories to hex colors:

import { FCC_COLORS } from './d3-utils';

FCC_COLORS.find;           // '#1E88E5'
FCC_COLORS.create;         // '#43A047'
FCC_COLORS.critique;       // '#E53935'
FCC_COLORS.core;           // '#607D8B'
FCC_COLORS.governance;     // '#7B1FA2'
FCC_COLORS.data_engineering; // '#6D4C41'

createColorScale

Creates a D3 ordinal color scale from category names, using FCC_COLORS with a d3.schemeCategory10 fallback for unknown categories:

import { createColorScale } from './d3-utils';

const categories = ['core', 'integration', 'governance'];
const scale = createColorScale(categories);

scale('core');       // '#607D8B'
scale('unknown');    // falls back to schemeCategory10

createTooltip

Creates a positioned HTML tooltip attached to a container element:

import { createTooltip } from './d3-utils';

const container = svgRef.current!.parentElement!;
const tip = createTooltip(container);

// Show tooltip near the mouse
tip.show(event.offsetX, event.offsetY, '<strong>RC</strong><br/>Core');

// Update position on mousemove
tip.update(event.offsetX, event.offsetY, content);

// Hide on mouseout
tip.hide();

responsiveSvg

Creates a responsive SVG element with viewBox and preserveAspectRatio:

import { responsiveSvg } from './d3-utils';

const svg = responsiveSvg(container, 800, 600);
// svg has viewBox="0 0 800 600" and preserveAspectRatio="xMidYMid meet"

getFccColor and lightenColor

import { getFccColor, lightenColor } from './d3-utils';

getFccColor('governance');              // '#7B1FA2'
getFccColor('unknown', '#cccccc');      // '#cccccc' (fallback)

lightenColor('#1976D2', 0.3);           // lighter variant for fills

Component Recipes

1. ForceGraph

File: frontend/src/visualizations/ForceGraph.tsx

Force-directed graph showing persona interactions. Nodes represent personas; edges represent cross-reference relationships.

import ForceGraph from './visualizations/ForceGraph';

<ForceGraph
  nodes={[
    { id: 'RC', name: 'Research Coordinator', category: 'core', phase: 'find' },
    { id: 'BC', name: 'Build Champion', category: 'core', phase: 'create' },
  ]}
  links={[
    { source: 'RC', target: 'BC', type: 'upstream', strength: 'primary' },
  ]}
  width={800}
  height={600}
/>

Features: zoom/pan, drag nodes, arrow markers, category-colored nodes, hover tooltips, category legend overlay.

2. SankeyWorkflow

File: frontend/src/visualizations/SankeyWorkflow.tsx

Sankey diagram showing flow volumes between workflow nodes (Find, Create, Critique phases).

3. VoronoiPersonaMap

File: frontend/src/visualizations/VoronoiPersonaMap.tsx

Voronoi tessellation where each cell represents a persona, sized by a metric (e.g., action count, dimension score).

4. ChordDiagram

File: frontend/src/visualizations/ChordDiagram.tsx

Chord diagram showing bilateral interaction strengths between persona categories from the cross-reference matrix.

5. HyperEdgeGraph

File: frontend/src/visualizations/HyperEdgeGraph.tsx

Hyperedge graph for visualizing multi-party interactions where a single relationship connects more than two personas.

6. ProvenanceFlow

File: frontend/src/visualizations/ProvenanceFlow.tsx

Flow diagram tracking the provenance of artifacts through the FCC workflow, showing which persona produced or modified each deliverable.

7. HeatmapMatrix

File: frontend/src/visualizations/HeatmapMatrix.tsx

Grid heatmap for displaying dimension scores, discernment ratings, or synergy matrices. Rows and columns can represent personas, dimensions, or projects.


Color Schemes

Primary Palettes

Two color maps are available, one on the frontend and one on the backend:

Frontend (d3-utils.ts -- FCC_COLORS):

Key Hex Usage
find #1E88E5 Find phase
create #43A047 Create phase
critique #E53935 Critique phase
build #FB8C00 Build phase
ops #8E24AA Operations phase
core #607D8B Core category
integration #00ACC1 Integration category
governance #7B1FA2 Governance category
stakeholder #F4511E Stakeholder category
champion #FFB300 Champion category

Backend Streamlit (apps/streamlit/_shared.py -- FCC_COLORS and CATEGORY_COLORS):

The backend color maps follow the same structure with slightly different hex values optimized for Plotly charts. Use CATEGORY_COLORS for per-category coloring across all 17+ categories.

Color-Blind Friendly Alternatives

For accessibility, consider supplementing colors with patterns or shapes. The d3.schemeCategory10 fallback uses colors that are reasonably distinguishable for common forms of color vision deficiency.

For critical visualizations, use the Viridis or Cividis sequential scales:

const colorScale = d3.scaleSequential(d3.interpolateViridis)
  .domain([0, maxValue]);

Accessibility

ARIA Labels

Every SVG container should have role="img" and an aria-label attribute describing the visualization:

<svg
  ref={svgRef}
  role="img"
  aria-label="Force-directed graph showing persona interactions across 5 categories"
/>

Keyboard Navigation

Add keyboard support for interactive elements:

nodeGroup
  .attr('tabindex', 0)
  .attr('role', 'button')
  .attr('aria-label', (d: SimNode) => `${d.name}, category ${d.category}`)
  .on('keydown', (event: KeyboardEvent, d: SimNode) => {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      handleNodeSelect(d);
    }
  });

Screen Reader Support

Add visually hidden text summarizing the data:

<div className="sr-only">
  Graph showing {nodes.length} personas connected by {links.length} relationships
  across {categories.length} categories.
</div>

Color-Blind Friendly Palettes

Supplement color coding with secondary visual channels:

// Use different shapes per category
const symbolScale = d3.scaleOrdinal<string, d3.SymbolType>()
  .domain(categories)
  .range([
    d3.symbolCircle,
    d3.symbolSquare,
    d3.symbolTriangle,
    d3.symbolDiamond,
    d3.symbolStar,
    d3.symbolCross,
  ]);

// Or use patterns
nodeGroup
  .attr('fill', (d) => colorScale(d.category))
  .attr('stroke-dasharray', (d) =>
    d.category === 'champion' ? '4,2' : 'none'
  );

Dark Mode Support

Detecting Theme

The FCC frontend uses Tailwind CSS dark mode. Detect the current theme in D3 code:

const isDark = document.documentElement.classList.contains('dark');

const textColor = isDark ? '#e5e7eb' : '#374151';
const gridColor = isDark ? '#374151' : '#e5e7eb';
const bgColor = isDark ? '#111827' : '#ffffff';

Adjusting Colors

// Lighten or darken based on theme
import { lightenColor } from './d3-utils';

const fillColor = isDark
  ? lightenColor(getFccColor(category), 0.5)
  : getFccColor(category);

Tailwind Integration

Use Tailwind's dark: prefix on the container and legend elements:

<svg
  ref={svgRef}
  className="bg-white dark:bg-gray-900 rounded-lg
             border border-gray-200 dark:border-gray-700"
/>

ThemeToggle Component

The frontend/src/components/ThemeToggle.tsx component handles theme switching. Visualizations should re-render when the theme changes by including a theme prop or using a React context:

const { theme } = useTheme();

useEffect(() => {
  // Re-render D3 content when theme changes
}, [data, width, height, theme]);

Responsive Design

ResizeObserver

Use ResizeObserver to dynamically resize visualizations:

useEffect(() => {
  const container = svgRef.current?.parentElement;
  if (!container) return;

  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const { width: w, height: h } = entry.contentRect;
      setDimensions({ width: w, height: h });
    }
  });

  observer.observe(container);
  return () => observer.disconnect();
}, []);

viewBox and preserveAspectRatio

All SVGs should use viewBox for intrinsic responsive scaling:

<svg
  ref={svgRef}
  viewBox={`0 0 ${width} ${height}`}
  preserveAspectRatio="xMidYMid meet"
  style={{ maxWidth: '100%', height: 'auto' }}
/>

The responsiveSvg utility in d3-utils.ts sets these attributes automatically.

Mobile Considerations

  • Hide legends and controls on small screens or move them to a collapsible panel.
  • Increase touch target sizes for interactive elements (minimum 44x44px).
  • Disable zoom/pan on very small viewports where gestures conflict with scrolling.

Testing

Jest Snapshots

Each visualization component has a corresponding test file in frontend/__tests__/visualizations/. Snapshot tests verify that the component renders the expected DOM structure:

import { render } from '@testing-library/react';
import ForceGraph from '../../src/visualizations/ForceGraph';

test('renders empty state when no nodes', () => {
  const { getByTestId } = render(
    <ForceGraph nodes={[]} links={[]} />
  );
  expect(getByTestId('force-graph-empty')).toBeInTheDocument();
});

test('renders SVG when nodes provided', () => {
  const nodes = [
    { id: 'RC', name: 'Research Coordinator', category: 'core', phase: 'find' },
  ];
  const { getByTestId } = render(
    <ForceGraph nodes={nodes} links={[]} />
  );
  expect(getByTestId('force-graph')).toBeInTheDocument();
});

Mock D3 Selections

For unit testing D3 logic in isolation, mock the D3 selection API:

jest.mock('d3', () => ({
  ...jest.requireActual('d3'),
  select: jest.fn().mockReturnValue({
    selectAll: jest.fn().mockReturnThis(),
    remove: jest.fn().mockReturnThis(),
    append: jest.fn().mockReturnThis(),
    attr: jest.fn().mockReturnThis(),
    data: jest.fn().mockReturnThis(),
    join: jest.fn().mockReturnThis(),
  }),
}));

d3-utils Tests

The shared utilities have their own test file at frontend/__tests__/visualizations/d3-utils.test.ts, covering color scale creation, tooltip lifecycle, and responsive SVG attributes.