From e8f00e06ecb2585dcb8516613679468d0c9ca133 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 15 Mar 2026 12:17:48 +0100 Subject: [PATCH] feat: replace sankey chart with column-based journey visualization --- app/sites/[id]/journeys/page.tsx | 6 +- components/journeys/ColumnJourney.tsx | 479 ++++++++++++++++++++++++++ components/journeys/SankeyDiagram.tsx | 457 ------------------------ package-lock.json | 73 +--- package.json | 2 - 5 files changed, 484 insertions(+), 533 deletions(-) create mode 100644 components/journeys/ColumnJourney.tsx delete mode 100644 components/journeys/SankeyDiagram.tsx diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index 30e5660..61ae39b 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' -import SankeyDiagram from '@/components/journeys/SankeyDiagram' +import ColumnJourney from '@/components/journeys/ColumnJourney' import TopPathsTable from '@/components/journeys/TopPathsTable' import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { @@ -172,9 +172,9 @@ export default function JourneysPage() { - {/* Sankey Diagram */} + {/* Journey Columns */}
- void +} + +interface ColumnPage { + path: string + sessionCount: number +} + +interface Column { + index: number + totalSessions: number + dropOffPercent: number + pages: ColumnPage[] +} + +interface LineDef { + sourceY: number + destY: number + sourceX: number + destX: number + weight: number +} + +// ─── Constants ────────────────────────────────────────────────────── + +const COLUMN_COLORS = [ + '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', + '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', +] +const MAX_NODES_PER_COLUMN = 10 + +function colorForColumn(col: number): string { + return COLUMN_COLORS[col % COLUMN_COLORS.length] +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +function smartLabel(path: string): string { + if (path === '/' || path === '(other)') return path + const segments = path.replace(/\/$/, '').split('/') + if (segments.length <= 2) return path + return `…/${segments[segments.length - 1]}` +} + +// ─── Data transformation ──────────────────────────────────────────── + +function buildColumns( + transitions: PathTransition[], + depth: number, + selections: Map, +): Column[] { + const numCols = depth + 1 + const columns: Column[] = [] + + // Build a filtered transitions set based on selections + // For each column N with a selection, only keep transitions at step_index=N + // where from_path matches the selection + let filteredTransitions = transitions + + for (let col = 0; col < numCols - 1; col++) { + const selected = selections.get(col) + if (selected) { + filteredTransitions = filteredTransitions.filter( + (t) => t.step_index !== col || t.from_path === selected + ) + } + } + + for (let col = 0; col < numCols; col++) { + const pageMap = new Map() + + if (col === 0) { + // Column 0: aggregate from_path across step_index=0 + for (const t of filteredTransitions) { + if (t.step_index === 0) { + pageMap.set(t.from_path, (pageMap.get(t.from_path) ?? 0) + t.session_count) + } + } + } else { + // Column N: aggregate to_path across step_index=N-1 + for (const t of filteredTransitions) { + if (t.step_index === col - 1) { + pageMap.set(t.to_path, (pageMap.get(t.to_path) ?? 0) + t.session_count) + } + } + } + + // Sort descending by count + let pages = Array.from(pageMap.entries()) + .map(([path, sessionCount]) => ({ path, sessionCount })) + .sort((a, b) => b.sessionCount - a.sessionCount) + + // Cap and merge into (other) + if (pages.length > MAX_NODES_PER_COLUMN) { + const kept = pages.slice(0, MAX_NODES_PER_COLUMN) + const otherCount = pages + .slice(MAX_NODES_PER_COLUMN) + .reduce((sum, p) => sum + p.sessionCount, 0) + kept.push({ path: '(other)', sessionCount: otherCount }) + pages = kept + } + + const totalSessions = pages.reduce((sum, p) => sum + p.sessionCount, 0) + const prevTotal = col > 0 ? columns[col - 1].totalSessions : totalSessions + const dropOffPercent = + col === 0 || prevTotal === 0 + ? 0 + : Math.round(((totalSessions - prevTotal) / prevTotal) * 100) + + columns.push({ index: col, totalSessions, dropOffPercent, pages }) + } + + return columns +} + +// ─── Sub-components ───────────────────────────────────────────────── + +function ColumnHeader({ + column, + color, +}: { + column: Column + color: string +}) { + return ( +
+ + {column.index + 1} + +
+ + {column.totalSessions.toLocaleString()} + + + visitors + + {column.dropOffPercent !== 0 && ( + + {column.dropOffPercent > 0 ? '+' : ''} + {column.dropOffPercent}% + + )} +
+
+ ) +} + +function PageRow({ + page, + colIndex, + columnTotal, + isSelected, + isOther, + onClick, +}: { + page: ColumnPage + colIndex: number + columnTotal: number + isSelected: boolean + isOther: boolean + onClick: () => void +}) { + const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 + + return ( + + ) +} + +function JourneyColumn({ + column, + color, + selectedPath, + onSelect, +}: { + column: Column + color: string + selectedPath: string | undefined + onSelect: (path: string) => void +}) { + if (column.pages.length === 0) { + return ( +
+ +
+ + No onward traffic + +
+
+ ) + } + + return ( +
+ +
+ {column.pages.map((page) => { + const isOther = page.path === '(other)' + return ( + { + if (!isOther) onSelect(page.path) + }} + /> + ) + })} +
+
+ ) +} + +// ─── Connection Lines ─────────────────────────────────────────────── + +function ConnectionLines({ + containerRef, + selections, + columns, + transitions, +}: { + containerRef: React.RefObject + selections: Map + columns: Column[] + transitions: PathTransition[] +}) { + const [lines, setLines] = useState<(LineDef & { color: string })[]>([]) + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + const container = containerRef.current + if (!container || selections.size === 0) { + setLines([]) + return + } + + const containerRect = container.getBoundingClientRect() + setDimensions({ + width: container.scrollWidth, + height: container.scrollHeight, + }) + + const newLines: (LineDef & { color: string })[] = [] + + for (const [colIdx, selectedPath] of selections) { + const nextCol = columns[colIdx + 1] + if (!nextCol) continue + + // Find the source row element + const sourceEl = container.querySelector( + `[data-col="${colIdx}"][data-path="${CSS.escape(selectedPath)}"]` + ) as HTMLElement | null + if (!sourceEl) continue + + const sourceRect = sourceEl.getBoundingClientRect() + const sourceY = + sourceRect.top + sourceRect.height / 2 - containerRect.top + container.scrollTop + const sourceX = sourceRect.right - containerRect.left + container.scrollLeft + + // Find matching transitions + const relevantTransitions = transitions.filter( + (t) => t.step_index === colIdx && t.from_path === selectedPath + ) + + const color = colorForColumn(colIdx) + + for (const t of relevantTransitions) { + const destEl = container.querySelector( + `[data-col="${colIdx + 1}"][data-path="${CSS.escape(t.to_path)}"]` + ) as HTMLElement | null + if (!destEl) continue + + const destRect = destEl.getBoundingClientRect() + const destY = + destRect.top + destRect.height / 2 - containerRect.top + container.scrollTop + const destX = destRect.left - containerRect.left + container.scrollLeft + + const maxCount = Math.max(...relevantTransitions.map((rt) => rt.session_count)) + const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) + + newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + } + } + + setLines(newLines) + }, [selections, columns, transitions, containerRef]) + + if (lines.length === 0) return null + + return ( + + {lines.map((line, i) => { + const midX = (line.sourceX + line.destX) / 2 + return ( + + ) + })} + + ) +} + +// ─── Main Component ───────────────────────────────────────────────── + +export default function ColumnJourney({ + transitions, + totalSessions, + depth, + onNodeClick, +}: ColumnJourneyProps) { + const [selections, setSelections] = useState>(new Map()) + const containerRef = useRef(null) + + // Clear selections when data changes + const transitionsKey = useMemo( + () => transitions.length + '-' + depth, + [transitions.length, depth] + ) + const prevKeyRef = useRef(transitionsKey) + if (prevKeyRef.current !== transitionsKey) { + prevKeyRef.current = transitionsKey + if (selections.size > 0) setSelections(new Map()) + } + + const columns = useMemo( + () => buildColumns(transitions, depth, selections), + [transitions, depth, selections] + ) + + const handleSelect = useCallback( + (colIndex: number, path: string) => { + // Column 0 click → set entry path filter (API-level) + if (colIndex === 0 && onNodeClick) { + onNodeClick(path) + return + } + + setSelections((prev) => { + const next = new Map(prev) + // Toggle: click same page deselects + if (next.get(colIndex) === path) { + next.delete(colIndex) + } else { + next.set(colIndex, path) + } + // Clear all selections after this column + for (const key of Array.from(next.keys())) { + if (key > colIndex) next.delete(key) + } + return next + }) + }, + [onNodeClick] + ) + + // ─── Empty state ──────────────────────────────────────────────── + if (!transitions.length) { + return ( +
+
+ +
+

+ No journey data yet +

+

+ Navigation flows will appear here as visitors browse through your site. +

+
+ ) + } + + return ( +
+
+
+ {columns.map((col) => ( + handleSelect(col.index, path)} + /> + ))} +
+ +
+
+ ) +} diff --git a/components/journeys/SankeyDiagram.tsx b/components/journeys/SankeyDiagram.tsx deleted file mode 100644 index 771b4b6..0000000 --- a/components/journeys/SankeyDiagram.tsx +++ /dev/null @@ -1,457 +0,0 @@ -'use client' - -import { useCallback, useMemo, useRef, useState } from 'react' -import { useTheme } from '@ciphera-net/ui' -import { TreeStructure } from '@phosphor-icons/react' -import { sankey, sankeyJustify } from 'd3-sankey' -import type { - SankeyNode as D3SankeyNode, - SankeyLink as D3SankeyLink, - SankeyExtraProperties, -} from 'd3-sankey' -import type { PathTransition } from '@/lib/api/journeys' - -// ─── Types ────────────────────────────────────────────────────────── - -interface SankeyDiagramProps { - transitions: PathTransition[] - totalSessions: number - depth: number - onNodeClick?: (path: string) => void -} - -interface NodeExtra extends SankeyExtraProperties { - id: string - label: string - color: string -} - -interface LinkExtra extends SankeyExtraProperties { - value: number -} - -type LayoutNode = D3SankeyNode -type LayoutLink = D3SankeyLink - -// ─── Constants ────────────────────────────────────────────────────── - -const COLUMN_COLORS = [ - '#FD5E0F', // brand orange (entry) - '#3B82F6', // blue - '#10B981', // emerald - '#F59E0B', // amber - '#8B5CF6', // violet - '#EC4899', // pink - '#06B6D4', // cyan - '#EF4444', // red - '#84CC16', // lime - '#F97316', // orange again - '#6366F1', // indigo -] -const EXIT_GREY = '#52525b' -const SVG_W = 1100 -const MARGIN = { top: 24, right: 140, bottom: 24, left: 10 } -const MAX_NODES_PER_COLUMN = 5 - -function colorForColumn(col: number): string { - return COLUMN_COLORS[col % COLUMN_COLORS.length] -} - -// ─── Smart label: show last meaningful path segment ───────────────── - -function smartLabel(path: string): string { - if (path === '/' || path === '(exit)') return path - // Remove trailing slash, split, take last 2 segments - const segments = path.replace(/\/$/, '').split('/') - if (segments.length <= 2) return path - // Show /last-segment for short paths, or …/last-segment for deep ones - const last = segments[segments.length - 1] - return `…/${last}` -} - -function truncateLabel(s: string, max: number) { - return s.length > max ? s.slice(0, max - 1) + '\u2026' : s -} - -function estimateTextWidth(s: string) { - return s.length * 7 -} - -// ─── Data transformation ──────────────────────────────────────────── - -function buildSankeyData(transitions: PathTransition[], depth: number) { - const numCols = depth + 1 - const nodeMap = new Map() - const links: Array<{ source: string; target: string; value: number }> = [] - const flowOut = new Map() - const flowIn = new Map() - - for (const t of transitions) { - if (t.step_index >= numCols || t.step_index + 1 >= numCols) continue - - const fromId = `${t.step_index}:${t.from_path}` - const toId = `${t.step_index + 1}:${t.to_path}` - - if (!nodeMap.has(fromId)) { - nodeMap.set(fromId, { id: fromId, label: t.from_path, color: colorForColumn(t.step_index) }) - } - if (!nodeMap.has(toId)) { - nodeMap.set(toId, { id: toId, label: t.to_path, color: colorForColumn(t.step_index + 1) }) - } - - links.push({ source: fromId, target: toId, value: t.session_count }) - flowOut.set(fromId, (flowOut.get(fromId) ?? 0) + t.session_count) - flowIn.set(toId, (flowIn.get(toId) ?? 0) + t.session_count) - } - - // ─── Cap nodes per column: keep top N by flow, merge rest into (other) ── - const columns = new Map() - for (const [nodeId] of nodeMap) { - if (nodeId === 'exit') continue - const col = parseInt(nodeId.split(':')[0], 10) - if (!columns.has(col)) columns.set(col, []) - columns.get(col)!.push(nodeId) - } - - for (const [col, nodeIds] of columns) { - if (nodeIds.length <= MAX_NODES_PER_COLUMN) continue - - // Sort by total flow (max of in/out) descending - nodeIds.sort((a, b) => { - const flowA = Math.max(flowIn.get(a) ?? 0, flowOut.get(a) ?? 0) - const flowB = Math.max(flowIn.get(b) ?? 0, flowOut.get(b) ?? 0) - return flowB - flowA - }) - - const keep = new Set(nodeIds.slice(0, MAX_NODES_PER_COLUMN)) - const otherId = `${col}:(other)` - nodeMap.set(otherId, { id: otherId, label: '(other)', color: colorForColumn(col) }) - - // Redirect links from/to pruned nodes to (other) - for (let i = 0; i < links.length; i++) { - const l = links[i] - if (!keep.has(l.source) && nodeIds.includes(l.source)) { - links[i] = { ...l, source: otherId } - } - if (!keep.has(l.target) && nodeIds.includes(l.target)) { - links[i] = { ...l, target: otherId } - } - } - - // Remove pruned nodes - for (const id of nodeIds) { - if (!keep.has(id)) nodeMap.delete(id) - } - } - - // Deduplicate links after merging (same source→target pairs) - const linkMap = new Map() - for (const l of links) { - const key = `${l.source}->${l.target}` - const existing = linkMap.get(key) - if (existing) { - existing.value += l.value - } else { - linkMap.set(key, { ...l }) - } - } - - // Recalculate flowOut/flowIn after merge - flowOut.clear() - flowIn.clear() - for (const l of linkMap.values()) { - flowOut.set(l.source, (flowOut.get(l.source) ?? 0) + l.value) - flowIn.set(l.target, (flowIn.get(l.target) ?? 0) + l.value) - } - - // Add exit nodes for flows that don't continue - for (const [nodeId] of nodeMap) { - if (nodeId === 'exit') continue - const col = parseInt(nodeId.split(':')[0], 10) - if (col >= numCols - 1) continue - - const totalIn = flowIn.get(nodeId) ?? 0 - const totalOut = flowOut.get(nodeId) ?? 0 - const flow = Math.max(totalIn, totalOut) - const exitCount = flow - totalOut - - if (exitCount > 0) { - const exitId = 'exit' - if (!nodeMap.has(exitId)) { - nodeMap.set(exitId, { id: exitId, label: '(exit)', color: EXIT_GREY }) - } - const key = `${nodeId}->exit` - const existing = linkMap.get(key) - if (existing) { - existing.value += exitCount - } else { - linkMap.set(key, { source: nodeId, target: exitId, value: exitCount }) - } - } - } - - return { - nodes: Array.from(nodeMap.values()), - links: Array.from(linkMap.values()), - } -} - -// ─── SVG path for a link ribbon ───────────────────────────────────── - -function ribbonPath(link: LayoutLink): string { - const src = link.source as LayoutNode - const tgt = link.target as LayoutNode - const sx = src.x1! - const tx = tgt.x0! - const w = link.width! - // d3-sankey y0/y1 are the CENTER of the link band, not the top - const sy = link.y0! - w / 2 - const ty = link.y1! - w / 2 - const mx = (sx + tx) / 2 - - return [ - `M${sx},${sy}`, - `C${mx},${sy} ${mx},${ty} ${tx},${ty}`, - `L${tx},${ty + w}`, - `C${mx},${ty + w} ${mx},${sy + w} ${sx},${sy + w}`, - 'Z', - ].join(' ') -} - -// ─── Component ────────────────────────────────────────────────────── - -export default function SankeyDiagram({ - transitions, - totalSessions, - depth, - onNodeClick, -}: SankeyDiagramProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - const [hovered, setHovered] = useState<{ type: 'link' | 'node'; id: string } | null>(null) - const svgRef = useRef(null) - - const data = useMemo( - () => buildSankeyData(transitions, depth), - [transitions, depth], - ) - - // Dynamic SVG height based on max nodes in any column - const svgH = useMemo(() => { - const columns = new Map() - for (const node of data.nodes) { - if (node.id === 'exit') continue - const col = parseInt(node.id.split(':')[0], 10) - columns.set(col, (columns.get(col) ?? 0) + 1) - } - const maxNodes = Math.max(1, ...columns.values()) - // Base 400 + 50px per node beyond 4 - return Math.max(400, Math.min(800, 400 + Math.max(0, maxNodes - 4) * 50)) - }, [data]) - - const layout = useMemo(() => { - if (!data.links.length) return null - - const generator = sankey() - .nodeId((d) => d.id) - .nodeWidth(18) - .nodePadding(16) - .nodeAlign(sankeyJustify) - .extent([ - [MARGIN.left, MARGIN.top], - [SVG_W - MARGIN.right, svgH - MARGIN.bottom], - ]) - - return generator({ - nodes: data.nodes.map((d) => ({ ...d })), - links: data.links.map((d) => ({ ...d })), - }) - }, [data, svgH]) - - // Single event handler on SVG — reads data-* attrs from e.target - const handleMouseOver = useCallback((e: React.MouseEvent) => { - const target = e.target as SVGElement - const el = target.closest('[data-node-id], [data-link-id]') as SVGElement | null - if (!el) return - const nodeId = el.getAttribute('data-node-id') - const linkId = el.getAttribute('data-link-id') - if (nodeId) { - setHovered((prev) => (prev?.type === 'node' && prev.id === nodeId) ? prev : { type: 'node', id: nodeId }) - } else if (linkId) { - setHovered((prev) => (prev?.type === 'link' && prev.id === linkId) ? prev : { type: 'link', id: linkId }) - } - }, []) - - const handleMouseLeave = useCallback(() => { - setHovered(null) - }, []) - - // ─── Empty state ──────────────────────────────────────────────── - if (!transitions.length || !layout) { - return ( -
-
- -
-

- No journey data yet -

-

- Navigation flows will appear here as visitors browse through your site. -

-
- ) - } - - // ─── Colors ───────────────────────────────────────────────────── - const labelColor = isDark ? '#e5e5e5' : '#404040' - const labelBg = isDark ? 'rgba(23, 23, 23, 0.9)' : 'rgba(255, 255, 255, 0.9)' - const nodeStroke = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)' - - return ( - - {/* Links */} - - {layout.links.map((link, i) => { - const src = link.source as LayoutNode - const tgt = link.target as LayoutNode - const srcId = String(src.id) - const tgtId = String(tgt.id) - const linkId = `${srcId}->${tgtId}` - - let isHighlighted = false - if (hovered?.type === 'link') { - isHighlighted = hovered.id === linkId - } else if (hovered?.type === 'node') { - isHighlighted = srcId === hovered.id || tgtId === hovered.id - } - - let opacity = isDark ? 0.45 : 0.5 - if (hovered) { - opacity = isHighlighted ? 0.75 : 0.08 - } - - return ( - - - {src.label} → {tgt.label}:{' '} - {(link.value as number).toLocaleString()} sessions - - - ) - })} - - - {/* Nodes */} - - {layout.nodes.map((node) => { - const nodeId = String(node.id) - const isExit = nodeId === 'exit' - const w = isExit ? 8 : (node.x1 ?? 0) - (node.x0 ?? 0) - const h = (node.y1 ?? 0) - (node.y0 ?? 0) - const x = isExit ? (node.x0 ?? 0) + 5 : (node.x0 ?? 0) - - return ( - { - if (onNodeClick && !isExit) onNodeClick(node.label) - }} - > - - {node.label} — {(node.value ?? 0).toLocaleString()} sessions - - - ) - })} - - - {/* Labels — only for nodes tall enough to avoid overlap */} - - {layout.nodes.map((node) => { - const x0 = node.x0 ?? 0 - const x1 = node.x1 ?? 0 - const y0 = node.y0 ?? 0 - const y1 = node.y1 ?? 0 - const nodeH = y1 - y0 - if (nodeH < 36) return null // hide labels for small nodes — hover for details - - const rawLabel = smartLabel(node.label) - const label = truncateLabel(rawLabel, 24) - const textW = estimateTextWidth(label) - const padX = 6 - const rectW = textW + padX * 2 - const rectH = 20 - - const isRight = x1 > SVG_W - MARGIN.right - 60 - const textX = isRight ? x0 - 6 : x1 + 6 - const textY = y0 + nodeH / 2 - const anchor = isRight ? 'end' : 'start' - const bgX = isRight ? textX - textW - padX : textX - padX - const bgY = textY - rectH / 2 - - const nodeId = String(node.id) - const isExit = nodeId === 'exit' - - return ( - - - { - if (onNodeClick && !isExit) onNodeClick(node.label) - }} - > - {label} - - - ) - })} - - - ) -} diff --git a/package-lock.json b/package-lock.json index 0e6b7c4..bcf20b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-frontend", - "version": "0.14.0-alpha", + "version": "0.15.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.14.0-alpha", + "version": "0.15.0-alpha", "dependencies": { "@ciphera-net/ui": "^0.2.5", "@ducanh2912/next-pwa": "^10.2.9", @@ -19,7 +19,6 @@ "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", - "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", @@ -42,7 +41,6 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14", @@ -5628,33 +5626,6 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, - "node_modules/@types/d3-sankey": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", - "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-shape": "^1" - } - }, - "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", - "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", - "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/d3-path": "^1" - } - }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -7917,46 +7888,6 @@ "node": ">=12" } }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", diff --git a/package.json b/package.json index e94b947..2443198 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", - "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", @@ -52,7 +51,6 @@ "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/d3-sankey": "^0.12.5", "@types/d3-scale": "^4.0.9", "@types/node": "^20.14.12", "@types/react": "^19.2.14",