reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

PathAnalysis

surface

경로 분석 Surface. SankeyChart + 6탭(Flow, Top Paths, Drop-off, Patterns, Entry/Exit, Dwell) + BarChart + DataTable 조합.

컴포넌트 의존 관계

깊이
▼ USES (8)PathAnalysissankey-chartbar-chartdata-tablefilter-barnumber-inputloading-overlaytime-range-selectortabs
100%

기본 사용

Path Analysis

Path analysis details
HomePricingDocsSign UpDashboard

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

PathAnalysis 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

PathAnalysis Props

Prop타입기본값설명
nodes*SankeyNode[]—산키 노드 배열
links*SankeyLink[]—산키 링크 배열
topPathsPathRow[]—상위 경로 테이블 데이터
dropoffDataPathRow[]—이탈 테이블 데이터
loopDataPathRow[]—Loop detection 데이터 (Patterns 탭)
urlPatternDataPathRow[]—URL 패턴 그룹핑 데이터 (Patterns 탭)
entryDataPathRow[]—진입 페이지 데이터 (Entry/Exit 탭)
exitDataPathRow[]—이탈 페이지 데이터 (Entry/Exit 탭)
conversionPathsPathRow[]—전환 경로 데이터 (Entry/Exit 탭)
dwellChartDataChartDataPoint[]—체류 시간 바 차트 데이터 (Dwell 탭)
dwellTableDataPathRow[]—체류 시간 테이블 데이터 (Dwell 탭)
filtersFilterGroupDef[]—FilterBar 필터 정의 (URL 그룹핑, 세그먼트 등)
widthnumber700산키 차트 너비
heightnumber350산키 차트 높이
depthnumber—경로 분석 깊이 (표시할 단계 수)
onDepthChange(depth: number) => void—깊이 변경 콜백
startPagestring—시작 페이지 필터
onStartPageChange(page: string) => void—시작 페이지 변경 콜백
endPagestring—종료 페이지 필터
onEndPageChange(page: string) => void—종료 페이지 변경 콜백
pageOptionsPageOption[]—시작/종료 페이지 셀렉터 옵션 ({ id, label })

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add path-analysis

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { PathAnalysis } from "@/components/surfaces/path-analysis";

Registry metadata

설명
경로 분석 Surface. SankeyChart + 6탭(Flow, Top Paths, Drop-off, Patterns, Entry/Exit, Dwell) + BarChart + DataTable 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
charttablefilteranalytics
Install notes
없음

포함 파일

  • path-analysis.tsx→path-analysis.tsx
Surface 소스 보기
path-analysis.tsx
"use client";

import * as React from "react";
import {
  TabsRoot,
  TabList,
  Tab,
  TabPanel,
  DataTable,
  FilterBar,
  NumberInput,
  SurfaceLayout,
  SummaryRow,
  TimeRangeSelector,
  type ColumnDef,
  type FilterGroupDef,
  type DateRange,
  type StatCardType,
} from "@reopt-ai/opt-ui";
import {
  SankeyChart,
  BarChart,
  type SankeyNode,
  type SankeyLink,
  type ChartDataPoint,
  type ChartSeriesDef,
} from "@reopt-ai/opt-charts";

interface PathRow {
  id: string;
  [k: string]: unknown;
}

/** Labels for `PathAnalysis`. */
export interface PathAnalysisLabels {
  title?: string;
  flow?: string;
  topPaths?: string;
  dropoff?: string;
  patterns?: string;
  entryExit?: string;
  dwell?: string;
  loopDetection?: string;
  urlPatterns?: string;
  entryPages?: string;
  exitPages?: string;
  conversionPaths?: string;
  filtersLabel?: string;
  controlsLabel?: string;
  detailsLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  /** Label for the depth control */
  depthLabel?: string;
  /** Label for the start page selector */
  startPageLabel?: string;
  /** Label for the end page selector */
  endPageLabel?: string;
  /** Placeholder for page selectors */
  pageSelectorPlaceholder?: string;
}

const defaultLabels: Required<PathAnalysisLabels> = {
  title: "Path Analysis",
  flow: "Flow",
  topPaths: "Top Paths",
  dropoff: "Drop-off",
  patterns: "Patterns",
  entryExit: "Entry / Exit",
  dwell: "Dwell Time",
  loopDetection: "Loop Detection",
  urlPatterns: "URL Patterns",
  entryPages: "Entry Pages",
  exitPages: "Exit Pages",
  conversionPaths: "Conversion Paths",
  filtersLabel: "Filters",
  controlsLabel: "Path controls",
  detailsLabel: "Path analysis details",
  emptyTitle: "No path data",
  emptyDescription: "There are no user paths to analyze yet.",
  depthLabel: "Depth",
  startPageLabel: "Start Page",
  endPageLabel: "End Page",
  pageSelectorPlaceholder: "All pages",
};

/** Option for page selectors */
export interface PageOption {
  id: string;
  label: string;
}

/** Props for `PathAnalysis`. */
export interface PathAnalysisProps {
  nodes: SankeyNode[];
  links: SankeyLink[];
  topPaths?: PathRow[];
  topPathColumns?: ColumnDef<PathRow>[];
  dropoffData?: PathRow[];
  dropoffColumns?: ColumnDef<PathRow>[];
  /** Loop detection rows (Patterns tab) */
  loopData?: PathRow[];
  loopColumns?: ColumnDef<PathRow>[];
  /** URL pattern grouping rows (Patterns tab) */
  urlPatternData?: PathRow[];
  urlPatternColumns?: ColumnDef<PathRow>[];
  /** Entry page rows (Entry/Exit tab) */
  entryData?: PathRow[];
  entryColumns?: ColumnDef<PathRow>[];
  /** Exit page rows (Entry/Exit tab) */
  exitData?: PathRow[];
  exitColumns?: ColumnDef<PathRow>[];
  /** Conversion path rows (Entry/Exit tab) */
  conversionPaths?: PathRow[];
  conversionPathColumns?: ColumnDef<PathRow>[];
  /** Dwell time bar chart data */
  dwellChartData?: ChartDataPoint[];
  dwellSeries?: ChartSeriesDef[];
  /** Dwell time table rows */
  dwellTableData?: PathRow[];
  dwellTableColumns?: ColumnDef<PathRow>[];
  /** Stats cards displayed above the tabs */
  stats?: StatCardType[];
  /** FilterBar definitions (e.g., URL grouping, segment) */
  filters?: FilterGroupDef[];
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  /** Path analysis depth (number of steps to show) */
  depth?: number;
  /** Callback when depth changes */
  onDepthChange?: (depth: number) => void;
  /** Start page filter for path analysis */
  startPage?: string;
  /** Callback when start page changes */
  onStartPageChange?: (page: string) => void;
  /** End page filter for path analysis */
  endPage?: string;
  /** Callback when end page changes */
  onEndPageChange?: (page: string) => void;
  /** Available page options for start/end page selectors */
  pageOptions?: PageOption[];
  width?: number;
  height?: number;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: PathAnalysisLabels;
  loading?: boolean;
  timeRange?: DateRange;
  onTimeRangeChange?: (range: DateRange) => void;
  className?: string;
}

/** Renders the `PathAnalysis` component. */
export function PathAnalysis({
  nodes,
  links,
  topPaths = [],
  topPathColumns = [
    { id: "path", header: "Path", accessor: "path" as keyof PathRow },
    { id: "users", header: "Users", accessor: "users" as keyof PathRow },
    {
      id: "conversion",
      header: "Conversion",
      accessor: "conversion" as keyof PathRow,
    },
  ],
  dropoffData = [],
  dropoffColumns = [
    { id: "step", header: "Step", accessor: "step" as keyof PathRow },
    { id: "dropoff", header: "Drop-off", accessor: "dropoff" as keyof PathRow },
    { id: "percentage", header: "%", accessor: "percentage" as keyof PathRow },
  ],
  loopData,
  loopColumns = [
    {
      id: "pattern",
      header: "Loop Pattern",
      accessor: "pattern" as keyof PathRow,
    },
    { id: "count", header: "Occurrences", accessor: "count" as keyof PathRow },
    {
      id: "avgCycles",
      header: "Avg Cycles",
      accessor: "avgCycles" as keyof PathRow,
    },
  ],
  urlPatternData,
  urlPatternColumns = [
    {
      id: "pattern",
      header: "URL Pattern",
      accessor: "pattern" as keyof PathRow,
    },
    { id: "pages", header: "Pages", accessor: "pages" as keyof PathRow },
    {
      id: "sessions",
      header: "Sessions",
      accessor: "sessions" as keyof PathRow,
    },
  ],
  entryData,
  entryColumns = [
    { id: "page", header: "Entry Page", accessor: "page" as keyof PathRow },
    {
      id: "sessions",
      header: "Sessions",
      accessor: "sessions" as keyof PathRow,
    },
    { id: "percentage", header: "%", accessor: "percentage" as keyof PathRow },
  ],
  exitData,
  exitColumns = [
    { id: "page", header: "Exit Page", accessor: "page" as keyof PathRow },
    {
      id: "sessions",
      header: "Sessions",
      accessor: "sessions" as keyof PathRow,
    },
    { id: "percentage", header: "%", accessor: "percentage" as keyof PathRow },
  ],
  conversionPaths,
  conversionPathColumns = [
    { id: "path", header: "Path", accessor: "path" as keyof PathRow },
    {
      id: "conversions",
      header: "Conversions",
      accessor: "conversions" as keyof PathRow,
    },
    { id: "rate", header: "Rate", accessor: "rate" as keyof PathRow },
  ],
  dwellChartData,
  dwellSeries = [
    { dataKey: "duration", name: "Avg Duration", color: "#3b82f6" },
  ],
  dwellTableData,
  dwellTableColumns = [
    { id: "page", header: "Page", accessor: "page" as keyof PathRow },
    {
      id: "avgDwell",
      header: "Avg Dwell",
      accessor: "avgDwell" as keyof PathRow,
    },
    {
      id: "medianDwell",
      header: "Median",
      accessor: "medianDwell" as keyof PathRow,
    },
  ],
  stats,
  filters,
  onFilterChange,
  depth,
  onDepthChange,
  startPage,
  onStartPageChange,
  endPage,
  onEndPageChange,
  pageOptions,
  width = 700,
  height = 350,
  header,
  actions,
  labels: customLabels,
  loading = false,
  timeRange,
  onTimeRangeChange,
  className,
}: PathAnalysisProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const sectionId = React.useId();

  const isEmpty = nodes.length === 0;
  const hasPatterns =
    (loopData && loopData.length > 0) ||
    (urlPatternData && urlPatternData.length > 0);
  const hasEntryExit =
    (entryData && entryData.length > 0) || (exitData && exitData.length > 0);
  const hasDwell =
    (dwellChartData && dwellChartData.length > 0) ||
    (dwellTableData && dwellTableData.length > 0);
  const hasFilters = filters && filters.length > 0;
  const hasControls =
    onDepthChange != null ||
    onStartPageChange != null ||
    onEndPageChange != null;

  return (
    <SurfaceLayout loading={loading} className={className}>
      {header || actions ? (
        <div className="flex items-center justify-between">
          {header && <div>{header}</div>}
          <div className="gap-element flex items-center">
            <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
            {actions}
          </div>
        </div>
      ) : (
        <div className="flex items-center justify-between">
          <h2 className="text-text-primary text-lg font-semibold">
            {labels.title}
          </h2>
          <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
        </div>
      )}

      {stats && stats.length > 0 && <SummaryRow stats={stats} columns={4} />}

      {hasFilters && (
        <section aria-labelledby={`${sectionId}-filters`}>
          <span id={`${sectionId}-filters`} className="sr-only">
            {labels.filtersLabel}
          </span>
          <FilterBar filters={filters} onFilterChange={onFilterChange} />
        </section>
      )}

      {/* Path analysis controls: depth, start page, end page */}
      {hasControls && (
        <section
          aria-labelledby={`${sectionId}-controls`}
          className="gap-group flex flex-wrap items-end"
        >
          <span id={`${sectionId}-controls`} className="sr-only">
            {labels.controlsLabel}
          </span>
          {onDepthChange && (
            <label className="flex flex-col gap-1">
              <span className="text-text-secondary text-xs font-medium">
                {labels.depthLabel}
              </span>
              <NumberInput
                value={depth ?? 5}
                onChange={onDepthChange}
                min={1}
                max={20}
                step={1}
              />
            </label>
          )}
          {onStartPageChange && pageOptions && (
            <label className="flex flex-col gap-1">
              <span className="text-text-secondary text-xs font-medium">
                {labels.startPageLabel}
              </span>
              <select
                value={startPage ?? ""}
                onChange={(e) => onStartPageChange(e.target.value)}
                className="border-border bg-surface text-text-primary rounded-md border px-2.5 py-1.5 text-sm"
              >
                <option value="">{labels.pageSelectorPlaceholder}</option>
                {pageOptions.map((p) => (
                  <option key={p.id} value={p.id}>
                    {p.label}
                  </option>
                ))}
              </select>
            </label>
          )}
          {onEndPageChange && pageOptions && (
            <label className="flex flex-col gap-1">
              <span className="text-text-secondary text-xs font-medium">
                {labels.endPageLabel}
              </span>
              <select
                value={endPage ?? ""}
                onChange={(e) => onEndPageChange(e.target.value)}
                className="border-border bg-surface text-text-primary rounded-md border px-2.5 py-1.5 text-sm"
              >
                <option value="">{labels.pageSelectorPlaceholder}</option>
                {pageOptions.map((p) => (
                  <option key={p.id} value={p.id}>
                    {p.label}
                  </option>
                ))}
              </select>
            </label>
          )}
        </section>
      )}

      {isEmpty ? (
        <div
          role="status"
          className="flex flex-col items-center justify-center py-16 text-center"
        >
          <h3 className="text-text-primary text-lg font-medium">
            {labels.emptyTitle}
          </h3>
          <p className="text-text-tertiary mt-1 text-sm">
            {labels.emptyDescription}
          </p>
        </div>
      ) : (
        <section aria-labelledby={`${sectionId}-tabs`}>
          <span id={`${sectionId}-tabs`} className="sr-only">
            {labels.detailsLabel}
          </span>
          <TabsRoot defaultSelectedId="flow">
            <TabList>
              <Tab id="flow">{labels.flow}</Tab>
              <Tab id="top-paths">{labels.topPaths}</Tab>
              <Tab id="dropoff">{labels.dropoff}</Tab>
              {hasPatterns && <Tab id="patterns">{labels.patterns}</Tab>}
              {hasEntryExit && <Tab id="entry-exit">{labels.entryExit}</Tab>}
              {hasDwell && <Tab id="dwell">{labels.dwell}</Tab>}
            </TabList>
            <TabPanel tabId="flow">
              <div className="overflow-x-auto pt-4">
                <SankeyChart
                  nodes={nodes}
                  links={links}
                  width={width}
                  height={height}
                  aria-label="User flow"
                />
              </div>
            </TabPanel>
            <TabPanel tabId="top-paths">
              <div className="pt-4">
                <DataTable
                  columns={topPathColumns}
                  data={topPaths}
                  keyExtractor={(r) => r.id}
                />
              </div>
            </TabPanel>
            <TabPanel tabId="dropoff">
              <div className="pt-4">
                <DataTable
                  columns={dropoffColumns}
                  data={dropoffData}
                  keyExtractor={(r) => r.id}
                />
              </div>
            </TabPanel>
            {hasPatterns && (
              <TabPanel tabId="patterns">
                <div className="gap-section flex flex-col pt-4">
                  {loopData && loopData.length > 0 && (
                    <div>
                      <h4 className="text-text-secondary mb-2 text-sm font-medium">
                        {labels.loopDetection}
                      </h4>
                      <DataTable
                        columns={loopColumns}
                        data={loopData}
                        keyExtractor={(r) => r.id}
                      />
                    </div>
                  )}
                  {urlPatternData && urlPatternData.length > 0 && (
                    <div>
                      <h4 className="text-text-secondary mb-2 text-sm font-medium">
                        {labels.urlPatterns}
                      </h4>
                      <DataTable
                        columns={urlPatternColumns}
                        data={urlPatternData}
                        keyExtractor={(r) => r.id}
                      />
                    </div>
                  )}
                </div>
              </TabPanel>
            )}
            {hasEntryExit && (
              <TabPanel tabId="entry-exit">
                <div className="gap-section flex flex-col pt-4">
                  <div className="gap-section grid md:grid-cols-2">
                    {entryData && entryData.length > 0 && (
                      <div>
                        <h4 className="text-text-secondary mb-2 text-sm font-medium">
                          {labels.entryPages}
                        </h4>
                        <DataTable
                          columns={entryColumns}
                          data={entryData}
                          keyExtractor={(r) => r.id}
                        />
                      </div>
                    )}
                    {exitData && exitData.length > 0 && (
                      <div>
                        <h4 className="text-text-secondary mb-2 text-sm font-medium">
                          {labels.exitPages}
                        </h4>
                        <DataTable
                          columns={exitColumns}
                          data={exitData}
                          keyExtractor={(r) => r.id}
                        />
                      </div>
                    )}
                  </div>
                  {conversionPaths && conversionPaths.length > 0 && (
                    <div>
                      <h4 className="text-text-secondary mb-2 text-sm font-medium">
                        {labels.conversionPaths}
                      </h4>
                      <DataTable
                        columns={conversionPathColumns}
                        data={conversionPaths}
                        keyExtractor={(r) => r.id}
                      />
                    </div>
                  )}
                </div>
              </TabPanel>
            )}
            {hasDwell && (
              <TabPanel tabId="dwell">
                <div className="gap-section flex flex-col pt-4">
                  {dwellChartData && dwellChartData.length > 0 && (
                    <BarChart
                      data={dwellChartData}
                      series={dwellSeries}
                      height={250}
                      aria-label="Dwell time distribution"
                    />
                  )}
                  {dwellTableData && dwellTableData.length > 0 && (
                    <DataTable
                      columns={dwellTableColumns}
                      data={dwellTableData}
                      keyExtractor={(r) => r.id}
                    />
                  )}
                </div>
              </TabPanel>
            )}
          </TabsRoot>
        </section>
      )}
    </SurfaceLayout>
  );
}

PathAnalysis.displayName = "PathAnalysis";