feat: add navigation funnel graph page (#1701)

This commit is contained in:
Konsti Wohlwend 2026-06-30 23:04:22 -07:00 committed by GitHub
parent c9612539c7
commit 244fc3ab7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1014 additions and 6 deletions

View File

@ -22,16 +22,21 @@
},
"dependencies": {
"@ai-sdk/react": "^3.0.72",
"@pierre/diffs": "^1.2.11",
"@assistant-ui/react": "^0.10.24",
"@assistant-ui/react-markdown": "^0.10.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hexclave/dashboard-ui-components": "workspace:*",
"@hexclave/next": "workspace:*",
"@hexclave/shared": "workspace:*",
"@hexclave/shared-backend": "workspace:*",
"@hexclave/ui": "workspace:*",
"@hookform/resolvers": "^3.3.4",
"@monaco-editor/react": "4.7.0",
"@oslojs/otp": "^1.1.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.11",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
@ -53,11 +58,6 @@
"@radix-ui/react-tooltip": "^1.1.3",
"@react-hook/resize-observer": "^2.0.2",
"@sentry/nextjs": "^10.11.0",
"@hexclave/dashboard-ui-components": "workspace:*",
"@hexclave/shared-backend": "workspace:*",
"@hexclave/next": "workspace:*",
"@hexclave/shared": "workspace:*",
"@hexclave/ui": "workspace:*",
"@stripe/connect-js": "^3.3.27",
"@stripe/react-connect-js": "^3.3.24",
"@stripe/react-stripe-js": "^3.8.1",

View File

@ -0,0 +1,314 @@
/**
* Graph layout using ForceAtlas2-style simulation with:
* 1. Landing page detection average distance for soft x-position
* 2. ForceAtlas2 forces with collision avoidance
* 3. Spring strength proportional to log(clicks)
* 4. Edge bundling for parallel edges
*/
export type GraphNode = {
id: string,
label: string,
domain: string,
pageViews: number,
width: number,
x: number,
y: number,
};
export type GraphEdge = {
from: string,
to: string,
count: number,
/** Weight (linear): raw transition count */
weight: number,
};
// Layout constants
const CARD_HEIGHT = 60;
const ITERATIONS = 500;
const GRAVITY = 0.005;
const REPULSION_SCALE = 8000;
const X_CONSTRAINT_STRENGTH = 0.25;
const DAMPING = 0.85;
const MIN_DIST = 50;
/**
* Detect landing pages: pages with high outbound relative to inbound,
* or common root paths like "/", "/home", etc.
*/
function findLandingPages(nodes: GraphNode[], edges: GraphEdge[]): Set<string> {
const inbound = new Map<string, number>();
const outbound = new Map<string, number>();
for (const n of nodes) {
inbound.set(n.id, 0);
outbound.set(n.id, 0);
}
for (const e of edges) {
outbound.set(e.from, (outbound.get(e.from) ?? 0) + e.count);
inbound.set(e.to, (inbound.get(e.to) ?? 0) + e.count);
}
const rootPatterns = ["/", "/index", "/home", "/landing"];
const landings = new Set<string>();
for (const n of nodes) {
const out = outbound.get(n.id) ?? 0;
const inn = inbound.get(n.id) ?? 0;
if (rootPatterns.some((p) => n.id === p || (n.id.endsWith("/") && n.id.slice(0, -1) === p))) {
landings.add(n.id);
continue;
}
if (out > inn * 1.3 && out > 30) {
landings.add(n.id);
}
}
if (landings.size === 0) {
const sorted = [...nodes].sort((a, b) => (outbound.get(b.id) ?? 0) - (outbound.get(a.id) ?? 0));
for (let i = 0; i < Math.min(3, sorted.length); i++) {
landings.add(sorted[i]!.id);
}
}
return landings;
}
/**
* Compute average shortest-path distance from landing pages using BFS.
*/
function computeDistanceFromLandings(
nodes: GraphNode[],
edges: GraphEdge[],
landings: Set<string>,
): Map<string, number> {
const adj = new Map<string, { to: string }[]>();
for (const n of nodes) {
adj.set(n.id, []);
}
for (const e of edges) {
adj.get(e.from)?.push({ to: e.to });
}
const distances = new Map<string, number[]>();
for (const n of nodes) {
distances.set(n.id, []);
}
for (const landing of landings) {
const dist = new Map<string, number>();
const queue: string[] = [landing];
dist.set(landing, 0);
while (queue.length > 0) {
const current = queue.shift()!;
const currentDist = dist.get(current)!;
const neighbors = adj.get(current) ?? [];
for (const { to } of neighbors) {
if (!dist.has(to)) {
dist.set(to, currentDist + 1);
queue.push(to);
}
}
}
for (const [nodeId, d] of dist) {
distances.get(nodeId)?.push(d);
}
}
const avgDist = new Map<string, number>();
let maxDist = 0;
for (const [nodeId, dists] of distances) {
if (dists.length > 0) {
const avg = dists.reduce((a, b) => a + b, 0) / dists.length;
avgDist.set(nodeId, avg);
maxDist = Math.max(maxDist, avg);
} else {
avgDist.set(nodeId, maxDist + 1);
}
}
// Normalize unreachable nodes
for (const [nodeId, d] of avgDist) {
if (d > maxDist) {
avgDist.set(nodeId, maxDist + 1);
}
}
return avgDist;
}
type SimNode = {
id: string,
label: string,
width: number,
x: number,
y: number,
vx: number,
vy: number,
targetX: number,
};
/**
* ForceAtlas2-style layout with:
* - Soft x-constraint from landing page distance
* - Repulsion inversely proportional to distance
* - Spring attraction proportional to log(clicks)
* - Collision avoidance based on card dimensions
*/
export function computeLayout(nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] {
if (nodes.length === 0) return [];
// Find landing pages and compute distances
const landings = findLandingPages(nodes, edges);
const distFromLanding = computeDistanceFromLandings(nodes, edges, landings);
// Normalize distances to x-range
const maxDist = Math.max(...distFromLanding.values(), 1);
const xSpread = nodes.length * 50;
// Initialize simulation nodes
const simNodes: SimNode[] = nodes.map((n, i) => {
const dist = distFromLanding.get(n.id) ?? 0;
const targetX = (dist / maxDist) * xSpread;
const ySpread = nodes.length * 60;
return {
id: n.id,
label: n.label,
width: n.width,
x: targetX + (Math.random() - 0.5) * 80,
y: (i / nodes.length - 0.5) * ySpread,
vx: 0,
vy: 0,
targetX,
};
});
const nodeIndex = new Map<string, number>();
for (let i = 0; i < simNodes.length; i++) {
nodeIndex.set(simNodes[i]!.id, i);
}
const maxWeight = edges.reduce((m, e) => Math.max(m, e.weight), 1);
// Collision padding
const collisionPadX = 30;
const collisionPadY = 25;
const collisionH = CARD_HEIGHT / 2 + collisionPadY;
for (let iter = 0; iter < ITERATIONS; iter++) {
const alpha = 1 - iter / ITERATIONS;
const cool = 0.1 + 0.9 * alpha;
// Repulsion between all pairs + collision avoidance
for (let i = 0; i < simNodes.length; i++) {
for (let j = i + 1; j < simNodes.length; j++) {
const a = simNodes[i]!;
const b = simNodes[j]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MIN_DIST) dist = MIN_DIST;
// Repulsion
const repForce = REPULSION_SCALE * cool / (dist * dist);
const fx = (dx / dist) * repForce;
const fy = (dy / dist) * repForce;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
// Collision avoidance: push apart if rectangular bounds overlap
// Use per-node width for accurate collision detection
const collisionW = (a.width + b.width) / 2 + collisionPadX;
const overlapX = collisionW - Math.abs(dx);
const overlapY = collisionH * 2 - Math.abs(dy);
if (overlapX > 0 && overlapY > 0) {
const pushX = (overlapX / 2) * Math.sign(dx || 1) * 0.8;
const pushY = (overlapY / 2) * Math.sign(dy || 1) * 0.8;
a.x -= pushX;
b.x += pushX;
a.y -= pushY;
b.y += pushY;
}
}
}
// Spring attraction along edges, strength ∝ log(clicks)
// Reverse x-force when arrow points left to bias left-to-right flow
for (const edge of edges) {
const ai = nodeIndex.get(edge.from);
const bi = nodeIndex.get(edge.to);
if (ai == null || bi == null) continue;
const a = simNodes[ai]!;
const b = simNodes[bi]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) continue;
const strength = 0.008 * (edge.weight / maxWeight) * cool;
const fy = dy * strength;
// Reverse x-component when edge points left (to.x < from.x)
// This makes backward edges repulsive on x, reinforcing left-to-right flow
const fx = dx < 0 ? -(dx * strength) : dx * strength;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
// Horizontal alignment bias: strong edges pull nodes toward same y-level
// Weak edges don't care about vertical alignment
for (const edge of edges) {
const ai = nodeIndex.get(edge.from);
const bi = nodeIndex.get(edge.to);
if (ai == null || bi == null) continue;
const a = simNodes[ai]!;
const b = simNodes[bi]!;
const dy = b.y - a.y;
// Strength proportional to normalized edge weight (squared for emphasis)
const relWeight = edge.weight / maxWeight;
const alignStrength = 0.012 * relWeight * relWeight * cool;
const fy = dy * alignStrength;
// Pull both nodes toward their shared y-midpoint
a.vy += fy;
b.vy -= fy;
}
// Soft x-constraint: pull toward target x based on distance from landings
for (const node of simNodes) {
const xDiff = node.targetX - node.x;
node.vx += xDiff * X_CONSTRAINT_STRENGTH * cool;
}
// Gravity toward center (y-axis only)
for (const node of simNodes) {
node.vy -= node.y * GRAVITY * cool;
}
// Apply velocity with damping
for (const node of simNodes) {
node.vx *= DAMPING;
node.vy *= DAMPING;
node.x += node.vx;
node.y += node.vy;
}
}
// Restore full node data with updated positions
const nodeById = new Map(nodes.map((n) => [n.id, n]));
return simNodes.map((n) => ({
...nodeById.get(n.id)!,
x: n.x,
y: n.y,
}));
}

View File

@ -0,0 +1,369 @@
"use client";
import type React from "react";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GraphEdge, GraphNode } from "./force-layout";
const CARD_HEIGHT = 60;
function edgeOpacity(count: number, maxCount: number): number {
if (maxCount === 0) return 0.1;
return 0.1 + 0.7 * (count / maxCount);
}
function edgeWidth(count: number, maxCount: number): number {
if (maxCount === 0) return 0.5;
return 0.5 + 1.5 * (count / maxCount);
}
/**
* Compute edge path with bundling offset for parallel edges.
* Uses rectangular node bounds for connection points.
*/
function getEdgePath(from: GraphNode, to: GraphNode, bundleOffset: number) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) return null;
const ndx = dx / dist;
const ndy = dy / dist;
// Ray-rectangle intersection for exit/entry points (per-node width)
const hwFrom = from.width / 2;
const hwTo = to.width / 2;
const hh = CARD_HEIGHT / 2;
const tFromX = ndx !== 0 ? hwFrom / Math.abs(ndx) : Infinity;
const tFromY = ndy !== 0 ? hh / Math.abs(ndy) : Infinity;
const tFrom = Math.min(tFromX, tFromY);
const fromX = from.x + ndx * tFrom;
const fromY = from.y + ndy * tFrom;
const tToX = ndx !== 0 ? hwTo / Math.abs(ndx) : Infinity;
const tToY = ndy !== 0 ? hh / Math.abs(ndy) : Infinity;
const tTo = Math.min(tToX, tToY);
const toX = to.x - ndx * tTo;
const toY = to.y - ndy * tTo;
// Perpendicular offset for bundling + subtle curve
const nx = -ndy;
const ny = ndx;
const curvature = Math.min(dist * 0.08, 15) + bundleOffset * 8;
const cx = (fromX + toX) / 2 + nx * curvature;
const cy = (fromY + toY) / 2 + ny * curvature;
return { fromX, fromY, toX, toY, cx, cy };
}
export function FunnelGraphCanvas({
nodes,
edges,
weakEdges,
}: {
nodes: GraphNode[],
edges: GraphEdge[],
weakEdges: GraphEdge[],
}) {
const containerRef = useRef<HTMLDivElement>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const [isPanning, setIsPanning] = useState(false);
const panStart = useRef({ x: 0, y: 0, tx: 0, ty: 0 });
const [containerSize, setContainerSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = containerRef.current;
if (el == null) return;
const obs = new ResizeObserver((entries) => {
const entry = entries[0];
setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height });
});
obs.observe(el);
return () => obs.disconnect();
}, []);
const maxCount = useMemo(() => edges.reduce((m, e) => Math.max(m, e.count), 0), [edges]);
const nodeStats = useMemo(() => {
const stats = new Map<string, { inbound: number, outbound: number }>();
for (const n of nodes) {
stats.set(n.id, { inbound: 0, outbound: 0 });
}
for (const e of edges) {
const from = stats.get(e.from);
const to = stats.get(e.to);
if (from != null) from.outbound += e.count;
if (to != null) to.inbound += e.count;
}
return stats;
}, [nodes, edges]);
const highlightedEdges = useMemo(() => {
if (hoveredNode == null) return null;
const set = new Set<string>();
for (const e of edges) {
if (e.from === hoveredNode || e.to === hoveredNode) {
set.add(`${e.from}\0${e.to}`);
}
}
// Also include weak edges connected to the hovered node
for (const e of weakEdges) {
if (e.from === hoveredNode || e.to === hoveredNode) {
set.add(`${e.from}\0${e.to}`);
}
}
return set;
}, [hoveredNode, edges, weakEdges]);
// Weak edges visible only on hover
const visibleWeakEdges = useMemo(() => {
if (hoveredNode == null) return [];
return weakEdges.filter((e) => e.from === hoveredNode || e.to === hoveredNode);
}, [hoveredNode, weakEdges]);
// Compute bundle offsets for parallel edges
const edgeBundleOffsets = useMemo(() => {
const pairCounts = new Map<string, number>();
const offsets = new Map<string, number>();
for (const e of edges) {
const [a, b] = [e.from, e.to].sort();
const pairKey = `${a}\0${b}`;
const count = pairCounts.get(pairKey) ?? 0;
offsets.set(`${e.from}\0${e.to}`, count);
pairCounts.set(pairKey, count + 1);
}
return offsets;
}, [edges]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
setIsPanning(true);
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.x, ty: transform.y };
}, [transform.x, transform.y]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning) return;
const dx = e.clientX - panStart.current.x;
const dy = e.clientY - panStart.current.y;
setTransform((t) => ({
...t,
x: panStart.current.tx + dx,
y: panStart.current.ty + dy,
}));
}, [isPanning]);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
useEffect(() => {
const handleGlobalUp = () => setIsPanning(false);
window.addEventListener("mouseup", handleGlobalUp);
return () => window.removeEventListener("mouseup", handleGlobalUp);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1;
setTransform((t) => ({
...t,
scale: Math.max(0.1, Math.min(5, t.scale * scaleFactor)),
}));
}, []);
const resetView = useCallback(() => {
setTransform({ x: 0, y: 0, scale: 1 });
}, []);
const nodeMap = useMemo(() => {
const map = new Map<string, GraphNode>();
for (const n of nodes) map.set(n.id, n);
return map;
}, [nodes]);
const graphCenter = useMemo(() => {
if (nodes.length === 0) return { x: 0, y: 0 };
let cx = 0, cy = 0;
for (const n of nodes) {
cx += n.x;
cy += n.y;
}
return { x: cx / nodes.length, y: cy / nodes.length };
}, [nodes]);
const offsetX = containerSize.w / 2 - graphCenter.x;
const offsetY = containerSize.h / 2 - graphCenter.y;
return (
<div
ref={containerRef}
className="relative w-full h-full select-none overflow-hidden"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
style={{ cursor: isPanning ? "grabbing" : "grab" }}
>
{/* Controls */}
<div className="absolute top-3 right-3 z-10 flex gap-1">
<button
onClick={resetView}
className="px-2 py-1 rounded-md text-xs bg-background/80 backdrop-blur border border-border/50 hover:bg-muted/50 transition-colors hover:transition-none"
>
Reset view
</button>
</div>
{/* Legend */}
<div className="absolute bottom-3 left-3 z-10 px-3 py-2 rounded-lg bg-background/80 backdrop-blur border border-border/50 text-xs text-muted-foreground space-y-1">
<div>Edge thickness = transitions</div>
<div>Scroll to zoom, drag to pan</div>
</div>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ overflow: "visible" }}
>
<defs>
<marker
id="funnel-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="4"
markerHeight="4"
orient="auto-start-reverse"
>
<path d="M 2 1 L 9 5 L 2 9" fill="none" className="stroke-foreground/50" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</marker>
</defs>
<g transform={`translate(${offsetX + transform.x}, ${offsetY + transform.y}) scale(${transform.scale})`}>
{edges.map((edge) => {
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (fromNode == null || toNode == null) return null;
const bundleOffset = edgeBundleOffsets.get(`${edge.from}\0${edge.to}`) ?? 0;
const path = getEdgePath(fromNode, toNode, bundleOffset);
if (path == null) return null;
const isHighlighted = highlightedEdges == null || highlightedEdges.has(`${edge.from}\0${edge.to}`);
const opacity = isHighlighted
? edgeOpacity(edge.count, maxCount)
: (hoveredNode != null ? 0.03 : edgeOpacity(edge.count, maxCount));
return (
<path
key={`${edge.from}\0${edge.to}`}
d={`M ${path.fromX} ${path.fromY} Q ${path.cx} ${path.cy} ${path.toX} ${path.toY}`}
fill="none"
className="stroke-foreground"
strokeWidth={edgeWidth(edge.count, maxCount)}
strokeOpacity={opacity}
markerEnd="url(#funnel-arrow)"
/>
);
})}
{/* Weak edges shown on hover */}
{visibleWeakEdges.map((edge) => {
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (fromNode == null || toNode == null) return null;
const path = getEdgePath(fromNode, toNode, 0);
if (path == null) return null;
return (
<path
key={`weak-${edge.from}\0${edge.to}`}
d={`M ${path.fromX} ${path.fromY} Q ${path.cx} ${path.cy} ${path.toX} ${path.toY}`}
fill="none"
className="stroke-foreground"
strokeWidth={edgeWidth(edge.count, maxCount)}
strokeOpacity={edgeOpacity(edge.count, maxCount) * 0.6}
strokeDasharray="3 3"
markerEnd="url(#funnel-arrow)"
/>
);
})}
{/* Edge labels on hover */}
{hoveredNode != null && [...edges, ...visibleWeakEdges]
.filter((e) => e.from === hoveredNode || e.to === hoveredNode)
.map((edge) => {
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (fromNode == null || toNode == null) return null;
const mx = (fromNode.x + toNode.x) / 2;
const my = (fromNode.y + toNode.y) / 2;
return (
<text
key={`label-${edge.from}\0${edge.to}`}
x={mx}
y={my - 8}
textAnchor="middle"
className="fill-foreground"
fontSize={11}
fontWeight="bold"
>
{edge.count.toLocaleString()}
</text>
);
})
}
</g>
</svg>
{/* HTML layer for node cards */}
<div
className="absolute inset-0 pointer-events-none"
style={{
transform: `translate(${offsetX + transform.x}px, ${offsetY + transform.y}px) scale(${transform.scale})`,
transformOrigin: "0 0",
}}
>
{nodes.map((node) => {
const stats = nodeStats.get(node.id);
const isHovered = hoveredNode === node.id;
return (
<div
key={node.id}
className={cn(
"absolute rounded-lg border px-2.5 py-1.5 pointer-events-auto cursor-pointer transition-shadow hover:transition-none",
"bg-card text-card-foreground border-border shadow-sm",
isHovered && "ring-2 ring-blue-500/50 shadow-md border-blue-400/60",
)}
style={{
left: node.x - node.width / 2,
top: node.y - CARD_HEIGHT / 2,
width: node.width,
height: CARD_HEIGHT,
}}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
>
{node.domain !== "" && (
<div className="text-[9px] text-muted-foreground/70 truncate leading-tight">
{node.domain}
</div>
)}
<div className="text-[11px] font-mono font-medium truncate leading-tight" title={node.label}>
{node.label}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground">
<span title="Page views">{node.pageViews.toLocaleString()} views</span>
<span className="text-muted-foreground/50">·</span>
<span title="Inbound transitions">{stats?.inbound.toLocaleString() ?? 0}</span>
<span title="Outbound transitions">{stats?.outbound.toLocaleString() ?? 0}</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
/**
* Normalize a URL path by replacing dynamic segments (UUIDs, numeric IDs,
* hashes, base64 tokens, etc.) with placeholder tokens. This groups
* similar pages (e.g. /users/abc123 and /users/def456 /users/:id).
*/
// UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Purely numeric segments (e.g. /posts/12345)
const NUMERIC_REGEX = /^\d+$/;
// Long hex strings (8+ chars) — likely IDs or hashes
const HEX_ID_REGEX = /^[0-9a-f]{8,}$/i;
// Base64-like tokens (16+ chars of alphanumeric + padding chars)
const BASE64_TOKEN_REGEX = /^[A-Za-z0-9_-]{16,}[=]{0,2}$/;
// MongoDB ObjectIDs (24 hex chars)
const OBJECTID_REGEX = /^[0-9a-f]{24}$/i;
// Short numeric-heavy mixed IDs (e.g. "a1b2c3d4", "usr_abc123")
// Require at least one digit in the suffix to avoid matching static words like "sign_in"
const PREFIXED_ID_REGEX = /^[a-z]{1,10}_[a-z0-9]*\d[a-z0-9]*$/i;
function isLikelyDynamicSegment(segment: string): boolean {
if (segment.length === 0) return false;
// Check each pattern from most specific to least
if (UUID_REGEX.test(segment)) return true;
if (OBJECTID_REGEX.test(segment)) return true;
if (NUMERIC_REGEX.test(segment)) return true;
if (HEX_ID_REGEX.test(segment)) return true;
if (PREFIXED_ID_REGEX.test(segment)) return true;
// Base64 tokens (only for longer segments to avoid false positives on
// short path segments like "api" or "auth")
if (segment.length >= 20 && BASE64_TOKEN_REGEX.test(segment)) return true;
return false;
}
export function normalizeUrlPath(path: string): string {
// Strip query string and hash
const cleanPath = path.split("?")[0]!.split("#")[0]!;
const segments = cleanPath.split("/");
const normalized = segments.map((seg) =>
isLikelyDynamicSegment(seg) ? ":id" : seg
);
// Collapse consecutive :id segments (e.g. /a/:id/:id → /a/:id)
const collapsed: string[] = [];
for (const seg of normalized) {
if (seg === ":id" && collapsed[collapsed.length - 1] === ":id") continue;
collapsed.push(seg);
}
return collapsed.join("/") || "/";
}

View File

@ -0,0 +1,260 @@
"use client";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import { Button, Typography } from "@/components/ui";
import { SpinnerGapIcon, ArrowClockwiseIcon } from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeUrlPath } from "./normalize-url";
import { computeLayout, type GraphNode, type GraphEdge } from "./force-layout";
import { FunnelGraphCanvas } from "./funnel-graph-canvas";
type TransitionRow = {
from_path: string,
to_path: string,
cnt: string,
};
type FunnelData = {
nodes: GraphNode[],
edges: GraphEdge[],
weakEdges: GraphEdge[],
};
const NAVIGATION_QUERY = `
SELECT
prev_path as from_path,
path as to_path,
count() as cnt
FROM (
SELECT
user_id,
JSONExtractString(toString(data), 'path') as path,
lagInFrame(JSONExtractString(toString(data), 'path')) OVER (
PARTITION BY user_id
ORDER BY event_at ASC
) as prev_path
FROM default.events
WHERE event_type = '$page-view'
AND JSONExtractString(toString(data), 'path') != ''
AND user_id != ''
) sub
WHERE prev_path != '' AND prev_path != path
GROUP BY from_path, to_path
ORDER BY cnt DESC
LIMIT 500
`;
const PAGE_VIEWS_QUERY = `
SELECT
JSONExtractString(toString(data), 'path') as path,
any(domain(JSONExtractString(toString(data), 'url'))) as page_domain,
count() as views
FROM default.events
WHERE event_type = '$page-view'
AND JSONExtractString(toString(data), 'path') != ''
GROUP BY path
ORDER BY views DESC
LIMIT 200
`;
const MIN_CARD_WIDTH = 100;
const MAX_CARD_WIDTH = 220;
function computeCardWidth(label: string): number {
// ~6.5px per character in 11px monospace font, plus padding (20px)
const textWidth = label.length * 6.5 + 20;
return Math.max(MIN_CARD_WIDTH, Math.min(MAX_CARD_WIDTH, textWidth));
}
export default function PageClient() {
const adminApp = useAdminApp();
const [data, setData] = useState<FunnelData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadedRef = useRef(false);
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await adminApp.queryAnalytics({
query: NAVIGATION_QUERY,
include_all_branches: false,
timeout_ms: 30000,
});
const rows = response.result.map((r) => {
const from_path = r.from_path;
const to_path = r.to_path;
const cnt = r.cnt;
if (typeof from_path !== "string" || typeof to_path !== "string") {
throw new Error("Unexpected navigation query result shape: from_path/to_path must be strings");
}
if (typeof cnt !== "string" && typeof cnt !== "number") {
throw new Error("Unexpected navigation query result shape: cnt must be string or number");
}
return { from_path, to_path, cnt: String(cnt) } satisfies TransitionRow;
});
// Normalize paths and aggregate
const edgeMap = new Map<string, number>();
const nodeSet = new Set<string>();
for (const row of rows) {
const fromNorm = normalizeUrlPath(row.from_path);
const toNorm = normalizeUrlPath(row.to_path);
if (fromNorm === toNorm) continue;
const key = `${fromNorm}\0${toNorm}`;
const count = Number(row.cnt);
if (!Number.isFinite(count)) {
throw new Error(`Invalid count value: ${row.cnt}`);
}
edgeMap.set(key, (edgeMap.get(key) ?? 0) + count);
nodeSet.add(fromNorm);
nodeSet.add(toNorm);
}
// Query page views and domain info
const pvResponse = await adminApp.queryAnalytics({
query: PAGE_VIEWS_QUERY,
include_all_branches: false,
timeout_ms: 30000,
});
const pageViewsMap = new Map<string, { views: number, domain: string }>();
for (const row of pvResponse.result) {
const path = row.path;
const domain = row.page_domain;
const views = row.views;
if (typeof path !== "string") continue;
const normPath = normalizeUrlPath(path);
const existing = pageViewsMap.get(normPath);
const viewCount = Number(views) || 0;
if (existing == null) {
pageViewsMap.set(normPath, { views: viewCount, domain: typeof domain === "string" ? domain : "" });
} else {
existing.views += viewCount;
if (existing.domain === "" && typeof domain === "string") {
existing.domain = domain;
}
}
}
// Build nodes
const nodeArray: GraphNode[] = Array.from(nodeSet).map((path) => {
const pvInfo = pageViewsMap.get(path);
return {
id: path,
label: path,
domain: pvInfo?.domain ?? "",
pageViews: pvInfo?.views ?? 0,
width: computeCardWidth(path),
x: 0,
y: 0,
};
});
// Build edges
const allEdges: { from: string, to: string, count: number, weight: number }[] = [];
for (const [key, count] of edgeMap) {
const [from, to] = key.split("\0") as [string, string];
allEdges.push({
from,
to,
count,
weight: count,
});
}
// For each source node, find the max outgoing edge count
const maxOutgoing = new Map<string, number>();
for (const e of allEdges) {
const current = maxOutgoing.get(e.from) ?? 0;
if (e.count > current) {
maxOutgoing.set(e.from, e.count);
}
}
// Filter: keep only edges with >= 10% of the strongest edge from the same source
const edges: GraphEdge[] = [];
const weakEdges: GraphEdge[] = [];
for (const e of allEdges) {
const maxFromSource = maxOutgoing.get(e.from) ?? 1;
if (e.count >= maxFromSource * 0.1) {
edges.push(e);
} else {
weakEdges.push(e);
}
}
// Only include nodes that have at least one visible edge
const visibleNodes = new Set<string>();
for (const e of edges) {
visibleNodes.add(e.from);
visibleNodes.add(e.to);
}
const filteredNodeArray = nodeArray.filter((n) => visibleNodes.has(n.id));
// Compute ForceAtlas2 layout with landing page distance for x-position
// Only strong edges are used for force calculations
const laidOutNodes = computeLayout(filteredNodeArray, edges);
setData({ nodes: laidOutNodes, edges, weakEdges });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [adminApp]);
useEffect(() => {
if (loadedRef.current) return;
loadedRef.current = true;
runAsynchronouslyWithAlert(loadData);
}, [loadData]);
return (
<AppEnabledGuard appId="analytics">
<PageLayout
title="Navigation Funnel"
description="Visualize user navigation flows between pages."
fillWidth
containedHeight
actions={
<Button
className="gap-1.5"
variant="secondary"
disabled={loading}
onClick={() => runAsynchronouslyWithAlert(loadData)}
>
<ArrowClockwiseIcon className="h-4 w-4" />
Refresh
</Button>
}
>
<div className="flex-1 min-h-0 rounded-2xl border border-black/[0.06] bg-white/90 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl dark:border-white/[0.06] dark:bg-zinc-900/90 overflow-hidden">
{loading && (
<div className="flex items-center justify-center h-full">
<SpinnerGapIcon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error != null && !loading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Typography variant="secondary" className="text-sm">{error}</Typography>
<Button variant="secondary" onClick={() => runAsynchronouslyWithAlert(loadData)}>
Retry
</Button>
</div>
)}
{data != null && !loading && error == null && (
<FunnelGraphCanvas nodes={data.nodes} edges={data.edges} weakEdges={data.weakEdges} />
)}
</div>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -0,0 +1,5 @@
import PageClient from "./page-client";
export default function Page() {
return <PageClient />;
}