feat: replace sankey chart with column-based journey visualization
This commit is contained in:
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
import { getDateRange, formatDate } from '@ciphera-net/ui'
|
||||||
import { Select, DatePicker } 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 TopPathsTable from '@/components/journeys/TopPathsTable'
|
||||||
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
@@ -172,9 +172,9 @@ export default function JourneysPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sankey Diagram */}
|
{/* Journey Columns */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<SankeyDiagram
|
<ColumnJourney
|
||||||
transitions={transitionsData?.transitions ?? []}
|
transitions={transitionsData?.transitions ?? []}
|
||||||
totalSessions={totalSessions}
|
totalSessions={totalSessions}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
|
|||||||
479
components/journeys/ColumnJourney.tsx
Normal file
479
components/journeys/ColumnJourney.tsx
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { TreeStructure } from '@phosphor-icons/react'
|
||||||
|
import type { PathTransition } from '@/lib/api/journeys'
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ColumnJourneyProps {
|
||||||
|
transitions: PathTransition[]
|
||||||
|
totalSessions: number
|
||||||
|
depth: number
|
||||||
|
onNodeClick?: (path: string) => 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<number, string>,
|
||||||
|
): 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<string, number>()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2.5 mb-3 px-1">
|
||||||
|
<span
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold text-white shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{column.index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||||
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white whitespace-nowrap">
|
||||||
|
{column.totalSessions.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400 whitespace-nowrap">
|
||||||
|
visitors
|
||||||
|
</span>
|
||||||
|
{column.dropOffPercent !== 0 && (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium whitespace-nowrap ${
|
||||||
|
column.dropOffPercent < 0 ? 'text-red-500' : 'text-emerald-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{column.dropOffPercent > 0 ? '+' : ''}
|
||||||
|
{column.dropOffPercent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isOther}
|
||||||
|
onClick={onClick}
|
||||||
|
title={page.path}
|
||||||
|
data-col={colIndex}
|
||||||
|
data-path={page.path}
|
||||||
|
className={`
|
||||||
|
group flex items-center justify-between w-full
|
||||||
|
h-9 px-2 rounded-lg text-left transition-colors
|
||||||
|
${isOther ? 'cursor-default' : 'cursor-pointer'}
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-orange/10 dark:bg-brand-orange/15 ring-1 ring-brand-orange/30'
|
||||||
|
: isOther
|
||||||
|
? ''
|
||||||
|
: 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-1 truncate text-sm ${
|
||||||
|
isOther
|
||||||
|
? 'italic text-neutral-400 dark:text-neutral-500'
|
||||||
|
: 'text-neutral-900 dark:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isOther ? page.path : smartLabel(page.path)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||||
|
{!isOther && (
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-150">
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-xs tabular-nums font-medium ${
|
||||||
|
isOther
|
||||||
|
? 'text-neutral-400 dark:text-neutral-500'
|
||||||
|
: 'text-neutral-500 dark:text-neutral-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.sessionCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JourneyColumn({
|
||||||
|
column,
|
||||||
|
color,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
column: Column
|
||||||
|
color: string
|
||||||
|
selectedPath: string | undefined
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
}) {
|
||||||
|
if (column.pages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="w-52 shrink-0">
|
||||||
|
<ColumnHeader column={column} color={color} />
|
||||||
|
<div className="flex items-center justify-center h-20 rounded-xl border border-dashed border-neutral-200 dark:border-neutral-700">
|
||||||
|
<span className="text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
|
No onward traffic
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-52 shrink-0">
|
||||||
|
<ColumnHeader column={column} color={color} />
|
||||||
|
<div className="space-y-0.5 max-h-[500px] overflow-y-auto">
|
||||||
|
{column.pages.map((page) => {
|
||||||
|
const isOther = page.path === '(other)'
|
||||||
|
return (
|
||||||
|
<PageRow
|
||||||
|
key={page.path}
|
||||||
|
page={page}
|
||||||
|
colIndex={column.index}
|
||||||
|
columnTotal={column.totalSessions}
|
||||||
|
isSelected={selectedPath === page.path}
|
||||||
|
isOther={isOther}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isOther) onSelect(page.path)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection Lines ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConnectionLines({
|
||||||
|
containerRef,
|
||||||
|
selections,
|
||||||
|
columns,
|
||||||
|
transitions,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
selections: Map<number, string>
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
className="absolute top-0 left-0 pointer-events-none"
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const midX = (line.sourceX + line.destX) / 2
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={`M ${line.sourceX},${line.sourceY} C ${midX},${line.sourceY} ${midX},${line.destY} ${line.destX},${line.destY}`}
|
||||||
|
stroke={line.color}
|
||||||
|
strokeWidth={line.weight}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ColumnJourney({
|
||||||
|
transitions,
|
||||||
|
totalSessions,
|
||||||
|
depth,
|
||||||
|
onNodeClick,
|
||||||
|
}: ColumnJourneyProps) {
|
||||||
|
const [selections, setSelections] = useState<Map<number, string>>(new Map())
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
||||||
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
||||||
|
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
||||||
|
No journey data yet
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
||||||
|
Navigation flows will appear here as visitors browse through your site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="overflow-x-auto -mx-6 px-6 pb-2 relative"
|
||||||
|
>
|
||||||
|
<div className="flex gap-6 min-w-fit py-2">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<JourneyColumn
|
||||||
|
key={col.index}
|
||||||
|
column={col}
|
||||||
|
color={colorForColumn(col.index)}
|
||||||
|
selectedPath={selections.get(col.index)}
|
||||||
|
onSelect={(path) => handleSelect(col.index, path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ConnectionLines
|
||||||
|
containerRef={containerRef}
|
||||||
|
selections={selections}
|
||||||
|
columns={columns}
|
||||||
|
transitions={transitions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<NodeExtra, LinkExtra>
|
|
||||||
type LayoutLink = D3SankeyLink<NodeExtra, LinkExtra>
|
|
||||||
|
|
||||||
// ─── 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<string, NodeExtra>()
|
|
||||||
const links: Array<{ source: string; target: string; value: number }> = []
|
|
||||||
const flowOut = new Map<string, number>()
|
|
||||||
const flowIn = new Map<string, number>()
|
|
||||||
|
|
||||||
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<number, string[]>()
|
|
||||||
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<string, { source: string; target: string; value: number }>()
|
|
||||||
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<SVGSVGElement>(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<number, number>()
|
|
||||||
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<NodeExtra, LinkExtra>()
|
|
||||||
.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<SVGSVGElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className="h-[400px] flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
||||||
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
||||||
<TreeStructure className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
||||||
No journey data yet
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
||||||
Navigation flows will appear here as visitors browse through your site.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 (
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
viewBox={`0 0 ${SVG_W} ${svgH}`}
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
className="w-full"
|
|
||||||
role="img"
|
|
||||||
aria-label="User journey Sankey diagram"
|
|
||||||
onMouseMove={handleMouseOver}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{/* Links */}
|
|
||||||
<g>
|
|
||||||
{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 (
|
|
||||||
<path
|
|
||||||
key={i}
|
|
||||||
d={ribbonPath(link)}
|
|
||||||
fill={src.color}
|
|
||||||
opacity={opacity}
|
|
||||||
style={{ transition: 'opacity 0.15s ease' }}
|
|
||||||
data-link-id={linkId}
|
|
||||||
>
|
|
||||||
<title>
|
|
||||||
{src.label} → {tgt.label}:{' '}
|
|
||||||
{(link.value as number).toLocaleString()} sessions
|
|
||||||
</title>
|
|
||||||
</path>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Nodes */}
|
|
||||||
<g>
|
|
||||||
{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 (
|
|
||||||
<rect
|
|
||||||
key={nodeId}
|
|
||||||
x={x}
|
|
||||||
y={node.y0}
|
|
||||||
width={w}
|
|
||||||
height={h}
|
|
||||||
fill={node.color}
|
|
||||||
stroke={nodeStroke}
|
|
||||||
strokeWidth={1}
|
|
||||||
rx={2}
|
|
||||||
className={
|
|
||||||
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
|
|
||||||
}
|
|
||||||
data-node-id={nodeId}
|
|
||||||
onClick={() => {
|
|
||||||
if (onNodeClick && !isExit) onNodeClick(node.label)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<title>
|
|
||||||
{node.label} — {(node.value ?? 0).toLocaleString()} sessions
|
|
||||||
</title>
|
|
||||||
</rect>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Labels — only for nodes tall enough to avoid overlap */}
|
|
||||||
<g>
|
|
||||||
{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 (
|
|
||||||
<g key={`label-${nodeId}`} data-node-id={nodeId}>
|
|
||||||
<rect
|
|
||||||
x={bgX}
|
|
||||||
y={bgY}
|
|
||||||
width={rectW}
|
|
||||||
height={rectH}
|
|
||||||
rx={3}
|
|
||||||
fill={labelBg}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={textX}
|
|
||||||
y={textY}
|
|
||||||
dy="0.35em"
|
|
||||||
textAnchor={anchor}
|
|
||||||
fill={labelColor}
|
|
||||||
fontSize={12}
|
|
||||||
fontFamily="system-ui, -apple-system, sans-serif"
|
|
||||||
className={
|
|
||||||
onNodeClick && !isExit ? 'cursor-pointer' : 'cursor-default'
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
if (onNodeClick && !isExit) onNodeClick(node.label)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.14.0-alpha",
|
"version": "0.15.0-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.14.0-alpha",
|
"version": "0.15.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.5",
|
"@ciphera-net/ui": "^0.2.5",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-sankey": "^0.12.3",
|
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-sankey": "^0.12.5",
|
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -5628,33 +5626,6 @@
|
|||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/d3-scale": {
|
||||||
"version": "4.0.9",
|
"version": "4.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
@@ -7917,46 +7888,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cobe": "^0.6.5",
|
"cobe": "^0.6.5",
|
||||||
"country-flag-icons": "^1.6.4",
|
"country-flag-icons": "^1.6.4",
|
||||||
"d3-sankey": "^0.12.3",
|
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
@@ -52,7 +51,6 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-sankey": "^0.12.5",
|
|
||||||
"@types/d3-scale": "^4.0.9",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/node": "^20.14.12",
|
"@types/node": "^20.14.12",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
Reference in New Issue
Block a user