mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
feat: add navigation funnel graph page (#1701)
This commit is contained in:
parent
c9612539c7
commit
244fc3ab7b
@ -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",
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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("/") || "/";
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user