reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 2026 reopt-ai. All rights reserved.

BreakdownAnalysis

block

분석 Surface. 4탭(Trend, Distribution, Composition, Data) + 다중 차트 + DataTable 조합.

컴포넌트 의존 관계

깊이
▼ USES (8)BreakdownAnalysisline-chartbar-chartpie-chartfilter-bardata-tableloading-overlaytime-range-selectortabs
100%

기본 사용

Event Breakdown

Event Breakdown

테스트 커버리지

2026년 2월 4일

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

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

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

BreakdownAnalysis Props

Prop타입기본값설명
titlestring"Breakdown"제목
lineDataChartDataPoint[]—추세 라인 차트 데이터
barDataChartDataPoint[]—분포 바 차트 데이터
pieDataPieChartDataPoint[]—구성 파이 차트 데이터
tableDataBreakdownRow[]—테이블 데이터

Surface 설치

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

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

Consumer target

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

tsx
import { BreakdownAnalysis } from "@/components/surfaces/breakdown-analysis";

Registry metadata

설명
분석 Surface. 4탭(Trend, Distribution, Composition, Data) + 다중 차트 + DataTable 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
chartfiltertableanalytics
Install notes
없음

포함 파일

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

import * as React from "react";
import { TabsRoot, TabList, Tab, TabPanel, FilterBar, DataTable, Button, SurfaceLayout, TimeRangeSelector, SelectRoot, SelectLabel, SelectTrigger, SelectPopover, SelectItem, type ColumnDef, type FilterGroupDef, type DateRange, } from "@reopt-ai/opt-ui";
import { LineChart, BarChart, PieChart, type ChartDataPoint, type ChartSeriesDef, type PieChartDataPoint, } from "@reopt-ai/opt-charts";

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

/** View modes supported by `BreakdownTimeSeries`. */
export type BreakdownTimeSeriesViewMode = "chart" | "table" | "split";

/** Labels for `BreakdownAnalysis`. */
export interface BreakdownAnalysisLabels {
  title?: string;
  trend?: string;
  distribution?: string;
  composition?: string;
  data?: string;
  filtersLabel?: string;
  noTrendData?: string;
  noDistributionData?: string;
  noCompositionData?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  chartView?: string;
  tableView?: string;
  splitView?: string;
  eventLabel?: string;
  propertyLabel?: string;
}

const defaultLabels: Required<BreakdownAnalysisLabels> = {
  title: "Breakdown",
  trend: "Trend",
  distribution: "Distribution",
  composition: "Composition",
  data: "Data",
  filtersLabel: "Filters",
  noTrendData: "No trend data",
  noDistributionData: "No distribution data",
  noCompositionData: "No composition data",
  emptyTitle: "No breakdown data",
  emptyDescription: "There is no data available to break down.",
  chartView: "Chart",
  tableView: "Table",
  splitView: "Split",
  eventLabel: "Event",
  propertyLabel: "Property",
};

/** Props for `BreakdownAnalysis`. */
export interface BreakdownAnalysisProps {
  title?: string;
  lineData?: ChartDataPoint[];
  lineSeries?: ChartSeriesDef[];
  barData?: ChartDataPoint[];
  barSeries?: ChartSeriesDef[];
  pieData?: PieChartDataPoint[];
  tableData?: BreakdownRow[];
  tableColumns?: ColumnDef<BreakdownRow>[];
  /** Table data for TimeSeries tab (when timeSeriesViewMode is "table" or "split") */
  timeSeriesTableData?: BreakdownRow[];
  /** Columns for TimeSeries tab table */
  timeSeriesTableColumns?: ColumnDef<BreakdownRow>[];
  /** View mode for the Trend tab: "chart" (default), "table", or "split" */
  timeSeriesViewMode?: BreakdownTimeSeriesViewMode;
  /** Callback when the Trend tab view mode changes */
  onTimeSeriesViewModeChange?: (mode: BreakdownTimeSeriesViewMode) => void;
  /** Currently selected event for filter selector */
  selectedEvent?: string;
  /** Callback when event selection changes */
  onEventChange?: (event: string) => void;
  /** Event options for the filter selector */
  eventOptions?: { id: string; label: string }[];
  /** Currently selected breakdown property */
  selectedProperty?: string;
  /** Callback when property selection changes */
  onPropertyChange?: (property: string) => void;
  /** Property options for the filter selector */
  propertyOptions?: { id: string; label: string }[];
  /** FilterBar definitions (e.g., event, source toggle, metric) */
  filters?: FilterGroupDef[];
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  /** Custom filter content rendered above the FilterBar (e.g., source toggle, property selector) */
  filterContent?: React.ReactNode;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: BreakdownAnalysisLabels;
  loading?: boolean;
  timeRange?: DateRange;
  onTimeRangeChange?: (range: DateRange) => void;
  className?: string;
}

/** Renders the `BreakdownAnalysis` component. */
export function BreakdownAnalysis({
  title,
  lineData = [],
  lineSeries = [],
  barData = [],
  barSeries = [],
  pieData = [],
  tableData = [],
  tableColumns = [
    { id: "name", header: "Name", accessor: "name" as keyof BreakdownRow },
    { id: "count", header: "Count", accessor: "count" as keyof BreakdownRow },
    {
      id: "percentage",
      header: "%",
      accessor: "percentage" as keyof BreakdownRow,
    },
  ],
  timeSeriesTableData,
  timeSeriesTableColumns = [
    { id: "name", header: "Name", accessor: "name" as keyof BreakdownRow },
    { id: "count", header: "Count", accessor: "count" as keyof BreakdownRow },
  ],
  timeSeriesViewMode,
  onTimeSeriesViewModeChange,
  selectedEvent,
  onEventChange,
  eventOptions,
  selectedProperty,
  onPropertyChange,
  propertyOptions,
  filters,
  onFilterChange,
  filterContent,
  header,
  actions,
  labels: customLabels,
  loading = false,
  timeRange,
  onTimeRangeChange,
  className,
}: BreakdownAnalysisProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const sectionId = React.useId();

  // Use explicit title prop if provided (backward compat), otherwise labels.title
  const displayTitle = title ?? labels.title;
  const hasFilters = filters && filters.length > 0;
  const hasEventSelector = eventOptions && eventOptions.length > 0;
  const hasPropertySelector = propertyOptions && propertyOptions.length > 0;

  const isEmpty =
    lineData.length === 0 &&
    barData.length === 0 &&
    pieData.length === 0 &&
    tableData.length === 0;

  const trendViewMode = timeSeriesViewMode ?? "chart";

  const viewModeButtons: {
    mode: BreakdownTimeSeriesViewMode;
    label: string;
  }[] = [
    { mode: "chart", label: labels.chartView },
    { mode: "table", label: labels.tableView },
    { mode: "split", label: labels.splitView },
  ];

  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">
            {displayTitle}
          </h2>
          <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
        </div>
      )}

      {/* Event / Property selectors */}
      {(hasEventSelector || hasPropertySelector) && (
        <div className="gap-group flex items-end">
          {hasEventSelector && (
            <div className="min-w-[160px]">
              <SelectRoot
                value={selectedEvent ?? ""}
                setValue={(v) =>
                  onEventChange?.(typeof v === "string" ? v : (v[0] ?? ""))
                }
              >
                <SelectLabel className="text-text-secondary mb-1 block text-xs font-medium">
                  {labels.eventLabel}
                </SelectLabel>
                <SelectTrigger className="border-border bg-surface text-text-primary inline-flex h-8 w-full items-center justify-between rounded-md border px-3 text-sm">
                  {eventOptions?.find((o) => o.id === selectedEvent)?.label ??
                    selectedEvent ??
                    ""}
                </SelectTrigger>
                <SelectPopover className="bg-surface border-border z-50 rounded-md border shadow-lg">
                  {eventOptions?.map((opt) => (
                    <SelectItem
                      key={opt.id}
                      value={opt.id}
                      className="text-text-primary hover:bg-bg-subtle cursor-pointer px-3 py-1.5 text-sm"
                    >
                      {opt.label}
                    </SelectItem>
                  ))}
                </SelectPopover>
              </SelectRoot>
            </div>
          )}
          {hasPropertySelector && (
            <div className="min-w-[160px]">
              <SelectRoot
                value={selectedProperty ?? ""}
                setValue={(v) =>
                  onPropertyChange?.(typeof v === "string" ? v : (v[0] ?? ""))
                }
              >
                <SelectLabel className="text-text-secondary mb-1 block text-xs font-medium">
                  {labels.propertyLabel}
                </SelectLabel>
                <SelectTrigger className="border-border bg-surface text-text-primary inline-flex h-8 w-full items-center justify-between rounded-md border px-3 text-sm">
                  {propertyOptions?.find((o) => o.id === selectedProperty)
                    ?.label ??
                    selectedProperty ??
                    ""}
                </SelectTrigger>
                <SelectPopover className="bg-surface border-border z-50 rounded-md border shadow-lg">
                  {propertyOptions?.map((opt) => (
                    <SelectItem
                      key={opt.id}
                      value={opt.id}
                      className="text-text-primary hover:bg-bg-subtle cursor-pointer px-3 py-1.5 text-sm"
                    >
                      {opt.label}
                    </SelectItem>
                  ))}
                </SelectPopover>
              </SelectRoot>
            </div>
          )}
        </div>
      )}

      {filterContent}

      {hasFilters && (
        <section aria-labelledby={`${sectionId}-filters`}>
          <span id={`${sectionId}-filters`} className="sr-only">
            {labels.filtersLabel}
          </span>
          <FilterBar filters={filters} onFilterChange={onFilterChange} />
        </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">
            {displayTitle}
          </span>
          <TabsRoot defaultSelectedId="trend">
            <TabList>
              <Tab id="trend">{labels.trend}</Tab>
              <Tab id="bar">{labels.distribution}</Tab>
              <Tab id="pie">{labels.composition}</Tab>
              <Tab id="table">{labels.data}</Tab>
            </TabList>
            <TabPanel tabId="trend">
              <div className="pt-4">
                {/* View mode toggle buttons (only shown when timeSeriesViewMode is provided) */}
                {timeSeriesViewMode !== undefined && (
                  <div className="mb-4 flex gap-1">
                    {viewModeButtons.map((btn) => (
                      <Button
                        key={btn.mode}
                        variant={
                          trendViewMode === btn.mode ? "primary" : "ghost"
                        }
                        size="sm"
                        onClick={() => onTimeSeriesViewModeChange?.(btn.mode)}
                      >
                        {btn.label}
                      </Button>
                    ))}
                  </div>
                )}

                {(trendViewMode === "chart" || trendViewMode === "split") &&
                  (lineData.length > 0 && lineSeries.length > 0 ? (
                    <LineChart
                      data={lineData}
                      series={lineSeries}
                      aria-label="Trend"
                    />
                  ) : (
                    <p className="text-text-tertiary py-8 text-center text-sm">
                      {labels.noTrendData}
                    </p>
                  ))}

                {(trendViewMode === "table" || trendViewMode === "split") && (
                  <div className={trendViewMode === "split" ? "mt-4" : ""}>
                    <DataTable
                      columns={timeSeriesTableColumns}
                      data={timeSeriesTableData ?? []}
                      keyExtractor={(r) => r.id}
                    />
                  </div>
                )}
              </div>
            </TabPanel>
            <TabPanel tabId="bar">
              <div className="pt-4">
                {barData.length > 0 && barSeries.length > 0 ? (
                  <BarChart
                    data={barData}
                    series={barSeries}
                    aria-label="Distribution"
                  />
                ) : (
                  <p className="text-text-tertiary py-8 text-center text-sm">
                    {labels.noDistributionData}
                  </p>
                )}
              </div>
            </TabPanel>
            <TabPanel tabId="pie">
              <div className="pt-4">
                {pieData.length > 0 ? (
                  <PieChart data={pieData} aria-label="Composition" />
                ) : (
                  <p className="text-text-tertiary py-8 text-center text-sm">
                    {labels.noCompositionData}
                  </p>
                )}
              </div>
            </TabPanel>
            <TabPanel tabId="table">
              <div className="pt-4">
                <DataTable
                  columns={tableColumns}
                  data={tableData}
                  keyExtractor={(r) => r.id}
                />
              </div>
            </TabPanel>
          </TabsRoot>
        </section>
      )}
    </SurfaceLayout>
  );
}

BreakdownAnalysis.displayName = "BreakdownAnalysis";