Files
q3alique 6c91bf5099 Fix SVG zoom/pan in flow panels and add scrollbars
Root cause: _tag_svg_id regex matched data-flow-id as a standalone
id= attribute, so flow panel SVGs never got an id attribute injected.
document.getElementById(svgId) returned null, silently no-oping all
zoom, pan, drag and fitView calls. Only the overview panel worked.

Fixes:
- _tag_svg_id: require whitespace before id= so data-flow-id does not
  false-positive
- Wrap SVG in .svg-canvas div; applyVp sizes canvas to (svgW x scale)
  so scrollbar range correctly reflects zoomed content
- .svg-wrap: overflow:auto -- native scrollbars when content > viewport
- Zoom via CSS scale on svg, pan via scrollLeft/scrollTop (scroll-anchored
  on cursor for wheel zoom)
- Thin styled scrollbars (dark + light theme)
- Print beforeprint/afterprint updated for canvas wrapper
2026-06-08 01:39:11 +02:00

1610 lines
63 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
from pathlib import Path
from codeflow.models.graph import CodeGraph
from codeflow.models.flow import Flow
from codeflow.models.edge import EdgeType
# ── Severity colours ──────────────────────────────────────────────────────────
_SEV_COLOR = {
"CRITICAL": "#e53e3e",
"HIGH": "#dd6b20",
"MEDIUM": "#d69e2e",
"LOW": "#68d391",
"INFO": "#718096",
}
# ── Embedded CSS ──────────────────────────────────────────────────────────────
EMBEDDED_CSS = """
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0b1120; color: #e2e8f0;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
}
/* ══════════════════════════════════════════════════════════
TOP BAR — logo + stats chips + actions
══════════════════════════════════════════════════════════ */
.topbar {
background: #111827;
border-bottom: 1px solid #1f2d40;
padding: 0 18px;
height: 52px;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.topbar-brand {
display: flex; align-items: center; gap: 9px; flex-shrink: 0;
}
.topbar-logo {
width: 28px; height: 28px; border-radius: 7px;
background: linear-gradient(135deg, #3b82f6 0%, #7c3aed 100%);
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 900; color: white; letter-spacing: -1px;
flex-shrink: 0;
}
.topbar-title {
font-size: 14px; font-weight: 700; color: #e2e8f0; white-space: nowrap;
}
.topbar-repo {
font-size: 12px; color: #475569; font-weight: 400;
}
.topbar-divider {
width: 1px; height: 24px; background: #1f2d40; flex-shrink: 0;
}
/* Stat chips in topbar */
.stat-chips { display: flex; gap: 6px; align-items: center; }
.stat-chip {
display: flex; align-items: center; gap: 5px;
background: #1e293b; border: 1px solid #2d3a50;
border-radius: 16px; padding: 3px 10px;
font-size: 11px; color: #94a3b8; white-space: nowrap;
}
.stat-chip .val { font-weight: 700; color: #e2e8f0; }
.stat-chip.danger .val { color: #fc8181; }
.stat-chip.warn .val { color: #fbd38d; }
.stat-chip.ok .val { color: #68d391; }
/* Action buttons */
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.act-btn {
display: flex; align-items: center; gap: 5px;
padding: 5px 11px; border-radius: 6px; cursor: pointer;
font-size: 11px; font-weight: 600; letter-spacing: .3px;
border: 1px solid #2d3a50; background: #1e293b; color: #94a3b8;
transition: all .14s; white-space: nowrap;
}
.act-btn:hover { background: #2d3a50; color: #e2e8f0; border-color: #4a5568; }
.act-btn.primary { background: #1d4ed8; border-color: #3b82f6; color: white; }
.act-btn.primary:hover { background: #2563eb; }
/* Search box in topbar */
#flow-search {
background: #1e293b; border: 1px solid #2d3a50; color: #e2e8f0;
border-radius: 6px; padding: 5px 11px; font-size: 12px; width: 180px;
outline: none; transition: border-color .14s;
}
#flow-search:focus { border-color: #3b82f6; background: #162032; }
#flow-search::placeholder { color: #334155; }
/* ══════════════════════════════════════════════════════════
FLOW NAV — scrollable pill strip
══════════════════════════════════════════════════════════ */
.flow-nav {
background: #0d1526;
border-bottom: 1px solid #1a2540;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 5px;
overflow-x: auto;
flex-shrink: 0;
scrollbar-width: thin;
scrollbar-color: #2d3a50 transparent;
}
.flow-nav::-webkit-scrollbar { height: 3px; }
.flow-nav::-webkit-scrollbar-track { background: transparent; }
.flow-nav::-webkit-scrollbar-thumb { background: #2d3a50; border-radius: 2px; }
.flow-pill {
display: flex; align-items: center; gap: 7px;
padding: 5px 13px 5px 9px;
border-radius: 8px;
border: 1px solid #1f2d40;
cursor: pointer; font-size: 12px; color: #4a5568;
background: #111827;
transition: all .14s; white-space: nowrap; flex-shrink: 0;
position: relative;
}
.flow-pill::before {
/* Left severity bar */
content: ''; display: block;
width: 3px; height: 20px; border-radius: 2px;
background: var(--sev-color, #334155);
flex-shrink: 0;
}
.flow-pill:hover {
background: #172033; border-color: #2d3a50; color: #94a3b8;
}
.flow-pill.active {
background: #1e293b; border-color: #3b4f6b;
color: #e2e8f0;
box-shadow: 0 2px 8px rgba(0,0,0,.5);
}
.flow-pill.active::before { opacity: 1; }
.method-chip {
display: inline-block; font-size: 9px; font-weight: 800;
padding: 1px 5px; border-radius: 3px; letter-spacing: .6px;
color: white; flex-shrink: 0;
}
.pill-name { font-weight: 500; }
.pill-sev {
font-size: 9px; font-weight: 700; letter-spacing: .4px;
padding: 1px 5px; border-radius: 3px;
color: white; background: var(--sev-color, #334155);
flex-shrink: 0; opacity: 0.9;
}
.flow-pill.active .pill-sev { opacity: 1; }
/* ══════════════════════════════════════════════════════════
MAIN LAYOUT
══════════════════════════════════════════════════════════ */
.main { display: flex; flex: 1; overflow: hidden; min-height: 0; }
/* overflow: hidden (not auto) is critical — lets flex children use calc'd height
so .svg-wrap { flex:1 } actually fills the remaining space instead of 0px */
.content { flex: 1; overflow: hidden; padding: 12px; display: flex; flex-direction: column; min-height: 0; }
.content-pane { display: none; flex-direction: column; flex: 1; min-height: 0; }
.content-pane.active { display: flex; }
/* ── Sidebar ── */
.sidebar {
width: 290px; background: #111827; border-left: 1px solid #1a2540;
overflow-y: auto; padding: 12px; flex-shrink: 0; display: flex;
flex-direction: column; gap: 12px;
}
.sidebar h3 { font-size: 13px; color: #64748b; margin-bottom: 6px; }
.detail-item { margin-bottom: 9px; }
.detail-label { font-size: 10px; color: #4a5568; text-transform: uppercase; letter-spacing: .5px; }
.detail-value { font-size: 12px; color: #e2e8f0; word-break: break-all; margin-top: 2px; }
.tainted-badge {
display: inline-block; background: #7b341e; color: #fed7aa;
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 10px; letter-spacing: .5px;
}
.sev-badge {
display: inline-block; color: white; font-size: 10px; font-weight: 700;
padding: 2px 10px; border-radius: 10px; letter-spacing: .5px;
}
/* Code snippet */
.code-snippet {
font-family: monospace; font-size: 11px; background: #0b1120;
border: 1px solid #1f2d40; border-radius: 4px; padding: 6px 0;
overflow-x: auto; margin-top: 4px; line-height: 1.5;
}
.code-line { display: flex; white-space: pre; }
.code-line.current { background: #1a150a; }
.line-num {
display: inline-block; min-width: 30px; text-align: right;
padding: 0 6px; color: #334155; user-select: none; flex-shrink: 0;
}
.line-code { color: #64748b; padding-right: 8px; }
.code-line.current .line-code { color: #f6ad55; }
/* Print node table — invisible on screen, rendered only in @media print */
.print-node-table { display: none; }
/* ── Legend ── */
.legend { border-top: 1px solid #1f2d40; padding-top: 12px; }
.legend-item {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: #64748b; margin-bottom: 7px;
}
.swatch {
display: inline-block; width: 18px; height: 12px;
border-radius: 3px; flex-shrink: 0;
}
.swatch-border {
display: inline-block; width: 18px; height: 12px;
border-radius: 3px; border: 2px solid #e53e3e; flex-shrink: 0;
}
.edge-line { display: inline-block; width: 24px; height: 2px; border-radius: 1px; flex-shrink: 0; }
.hint { font-size: 10px; color: #334155; line-height: 1.6; }
.sev-dot {
display: inline-block; width: 7px; height: 7px;
border-radius: 50%; flex-shrink: 0;
}
/* ── Stats strip (inside content pane) ── */
.stats { display: flex; gap: 8px; margin-bottom: 10px; flex-shrink: 0; flex-wrap: wrap; }
.stat-card {
background: #111827; padding: 8px 14px; border-radius: 6px;
border: 1px solid #1f2d40;
}
.stat-card .value { font-size: 20px; font-weight: 700; }
.stat-card .label { font-size: 11px; color: #4a5568; margin-top: 1px; }
/* Flow pane meta row */
.flow-meta { font-size: 12px; color: #4a5568; margin-bottom: 8px; flex-shrink: 0; }
.flow-meta span { margin-right: 14px; }
/* ── SVG wrapper ── */
.svg-wrap {
flex: 1; overflow: auto; position: relative;
border: 1px solid #1f2d40; border-radius: 0 0 6px 6px; background: #111827;
user-select: none; cursor: grab;
scrollbar-width: thin;
scrollbar-color: #2d3a50 transparent;
}
.svg-wrap::-webkit-scrollbar { width: 7px; height: 7px; }
.svg-wrap::-webkit-scrollbar-track { background: transparent; }
.svg-wrap::-webkit-scrollbar-thumb { background: #2d3a50; border-radius: 4px; }
.svg-wrap::-webkit-scrollbar-corner { background: transparent; }
/* Canvas div is sized to (svgW × scale) by JS so scroll range = zoomed content */
.svg-canvas { display: inline-block; position: relative; }
.svg-wrap svg { display: block; transform-origin: 0 0; }
/* ── SVG element fills — CSS-only, no presentation attributes in SVG markup ──
Dark values are the defaults; body.light overrides switch to light palette. */
/* Swim-lane bands */
.lane-sink { fill: #3b0a0a; }
.lane-entry { fill: #0a2018; }
.lane-input { fill: #261200; }
.lane-default { fill: #101820; }
/* Swim-lane labels */
.lane-lbl-sink { fill: #fc8181; }
.lane-lbl-entry { fill: #68d391; }
.lane-lbl-input { fill: #f6ad55; }
/* Overview module boxes */
.module-box-body { fill: #1e293b; stroke: #334155; }
.module-box-header { fill: #2d3748; }
.module-header { fill: #a0aec0; }
.module-name { fill: #e2e8f0; }
.module-file { fill: #718096; }
/* Overview entry-point text */
.ep-method { fill: #ffffff; }
.ep-route { fill: #e2e8f0; }
/* Flow-view node text */
.node-label { fill: #e2e8f0; } /* main wrapped label — inside node shape */
.badge-lbl { fill: #ffffff; } /* type badge text — on coloured pill */
.node-sub-sink { fill: #fc8181; } /* sink type subtitle */
.node-sub-src { fill: #f6ad55; } /* source input-type subtitle */
.node-sub-env { fill: #b794f4; } /* env-var subtitle */
/* ── Zoom toolbar ── */
.zoom-bar {
display: flex; gap: 4px; padding: 5px 8px; background: #111827;
border: 1px solid #1f2d40; border-bottom: none;
flex-shrink: 0; border-radius: 6px 6px 0 0; align-items: center;
}
.zoom-btn {
background: #1e293b; border: 1px solid #2d3a50; color: #94a3b8;
width: 26px; height: 26px; border-radius: 4px; cursor: pointer;
font-size: 14px; display: flex; align-items: center; justify-content: center;
transition: all .1s;
}
.zoom-btn:hover { background: #2d3a50; color: #e2e8f0; }
.zoom-label { color: #475569; font-size: 11px; padding: 0 6px; min-width: 36px; text-align: center; }
.zoom-hint { color: #2d3a50; font-size: 10px; margin-left: auto; }
h2 { font-size: 13px; margin-bottom: 6px; color: #64748b; flex-shrink: 0; font-weight: 500; }
/* ── Node interaction ── */
.node { cursor: move; }
.node:hover rect { stroke-width: 3.5 !important; }
.node:hover polygon { stroke-width: 3.5 !important; }
/* ── Taint-path highlighting ── */
.taint-active .node { opacity: 0.08; transition: opacity .18s; }
.taint-active .flow-edge { opacity: 0.04; transition: opacity .18s; }
.taint-active .node.taint-on { opacity: 1; }
.taint-active .flow-edge.taint-on { opacity: 1; }
/* ══════════════════════════════════════════════════════════
PRINT / PDF EXPORT — portrait A4, graph on top, node cards below
══════════════════════════════════════════════════════════ */
@media print {
@page { size: A4 portrait; margin: 12mm; }
/* Reset body / chrome to flat block flow */
body { background: #fff !important; color: #0f172a !important;
overflow: visible !important; height: auto !important;
display: block !important; }
.topbar, .flow-nav, .zoom-bar { display: none !important; }
/* Flatten the flex shell — each pane is a plain block */
.main { display: block !important; overflow: visible !important; }
.content { display: block !important; overflow: visible !important;
padding: 0 !important; }
.content-pane { display: none !important; }
.content-pane.active { display: block !important; }
/* Hide the interactive sidebar — we embed node detail cards inline */
.sidebar { display: none !important; }
/* Flow heading + meta */
h2 { color: #0f172a !important; font-size: 14px !important;
margin: 0 0 3px 0 !important; }
.flow-meta { color: #475569 !important; font-size: 10px !important;
margin-bottom: 4px !important; }
/* Graph area — fixed height, first page.
overflow:hidden clips the SVG to the container so nothing bleeds out.
The beforeprint JS resets canvas size + scale, sets the SVG
to width/height 100%, letting the viewBox scale the full graph to fit. */
.svg-wrap {
display: block !important; overflow: hidden !important;
height: 148mm !important; border: 1px solid #e2e8f0 !important;
background: #f8fafc !important; border-radius: 4px !important;
}
/* canvas must fill the wrap so SVG 100%/100% resolves correctly */
.svg-canvas { display: block !important; width: 100% !important; height: 100% !important; }
.svg-wrap svg { width: 100% !important; height: 100% !important;
display: block !important; transform: none !important; }
/* ── Print node table — shown below the graph ───────────────────────── */
.print-node-table {
display: block !important;
margin-top: 6mm !important;
}
.pnt-section-title {
font-size: 9px !important; font-weight: 700 !important;
letter-spacing: 1px !important; text-transform: uppercase !important;
color: #475569 !important; border-bottom: 1px solid #e2e8f0 !important;
padding-bottom: 2mm !important; margin-bottom: 4mm !important;
}
/* Single-column list of node cards */
.pnt-grid {
display: flex !important;
flex-direction: column !important;
gap: 3mm !important;
}
.pnt-card {
border: 1px solid #e2e8f0 !important; border-radius: 4px !important;
padding: 2.5mm !important; background: #fff !important;
page-break-inside: avoid !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact;
}
/* Badges row */
.pnt-badges { display: flex !important; gap: 1.5mm !important;
flex-wrap: wrap !important; margin-bottom: 1.5mm !important; }
.pnt-type, .pnt-tainted, .pnt-sev {
display: inline-block !important; padding: 1px 4px !important;
border-radius: 3px !important; font-size: 7px !important;
font-weight: 700 !important; color: #fff !important;
letter-spacing: 0.4px !important; text-transform: uppercase !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact;
}
.pnt-tainted { background: #7c3aed !important; }
/* node label */
.pnt-label { font-size: 9px !important; font-weight: 600 !important;
color: #0f172a !important; font-family: monospace !important;
margin-bottom: 1mm !important; word-break: break-all !important; }
/* file:line */
.pnt-file { font-size: 8px !important; color: #64748b !important;
margin-bottom: 1.5mm !important; }
/* extra metadata */
.pnt-meta { margin-bottom: 1.5mm !important; }
.pnt-meta-row { display: flex !important; gap: 2mm !important;
font-size: 8px !important; line-height: 1.4 !important; }
.pnt-meta-key { color: #64748b !important; min-width: 14mm !important;
flex-shrink: 0 !important; }
.pnt-meta-val { color: #0f172a !important; word-break: break-all !important; }
/* inline code snippet inside card */
.pnt-snippet {
font-size: 7px !important; font-family: monospace !important;
background: #f8fafc !important; border: 1px solid #e2e8f0 !important;
border-radius: 3px !important; padding: 1mm !important;
max-height: 20mm !important; overflow: hidden !important;
margin-top: 1mm !important; line-height: 1.4 !important;
}
.pnt-snippet .line-num { color: #94a3b8 !important; min-width: 18px !important;
font-size: 7px !important; }
.pnt-snippet .line-code { color: #334155 !important; font-size: 7px !important; }
.pnt-snippet .code-line.current .line-code { color: #b45309 !important; }
/* Regular code-snippet in sidebar — keep styled if ever shown */
.code-snippet {
background: #f8fafc !important; border: 1px solid #e2e8f0 !important;
font-size: 8px !important; overflow: hidden !important;
}
.line-num { color: #94a3b8 !important; }
.line-code { color: #334155 !important; }
.code-line.current .line-code { color: #b45309 !important; }
/* Suppress interactive-only chrome */
.legend { display: none !important; }
.hint { display: none !important; }
.sev-badge, .tainted-badge {
-webkit-print-color-adjust: exact; print-color-adjust: exact;
}
}
/* ══════════════════════════════════════════════════════════
LIGHT THEME (body.light overrides every dark colour)
══════════════════════════════════════════════════════════ */
body.light { background: #f0f4f8; color: #0f172a; }
body.light .topbar { background: #ffffff; border-color: #e2e8f0; }
body.light .topbar-title { color: #0f172a; }
body.light .topbar-repo { color: #64748b; }
body.light .topbar-divider { background: #e2e8f0; }
body.light .stat-chip { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
body.light .stat-chip .val { color: #0f172a; }
body.light .stat-chip.danger .val { color: #dc2626; }
body.light .stat-chip.warn .val { color: #b45309; }
body.light .stat-chip.ok .val { color: #16a34a; }
body.light .act-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
body.light .act-btn:hover { background: #e2e8f0; color: #0f172a; border-color: #94a3b8; }
body.light .act-btn.primary { background: #2563eb; border-color: #3b82f6; color: #fff; }
body.light .act-btn.primary:hover { background: #1d4ed8; }
body.light #flow-search { background: #f1f5f9; border-color: #e2e8f0; color: #0f172a; }
body.light #flow-search:focus { border-color: #3b82f6; background: #fff; }
body.light #flow-search::placeholder { color: #94a3b8; }
body.light .flow-nav { background: #e8edf5; border-color: #d1dae8; }
body.light .flow-pill { background: #ffffff; border-color: #d1dae8; color: #64748b; }
body.light .flow-pill:hover { background: #f8fafc; border-color: #94a3b8; color: #334155; }
body.light .flow-pill.active { background: #f0f4f8; border-color: #94a3b8; color: #0f172a;
box-shadow: 0 1px 4px rgba(0,0,0,.1); }
body.light .main { /* flex container, inherits */ }
body.light .content { /* flex container, inherits */ }
body.light .sidebar { background: #ffffff; border-color: #e2e8f0; }
body.light .sidebar h3 { color: #64748b; }
body.light .detail-label { color: #94a3b8; }
body.light .detail-value { color: #0f172a; }
body.light .zoom-bar { background: #ffffff; border-color: #e2e8f0; }
body.light .zoom-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
body.light .zoom-btn:hover { background: #e2e8f0; color: #0f172a; }
body.light .zoom-label { color: #94a3b8; }
body.light .zoom-hint { color: #cbd5e1; }
body.light .svg-wrap { background: #f8fafc; border-color: #e2e8f0;
scrollbar-color: #cbd5e1 transparent; }
body.light .svg-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; }
/* ── SVG light-mode overrides ──────────────────────────────────────────────
Node shapes use fill-opacity:0.22 — in dark mode the shapes are a faint
tint on a dark canvas, in light mode a faint tint on a white canvas.
Text that was white/light on dark becomes invisible on the near-white node.
ALL SVG text colours must therefore be flipped in light mode.
───────────────────────────────────────────────────────────────────────── */
/* Swim-lane bands → soft tinted backgrounds */
body.light .lane-sink { fill: #fee2e2; }
body.light .lane-entry { fill: #dcfce7; }
body.light .lane-input { fill: #fef3c7; }
body.light .lane-default { fill: #f1f5f9; }
/* Swim-lane label text → darker so readable on light bands */
body.light .lane-lbl-sink { fill: #b91c1c; }
body.light .lane-lbl-entry { fill: #15803d; }
body.light .lane-lbl-input { fill: #b45309; }
/* Overview module boxes → light card style */
body.light .module-box-body { fill: #ffffff; stroke: #e2e8f0; }
body.light .module-box-header { fill: #f1f5f9; }
/* Module text → dark so readable on now-light box */
body.light .module-header { fill: #94a3b8; }
body.light .module-name { fill: #1e293b; }
body.light .module-file { fill: #64748b; }
/* EP text → dark (box has fill-opacity:0.4 → light blended bg in light mode) */
body.light .ep-route { fill: #1e293b; }
body.light .ep-method { fill: #1e293b; }
/* Flow-view node labels: near-white text → dark on light canvas */
body.light .node-label { fill: #1e293b; }
/* Badge text: white → dark (badge pill is 60% opacity coloured bg — dark text
is readable on any hue at that opacity level) */
body.light .badge-lbl { fill: #1e293b; }
/* Subtitles: light accent colours → darker saturated equivalents */
body.light .node-sub-sink { fill: #dc2626; }
body.light .node-sub-src { fill: #b45309; }
body.light .node-sub-env { fill: #7c3aed; }
body.light h2 { color: #64748b; }
body.light .flow-meta { color: #94a3b8; }
body.light .stat-card { background: #ffffff; border-color: #e2e8f0; }
body.light .stat-card .label { color: #64748b; }
body.light .legend { border-color: #e2e8f0; }
body.light .legend-item { color: #64748b; }
body.light .hint { color: #94a3b8; }
body.light .code-snippet { background: #f8fafc; border-color: #e2e8f0; }
body.light .line-num { color: #94a3b8; }
body.light .line-code { color: #475569; }
body.light .code-line.current { background: #fffbeb; }
body.light .code-line.current .line-code { color: #b45309; }
/* Theme toggle button in topbar */
.theme-btn {
width: 28px; height: 28px; border-radius: 6px;
border: 1px solid #2d3a50; background: #1e293b;
color: #94a3b8; cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: all .14s; flex-shrink: 0;
}
.theme-btn:hover { background: #2d3a50; color: #e2e8f0; }
body.light .theme-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
body.light .theme-btn:hover { background: #e2e8f0; color: #0f172a; }
"""
# ── Embedded JavaScript ───────────────────────────────────────────────────────
EMBEDDED_JS = r"""
/* ─────────────────────────────────────────────────────────────────────────────
codeflow interactive viewer
──────────────────────────────────────────────────────────────────────────── */
/* ── 1. Viewport state (scale + pan per SVG) ────────────────────────────── */
var _vp = {};
function vp(id) {
if (!_vp[id]) _vp[id] = {s:1, drag:false, ox:0, oy:0, stx:0, sty:0};
return _vp[id];
}
/* Natural (unscaled) SVG dimensions from viewBox or width/height attrs */
function _svgNatural(svg) {
var vb = svg.viewBox && svg.viewBox.baseVal;
return {
w: vb && vb.width ? vb.width : parseFloat(svg.getAttribute('width') || '800'),
h: vb && vb.height ? vb.height : parseFloat(svg.getAttribute('height') || '600')
};
}
/*
* applyVp: apply zoom via CSS scale on the <svg> element, and size the
* .svg-canvas wrapper so the scroll range correctly reflects scaled content.
* Pan is handled entirely by scrollLeft/scrollTop on .svg-wrap.
*/
function applyVp(id) {
var v = vp(id), svg = document.getElementById(id);
if (!svg) return;
var nat = _svgNatural(svg);
/* Scale the SVG element itself (origin top-left) */
svg.style.transform = 'scale('+v.s+')';
svg.style.transformOrigin = '0 0';
/* Resize .svg-canvas so the scrollbar range equals the zoomed content */
var canvas = svg.parentElement;
if (canvas && canvas.classList.contains('svg-canvas')) {
canvas.style.width = Math.ceil(nat.w * v.s) + 'px';
canvas.style.height = Math.ceil(nat.h * v.s) + 'px';
}
var lbl = document.getElementById(id+'-zoom-label');
if (lbl) lbl.textContent = Math.round(v.s*100)+'%';
}
/* ── 2. Zoom controls ───────────────────────────────────────────────────── */
window.zoomIn = function(id){ var v=vp(id); v.s=Math.min(10,v.s+0.2); applyVp(id); };
window.zoomOut = function(id){ var v=vp(id); v.s=Math.max(0.05,v.s-0.2); applyVp(id); };
window.zoomReset = function(id){
var v=vp(id); v.s=1; applyVp(id);
var svg = document.getElementById(id);
var wrap = svg && svg.closest('.svg-wrap');
if (wrap) { wrap.scrollLeft = 0; wrap.scrollTop = 0; }
};
window.fitView = function(id){
var svg = document.getElementById(id);
var wrap = svg && svg.closest('.svg-wrap');
if (!svg || !wrap) return;
var nat = _svgNatural(svg);
if (!nat.w || !nat.h) return;
var ww = wrap.clientWidth, wh = wrap.clientHeight;
if (ww <= 0 || wh <= 0) return; /* pane not yet laid out — bail */
var sc = Math.min(ww / nat.w, wh / nat.h, 1.5);
vp(id).s = sc;
applyVp(id);
/* Centre by scrolling: if scaled content < wrap, scroll is 0 (clamped by browser) */
wrap.scrollLeft = Math.round((nat.w * sc - ww) / 2);
wrap.scrollTop = Math.round((nat.h * sc - wh) / 2);
};
/* ── 3. Mouse wheel zoom (anchor on cursor position) ────────────────────── */
window.handleWheel = function(ev) {
ev.preventDefault();
var wrap = ev.currentTarget;
var svg = wrap.querySelector('svg[id]');
if (!svg) return;
var id = svg.id, v = vp(id);
var rect = wrap.getBoundingClientRect();
/* cursor in scroll-space (accounts for current scroll position) */
var mx = ev.clientX - rect.left + wrap.scrollLeft;
var my = ev.clientY - rect.top + wrap.scrollTop;
/* cursor in natural SVG coords (undo current scale) */
var svgX = mx / v.s, svgY = my / v.s;
var step = (ev.ctrlKey || ev.metaKey) ? 0.20 : 0.10;
v.s = Math.max(0.05, Math.min(10, v.s + (ev.deltaY > 0 ? -step : step)));
applyVp(id);
/* scroll so the same SVG point stays under the cursor */
wrap.scrollLeft = Math.round(svgX * v.s - (ev.clientX - rect.left));
wrap.scrollTop = Math.round(svgY * v.s - (ev.clientY - rect.top));
};
/* ── 4. Node position + element cache ───────────────────────────────────── */
/*
* nodePos : nid → {x,y,w,h,dx,dy} layout coords + accumulated drag delta
* _nodeEls : nid → SVGElement direct ref — avoids CSS selector issues
* with IDs containing :: [ ] " etc.
*/
var nodePos = {};
var _nodeEls = {};
function initNodePositions() {
document.querySelectorAll('.node[data-x]').forEach(function(g) {
var nid = g.getAttribute('data-node-id');
if (!nid) return;
nodePos[nid] = {
x: parseFloat(g.getAttribute('data-x') || 0),
y: parseFloat(g.getAttribute('data-y') || 0),
w: parseFloat(g.getAttribute('data-w') || 180),
h: parseFloat(g.getAttribute('data-h') || 56),
dx: 0, dy: 0
};
_nodeEls[nid] = g;
});
}
/* ── 5. Active node-drag state ──────────────────────────────────────────── */
var _nd = null;
/* ── 6. Edge routing — simple cubic bezier (identical to Python _edge_path) */
function f1(n) { return Math.round(n*10)/10; }
function edgePath(p1, p2) {
var cy1 = p1.y + p1.h/2, cy2 = p2.y + p2.h/2;
/* Same layer → side-exit S-curve */
if (Math.abs(cy1 - cy2) < Math.max(p1.h, p2.h)) {
var x1=p1.x+p1.w, y1=cy1, x2=p2.x, y2=cy2, mx=(x1+x2)/2;
return 'M'+f1(x1)+','+f1(y1)+
' C'+f1(mx)+','+f1(y1)+' '+f1(mx)+','+f1(y2)+' '+f1(x2)+','+f1(y2);
}
/* Cross-layer → cubic bezier bottom-centre to top-centre */
var x1=p1.x+p1.w/2, y1=p1.y+p1.h, x2=p2.x+p2.w/2, y2=p2.y;
var dy=Math.abs(y2-y1)*0.5;
return 'M'+f1(x1)+','+f1(y1)+
' C'+f1(x1)+','+f1(y1+dy)+' '+f1(x2)+','+f1(y2-dy)+' '+f1(x2)+','+f1(y2);
}
/* ── 7. Recompute edges for a moved node ────────────────────────────────── */
function updateEdgesForNode(nid) {
/* Scope search to the node's own SVG to avoid cross-tab collisions */
var g = _nodeEls[nid];
var svg = g && g.closest('svg');
if (!svg) return;
svg.querySelectorAll('.flow-edge').forEach(function(path) {
var src = path.getAttribute('data-src');
var tgt = path.getAttribute('data-tgt');
if (src !== nid && tgt !== nid) return;
var ps = nodePos[src], pt = nodePos[tgt];
if (!ps || !pt) return;
path.setAttribute('d', edgePath(
{x: ps.x+ps.dx, y: ps.y+ps.dy, w: ps.w, h: ps.h},
{x: pt.x+pt.dx, y: pt.y+pt.dy, w: pt.w, h: pt.h}
));
});
}
/* Global handlers — live as long as a node drag is in progress */
function _onGlobalMove(ev) {
if (!_nd) { document.removeEventListener('mousemove', _onGlobalMove); return; }
var nd = _nd;
var scale = vp(nd.svgId).s || 1;
var ddx = (ev.clientX - nd.startX) / scale;
var ddy = (ev.clientY - nd.startY) / scale;
if (!nd.moved && (Math.abs(ddx) > 3 || Math.abs(ddy) > 3)) nd.moved = true;
if (!nd.moved) return;
var np = nodePos[nd.nid];
if (!np) return;
np.dx = nd.startDx + ddx;
np.dy = nd.startDy + ddy;
/* Shapes inside <g> are at absolute SVG coords; translate the group by the
accumulated delta — this correctly layers on top of the layout position. */
var g = _nodeEls[nd.nid];
if (g) g.setAttribute('transform', 'translate('+f1(np.dx)+','+f1(np.dy)+')');
updateEdgesForNode(nd.nid);
}
function _onGlobalUp() {
document.removeEventListener('mousemove', _onGlobalMove);
if (_nd) {
if (!_nd.moved) {
showNodeDetail(_nd.nid);
highlightTaintChain(_nd.nid);
}
_nd = null;
}
}
/* ── 8. Pan mouse handlers (scroll-based) ───────────────────────────────── */
window.handleMouseDown = function(ev) {
if (ev.button !== 0) return;
ev.preventDefault();
var ng = ev.target.closest('.node[data-node-id]');
if (ng) {
var nid = ng.getAttribute('data-node-id');
if (nodePos[nid]) {
var svgEl = ng.closest('svg');
_nd = {
nid: nid, svgId: svgEl ? svgEl.id : '',
startX: ev.clientX, startY: ev.clientY,
startDx: nodePos[nid].dx,
startDy: nodePos[nid].dy,
moved: false
};
document.addEventListener('mousemove', _onGlobalMove);
document.addEventListener('mouseup', _onGlobalUp, {once: true});
return;
}
}
/* Background → clear highlight + start scroll-pan */
clearHighlight();
var wrap = ev.currentTarget;
var svg = wrap.querySelector('svg[id]');
if (!svg) return;
var v = vp(svg.id);
v.drag = true;
v.ox = ev.clientX; v.oy = ev.clientY;
v.stx = wrap.scrollLeft; v.sty = wrap.scrollTop;
wrap.style.cursor = 'grabbing';
};
window.handleMouseMove = function(ev) {
if (_nd) return;
var wrap = ev.currentTarget;
var svg = wrap.querySelector('svg[id]');
if (!svg) return;
var v = vp(svg.id);
if (!v.drag) return;
/* Scroll the wrapper — drag right = content moves right = scrollLeft decreases */
wrap.scrollLeft = v.stx - (ev.clientX - v.ox);
wrap.scrollTop = v.sty - (ev.clientY - v.oy);
};
window.handleMouseUp = function(ev) {
if (_nd) return;
var wrap = ev.currentTarget;
var svg = wrap.querySelector('svg[id]');
if (svg) vp(svg.id).drag = false;
wrap.style.cursor = 'grab';
};
/* ── 9. Taint-path highlighting ─────────────────────────────────────────── */
var _highlightActive = false;
function highlightTaintChain(nid) {
var data = nodeData[nid];
if (!data) return;
/* Use cached element reference — no CSS selector needed */
var g = _nodeEls[nid];
var svg = g && g.closest('svg');
if (!svg) return;
var flowId = svg.getAttribute('data-flow-id') || '';
var fEdges = flowEdges[flowId] || [];
/* Build bi-directional adjacency for CALLS / DATA_FLOW / TAINTED_FLOW */
var fwd = {}, bwd = {};
fEdges.forEach(function(e) {
if (!fwd[e.src]) fwd[e.src] = [];
fwd[e.src].push(e.tgt);
if (!bwd[e.tgt]) bwd[e.tgt] = [];
bwd[e.tgt].push(e.src);
});
/* BFS outward from clicked node, only through tainted nodes */
var visited = {}; visited[nid] = true;
var queue = [nid];
while (queue.length) {
var cur = queue.shift();
var neighbours = (fwd[cur] || []).concat(bwd[cur] || []);
neighbours.forEach(function(n) {
if (visited[n]) return;
var nd2 = nodeData[n];
if (nd2 && (nd2.tainted || n === nid)) {
visited[n] = true;
queue.push(n);
}
});
}
/* Always include direct neighbours even if not tainted, so the path is visible */
(fwd[nid] || []).forEach(function(n) { visited[n] = true; });
(bwd[nid] || []).forEach(function(n) { visited[n] = true; });
/* Apply classes */
_highlightActive = true;
svg.classList.add('taint-active');
svg.querySelectorAll('.node').forEach(function(el) {
el.classList.toggle('taint-on', !!visited[el.getAttribute('data-node-id')]);
});
svg.querySelectorAll('.flow-edge').forEach(function(el) {
var on = !!(visited[el.getAttribute('data-src')] && visited[el.getAttribute('data-tgt')]);
el.classList.toggle('taint-on', on);
});
}
function clearHighlight() {
if (!_highlightActive) return;
document.querySelectorAll('.taint-active').forEach(function(svg) {
svg.classList.remove('taint-active');
svg.querySelectorAll('.taint-on').forEach(function(el) {
el.classList.remove('taint-on');
});
});
_highlightActive = false;
}
/* ── 10. Tab switching & flow search ────────────────────────────────────── */
function showTab(tabId) {
document.querySelectorAll('.flow-pill').forEach(function(b) {
b.classList.toggle('active', b.getAttribute('data-tab') === tabId);
});
document.querySelectorAll('.content-pane').forEach(function(p) {
p.classList.toggle('active', p.id === tabId);
});
/* Fit the newly-revealed SVG — double-RAF waits for the browser to reflow
the pane from display:none → display:flex before measuring clientHeight */
var pane = document.getElementById(tabId);
if (pane) {
var svg = pane.querySelector('svg[id]');
if (svg) {
var svgId = svg.id;
requestAnimationFrame(function() {
requestAnimationFrame(function() { fitView(svgId); });
});
}
}
clearHighlight();
}
window.filterFlows = function(q) {
q = q.trim().toLowerCase();
document.querySelectorAll('.flow-pill').forEach(function(b) {
var match = !q || b.textContent.toLowerCase().indexOf(q) !== -1;
b.style.display = match ? '' : 'none';
});
};
/* ── 10b. Export ─────────────────────────────────────────────────────────── */
window.exportPDF = function() {
window.print();
};
/* ── 10c. Light / dark theme toggle ─────────────────────────────────────── */
window.toggleTheme = function() {
var isLight = document.body.classList.toggle('light');
try { localStorage.setItem('cf-theme', isLight ? 'light' : 'dark'); } catch(e){}
var btn = document.getElementById('theme-toggle-btn');
if (btn) btn.textContent = isLight ? '🌙' : '☀️';
};
window.exportSVG = function() {
var pane = document.querySelector('.content-pane.active');
if (!pane) return;
var svg = pane.querySelector('svg');
if (!svg) return;
var data = new XMLSerializer().serializeToString(svg);
var blob = new Blob([data], {type: 'image/svg+xml'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = (pane.id || 'codeflow') + '.svg';
document.body.appendChild(a);
a.click();
setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 600);
};
/* ── 11. Node detail panel ──────────────────────────────────────────────── */
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function detail(label, value) {
return '<div class="detail-item">'
+ '<div class="detail-label">'+esc(label)+'</div>'
+ '<div class="detail-value">'+esc(String(value))+'</div>'
+ '</div>';
}
function showNodeDetail(nid) {
var data = nodeData[nid];
if (!data) return;
var sev = (data.metadata && data.metadata.severity) || '';
var sevColor = {CRITICAL:'#e53e3e',HIGH:'#dd6b20',MEDIUM:'#d69e2e',LOW:'#68d391',INFO:'#718096'}[sev] || '#718096';
var out = '<h3 style="margin-bottom:10px">Node Details</h3>';
/* severity + taint badges */
if (sev && sev !== 'INFO') {
out += '<div class="detail-item"><span class="sev-badge" style="background:'+sevColor+'">'+esc(sev)+'</span></div>';
}
if (data.tainted) {
out += '<div class="detail-item"><span class="tainted-badge">TAINTED</span></div>';
}
out += detail('Type', data.type);
out += detail('Label', data.label);
/* file + line */
if (data.file) {
var fname = data.file.replace(/\\/g,'/').split('/').pop();
out += detail('File', fname + (data.line ? ':'+data.line : ''));
}
/* metadata (skip severity since we show it as badge) */
var meta = data.metadata || {};
Object.keys(meta).forEach(function(k) {
if (k === 'severity') return;
out += detail(k, meta[k]);
});
/* code snippet */
var snip = data.snippet;
if (snip && snip.length) {
out += '<div class="detail-item"><div class="detail-label">Source</div>';
out += '<div class="code-snippet">';
snip.forEach(function(ln) {
var cur = (ln.n === data.line) ? ' current' : '';
out += '<div class="code-line'+cur+'">'
+ '<span class="line-num">'+ln.n+'</span>'
+ '<span class="line-code">'+esc(ln.code)+'</span>'
+ '</div>';
});
out += '</div></div>';
}
if (data.tainted) {
out += '<div class="hint" style="margin-top:8px">Click node to highlight taint path. Click background to clear.</div>';
}
document.getElementById('sidebar-node').innerHTML = out;
}
/* ── 12. Init ───────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', function() {
/* Restore saved theme */
try {
if (localStorage.getItem('cf-theme') === 'light') {
document.body.classList.add('light');
var btn = document.getElementById('theme-toggle-btn');
if (btn) btn.textContent = '🌙';
}
} catch(e){}
/* Activate first pill */
var firstPill = document.querySelector('.flow-pill');
if (firstPill) showTab(firstPill.getAttribute('data-tab'));
/* Pill click events */
document.querySelectorAll('.flow-pill').forEach(function(btn) {
btn.addEventListener('click', function() {
showTab(this.getAttribute('data-tab'));
});
});
/* Search box */
var searchBox = document.getElementById('flow-search');
if (searchBox) searchBox.addEventListener('input', function() { filterFlows(this.value); });
/* Initialise node positions from SVG data attributes */
initNodePositions();
/* Fit the initially-visible SVG — double-RAF ensures layout is complete */
document.querySelectorAll('.content-pane.active svg[id]').forEach(function(svg) {
var svgId = svg.id;
requestAnimationFrame(function() {
requestAnimationFrame(function() { fitView(svgId); });
});
});
});
/* ── 13. Print: fit full graph into page without clipping ────────────────────
Architecture recap:
• Pan/zoom → svg.style.transform 'translate(tx,ty) scale(s)' (applyVp)
• Node drag → node <g>.transform 'translate(dx,dy)' (drag)
• Lane bands → x=-9999 width=99999 (must be excluded from bbox)
Fix: clear the CSS pan/zoom, recompute viewBox from node data attrs + their
individual drag offsets, then hand width/height control to CSS. */
window.addEventListener('beforeprint', function() {
document.querySelectorAll('.svg-wrap svg[id]').forEach(function(svg) {
/* Save state */
svg.dataset.printW = svg.getAttribute('width') || '';
svg.dataset.printH = svg.getAttribute('height') || '';
svg.dataset.printVB = svg.getAttribute('viewBox') || '';
svg.dataset.printTr = svg.style.transform || '';
/* 1. Clear CSS scale so SVG renders at natural viewBox origin */
svg.style.transform = '';
svg.style.transformOrigin = '';
/* 2. Also reset the canvas size so it doesn't interfere with print layout */
var canvas = svg.parentElement;
if (canvas && canvas.classList.contains('svg-canvas')) {
svg.dataset.printCanvasW = canvas.style.width || '';
svg.dataset.printCanvasH = canvas.style.height || '';
canvas.style.width = '';
canvas.style.height = '';
}
/* 3. Compute tight viewBox from node positions + drag offsets.
Lane bands have x=-9999 — skip them by selecting only g.node. */
var nodes = svg.querySelectorAll('g.node[data-x]');
var pad = 30;
if (nodes.length > 0) {
var minX = Infinity, minY = Infinity;
var maxX = -Infinity, maxY = -Infinity;
nodes.forEach(function(g) {
var nx = parseFloat(g.getAttribute('data-x') || '0');
var ny = parseFloat(g.getAttribute('data-y') || '0');
var nw = parseFloat(g.getAttribute('data-w') || '0');
var nh = parseFloat(g.getAttribute('data-h') || '0');
var dx = 0, dy = 0;
var m = (g.getAttribute('transform') || '')
.match(/translate\(\s*([+-]?[\d.]+)\s*,\s*([+-]?[\d.]+)\s*\)/);
if (m) { dx = parseFloat(m[1]); dy = parseFloat(m[2]); }
minX = Math.min(minX, nx + dx);
minY = Math.min(minY, ny + dy);
maxX = Math.max(maxX, nx + dx + nw);
maxY = Math.max(maxY, ny + dy + nh);
});
if (maxX > minX && maxY > minY) {
svg.setAttribute('viewBox',
(minX-pad)+' '+(minY-pad)+' '+
(maxX-minX+pad*2)+' '+(maxY-minY+pad*2));
}
}
/* 4. Let CSS (@media print) control the rendered size */
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
});
});
window.addEventListener('afterprint', function() {
document.querySelectorAll('.svg-wrap svg[id]').forEach(function(svg) {
/* Restore CSS scale */
svg.style.transform = svg.dataset.printTr || '';
svg.style.transformOrigin = svg.dataset.printTr ? '0 0' : '';
/* Restore canvas size */
var canvas = svg.parentElement;
if (canvas && canvas.classList.contains('svg-canvas')) {
canvas.style.width = svg.dataset.printCanvasW || '';
canvas.style.height = svg.dataset.printCanvasH || '';
delete svg.dataset.printCanvasW;
delete svg.dataset.printCanvasH;
}
/* Restore SVG attributes */
if (svg.dataset.printVB) svg.setAttribute('viewBox', svg.dataset.printVB);
if (svg.dataset.printW) svg.setAttribute('width', svg.dataset.printW);
if (svg.dataset.printH) svg.setAttribute('height', svg.dataset.printH);
delete svg.dataset.printW;
delete svg.dataset.printH;
delete svg.dataset.printVB;
delete svg.dataset.printTr;
});
});
"""
# ── Legend HTML ───────────────────────────────────────────────────────────────
_LEGEND_HTML = """
<div class="legend">
<h3>Legend — Nodes</h3>
<div class="legend-item">
<span class="swatch" style="background:#c05621;opacity:.35;border:1.5px solid #c05621"></span>
User Input (SOURCE ◇)
</div>
<div class="legend-item">
<span class="swatch" style="background:#276749;opacity:.35;border:1.5px solid #276749"></span>
Entry Point
</div>
<div class="legend-item">
<span class="swatch" style="background:#2b6cb0;opacity:.35;border:1.5px solid #2b6cb0"></span>
Function
</div>
<div class="legend-item">
<span class="swatch" style="background:#9b2c2c;opacity:.35;border:1.5px solid #9b2c2c"></span>
Dangerous Sink ⬡
</div>
<div class="legend-item">
<span class="swatch" style="background:#2c5282;opacity:.35;border:1.5px solid #2c5282"></span>
Parameter ▱
</div>
<div class="legend-item">
<span class="swatch-border"></span>
Tainted node (red border)
</div>
<div class="legend-item">
<span class="swatch" style="background:#4a5568;opacity:.35;border:1.5px dashed #4a5568"></span>
Sanitizer / Boundary
</div>
<div style="margin:8px 0 4px;font-size:10px;color:#4a5568;text-transform:uppercase;letter-spacing:.5px">Edges</div>
<div class="legend-item">
<span class="edge-line" style="background:#718096;opacity:.5"></span>
Function call
</div>
<div class="legend-item">
<span class="edge-line" style="background:#dd6b20"></span>
Data flow
</div>
<div class="legend-item">
<span class="edge-line" style="background:#e53e3e"></span>
Tainted flow
</div>
<div style="margin:8px 0 4px;font-size:10px;color:#4a5568;text-transform:uppercase;letter-spacing:.5px">Severity</div>
<div class="legend-item"><span class="sev-dot" style="background:#e53e3e"></span> CRITICAL</div>
<div class="legend-item"><span class="sev-dot" style="background:#dd6b20"></span> HIGH</div>
<div class="legend-item"><span class="sev-dot" style="background:#d69e2e"></span> MEDIUM</div>
<div class="legend-item"><span class="sev-dot" style="background:#68d391"></span> LOW</div>
<div style="margin:10px 0 4px"></div>
<div class="hint">
🖱 Scroll to zoom &nbsp;·&nbsp; Drag background to pan<br>
✋ Drag any node to reposition it<br>
🖱 Click node → details + taint highlight<br>
🖱 Click background → clear highlight<br>
⬛ Fit button → auto-scale to window
</div>
</div>"""
# ── Helpers ───────────────────────────────────────────────────────────────────
def _tag_svg_id(svg: str, svg_id: str) -> str:
# Check only the opening <svg ...> tag for a *standalone* id= attribute.
# Must be preceded by whitespace to avoid matching data-*-id= attributes
# (e.g. data-flow-id in flow SVGs would false-positive the old regex).
m = re.match(r'<svg\b[^>]*>', svg, re.DOTALL)
if m and re.search(r'\sid\s*=', m.group()):
return svg
return svg.replace("<svg", f'<svg id="{svg_id}"', 1)
def _zoom_bar(svg_id: str) -> str:
return (
f'<div class="zoom-bar">'
f'<button class="zoom-btn" onclick="zoomIn(\'{svg_id}\')" title="Zoom in">+</button>'
f'<button class="zoom-btn" onclick="zoomOut(\'{svg_id}\')" title="Zoom out">&minus;</button>'
f'<button class="zoom-btn" onclick="zoomReset(\'{svg_id}\')" title="Reset zoom (1:1)">&#x21BA;</button>'
f'<button class="zoom-btn" onclick="fitView(\'{svg_id}\')" title="Fit to window" style="font-size:11px">&#x26F6;</button>'
f'<span class="zoom-label" id="{svg_id}-zoom-label">100%</span>'
f'<span class="zoom-hint">scroll = zoom &nbsp;·&nbsp; drag bg = pan &nbsp;·&nbsp; drag node = move</span>'
f'</div>'
)
def _svg_wrap(svg: str) -> str:
# .svg-wrap — overflow:auto container with scrollbars + drag-to-pan
# .svg-canvas — sized by JS to (svgW × scale) so scroll range = zoomed content
return (
'<div class="svg-wrap"'
' onwheel="handleWheel(event)"'
' onmousedown="handleMouseDown(event)"'
' onmousemove="handleMouseMove(event)"'
' onmouseup="handleMouseUp(event)"'
' onmouseleave="handleMouseUp(event)">'
'<div class="svg-canvas">'
+ svg +
'</div>'
'</div>'
)
def _get_snippet(file_path: str, line_start: int, context: int = 3) -> list[dict]:
"""Extract source lines around line_start for the sidebar code panel."""
if not file_path or not line_start:
return []
try:
p = Path(file_path)
if not p.exists() or p.stat().st_size > 500_000:
return []
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
lo = max(0, line_start - context - 1)
hi = min(len(lines), line_start + context)
return [{"n": lo + i + 1, "code": lines[lo + i]} for i in range(hi - lo)]
except Exception:
return []
def _compute_stats(graph: CodeGraph, flows: list[Flow]) -> dict:
all_nodes = graph.nodes()
tainted_flows = sum(1 for f in flows if f.severity in ("CRITICAL", "HIGH", "MEDIUM"))
return {
"nodes": len(all_nodes),
"entries": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "ENTRY_POINT"),
"sources": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "SOURCE"),
"sinks": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "SINK"),
"tainted": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.is_tainted),
"flows": len(flows),
"tainted_flows": tainted_flows,
"edges": graph.edge_count(),
}
def _node_data_json(graph: CodeGraph) -> str:
data = {}
for nid in graph.nodes():
node = graph.get_node(nid)
if node:
data[node.id] = {
"type": node.node_type.value,
"label": node.label,
"file": node.file_path,
"line": node.line_start,
"tainted": node.is_tainted,
"snippet": _get_snippet(node.file_path, node.line_start),
"metadata": {
k: v for k, v in node.metadata.items()
if isinstance(v, (str, int, float, bool))
},
}
return json.dumps(data)
def _flow_edges_json(graph: CodeGraph, flows: list[Flow]) -> str:
"""Build per-flow edge list for taint-path highlighting in JS."""
_TAINT_TYPES = {EdgeType.CALLS, EdgeType.DATA_FLOW, EdgeType.TAINTED_FLOW}
result: dict[str, list[dict]] = {}
for flow in flows:
flow_set = set(flow.node_ids)
edges = []
for u, v, edge in graph.edges():
if edge.edge_type in _TAINT_TYPES and u in flow_set and v in flow_set:
edges.append({"src": u, "tgt": v, "type": edge.edge_type.value})
result[flow.id] = edges
return json.dumps(result)
# ── HTTP method colours (for pill chips) ─────────────────────────────────────
_METHOD_COLOR = {
"GET": "#276749", # green
"POST": "#1d4ed8", # blue
"PUT": "#b45309", # amber
"DELETE": "#9b1c1c", # red
"PATCH": "#5b21b6", # violet
}
# ── Print node table ─────────────────────────────────────────────────────────
# Colour map for node-type badges in the print cards
_PRINT_TYPE_COLOR = {
"ENTRY_POINT": "#2563eb",
"SOURCE": "#d97706",
"ENV_VAR": "#059669",
"SINK": "#dc2626",
"FUNCTION": "#7c3aed",
"MODULE": "#475569",
}
# Sort order: entry → inputs → functions/modules → sinks
_PRINT_TYPE_ORDER = {
"ENTRY_POINT": 0,
"SOURCE": 1,
"ENV_VAR": 2,
"FUNCTION": 3,
"MODULE": 4,
"SINK": 5,
}
def _print_node_table(graph: CodeGraph, flow: Flow) -> str:
"""
Build a hidden-on-screen, print-only div that lists every node in the flow
as a compact card (type badge, label, file:line, metadata, code snippet).
Injected into each flow pane; shown only via @media print CSS.
"""
import html as _html
sorted_nids = sorted(
flow.node_ids,
key=lambda nid: _PRINT_TYPE_ORDER.get(
graph.get_node(nid).node_type.name if graph.get_node(nid) else "", 99
),
)
cards: list[str] = []
for nid in sorted_nids:
node = graph.get_node(nid)
if not node:
continue
type_name = node.node_type.name
type_color = _PRINT_TYPE_COLOR.get(type_name, "#475569")
sev = node.metadata.get("severity", "")
sev_color = _SEV_COLOR.get(sev, "#718096")
# File + line label
fname = ""
if node.file_path:
fname = node.file_path.replace("\\", "/").split("/")[-1]
if node.line_start:
fname += f":{node.line_start}"
# ── Badges ──
type_lbl = type_name.replace("_", " ")
badges = (
f'<span class="pnt-type" style="background:{type_color}">'
f'{_html.escape(type_lbl)}</span>'
)
if node.is_tainted:
badges += '<span class="pnt-tainted">TAINTED</span>'
if sev and sev != "INFO":
badges += (
f'<span class="pnt-sev" style="background:{sev_color}">'
f'{_html.escape(sev)}</span>'
)
# ── Metadata rows ──
meta_rows = ""
for k, v in node.metadata.items():
if k == "severity" or not isinstance(v, (str, int, float, bool)):
continue
meta_rows += (
f'<div class="pnt-meta-row">'
f'<span class="pnt-meta-key">{_html.escape(str(k))}</span>'
f'<span class="pnt-meta-val">{_html.escape(str(v))}</span>'
f'</div>'
)
# ── Code snippet ──
snippet_html = ""
snip = _get_snippet(node.file_path, node.line_start, context=3)
if snip:
lines_html = ""
for ln in snip:
cur_cls = " current" if ln["n"] == node.line_start else ""
lines_html += (
f'<div class="code-line{cur_cls}">'
f'<span class="line-num">{ln["n"]}</span>'
f'<span class="line-code">{_html.escape(ln["code"])}</span>'
f'</div>'
)
snippet_html = f'<div class="pnt-snippet">{lines_html}</div>'
# ── Assemble card ──
card = (
f'<div class="pnt-card">'
f'<div class="pnt-badges">{badges}</div>'
f'<div class="pnt-label">{_html.escape(node.label)}</div>'
)
if fname:
card += f'<div class="pnt-file">{_html.escape(fname)}</div>'
if meta_rows:
card += f'<div class="pnt-meta">{meta_rows}</div>'
card += snippet_html
card += '</div>'
cards.append(card)
if not cards:
return ""
return (
'<div class="print-node-table">'
'<div class="pnt-section-title">Node Details</div>'
'<div class="pnt-grid">'
+ "".join(cards)
+ '</div></div>'
)
# ── Main builder ──────────────────────────────────────────────────────────────
def build_html(
graph: CodeGraph,
flows: list[Flow],
flow_svgs: dict[str, str],
overview_svg: str,
repo_name: str = "repository",
) -> str:
import random
stats = _compute_stats(graph, flows)
node_data_json = _node_data_json(graph)
flow_edges_js = _flow_edges_json(graph, flows)
# ── Overview pane ──
ov_svg_id = "svg-overview"
ov_svg = _tag_svg_id(overview_svg, ov_svg_id)
ov_pane = (
'<div class="content-pane active" id="overview">'
'<h2>Overview — all flows</h2>'
+ _zoom_bar(ov_svg_id)
+ _svg_wrap(ov_svg)
+ '</div>'
)
# Overview pill (no severity bar, no method chip)
pills = '<button class="flow-pill active" data-tab="overview" style="--sev-color:#475569">'
pills += '<span class="pill-name">Overview</span></button>\n'
tab_panes = ov_pane + "\n"
# ── Flow pills + panes ──
for flow in flows:
safe_id = "".join(
c for c in flow.id if c.isalnum() or c in "-_"
).strip() or f"flow_{random.randint(0, 9999)}"
tab_id = f"flow-{safe_id}"
svg_id = f"svg-{safe_id}"
svg = _tag_svg_id(flow_svgs.get(flow.id, ""), svg_id)
sev = flow.severity
sev_color = _SEV_COLOR.get(sev, "#475569")
# HTTP method from entry node metadata
http_method = ""
if flow.entry_node_id:
entry_nd = graph.get_node(flow.entry_node_id)
if entry_nd:
http_method = (
entry_nd.metadata.get("http_method")
or entry_nd.metadata.get("method")
or ""
).upper()
method_chip_html = ""
if http_method and http_method in _METHOD_COLOR:
mc = _METHOD_COLOR[http_method]
method_chip_html = (
f'<span class="method-chip" style="background:{mc}">{http_method}</span>'
)
sev_chip_html = ""
if sev not in ("INFO", ""):
sev_chip_html = (
f'<span class="pill-sev" style="--sev-color:{sev_color}">{sev}</span>'
)
pills += (
f'<button class="flow-pill" data-tab="{tab_id}"'
f' style="--sev-color:{sev_color}">'
f'{method_chip_html}'
f'<span class="pill-name">{flow.name}</span>'
f'{sev_chip_html}'
f'</button>\n'
)
# Pane meta row
meta_parts = []
if flow.source_node_ids:
meta_parts.append(f'<span>Inputs: {len(flow.source_node_ids)}</span>')
if flow.sink_node_ids:
meta_parts.append(f'<span>Sinks: {len(flow.sink_node_ids)}</span>')
if flow.tainted_node_ids:
meta_parts.append(f'<span style="color:#fc8181">Tainted: {len(flow.tainted_node_ids)}</span>')
if sev != "INFO":
meta_parts.append(
f'<span style="color:{sev_color};font-weight:600">Severity: {sev}</span>'
)
meta_html = f'<div class="flow-meta">{"".join(meta_parts)}</div>' if meta_parts else ""
pane = (
f'<div class="content-pane" id="{tab_id}">'
f'<h2>{flow.name}</h2>'
+ meta_html
+ _zoom_bar(svg_id)
+ _svg_wrap(svg)
+ _print_node_table(graph, flow)
+ '</div>'
)
tab_panes += pane + "\n"
# ── Top-bar stat chips ──
def _chip(val: str, label: str, cls: str = "") -> str:
return (
f'<div class="stat-chip {cls}">'
f'<span class="val">{val}</span> {label}'
f'</div>'
)
tainted_cls = "danger" if stats["tainted_flows"] > 0 else "ok"
chips_html = (
'<div class="stat-chips">'
+ _chip(str(stats["flows"]), "flows")
+ _chip(str(stats["tainted_flows"]), "findings", tainted_cls)
+ _chip(str(stats["entries"]), "entries", "ok")
+ _chip(str(stats["sources"]), "sources", "warn")
+ _chip(str(stats["sinks"]), "sinks", "danger")
+ _chip(str(stats["nodes"]), "nodes")
+ '</div>'
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>codeflow &mdash; {repo_name}</title>
<style>{EMBEDDED_CSS}</style>
</head>
<body>
<!-- ══ TOP BAR ══════════════════════════════════════════════════════════ -->
<div class="topbar">
<div class="topbar-brand">
<div class="topbar-logo">CF</div>
<div>
<div class="topbar-title">codeflow</div>
</div>
</div>
<div class="topbar-divider"></div>
<span class="topbar-repo">{repo_name}</span>
<div class="topbar-divider"></div>
{chips_html}
<div class="topbar-actions">
<input type="search" id="flow-search" placeholder="&#128269; filter&hellip;" autocomplete="off">
<button class="act-btn" onclick="exportSVG()" title="Export active flow as SVG">&#x2193; SVG</button>
<button class="act-btn primary" onclick="exportPDF()" title="Print / Save as PDF">&#x1F5A8; PDF</button>
<button class="theme-btn" id="theme-toggle-btn" onclick="toggleTheme()" title="Toggle light / dark theme">&#x2600;&#xFE0F;</button>
</div>
</div>
<!-- ══ FLOW NAV ═════════════════════════════════════════════════════════ -->
<div class="flow-nav">
{pills}
</div>
<!-- ══ MAIN ═════════════════════════════════════════════════════════════ -->
<div class="main">
<div class="content">
{tab_panes}
</div>
<div class="sidebar">
<div id="sidebar-node">
<h3>Node Details</h3>
<p class="hint" style="margin-top:6px">Click any node to inspect it.<br>Drag nodes to rearrange the layout.</p>
</div>
{_LEGEND_HTML}
</div>
</div>
<script>
var nodeData = {node_data_json};
var flowEdges = {flow_edges_js};
{EMBEDDED_JS}
</script>
</body>
</html>"""