From 34e6987ceaef33de83357c876d9c5ea4ef1a85f8 Mon Sep 17 00:00:00 2001 From: oskarth Date: Fri, 20 Feb 2026 11:24:52 +0800 Subject: [PATCH] feat: add interactive Privacy Map Explorer (MVP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static web app (Astro + React + D3.js) that visualizes the IPTF Map knowledge graph. Built from markdown content at build time, zero backend. Three views: - Galaxy: force-directed graph with domain clustering, layer stratification - Tree: left-to-right flow (Use Cases → Approaches → Patterns) - Browse: filterable card grid Data pipeline parses 104 nodes and 355 edges from existing markdown. 45 tests covering data extraction, layout logic, and integration. --- site/.gitignore | 4 + site/SPEC.md | 295 +++++++++++++++++++++++ site/astro.config.mjs | 6 + site/package.json | 29 +++ site/scripts/build-graph.mjs | 309 ++++++++++++++++++++++++ site/src/components/BrowseGrid.tsx | 155 ++++++++++++ site/src/components/DetailPanel.tsx | 176 ++++++++++++++ site/src/components/FilterBar.tsx | 97 ++++++++ site/src/components/Galaxy.tsx | 361 ++++++++++++++++++++++++++++ site/src/components/NodeTooltip.tsx | 56 +++++ site/src/components/TreeView.tsx | 281 ++++++++++++++++++++++ site/src/layouts/Layout.astro | 110 +++++++++ site/src/lib/graph-layout.ts | 169 +++++++++++++ site/src/lib/graph-types.ts | 50 ++++ site/src/pages/browse.astro | 13 + site/src/pages/index.astro | 13 + site/src/pages/tree.astro | 13 + site/tests/build-graph.test.mjs | 294 ++++++++++++++++++++++ site/tests/graph-layout.test.ts | 113 +++++++++ site/tsconfig.json | 7 + 20 files changed, 2551 insertions(+) create mode 100644 site/.gitignore create mode 100644 site/SPEC.md create mode 100644 site/astro.config.mjs create mode 100644 site/package.json create mode 100644 site/scripts/build-graph.mjs create mode 100644 site/src/components/BrowseGrid.tsx create mode 100644 site/src/components/DetailPanel.tsx create mode 100644 site/src/components/FilterBar.tsx create mode 100644 site/src/components/Galaxy.tsx create mode 100644 site/src/components/NodeTooltip.tsx create mode 100644 site/src/components/TreeView.tsx create mode 100644 site/src/layouts/Layout.astro create mode 100644 site/src/lib/graph-layout.ts create mode 100644 site/src/lib/graph-types.ts create mode 100644 site/src/pages/browse.astro create mode 100644 site/src/pages/index.astro create mode 100644 site/src/pages/tree.astro create mode 100644 site/tests/build-graph.test.mjs create mode 100644 site/tests/graph-layout.test.ts create mode 100644 site/tsconfig.json diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..c8d65ab --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src/data/graph.json +.astro/ diff --git a/site/SPEC.md b/site/SPEC.md new file mode 100644 index 0000000..ad7c767 --- /dev/null +++ b/site/SPEC.md @@ -0,0 +1,295 @@ +# IPTF Privacy Map Explorer - Technical Specification + +## Overview + +A static web app that visualizes the IPTF Map knowledge graph as an interactive +explorer. Built from the existing markdown content at build time, deployed as +static files to GitHub Pages. + +**MVP scope:** Data pipeline + Browse view + Galaxy View (interactive graph). + +--- + +## Architecture + +``` +repo root/ +├── patterns/*.md ─┐ +├── use-cases/*.md │ +├── approaches/*.md ├── Source content (existing) +├── domains/*.md │ +├── jurisdictions/*.md │ +├── vendors/*.md ─┘ +│ +└── site/ ─── New: web app + ├── SPEC.md This file + ├── scripts/ + │ └── build-graph.mjs Markdown → graph.json pipeline + ├── src/ + │ ├── data/ + │ │ └── graph.json Generated at build time + │ ├── layouts/ + │ │ └── Layout.astro Base HTML shell + │ ├── pages/ + │ │ ├── index.astro Landing: Galaxy View + │ │ └── browse.astro Card grid browse view + │ ├── components/ + │ │ ├── Galaxy.tsx D3 force graph (React island) + │ │ ├── FilterBar.tsx Domain/layer/maturity/type/search filters + │ │ ├── DetailPanel.tsx Slide-in panel for selected node + │ │ ├── NodeTooltip.tsx Hover tooltip + │ │ └── BrowseGrid.tsx Filterable card grid + │ └── lib/ + │ ├── graph-types.ts TypeScript interfaces + │ └── graph-layout.ts D3 force configuration + ├── tests/ + │ ├── build-graph.test.mjs Pipeline unit tests + │ └── graph-layout.test.ts Layout logic tests + ├── public/ + │ └── favicon.svg + ├── package.json + ├── astro.config.mjs + ├── tailwind.config.mjs + └── tsconfig.json +``` + +--- + +## Data Model + +### graph.json schema + +```typescript +interface GraphData { + nodes: Node[]; + edges: Edge[]; + meta: { + generated_at: string; // ISO timestamp + node_count: number; + edge_count: number; + }; +} + +interface Node { + id: string; // "pattern/zk-shielded-balances" + type: NodeType; + title: string; // From frontmatter title, cleaned + slug: string; // URL-safe: "zk-shielded-balances" + file: string; // Relative path: "patterns/pattern-zk-shielded-balances.md" + + // Type-specific metadata (from frontmatter) + layer?: "L1" | "L2" | "offchain" | "hybrid"; + maturity?: string; // "experimental" | "PoC" | "pilot" | "prod" + status?: "draft" | "ready"; + privacy_goal?: string; + primary_domain?: string; + region?: string; + + // Content + summary: string; // First paragraph or Intent section (~200 chars) + content: string; // Full markdown content (raw, rendered client-side) +} + +type NodeType = "pattern" | "use-case" | "approach" + | "domain" | "jurisdiction" | "vendor"; + +interface Edge { + source: string; // Node id + target: string; // Node id + type: EdgeType; +} + +type EdgeType = "see-also" | "uses-pattern" | "implements" + | "recommends" | "in-domain" | "regulated-by"; +``` + +### Node ID convention + +- `pattern/` - e.g. `pattern/zk-shielded-balances` +- `use-case/` - e.g. `use-case/private-bonds` +- `approach/` - e.g. `approach/private-bonds` +- `domain/` - e.g. `domain/payments` +- `jurisdiction/` - e.g. `jurisdiction/eu-MiCA` +- `vendor/` - e.g. `vendor/aztec` + +Slug is derived from filename: strip prefix (`pattern-`, `approach-`), strip `.md`. + +### Edge extraction rules + +| Source section / context | Edge type | Source type | Target type | +|-------------------------------------|----------------|-------------|-------------| +| `## See also` links | see-also | pattern | pattern | +| Approach body links to patterns | uses-pattern | approach | pattern | +| Vendor `## Fits with patterns` | implements | vendor | pattern | +| Use case `## Recommended Approaches`| recommends | use-case | approach | +| Domain body links to patterns | in-domain | domain | pattern | +| Domain body links to vendors | in-domain | domain | vendor | +| Any link to jurisdictions | regulated-by | * | jurisdiction| + +Links are extracted by regex matching `[text](../type/file.md)` patterns in +the markdown body. + +--- + +## Build Pipeline + +`site/scripts/build-graph.mjs`: + +1. Glob all `.md` files in `patterns/`, `use-cases/`, `approaches/`, `domains/`, + `jurisdictions/`, `vendors/` (excluding `_template.md` and `README.md`) +2. For each file: + a. Parse frontmatter with `gray-matter` + b. Extract node metadata from frontmatter fields + c. Extract first paragraph (or `## Intent` section) as summary + d. Find all markdown links `[text](path)` pointing to other content files + e. Classify each link into an edge type based on the rules above +3. Resolve link targets to node IDs (handle relative paths like `../patterns/...`) +4. Deduplicate edges +5. Write `site/src/data/graph.json` + +**Invocation:** `node site/scripts/build-graph.mjs` +**Added to root package.json:** `"build:graph": "node site/scripts/build-graph.mjs"` + +--- + +## Galaxy View (D3 Force Graph) + +### Layout + +D3 force simulation with these forces: + +1. **Domain clustering** - 6 fixed anchor points in a 3x2 grid. + Patterns linked to a domain are attracted toward that domain's anchor. + Patterns linked to multiple domains position between them. + +2. **Layer stratification** - Weak Y-axis force: L1 pushed down (y+), + offchain pushed up (y-), L2 centered. Only applies to pattern nodes. + +3. **Link force** - Standard D3 link force connecting nodes with edges. + +4. **Collision** - Prevents node overlap. Radius based on maturity for patterns, + fixed for other types. + +5. **Center** - Keeps graph centered in viewport. + +### Visual encoding + +| Node type | SVG shape | Fill color | Radius | +|------------- |----------------|-----------------------------------------|---------| +| pattern | circle | L1=#3B82F6, L2=#8B5CF6, off=#10B981, hybrid=#06B6D4 | by maturity: 6/10/16/22 | +| use-case | rect (rounded) | #F59E0B | 14 | +| approach | polygon (hex) | #EAB308 | 14 | +| domain | circle | #6B728020 fill, #6B7280 stroke | 40 | +| jurisdiction | polygon (shield)| #EF4444 | 10 | +| vendor | polygon (diamond)| #14B8A6 | 12 | + +Edges: thin gray lines. On hover/select, relevant edges brighten. + +### Interactions + +1. **Hover node** - Tooltip with title + metadata badges + summary. + Connected nodes highlight, others dim to 15% opacity. + +2. **Click node** - Detail panel slides in (right, 380px wide). + Shows full rendered markdown + list of connected nodes as clickable chips. + Graph recenters on selected node. + +3. **Click connection chip** - Animate graph to center on that node, + update detail panel. + +4. **Filter bar** - Dropdowns for domain, layer, maturity, type. + Text search (Fuse.js). Non-matching nodes dim to 10% opacity. + +5. **Zoom/pan** - D3 zoom behavior on the SVG container. + +--- + +## Browse View + +Filterable card grid showing all nodes. Each card shows: +- Node type badge (colored) +- Title +- Layer / maturity badges (if pattern) +- Summary text (truncated to 2 lines) +- Click → opens detail panel or navigates to Galaxy with that node selected + +Filter controls: same as Galaxy filter bar. + +--- + +## Test Plan + +### Unit tests (site/tests/) + +**build-graph.test.mjs:** +- Parses a sample pattern markdown file correctly (frontmatter + summary) +- Extracts "See also" links as see-also edges +- Extracts vendor "Fits with patterns" links as implements edges +- Handles missing/optional frontmatter fields gracefully +- Skips _template.md and README.md files +- Resolves relative paths to correct node IDs +- Deduplicates edges +- Produces valid graph.json structure (nodes array, edges array, meta) + +**graph-layout.test.ts:** +- Domain anchor positions are correctly computed for viewport +- Layer force returns correct Y values for L1/L2/offchain +- getNodeRadius returns correct sizes for each maturity level +- getNodeColor returns correct colors for each layer +- Filter logic correctly identifies matching/non-matching nodes + +### Integration test + +- Run build-graph.mjs against the real repository content +- Verify node count matches expected (~100 nodes) +- Verify edge count > 0 +- Verify no dangling edges (all source/target IDs exist in nodes) +- Verify all node types are represented + +### Manual test + +- `npm run dev` in site/ → Galaxy view renders with nodes +- Hover shows tooltip +- Click shows detail panel +- Filters dim non-matching nodes +- Browse view shows card grid +- Mobile: graceful degradation + +--- + +## Tech Stack + +| Concern | Tool | Version | +|-------------|-------------------|---------| +| Framework | Astro | 5.x | +| UI islands | React | 19.x | +| Graph | D3.js | 7.x | +| Content | gray-matter | 4.x | +| Markdown | marked | 15.x | +| Styling | Tailwind CSS | 4.x | +| Search | Fuse.js | 7.x | +| Tests | Vitest | 3.x | +| Deploy | GitHub Pages | - | + +--- + +## Scripts + +Added to root `package.json`: +```json +{ + "build:graph": "node site/scripts/build-graph.mjs", + "site:dev": "cd site && npm run dev", + "site:build": "npm run build:graph && cd site && npm run build" +} +``` + +Site `package.json` scripts: +```json +{ + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "test": "vitest run" +} +``` diff --git a/site/astro.config.mjs b/site/astro.config.mjs new file mode 100644 index 0000000..657f300 --- /dev/null +++ b/site/astro.config.mjs @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + integrations: [react()], +}); diff --git a/site/package.json b/site/package.json new file mode 100644 index 0000000..aa8e8ff --- /dev/null +++ b/site/package.json @@ -0,0 +1,29 @@ +{ + "name": "iptf-map-explorer", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:graph": "node scripts/build-graph.mjs", + "dev": "npm run build:graph && astro dev", + "build": "npm run build:graph && astro build", + "preview": "astro preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "astro": "^5.0.0", + "@astrojs/react": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "d3": "^7.9.0", + "marked": "^15.0.0" + }, + "devDependencies": { + "@types/d3": "^7.4.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "vitest": "^3.0.0", + "typescript": "^5.7.0" + } +} diff --git a/site/scripts/build-graph.mjs b/site/scripts/build-graph.mjs new file mode 100644 index 0000000..4505d9e --- /dev/null +++ b/site/scripts/build-graph.mjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node +/** + * build-graph.mjs + * + * Parses all markdown content files in the IPTF Map repository and produces + * a graph.json file with nodes (content items) and edges (cross-references). + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs'; +import { join, relative, basename, dirname } from 'path'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const REPO_ROOT = join(import.meta.dirname, '..', '..'); +const OUTPUT_PATH = join(import.meta.dirname, '..', 'src', 'data', 'graph.json'); + +const CONTENT_DIRS = [ + { dir: 'patterns', type: 'pattern', prefix: 'pattern-' }, + { dir: 'use-cases', type: 'use-case', prefix: '' }, + { dir: 'approaches', type: 'approach', prefix: 'approach-' }, + { dir: 'domains', type: 'domain', prefix: '' }, + { dir: 'jurisdictions', type: 'jurisdiction', prefix: '' }, + { dir: 'vendors', type: 'vendor', prefix: '' }, +]; + +const SKIP_FILES = ['_template.md', 'README.md']; + +// --------------------------------------------------------------------------- +// Frontmatter parser (simple, no dependency needed) +// --------------------------------------------------------------------------- + +export function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return { data: {}, body: content }; + + const body = content.slice(match[0].length).trim(); + const data = {}; + let currentKey = null; + let inArray = false; + + for (const line of match[1].split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Array item + if (trimmed.startsWith('- ') && inArray && currentKey) { + if (!Array.isArray(data[currentKey])) data[currentKey] = []; + data[currentKey].push(trimmed.slice(2).trim()); + continue; + } + + // Key: value pair + const kvMatch = trimmed.match(/^([a-z_-]+)\s*:\s*(.*)/i); + if (kvMatch) { + currentKey = kvMatch[1]; + const val = kvMatch[2].trim(); + if (val === '' || val === '|') { + // Start of array or multiline + inArray = true; + data[currentKey] = []; + } else { + inArray = false; + // Strip quotes + data[currentKey] = val.replace(/^["']|["']$/g, ''); + } + } + } + + return { data, body }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function fileToSlug(filename, prefix) { + return basename(filename, '.md') + .replace(new RegExp(`^${prefix}`), ''); +} + +export function fileToNodeId(dirType, filename, prefix) { + return `${dirType}/${fileToSlug(filename, prefix)}`; +} + +/** Extract the first meaningful paragraph or ## Intent section as summary. */ +export function extractSummary(body, maxLen = 200) { + // Try ## Intent section first + const intentMatch = body.match(/## Intent\s*\n+([\s\S]*?)(?=\n## |\n$)/); + if (intentMatch) { + const text = intentMatch[1].trim().split('\n')[0]; + return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; + } + + // Try ## TLDR + const tldrMatch = body.match(/## TLDR\s*\n+([\s\S]*?)(?=\n## |\n$)/); + if (tldrMatch) { + const text = tldrMatch[1].trim().split('\n')[0].replace(/^-\s*/, ''); + return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; + } + + // Try ## What it is (vendors) + const whatMatch = body.match(/## What it is\s*\n+([\s\S]*?)(?=\n## |\n$)/); + if (whatMatch) { + const text = whatMatch[1].trim().split('\n')[0]; + return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; + } + + // Try ## 1) Use Case + const ucMatch = body.match(/## 1\) Use Case\s*\n+([\s\S]*?)(?=\n## |\n$)/); + if (ucMatch) { + const text = ucMatch[1].trim().split('\n')[0]; + return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; + } + + // Fallback: first non-empty, non-heading paragraph + for (const line of body.split('\n')) { + const t = line.trim(); + if (t && !t.startsWith('#') && !t.startsWith('-') && !t.startsWith('|') && !t.startsWith('*')) { + return t.length > maxLen ? t.slice(0, maxLen) + '...' : t; + } + } + + return ''; +} + +/** + * Extract markdown links from body text. + * Returns array of { text, href, section } where section is the ## heading + * the link appears under. + */ +export function extractLinks(body) { + const links = []; + let currentSection = ''; + + for (const line of body.split('\n')) { + const headingMatch = line.match(/^##\s+(.+)/); + if (headingMatch) { + currentSection = headingMatch[1].trim(); + continue; + } + + // Match markdown links: [text](path) + const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; + let m; + while ((m = linkRegex.exec(line)) !== null) { + const href = m[2]; + // Only care about internal .md links + if (href.endsWith('.md') && !href.startsWith('http')) { + links.push({ text: m[1], href, section: currentSection }); + } + } + } + + return links; +} + +/** + * Resolve a relative link href to a node ID. + * E.g. "../patterns/pattern-shielding.md" from approaches/ → "pattern/shielding" + */ +export function resolveLink(href, nodeIndex) { + // Normalize: strip leading ../ segments, get just dir/file + const parts = href.split('/').filter(p => p !== '..' && p !== '.'); + if (parts.length === 0) return null; + + const filename = parts[parts.length - 1]; + const dirName = parts.length > 1 ? parts[parts.length - 2] : null; + + // Try matching by directory name first, then fall back to trying all configs + for (const cfg of CONTENT_DIRS) { + if (dirName && dirName !== cfg.dir) continue; + const candidateId = fileToNodeId(cfg.type, filename, cfg.prefix); + if (nodeIndex.has(candidateId)) return candidateId; + } + + return null; +} + +/** + * Classify an edge based on source node type and the section the link appears in. + */ +export function classifyEdge(sourceType, targetType, section) { + const s = section.toLowerCase(); + + if (s.includes('see also')) return 'see-also'; + if (s.includes('fits with patterns')) return 'implements'; + if (s.includes('recommended approach')) return 'recommends'; + if (s.includes('shortest-path') || s.includes('primary use case')) return 'in-domain'; + if (s.includes('adjacent vendor')) return 'in-domain'; + + // By source type defaults + if (sourceType === 'approach') return 'uses-pattern'; + if (sourceType === 'domain') return 'in-domain'; + if (sourceType === 'vendor') return 'implements'; + if (targetType === 'jurisdiction') return 'regulated-by'; + + return 'see-also'; +} + +// --------------------------------------------------------------------------- +// Main build +// --------------------------------------------------------------------------- + +export function buildGraph(repoRoot = REPO_ROOT) { + const nodes = []; + const edges = []; + const nodeIndex = new Set(); + + // Pass 1: Create all nodes + for (const cfg of CONTENT_DIRS) { + const dirPath = join(repoRoot, cfg.dir); + if (!existsSync(dirPath)) continue; + + const files = readdirSync(dirPath).filter( + f => f.endsWith('.md') && !SKIP_FILES.includes(f) + ); + + for (const file of files) { + const content = readFileSync(join(dirPath, file), 'utf-8'); + const { data, body } = parseFrontmatter(content); + + const id = fileToNodeId(cfg.type, file, cfg.prefix); + nodeIndex.add(id); + + const title = (data.title || basename(file, '.md')) + .replace(/^(Pattern|Vendor|Domain):\s*/i, ''); + + nodes.push({ + id, + type: cfg.type, + title, + slug: fileToSlug(file, cfg.prefix), + file: `${cfg.dir}/${file}`, + // Metadata + ...(data.layer && { layer: data.layer }), + ...(data.maturity && { maturity: data.maturity }), + ...(data.status && { status: data.status }), + ...(data.privacy_goal && { privacy_goal: data.privacy_goal }), + ...(data.primary_domain && { primary_domain: data.primary_domain }), + ...(data.region && { region: data.region }), + // Content + summary: extractSummary(body), + content: body, + }); + } + } + + // Pass 2: Extract edges from links + const edgeSet = new Set(); + + for (const cfg of CONTENT_DIRS) { + const dirPath = join(repoRoot, cfg.dir); + if (!existsSync(dirPath)) continue; + + const files = readdirSync(dirPath).filter( + f => f.endsWith('.md') && !SKIP_FILES.includes(f) + ); + + for (const file of files) { + const content = readFileSync(join(dirPath, file), 'utf-8'); + const { body } = parseFrontmatter(content); + const sourceId = fileToNodeId(cfg.type, file, cfg.prefix); + const links = extractLinks(body); + + for (const link of links) { + const targetId = resolveLink(link.href, nodeIndex); + if (!targetId || targetId === sourceId) continue; + + const targetNode = nodes.find(n => n.id === targetId); + const targetType = targetNode ? targetNode.type : 'pattern'; + const edgeType = classifyEdge(cfg.type, targetType, link.section); + const edgeKey = `${sourceId}|${targetId}|${edgeType}`; + + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + edges.push({ + source: sourceId, + target: targetId, + type: edgeType, + }); + } + } + } + } + + return { + nodes, + edges, + meta: { + generated_at: new Date().toISOString(), + node_count: nodes.length, + edge_count: edges.length, + }, + }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); +if (isMain) { + const graph = buildGraph(); + writeFileSync(OUTPUT_PATH, JSON.stringify(graph, null, 2)); + console.log(`Graph built: ${graph.meta.node_count} nodes, ${graph.meta.edge_count} edges`); + console.log(`Written to: ${OUTPUT_PATH}`); +} diff --git a/site/src/components/BrowseGrid.tsx b/site/src/components/BrowseGrid.tsx new file mode 100644 index 0000000..5dcf8d2 --- /dev/null +++ b/site/src/components/BrowseGrid.tsx @@ -0,0 +1,155 @@ +import { useState, useMemo } from 'react'; +import type { GraphData, GraphNode } from '../lib/graph-types'; +import { getNodeColor, TYPE_LABELS, nodeMatchesFilters } from '../lib/graph-layout'; + +interface Props { + graph: GraphData; +} + +export function BrowseGrid({ graph }: Props) { + const [filters, setFilters] = useState>({}); + const [selected, setSelected] = useState(null); + + const filtered = useMemo(() => { + return graph.nodes + .filter(n => n.type !== 'domain') // domains are organizational, not browseable + .filter(n => nodeMatchesFilters(n, filters)) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [graph.nodes, filters]); + + const types = useMemo(() => { + const s = new Set(); + graph.nodes.forEach(n => { if (n.type !== 'domain') s.add(n.type); }); + return [...s].sort(); + }, [graph.nodes]); + + const set = (key: string, val: string) => { + const next = { ...filters }; + if (val) next[key] = val; + else delete next[key]; + setFilters(next); + }; + + return ( +
+ {/* Filters */} +
+ set('search', e.target.value)} + /> + + + {filtered.length} items + +
+ + {/* Grid */} +
+ {filtered.map(node => ( +
setSelected(selected?.id === node.id ? null : node)} + style={{ + background: selected?.id === node.id ? '#334155' : '#1E293B', + border: `1px solid ${selected?.id === node.id ? getNodeColor(node) : '#334155'}`, + borderRadius: 8, + padding: 14, + cursor: 'pointer', + transition: 'border-color 0.15s, background 0.15s', + }} + onMouseEnter={e => { + if (selected?.id !== node.id) + e.currentTarget.style.borderColor = '#475569'; + }} + onMouseLeave={e => { + if (selected?.id !== node.id) + e.currentTarget.style.borderColor = '#334155'; + }} + > + {/* Type badge */} +
+ + {TYPE_LABELS[node.type]} + + {node.layer && ( + {node.layer} + )} + {node.maturity && ( + {node.maturity} + )} +
+ {/* Title */} +
+ {node.title} +
+ {/* Summary */} +
+ {node.summary} +
+ + {/* Expanded detail */} + {selected?.id === node.id && ( +
+ {node.privacy_goal && ( +

{node.privacy_goal}

+ )} + {node.primary_domain && ( +

Domain: {node.primary_domain}

+ )} +
+ Source: {node.file} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/site/src/components/DetailPanel.tsx b/site/src/components/DetailPanel.tsx new file mode 100644 index 0000000..57961e2 --- /dev/null +++ b/site/src/components/DetailPanel.tsx @@ -0,0 +1,176 @@ +import { useMemo } from 'react'; +import type { GraphData, GraphNode } from '../lib/graph-types'; +import { getNodeColor, TYPE_LABELS } from '../lib/graph-layout'; +import { marked } from 'marked'; + +interface Props { + node: GraphNode; + graph: GraphData; + onClose: () => void; + onSelectNode: (nodeId: string) => void; +} + +export function DetailPanel({ node, graph, onClose, onSelectNode }: Props) { + const connections = useMemo(() => { + const result: { node: GraphNode; edgeType: string; direction: 'to' | 'from' }[] = []; + const seen = new Set(); + + for (const edge of graph.edges) { + if (edge.source === node.id && !seen.has(edge.target)) { + seen.add(edge.target); + const target = graph.nodes.find(n => n.id === edge.target); + if (target) result.push({ node: target, edgeType: edge.type, direction: 'to' }); + } + if (edge.target === node.id && !seen.has(edge.source)) { + seen.add(edge.source); + const source = graph.nodes.find(n => n.id === edge.source); + if (source) result.push({ node: source, edgeType: edge.type, direction: 'from' }); + } + } + + // Sort: use-cases first, then approaches, patterns, vendors, jurisdictions + const typeOrder: Record = { + 'use-case': 0, approach: 1, pattern: 2, vendor: 3, domain: 4, jurisdiction: 5, + }; + result.sort((a, b) => (typeOrder[a.node.type] ?? 9) - (typeOrder[b.node.type] ?? 9)); + + return result; + }, [node, graph]); + + const contentHtml = useMemo(() => { + // Render just the first few sections, not the entire content + const sections = node.content.split(/\n(?=## )/); + const limited = sections.slice(0, 4).join('\n'); + return marked.parse(limited) as string; + }, [node.content]); + + const badges = [ + node.layer, + node.maturity, + node.status, + node.primary_domain, + ].filter(Boolean); + + return ( +
+ {/* Header */} +
+
+
+
+ {TYPE_LABELS[node.type]} +
+
{node.title}
+
+ +
+
+ {badges.map((b, i) => ( + {b} + ))} +
+ {node.privacy_goal && ( +
+ {node.privacy_goal} +
+ )} +
+ + {/* Content */} +
+ + {/* Connections */} + {connections.length > 0 && ( +
+
+ Connected ({connections.length}) +
+ {connections.map(({ node: cn, edgeType }) => ( + + ))} +
+ )} + + {/* Source file link */} +
+ Source: {node.file} +
+ + +
+ ); +} diff --git a/site/src/components/FilterBar.tsx b/site/src/components/FilterBar.tsx new file mode 100644 index 0000000..22313ce --- /dev/null +++ b/site/src/components/FilterBar.tsx @@ -0,0 +1,97 @@ +import { useState, useMemo } from 'react'; +import type { GraphData } from '../lib/graph-types'; +import { TYPE_LABELS } from '../lib/graph-layout'; + +interface Props { + graph: GraphData; + filters: Record; + onFilterChange: (filters: Record) => void; +} + +const selectStyle: React.CSSProperties = { + background: '#334155', + color: '#F1F5F9', + border: '1px solid #475569', + borderRadius: 6, + padding: '4px 8px', + fontSize: 13, + cursor: 'pointer', + outline: 'none', +}; + +const inputStyle: React.CSSProperties = { + ...selectStyle, + width: 180, + padding: '4px 10px', +}; + +export function FilterBar({ graph, filters, onFilterChange }: Props) { + const layers = useMemo(() => { + const s = new Set(); + graph.nodes.forEach(n => { if (n.layer) s.add(n.layer); }); + return [...s].sort(); + }, [graph]); + + const maturities = useMemo(() => { + const s = new Set(); + graph.nodes.forEach(n => { if (n.maturity) s.add(n.maturity); }); + return [...s].sort(); + }, [graph]); + + const types = useMemo(() => { + const s = new Set(); + graph.nodes.forEach(n => s.add(n.type)); + return [...s].sort(); + }, [graph]); + + const set = (key: string, val: string) => { + const next = { ...filters }; + if (val) next[key] = val; + else delete next[key]; + onFilterChange(next); + }; + + const activeCount = Object.values(filters).filter(Boolean).length; + + return ( +
+ set('search', e.target.value)} + /> + + + + {activeCount > 0 && ( + + )} +
+ ); +} diff --git a/site/src/components/Galaxy.tsx b/site/src/components/Galaxy.tsx new file mode 100644 index 0000000..afedb18 --- /dev/null +++ b/site/src/components/Galaxy.tsx @@ -0,0 +1,361 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import * as d3 from 'd3'; +import type { GraphData, GraphNode, SimNode, SimEdge } from '../lib/graph-types'; +import { + getNodeRadius, getNodeColor, getNodeOpacity, getEdgeColor, + getLayerYOffset, getDomainAnchor, nodeMatchesFilters, DOMAIN_ANCHORS, +} from '../lib/graph-layout'; +import { FilterBar } from './FilterBar'; +import { DetailPanel } from './DetailPanel'; +import { NodeTooltip } from './NodeTooltip'; + +interface Props { + graph: GraphData; +} + +// SVG shape renderers +function renderNodeShape( + g: d3.Selection, +) { + // Circles for patterns + g.filter(d => d.type === 'pattern') + .append('circle') + .attr('r', d => getNodeRadius(d)); + + // Rounded rects for use-cases + g.filter(d => d.type === 'use-case') + .append('rect') + .attr('width', 28).attr('height', 20) + .attr('x', -14).attr('y', -10) + .attr('rx', 4); + + // Hexagons for approaches + g.filter(d => d.type === 'approach') + .append('polygon') + .attr('points', hexPoints(14)); + + // Large circles for domains + g.filter(d => d.type === 'domain') + .append('circle') + .attr('r', 40) + .style('fill-opacity', 0.08) + .style('stroke-opacity', 0.3); + + // Diamonds for vendors + g.filter(d => d.type === 'vendor') + .append('polygon') + .attr('points', diamondPoints(12)); + + // Small circles for jurisdictions + g.filter(d => d.type === 'jurisdiction') + .append('circle') + .attr('r', 10); +} + +function hexPoints(r: number): string { + return Array.from({ length: 6 }, (_, i) => { + const angle = (Math.PI / 3) * i - Math.PI / 6; + return `${r * Math.cos(angle)},${r * Math.sin(angle)}`; + }).join(' '); +} + +function diamondPoints(r: number): string { + return `0,${-r} ${r},0 0,${r} ${-r},0`; +} + +export function Galaxy({ graph }: Props) { + const svgRef = useRef(null); + const simRef = useRef | null>(null); + const [selected, setSelected] = useState(null); + const [hovered, setHovered] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const [filters, setFilters] = useState>({}); + + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + + const width = svg.clientWidth || window.innerWidth; + const height = svg.clientHeight || (window.innerHeight - 48); + + // Clear previous + d3.select(svg).selectAll('*').remove(); + + // Build domain edge map + const dEdges = new Map(); + for (const e of graph.edges) { + if (e.type === 'in-domain') { + const src = e.source; + const tgt = e.target; + // Figure out which is the domain + const srcNode = graph.nodes.find(n => n.id === src); + const tgtNode = graph.nodes.find(n => n.id === tgt); + if (srcNode?.type === 'domain') { + if (!dEdges.has(tgt)) dEdges.set(tgt, []); + dEdges.get(tgt)!.push(srcNode.slug); + } else if (tgtNode?.type === 'domain') { + if (!dEdges.has(src)) dEdges.set(src, []); + dEdges.get(src)!.push(tgtNode.slug); + } + } + } + // Create simulation nodes & edges (deep copy) + const nodes: SimNode[] = graph.nodes.map(n => ({ ...n, x: 0, y: 0 })); + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const edges: SimEdge[] = graph.edges + .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)) + .map(e => ({ + source: nodeMap.get(e.source)!, + target: nodeMap.get(e.target)!, + type: e.type, + })); + + // Initialize positions near domain anchors + for (const node of nodes) { + const anchor = getDomainAnchor(node, dEdges, width, height); + if (anchor) { + node.x = anchor.x + (Math.random() - 0.5) * 80; + node.y = anchor.y + (Math.random() - 0.5) * 80; + } else { + node.x = width / 2 + (Math.random() - 0.5) * 200; + node.y = height / 2 + (Math.random() - 0.5) * 200; + } + // Fix domain nodes at their anchor + if (node.type === 'domain') { + const a = DOMAIN_ANCHORS[node.slug] ?? DOMAIN_ANCHORS[node.title]; + if (a) { + node.fx = a.x * width; + node.fy = a.y * height; + } + } + } + + // SVG structure + const svgSel = d3.select(svg); + const g = svgSel.append('g'); + + // Zoom + const zoom = d3.zoom() + .scaleExtent([0.3, 4]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + }); + svgSel.call(zoom); + + // Edges + const edgeSel = g.append('g').attr('class', 'edges') + .selectAll('line') + .data(edges) + .join('line') + .attr('stroke', d => getEdgeColor(d.type)) + .attr('stroke-width', 0.5) + .attr('stroke-opacity', 0.2); + + // Node groups + const nodeSel = g.append('g').attr('class', 'nodes') + .selectAll('g') + .data(nodes) + .join('g') + .attr('cursor', 'pointer') + .call(d3.drag() + .on('start', (event, d) => { + if (!event.active) sim.alphaTarget(0.1).restart(); + d.fx = d.x; + d.fy = d.y; + }) + .on('drag', (event, d) => { + d.fx = event.x; + d.fy = event.y; + }) + .on('end', (event, d) => { + if (!event.active) sim.alphaTarget(0); + // Keep domain nodes fixed + if (d.type !== 'domain') { + d.fx = null; + d.fy = null; + } + }) + ); + + // Render shapes + renderNodeShape(nodeSel); + + // Apply colors + nodeSel.selectAll('circle, rect, polygon') + .attr('fill', (_, i, nodes) => { + const d = d3.select(nodes[i].parentNode as Element).datum() as SimNode; + return getNodeColor(d); + }) + .attr('stroke', (_, i, nodes) => { + const d = d3.select(nodes[i].parentNode as Element).datum() as SimNode; + return getNodeColor(d); + }) + .attr('stroke-width', 1.5) + .attr('opacity', (_, i, nodes) => { + const d = d3.select(nodes[i].parentNode as Element).datum() as SimNode; + return getNodeOpacity(d); + }); + + // Labels (only for domains and large nodes) + nodeSel.append('text') + .text(d => d.type === 'domain' ? d.title : '') + .attr('text-anchor', 'middle') + .attr('dy', d => d.type === 'domain' ? getNodeRadius(d) + 16 : getNodeRadius(d) + 12) + .attr('fill', '#94A3B8') + .attr('font-size', d => d.type === 'domain' ? '12px' : '9px') + .attr('font-weight', d => d.type === 'domain' ? '600' : '400') + .attr('pointer-events', 'none'); + + // Interaction handlers + nodeSel + .on('mouseenter', function (event, d) { + setHovered(d); + setMousePos({ x: event.clientX, y: event.clientY }); + // Highlight connected + const connected = new Set(); + for (const e of edges) { + if (e.source.id === d.id) connected.add(e.target.id); + if (e.target.id === d.id) connected.add(e.source.id); + } + connected.add(d.id); + + nodeSel.transition().duration(150) + .attr('opacity', n => connected.has(n.id) ? 1 : 0.1); + edgeSel.transition().duration(150) + .attr('stroke-opacity', e => + e.source.id === d.id || e.target.id === d.id ? 0.6 : 0.03) + .attr('stroke-width', e => + e.source.id === d.id || e.target.id === d.id ? 1.5 : 0.5); + }) + .on('mouseleave', function () { + setHovered(null); + nodeSel.transition().duration(150).attr('opacity', 1); + edgeSel.transition().duration(150) + .attr('stroke-opacity', 0.2) + .attr('stroke-width', 0.5); + }) + .on('click', function (_, d) { + if (d.type === 'domain') return; + setSelected(prev => prev?.id === d.id ? null : d); + }); + + // Domain clustering force + function domainClusterForce(alpha: number) { + for (const node of nodes) { + if (node.type === 'domain') continue; + const anchor = getDomainAnchor(node, dEdges, width, height); + if (anchor) { + node.vx! += (anchor.x - node.x) * alpha * 0.04; + node.vy! += (anchor.y - node.y) * alpha * 0.04; + } + } + } + + // Layer stratification force + function layerForce(alpha: number) { + for (const node of nodes) { + if (node.type !== 'pattern' || !node.layer) continue; + const anchor = getDomainAnchor(node, dEdges, width, height); + const baseY = anchor?.y ?? height / 2; + const targetY = baseY + getLayerYOffset(node.layer); + node.vy! += (targetY - node.y) * alpha * 0.01; + } + } + + // Simulation + const sim = d3.forceSimulation(nodes) + .force('link', d3.forceLink(edges) + .id(d => d.id) + .distance(60) + .strength(0.1)) + .force('charge', d3.forceManyBody().strength(-30)) + .force('collision', d3.forceCollide() + .radius(d => getNodeRadius(d) + 4)) + .force('center', d3.forceCenter(width / 2, height / 2).strength(0.02)) + .force('domainCluster', (alpha) => domainClusterForce(alpha)) + .force('layer', (alpha) => layerForce(alpha)) + .alpha(0.8) + .alphaDecay(0.02) + .on('tick', () => { + edgeSel + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + nodeSel.attr('transform', d => `translate(${d.x},${d.y})`); + }); + + simRef.current = sim; + + return () => { sim.stop(); }; + }, [graph]); + + // Apply filters + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + + const hasFilters = Object.values(filters).some(v => v); + const g = d3.select(svg).select('g.nodes'); + + g.selectAll('g') + .transition().duration(200) + .attr('opacity', d => { + if (!hasFilters) return 1; + return nodeMatchesFilters(d, filters) ? 1 : 0.08; + }); + }, [filters]); + + // Handle selecting a node from the detail panel + const handleSelectConnected = useCallback((nodeId: string) => { + const node = graph.nodes.find(n => n.id === nodeId); + if (node) setSelected(node); + }, [graph.nodes]); + + return ( +
+ + + {hovered && !selected && ( + + )} + {selected && ( + setSelected(null)} + onSelectNode={handleSelectConnected} + /> + )} + {/* Legend */} +
+ L1 + L2 + Offchain + Hybrid + + Use Case + Approach + Vendor + Jurisdiction +
+
+ ); +} diff --git a/site/src/components/NodeTooltip.tsx b/site/src/components/NodeTooltip.tsx new file mode 100644 index 0000000..9498dac --- /dev/null +++ b/site/src/components/NodeTooltip.tsx @@ -0,0 +1,56 @@ +import type { GraphNode } from '../lib/graph-types'; +import { getNodeColor, TYPE_LABELS } from '../lib/graph-layout'; + +interface Props { + node: GraphNode; + x: number; + y: number; +} + +export function NodeTooltip({ node, x, y }: Props) { + const badges = [ + node.type !== 'pattern' && TYPE_LABELS[node.type], + node.layer, + node.maturity, + node.status, + ].filter(Boolean); + + return ( +
+
+ {node.title} +
+
+ {badges.map((b, i) => ( + {b} + ))} +
+ {node.summary && ( +
+ {node.summary.slice(0, 150)} + {node.summary.length > 150 ? '...' : ''} +
+ )} +
+ ); +} diff --git a/site/src/components/TreeView.tsx b/site/src/components/TreeView.tsx new file mode 100644 index 0000000..a70fe5d --- /dev/null +++ b/site/src/components/TreeView.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import * as d3 from 'd3'; +import type { GraphData, GraphNode } from '../lib/graph-types'; +import { getNodeColor, NODE_COLORS } from '../lib/graph-layout'; +import { DetailPanel } from './DetailPanel'; + +interface Props { + graph: GraphData; +} + +interface LayerNode { + node: GraphNode; + layer: number; // 0=use-case, 1=approach, 2=pattern + x: number; + y: number; +} + +interface LayerEdge { + source: LayerNode; + target: LayerNode; +} + +const LAYER_LABELS = ['Use Cases', 'Approaches', 'Patterns']; +const LAYER_COLORS = [NODE_COLORS['use-case'], NODE_COLORS.approach, '#8B5CF6']; +const NODE_HEIGHT = 32; +const NODE_GAP = 8; +const LAYER_WIDTH = 260; +const LAYER_GAP = 180; +const PADDING = { top: 60, left: 40, right: 40, bottom: 40 }; + +export function TreeView({ graph }: Props) { + const svgRef = useRef(null); + const [selected, setSelected] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + + // Build the layered data + const { layerNodes, layerEdges, totalHeight } = useMemo(() => { + // Get use cases, approaches, patterns that are connected + const useCases = graph.nodes.filter(n => n.type === 'use-case'); + const approaches = graph.nodes.filter(n => n.type === 'approach'); + const patterns = graph.nodes.filter(n => n.type === 'pattern'); + + // Build edge maps + const ucToApproach = new Map(); // use-case → approaches + const approachToPattern = new Map(); // approach → patterns + + for (const edge of graph.edges) { + if (edge.type === 'recommends') { + // use-case recommends approach + if (!ucToApproach.has(edge.source)) ucToApproach.set(edge.source, []); + ucToApproach.get(edge.source)!.push(edge.target); + } + if (edge.type === 'uses-pattern') { + // approach uses pattern + if (!approachToPattern.has(edge.source)) approachToPattern.set(edge.source, []); + approachToPattern.get(edge.source)!.push(edge.target); + } + } + + // Filter to only connected nodes + const connectedApproachIds = new Set(); + const connectedPatternIds = new Set(); + const connectedUcIds = new Set(); + + for (const [ucId, appIds] of ucToApproach) { + if (appIds.length > 0) connectedUcIds.add(ucId); + for (const appId of appIds) { + connectedApproachIds.add(appId); + const patIds = approachToPattern.get(appId) ?? []; + for (const patId of patIds) connectedPatternIds.add(patId); + } + } + + // Also include approaches that have patterns but no use-case link + for (const [appId, patIds] of approachToPattern) { + if (patIds.length > 0) { + connectedApproachIds.add(appId); + for (const patId of patIds) connectedPatternIds.add(patId); + } + } + + const filteredUc = useCases.filter(n => connectedUcIds.has(n.id)); + const filteredApp = approaches.filter(n => connectedApproachIds.has(n.id)); + const filteredPat = patterns.filter(n => connectedPatternIds.has(n.id)); + + // Sort each layer alphabetically + filteredUc.sort((a, b) => a.title.localeCompare(b.title)); + filteredApp.sort((a, b) => a.title.localeCompare(b.title)); + filteredPat.sort((a, b) => a.title.localeCompare(b.title)); + + // Position nodes in columns + const layers = [filteredUc, filteredApp, filteredPat]; + const layerNodes: LayerNode[] = []; + const nodeMap = new Map(); + + for (let col = 0; col < 3; col++) { + const x = PADDING.left + col * (LAYER_WIDTH + LAYER_GAP); + layers[col].forEach((node, row) => { + const y = PADDING.top + row * (NODE_HEIGHT + NODE_GAP); + const ln: LayerNode = { node, layer: col, x, y }; + layerNodes.push(ln); + nodeMap.set(node.id, ln); + }); + } + + // Build edges between layer nodes + const layerEdges: LayerEdge[] = []; + for (const [ucId, appIds] of ucToApproach) { + const src = nodeMap.get(ucId); + if (!src) continue; + for (const appId of appIds) { + const tgt = nodeMap.get(appId); + if (tgt) layerEdges.push({ source: src, target: tgt }); + } + } + for (const [appId, patIds] of approachToPattern) { + const src = nodeMap.get(appId); + if (!src) continue; + for (const patId of patIds) { + const tgt = nodeMap.get(patId); + if (tgt) layerEdges.push({ source: src, target: tgt }); + } + } + + const maxRows = Math.max(filteredUc.length, filteredApp.length, filteredPat.length); + const totalHeight = PADDING.top + maxRows * (NODE_HEIGHT + NODE_GAP) + PADDING.bottom; + + return { layerNodes, layerEdges, totalHeight }; + }, [graph]); + + // Compute which nodes are connected to hovered node + const highlightedIds = useMemo(() => { + if (!hoveredId) return null; + const ids = new Set([hoveredId]); + for (const e of layerEdges) { + if (e.source.node.id === hoveredId) ids.add(e.target.node.id); + if (e.target.node.id === hoveredId) ids.add(e.source.node.id); + } + return ids; + }, [hoveredId, layerEdges]); + + const totalWidth = PADDING.left + 3 * LAYER_WIDTH + 2 * LAYER_GAP + PADDING.right; + + const handleSelectConnected = useCallback((nodeId: string) => { + const node = graph.nodes.find(n => n.id === nodeId); + if (node) setSelected(node); + }, [graph.nodes]); + + return ( +
+ + {/* Layer labels */} + {LAYER_LABELS.map((label, i) => ( + + {label.toUpperCase()} + + ))} + + {/* Edges - curved bezier lines */} + + {layerEdges.map((e, i) => { + const sx = e.source.x + LAYER_WIDTH; + const sy = e.source.y + NODE_HEIGHT / 2; + const tx = e.target.x; + const ty = e.target.y + NODE_HEIGHT / 2; + const mx = (sx + tx) / 2; + + const isHighlighted = highlightedIds + ? highlightedIds.has(e.source.node.id) && highlightedIds.has(e.target.node.id) + : false; + const isDimmed = highlightedIds && !isHighlighted; + + return ( + + ); + })} + + + {/* Nodes */} + + {layerNodes.map((ln) => { + const isDimmed = highlightedIds && !highlightedIds.has(ln.node.id); + const isHovered = hoveredId === ln.node.id; + const color = ln.layer === 2 ? getNodeColor(ln.node) : LAYER_COLORS[ln.layer]; + + return ( + setHoveredId(ln.node.id)} + onMouseLeave={() => setHoveredId(null)} + onClick={() => setSelected(ln.node)} + > + + {/* Color accent bar */} + + {/* Title text */} + + {ln.node.title.length > 32 + ? ln.node.title.slice(0, 30) + '...' + : ln.node.title} + + {/* Layer badge for patterns */} + {ln.layer === 2 && ln.node.layer && ( + + {ln.node.layer} + + )} + + ); + })} + + + + {selected && ( + setSelected(null)} + onSelectNode={handleSelectConnected} + /> + )} +
+ ); +} diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro new file mode 100644 index 0000000..3dd9f78 --- /dev/null +++ b/site/src/layouts/Layout.astro @@ -0,0 +1,110 @@ +--- +interface Props { + title?: string; +} +const { title = 'IPTF Privacy Map' } = Astro.props; +--- + + + + + + {title} + + + + +
+ +
+ + diff --git a/site/src/lib/graph-layout.ts b/site/src/lib/graph-layout.ts new file mode 100644 index 0000000..5241742 --- /dev/null +++ b/site/src/lib/graph-layout.ts @@ -0,0 +1,169 @@ +import type { NodeType, GraphNode } from './graph-types'; + +// --------------------------------------------------------------------------- +// Visual encoding constants +// --------------------------------------------------------------------------- + +export const NODE_COLORS: Record = { + // Pattern layer colors + L1: '#3B82F6', + L2: '#8B5CF6', + offchain: '#10B981', + hybrid: '#06B6D4', + // Node type colors (non-patterns) + 'use-case': '#F59E0B', + approach: '#EAB308', + domain: '#6B7280', + jurisdiction: '#EF4444', + vendor: '#14B8A6', +}; + +export const EDGE_COLORS: Record = { + 'see-also': '#9CA3AF', + 'uses-pattern': '#EAB308', + implements: '#14B8A6', + recommends: '#F59E0B', + 'in-domain': '#D1D5DB', + 'regulated-by': '#EF4444', +}; + +const MATURITY_RADIUS: Record = { + experimental: 6, + PoC: 10, + pilot: 16, + prod: 22, + production: 22, +}; + +const TYPE_RADIUS: Record = { + pattern: 10, // overridden by maturity + 'use-case': 14, + approach: 14, + domain: 40, + jurisdiction: 10, + vendor: 12, +}; + +// Domain cluster positions (normalized 0-1, mapped to viewport later) +export const DOMAIN_ANCHORS: Record = { + payments: { x: 0.17, y: 0.25 }, + trading: { x: 0.5, y: 0.25 }, + custody: { x: 0.83, y: 0.25 }, + 'funds-assets': { x: 0.17, y: 0.75 }, + 'Funds & Assets': { x: 0.17, y: 0.75 }, + 'identity-compliance': { x: 0.5, y: 0.75 }, + 'Identity & Compliance': { x: 0.5, y: 0.75 }, + 'data-oracles': { x: 0.83, y: 0.75 }, + 'Data & Oracles': { x: 0.83, y: 0.75 }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function getNodeRadius(node: GraphNode): number { + if (node.type === 'pattern' && node.maturity) { + return MATURITY_RADIUS[node.maturity] ?? MATURITY_RADIUS.PoC; + } + return TYPE_RADIUS[node.type] ?? 10; +} + +export function getNodeColor(node: GraphNode): string { + if (node.type === 'pattern') { + return NODE_COLORS[node.layer ?? 'hybrid'] ?? NODE_COLORS.hybrid; + } + return NODE_COLORS[node.type] ?? '#6B7280'; +} + +export function getNodeOpacity(node: GraphNode): number { + return node.status === 'draft' ? 0.6 : 1.0; +} + +export function getEdgeColor(type: string): string { + return EDGE_COLORS[type] ?? '#D1D5DB'; +} + +/** Y-offset for layer stratification (negative = up) */ +export function getLayerYOffset(layer?: string): number { + switch (layer) { + case 'L1': return 40; + case 'L2': return 0; + case 'offchain': return -40; + case 'hybrid': return 20; + default: return 0; + } +} + +/** Find the domain anchor closest to a node based on its primary_domain or edges. */ +export function getDomainAnchor( + node: GraphNode, + domainEdges: Map, + width: number, + height: number, +): { x: number; y: number } | null { + // Domain nodes use their own slug + if (node.type === 'domain') { + const anchor = DOMAIN_ANCHORS[node.slug] ?? DOMAIN_ANCHORS[node.title]; + if (anchor) return { x: anchor.x * width, y: anchor.y * height }; + } + + // Use primary_domain from frontmatter + if (node.primary_domain) { + for (const [key, pos] of Object.entries(DOMAIN_ANCHORS)) { + if (node.primary_domain.toLowerCase().includes(key.replace(/-/g, ' ').toLowerCase()) || + key.toLowerCase().includes(node.primary_domain.toLowerCase())) { + return { x: pos.x * width, y: pos.y * height }; + } + } + } + + // Use domain edges + const domains = domainEdges.get(node.id); + if (domains && domains.length > 0) { + // Average position of all connected domains + let sx = 0, sy = 0, count = 0; + for (const domainSlug of domains) { + const anchor = DOMAIN_ANCHORS[domainSlug]; + if (anchor) { + sx += anchor.x * width; + sy += anchor.y * height; + count++; + } + } + if (count > 0) return { x: sx / count, y: sy / count }; + } + + return null; +} + +/** Check if a node matches the current filters */ +export function nodeMatchesFilters( + node: GraphNode, + filters: { + domain?: string; + layer?: string; + maturity?: string; + type?: string; + search?: string; + }, +): boolean { + if (filters.type && node.type !== filters.type) return false; + if (filters.layer && node.type === 'pattern' && node.layer !== filters.layer) return false; + if (filters.maturity && node.type === 'pattern' && node.maturity !== filters.maturity) return false; + if (filters.search) { + const q = filters.search.toLowerCase(); + const haystack = `${node.title} ${node.summary} ${node.privacy_goal ?? ''}`.toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; +} + +// Type labels for display +export const TYPE_LABELS: Record = { + pattern: 'Pattern', + 'use-case': 'Use Case', + approach: 'Approach', + domain: 'Domain', + jurisdiction: 'Jurisdiction', + vendor: 'Vendor', +}; diff --git a/site/src/lib/graph-types.ts b/site/src/lib/graph-types.ts new file mode 100644 index 0000000..4070092 --- /dev/null +++ b/site/src/lib/graph-types.ts @@ -0,0 +1,50 @@ +export type NodeType = 'pattern' | 'use-case' | 'approach' | 'domain' | 'jurisdiction' | 'vendor'; +export type EdgeType = 'see-also' | 'uses-pattern' | 'implements' | 'recommends' | 'in-domain' | 'regulated-by'; + +export interface GraphNode { + id: string; + type: NodeType; + title: string; + slug: string; + file: string; + layer?: string; + maturity?: string; + status?: string; + privacy_goal?: string; + primary_domain?: string; + region?: string; + summary: string; + content: string; +} + +export interface GraphEdge { + source: string; + target: string; + type: EdgeType; +} + +export interface GraphData { + nodes: GraphNode[]; + edges: GraphEdge[]; + meta: { + generated_at: string; + node_count: number; + edge_count: number; + }; +} + +// D3 simulation node (extends GraphNode with x, y, etc.) +export interface SimNode extends GraphNode { + x: number; + y: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface SimEdge { + source: SimNode; + target: SimNode; + type: EdgeType; +} diff --git a/site/src/pages/browse.astro b/site/src/pages/browse.astro new file mode 100644 index 0000000..4a2d533 --- /dev/null +++ b/site/src/pages/browse.astro @@ -0,0 +1,13 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { BrowseGrid } from '../components/BrowseGrid'; +import graphData from '../data/graph.json'; +--- + + + + diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro new file mode 100644 index 0000000..d69c39e --- /dev/null +++ b/site/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { Galaxy } from '../components/Galaxy'; +import graphData from '../data/graph.json'; +--- + + + + diff --git a/site/src/pages/tree.astro b/site/src/pages/tree.astro new file mode 100644 index 0000000..6a8ec56 --- /dev/null +++ b/site/src/pages/tree.astro @@ -0,0 +1,13 @@ +--- +import Layout from '../layouts/Layout.astro'; +import { TreeView } from '../components/TreeView'; +import graphData from '../data/graph.json'; +--- + + + + diff --git a/site/tests/build-graph.test.mjs b/site/tests/build-graph.test.mjs new file mode 100644 index 0000000..48d7139 --- /dev/null +++ b/site/tests/build-graph.test.mjs @@ -0,0 +1,294 @@ +import { describe, it, expect } from 'vitest'; +import { + parseFrontmatter, + fileToSlug, + fileToNodeId, + extractSummary, + extractLinks, + classifyEdge, + buildGraph, +} from '../scripts/build-graph.mjs'; +import { join } from 'path'; + +// --------------------------------------------------------------------------- +// parseFrontmatter +// --------------------------------------------------------------------------- + +describe('parseFrontmatter', () => { + it('parses key-value pairs', () => { + const { data, body } = parseFrontmatter(`--- +title: "Pattern: ZK Shielded Balances" +status: draft +maturity: PoC +layer: L2 +--- + +## Intent + +Some content here.`); + + expect(data.title).toBe('Pattern: ZK Shielded Balances'); + expect(data.status).toBe('draft'); + expect(data.maturity).toBe('PoC'); + expect(data.layer).toBe('L2'); + expect(body).toContain('## Intent'); + }); + + it('parses array fields', () => { + const { data } = parseFrontmatter(`--- +title: Test +dependencies: + - ERC-6123 + - ERC-7573 +--- + +Body`); + + expect(data.dependencies).toEqual(['ERC-6123', 'ERC-7573']); + }); + + it('handles missing frontmatter', () => { + const { data, body } = parseFrontmatter('# Just a heading\n\nSome text.'); + expect(data).toEqual({}); + expect(body).toContain('Just a heading'); + }); + + it('strips quotes from values', () => { + const { data } = parseFrontmatter(`--- +title: "Vendor: Aztec" +--- +`); + expect(data.title).toBe('Vendor: Aztec'); + }); +}); + +// --------------------------------------------------------------------------- +// fileToSlug / fileToNodeId +// --------------------------------------------------------------------------- + +describe('fileToSlug', () => { + it('strips pattern- prefix and .md', () => { + expect(fileToSlug('pattern-zk-shielded-balances.md', 'pattern-')) + .toBe('zk-shielded-balances'); + }); + + it('strips approach- prefix', () => { + expect(fileToSlug('approach-private-bonds.md', 'approach-')) + .toBe('private-bonds'); + }); + + it('handles no prefix', () => { + expect(fileToSlug('private-bonds.md', '')) + .toBe('private-bonds'); + }); +}); + +describe('fileToNodeId', () => { + it('creates correct pattern ID', () => { + expect(fileToNodeId('pattern', 'pattern-shielding.md', 'pattern-')) + .toBe('pattern/shielding'); + }); + + it('creates correct vendor ID', () => { + expect(fileToNodeId('vendor', 'aztec.md', '')) + .toBe('vendor/aztec'); + }); +}); + +// --------------------------------------------------------------------------- +// extractSummary +// --------------------------------------------------------------------------- + +describe('extractSummary', () => { + it('extracts from ## Intent section', () => { + const body = `## Intent + +Maintain **confidential balances** inside a shielded pool. + +## Ingredients`; + + expect(extractSummary(body)).toContain('confidential balances'); + }); + + it('extracts from ## What it is (vendors)', () => { + const body = `## What it is + +Aztec is a privacy focused rollup. + +## Architecture`; + + expect(extractSummary(body)).toContain('Aztec'); + }); + + it('extracts from ## TLDR (domains)', () => { + const body = `## TLDR +- Institutional cash movement: payouts, PvP, DvP. + +## Primary use cases`; + + expect(extractSummary(body)).toContain('Institutional cash movement'); + }); + + it('extracts from ## 1) Use Case', () => { + const body = `## 1) Use Case + +Bond issuance and trading on public blockchains. + +## 2) Context`; + + expect(extractSummary(body)).toContain('Bond issuance'); + }); + + it('truncates long summaries', () => { + const longText = 'A'.repeat(300); + const body = `## Intent\n\n${longText}\n\n## Next`; + const summary = extractSummary(body, 200); + expect(summary.length).toBeLessThanOrEqual(203); // 200 + "..." + expect(summary.endsWith('...')).toBe(true); + }); + + it('returns empty string for empty body', () => { + expect(extractSummary('')).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// extractLinks +// --------------------------------------------------------------------------- + +describe('extractLinks', () => { + it('extracts links with sections', () => { + const body = `## See also + +- [pattern-shielding.md](pattern-shielding.md) +- [pattern-l1-zk.md](../patterns/pattern-l1-zk-commitment-pool.md)`; + + const links = extractLinks(body); + expect(links).toHaveLength(2); + expect(links[0].href).toBe('pattern-shielding.md'); + expect(links[0].section).toBe('See also'); + expect(links[1].href).toBe('../patterns/pattern-l1-zk-commitment-pool.md'); + }); + + it('ignores external links', () => { + const body = `## Links + +- [GitHub](https://github.com/example) +- [Internal](../patterns/pattern-foo.md)`; + + const links = extractLinks(body); + expect(links).toHaveLength(1); + expect(links[0].href).toBe('../patterns/pattern-foo.md'); + }); + + it('tracks section context', () => { + const body = `## Fits with patterns + +- [pattern-a.md](../patterns/pattern-a.md) + +## Architecture + +Some text with [link](../patterns/pattern-b.md).`; + + const links = extractLinks(body); + expect(links[0].section).toBe('Fits with patterns'); + expect(links[1].section).toBe('Architecture'); + }); + + it('ignores non-.md links', () => { + const body = `- [pic](image.png) +- [doc](../patterns/pattern-x.md)`; + const links = extractLinks(body); + expect(links).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// classifyEdge +// --------------------------------------------------------------------------- + +describe('classifyEdge', () => { + it('classifies See also as see-also', () => { + expect(classifyEdge('pattern', 'pattern', 'See also')).toBe('see-also'); + }); + + it('classifies Fits with patterns as implements', () => { + expect(classifyEdge('vendor', 'pattern', 'Fits with patterns')).toBe('implements'); + }); + + it('classifies Recommended Approaches as recommends', () => { + expect(classifyEdge('use-case', 'approach', '5) Recommended Approaches')).toBe('recommends'); + }); + + it('classifies Shortest-path patterns as in-domain', () => { + expect(classifyEdge('domain', 'pattern', 'Shortest-path patterns')).toBe('in-domain'); + }); + + it('classifies approach links as uses-pattern by default', () => { + expect(classifyEdge('approach', 'pattern', 'Architecture')).toBe('uses-pattern'); + }); + + it('classifies links to jurisdictions as regulated-by', () => { + expect(classifyEdge('use-case', 'jurisdiction', 'Requirements')).toBe('regulated-by'); + }); +}); + +// --------------------------------------------------------------------------- +// buildGraph (integration test against real repo) +// --------------------------------------------------------------------------- + +describe('buildGraph (integration)', () => { + const repoRoot = join(import.meta.dirname, '..', '..'); + let graph; + + // Build once for all assertions + it('builds without errors', () => { + graph = buildGraph(repoRoot); + expect(graph).toBeDefined(); + expect(graph.nodes).toBeInstanceOf(Array); + expect(graph.edges).toBeInstanceOf(Array); + expect(graph.meta).toBeDefined(); + }); + + it('has a reasonable number of nodes (80+)', () => { + expect(graph.nodes.length).toBeGreaterThan(80); + }); + + it('has edges', () => { + expect(graph.edges.length).toBeGreaterThan(0); + }); + + it('includes all node types', () => { + const types = new Set(graph.nodes.map(n => n.type)); + expect(types).toContain('pattern'); + expect(types).toContain('use-case'); + expect(types).toContain('approach'); + expect(types).toContain('domain'); + expect(types).toContain('jurisdiction'); + expect(types).toContain('vendor'); + }); + + it('has no dangling edges', () => { + const nodeIds = new Set(graph.nodes.map(n => n.id)); + for (const edge of graph.edges) { + expect(nodeIds.has(edge.source)).toBe(true); + expect(nodeIds.has(edge.target)).toBe(true); + } + }); + + it('has meta with counts', () => { + expect(graph.meta.node_count).toBe(graph.nodes.length); + expect(graph.meta.edge_count).toBe(graph.edges.length); + expect(graph.meta.generated_at).toBeTruthy(); + }); + + it('pattern nodes have expected fields', () => { + const pattern = graph.nodes.find(n => n.id === 'pattern/zk-shielded-balances'); + expect(pattern).toBeDefined(); + expect(pattern.title).toContain('ZK Shielded Balances'); + expect(pattern.layer).toBe('L2'); + expect(pattern.maturity).toBe('PoC'); + expect(pattern.summary).toBeTruthy(); + expect(pattern.content).toBeTruthy(); + }); +}); diff --git a/site/tests/graph-layout.test.ts b/site/tests/graph-layout.test.ts new file mode 100644 index 0000000..76c17cb --- /dev/null +++ b/site/tests/graph-layout.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { + getNodeRadius, + getNodeColor, + getNodeOpacity, + getEdgeColor, + getLayerYOffset, + nodeMatchesFilters, + NODE_COLORS, +} from '../src/lib/graph-layout'; +import type { GraphNode } from '../src/lib/graph-types'; + +function makeNode(overrides: Partial = {}): GraphNode { + return { + id: 'pattern/test', + type: 'pattern', + title: 'Test Pattern', + slug: 'test', + file: 'patterns/pattern-test.md', + summary: 'A test pattern', + content: '## Intent\n\nTest', + ...overrides, + }; +} + +describe('getNodeRadius', () => { + it('returns maturity-based radius for patterns', () => { + expect(getNodeRadius(makeNode({ maturity: 'PoC' }))).toBe(10); + expect(getNodeRadius(makeNode({ maturity: 'pilot' }))).toBe(16); + expect(getNodeRadius(makeNode({ maturity: 'prod' }))).toBe(22); + expect(getNodeRadius(makeNode({ maturity: 'experimental' }))).toBe(6); + }); + + it('returns fixed radius for non-pattern types', () => { + expect(getNodeRadius(makeNode({ type: 'use-case' }))).toBe(14); + expect(getNodeRadius(makeNode({ type: 'domain' }))).toBe(40); + expect(getNodeRadius(makeNode({ type: 'vendor' }))).toBe(12); + expect(getNodeRadius(makeNode({ type: 'jurisdiction' }))).toBe(10); + }); +}); + +describe('getNodeColor', () => { + it('returns layer-based color for patterns', () => { + expect(getNodeColor(makeNode({ layer: 'L1' }))).toBe('#3B82F6'); + expect(getNodeColor(makeNode({ layer: 'L2' }))).toBe('#8B5CF6'); + expect(getNodeColor(makeNode({ layer: 'offchain' }))).toBe('#10B981'); + expect(getNodeColor(makeNode({ layer: 'hybrid' }))).toBe('#06B6D4'); + }); + + it('returns type-based color for non-patterns', () => { + expect(getNodeColor(makeNode({ type: 'use-case' }))).toBe(NODE_COLORS['use-case']); + expect(getNodeColor(makeNode({ type: 'vendor' }))).toBe(NODE_COLORS.vendor); + }); +}); + +describe('getNodeOpacity', () => { + it('returns 0.6 for draft', () => { + expect(getNodeOpacity(makeNode({ status: 'draft' }))).toBe(0.6); + }); + it('returns 1.0 for ready', () => { + expect(getNodeOpacity(makeNode({ status: 'ready' }))).toBe(1.0); + }); +}); + +describe('getEdgeColor', () => { + it('returns correct colors for edge types', () => { + expect(getEdgeColor('see-also')).toBe('#9CA3AF'); + expect(getEdgeColor('implements')).toBe('#14B8A6'); + expect(getEdgeColor('recommends')).toBe('#F59E0B'); + }); +}); + +describe('getLayerYOffset', () => { + it('L1 goes down, offchain goes up', () => { + expect(getLayerYOffset('L1')).toBeGreaterThan(0); + expect(getLayerYOffset('offchain')).toBeLessThan(0); + expect(getLayerYOffset('L2')).toBe(0); + }); +}); + +describe('nodeMatchesFilters', () => { + const node = makeNode({ + type: 'pattern', + layer: 'L2', + maturity: 'PoC', + title: 'ZK Shielded Balances', + summary: 'Confidential balances', + }); + + it('matches with no filters', () => { + expect(nodeMatchesFilters(node, {})).toBe(true); + }); + + it('filters by type', () => { + expect(nodeMatchesFilters(node, { type: 'pattern' })).toBe(true); + expect(nodeMatchesFilters(node, { type: 'vendor' })).toBe(false); + }); + + it('filters by layer', () => { + expect(nodeMatchesFilters(node, { layer: 'L2' })).toBe(true); + expect(nodeMatchesFilters(node, { layer: 'L1' })).toBe(false); + }); + + it('filters by search text', () => { + expect(nodeMatchesFilters(node, { search: 'shielded' })).toBe(true); + expect(nodeMatchesFilters(node, { search: 'xyz' })).toBe(false); + }); + + it('combines filters (AND)', () => { + expect(nodeMatchesFilters(node, { type: 'pattern', layer: 'L2' })).toBe(true); + expect(nodeMatchesFilters(node, { type: 'pattern', layer: 'L1' })).toBe(false); + }); +}); diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 0000000..b7243b9 --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +}