@@ -1,6 +1,6 @@
import { memo , useContext , useLayoutEffect , useRef , useState } from "react" ;
import { memo , useContext , useLayoutEffect , useMemo , useRef , useState } from "react" ;
import { motion , useAnimationControls , useReducedMotion } from "motion/react" ;
import { BarChart3 , X , Database , SlidersHorizontal , Table2 , TrendingUp } from "lucide-react" ;
import { BarChart3 , X , Database , FileText , SlidersHorizontal , Table2 , TrendingUp } from "lucide-react" ;
import { FloatingWindow } from "@/shared/ui/floating-window" ;
import { Card , CardContent , CardHeader , CardTitle } from "@/shared/ui/card" ;
import { Tabs , TabsContent , TabsList , TabsTrigger } from "@/shared/ui/tabs" ;
@@ -11,8 +11,9 @@ import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart" ;
import { agentEase } from "../components/agent-motion" ;
import type { SafeChartSeries } from "../chart-data" ;
import { chartPointTooltip , chartSampleTime , chartValueBounds , chartValueLabel } from "./chart-presentation " ;
import { Popover , PopoverContent , PopoverTrigger } from "@/shared/ui/popover " ;
import { AnswerInteractionContext } from "./answer-ui-interaction-context" ;
import { chartPointTooltip , chartSampleTime , chartUsesIntegerTicks , chartValueBounds , chartValueLabel } from "./chart-presentation" ;
export type AnswerChartProps = {
title : string ;
@@ -35,7 +36,7 @@ function PointSelect({ label, value, labels, onChange }: {
< SelectContent > { labels . map ( ( text , index ) = > < SelectItem key = { index } value = { String ( index ) } > { text } < / SelectItem > ) } < / SelectContent >
< / Select > ;
}
type ChartPresentation = { expanded? : boolean ; plotHeight? : number ; embedded? : boolean ; xAxisName? : string ; yAxisName? : string } ;
type ChartPresentation = { tile? : boolean ; expanded? : boolean ; plotHeight? : number ; embedded? : boolean ; xAxisName? : string ; yAxisName? : string } ;
const standaloneRegistry = { } ;
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */
@@ -47,7 +48,9 @@ export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresen
}
export const InteractiveChart = memo ( function InteractiveChartView ( props : AnswerChartProps & ChartPresentation ) {
const { chartWindowKey } = useContext ( AnswerInteractionContext ) ;
const { chartPresentation } = useContext ( AnswerInteractionContext ) ;
const tile = props . tile ? ? chartPresentation === "tile" ;
const fitted = Boolean ( props . expanded || tile ) ;
const [ { view , hidden , range , point , inspecting , table } , patch , migrationNotice ] = useAnswerNodeState < AnswerChartState > ( "Chart" , props ) ;
const reducedMotion = useReducedMotion ( ) ;
const pointer = useRef ( false ) ;
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const [ plotWidth , setPlotWidth ] = useState ( 360 ) ;
useLayoutEffect ( ( ) = > {
const element = plot . current ;
if ( props . expanded || ! element || typeof ResizeObserver === "undefined" ) return ;
if ( fitted || ! element || typeof ResizeObserver === "undefined" ) return ;
const resize = ( ) = > setPlotWidth ( element . clientWidth ) ;
resize ( ) ;
const observer = new ResizeObserver ( resize ) ;
observer . observe ( element ) ;
return ( ) = > observer . disconnect ( ) ;
} , [ view , props . expanded ] ) ;
} , [ view , fitted ] ) ;
const contentMotion = useAnimationControls ( ) ;
const pendingTransition = useRef ( false ) ;
useLayoutEffect ( ( ) = > {
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
} , [ contentMotion , view ] ) ;
const setView = ( next : string ) = > {
if ( next === view ) return ;
const animate = pointer . current && ! reducedMotion ;
const animate = pointer . current && ! reducedMotion && ! tile ;
setAnimateInteraction ( animate ) ;
contentMotion . stop ( ) ;
contentMotion . set ( animate ? { opacity : 0 , y : 4 } : { opacity : 1 , y : 0 } ) ;
@@ -85,8 +88,11 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const setRange = ( range : [ number , number ] ) = > patch ( { range , table : { query : "" , sort : null , . . . table , selection : [ ] } } , [ "range" ] ) ;
const setPoint = ( point : number ) = > patch ( { point } ) ;
const setInspecting = ( inspecting : boolean ) = > patch ( { inspecting } ) ;
const visible = props . series . filter ( ( _ , index ) = > ! hidden . includes ( index ) ) ;
const labels = props . x_data . slice ( range [ 0 ] , range [ 1 ] + 1 ) ;
const hiddenKey = hidden . join ( "," ) ;
const hiddenSeries = useMemo ( ( ) = > new Set ( hiddenKey . split ( "," ) . filter ( Boolean ) . map ( Number ) ) , [ hiddenKey ] ) ;
const visible = useMemo ( ( ) = > props . series . filter ( ( _ , index ) = > ! hiddenSeries . has ( index ) ) , [ props . series , hiddenSeries ] ) ;
const [ rangeStart , rangeEnd ] = range ;
const labels = useMemo ( ( ) = > props . x_data . slice ( rangeStart , rangeEnd + 1 ) , [ props . x_data , rangeStart , rangeEnd ] ) ;
const pointIndex = Math . min ( range [ 1 ] , Math . max ( range [ 0 ] , point ) ) ;
const fullRange = range [ 0 ] === 0 && range [ 1 ] === props . x_data . length - 1 ;
const summaries = visible . map ( ( series ) = > {
@@ -98,61 +104,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const categoryColors = visible . length === 1 ;
const horizontalBars = props . x_data . some ( ( label ) = > label . length > 10 ) ;
const numberLabel = chartValueLabel ;
const option = ( type : "line" | "bar" ) = > {
const horizontal = type === "bar" && horizontalBars ;
const seriesType = ( series : SafeChartSeries ) = > view === ( props . chart_type === "bar" ? "bar" : "line" ) ? series . type ? ? type : type ;
const includesBars = visible . some ( ( series ) = > seriesType ( series ) === "bar" ) ;
const categoryAxis = { type : "category" , data : labels , boundaryGap : type === "bar" ,
axisLabel : { fontSize : 11 , color : "#64748b" , hideOverlap : ! horizontal , interval : horizontal ? 0 : "auto" ,
. . . ( horizontal ? { width : props.expanded ? 150 : Math.min ( 210 , Math . max ( 96 , plotWidth * 0.28 ) ) , overflow : "break" , lineHeight : 16 } : { } ) } ,
axisTick : { alignWithLabel : true } , axisLine : { lineStyle : { color : "#cbd5e1" } } } ;
const valueAxis = { type : "valu e", scale : ! includesBars ,
. . . ( ! includesBars ? chartValueBounds ( visible , range [ 0 ] , range [ 1 ] ) : { } ) ,
axisLabel : { fontSize : 11 , color : "#64748b ", formatter : numberLabel } ,
splitLine : { lineStyle : { color : "#e2e8f0" } } } ;
return {
animation : animateInteraction && ! reducedMotion , animationDuration : 180 , animationDurationUpdate : 180 ,
textStyle : { fontFamily : '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' } ,
color : colors.filter ( ( _ , index ) = > ! hidden . includes ( index ) ) , aria : { enabled : true } ,
tooltip : { trigger : composite ? "item" : "axis" , renderMode : composite ? "html" : "richText" , confine : true ,
textStyle : { fontFamily : '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' , fontSize : 12 } ,
. . . ( composite ? { formatter : ( params : unknown ) = > {
const first = ( Array . isArray ( params ) ? params [ 0 ] : params ) as { dataIndex? : number ; seriesIndex? : number } | undefined ;
const focused = composite && typeof first ? . seriesIndex === "number" ? visible . slice ( first . seriesIndex , first . seriesIndex + 1 ) : visible ;
const text = typeof first ? . dataIndex === "number"
? chartPointTooltip ( labels [ first . dataIndex ] , focused , range [ 0 ] + first . dataIndex ) : "" ;
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML.
const content = document . createElement ( "div" ) ;
content . textContent = text ;
content . style . whiteSpace = "pre-line" ;
return content ;
} } : { } ) , axisPointer : { type : type === "ba r" ? "shadow" : "line" } } ,
grid : { top : 28 , right : 24 , bottom : ( props . xAxisName ? ? props . x_axis_name ) ? 48 : 28 , left : 12 , containLabel : true } ,
xAxis : { . . . ( horizontal ? valueAxis : categoryAxis ) , name : horizontal ? props . yAxisName ? ? props.y_axis_name : props.xAxisName ? ? props . x_axis_name ,
nameLocation : "middle" , nameGap : 30 } ,
yAxis : horizontal ? { . . . categoryAxis , inverse : true , name : props.xAxisName ? ? props . x_axis_name } : { . . . valueAxis , name : props.yAxisName ? ? props . y_axis_name } ,
series : visible.map ( ( series ) = > ( { name : series.name , type : seriesType ( series ) ,
data : series.data.slice ( range [ 0 ] , range [ 1 ] + 1 ) . map ( ( value , index ) = > type === "bar" && categoryColors
? { value , itemStyle : { color : colors [ ( range [ 0 ] + index ) % colors . length ] } } : value ) ,
barMaxWidth : 32 , barCategoryGap : "35%" , connectNulls : false ,
showSymbol : labels.length <= 24 , symbolSize : seriesType ( series ) === "scatte r" ? 8 : 6 , emphasis : { focus : "series " } } ) )
const xAxisName = props . xAxisName ? ? props . x_axis_name ;
const yAxisName = props . yAxisName ? ? props . y_axis_name ;
// Keep tooltip functions and options stable across sizing, focus and table-only updates.
const options = useMemo ( ( ) = > {
const option = ( type : "line" | "bar" ) = > {
const horizontal = type === "bar" && horizontalBars ;
const horizontalAxisName = horizontal ? yAxisName : xAxisName ;
const verticalAxisName = horizontal ? xAxisName : yAxisName ;
const seriesType = ( series : SafeChartSeries ) = > view === ( props . chart_type === "bar" ? "bar" : "lin e") ? series . type ? ? type : type ;
const includesBars = visible . some ( ( series ) = > seriesType ( series ) === "bar" ) ;
const categoryAxis = { type : "category ", data : labels , boundaryGap : type === "bar" ,
axisLabel : { fontSize : tile ? 10 : 11 , color : "#64748b" , hideOverlap : ! horizontal , interval : horizontal ? 0 : "auto" ,
. . . ( tile ? { margin : 4 , lineHeight : 12 } : { } ) ,
. . . ( horizontal ? { width : fitted && ! tile ? 150 : Math.min ( 210 , Math . max ( 96 , plotWidth * 0.28 ) ) , overflow : "break" , lineHeight : tile ? 12 : 16 } : { } ) } ,
axisTick : { alignWithLabel : true } , axisLine : { lineStyle : { color : "#cbd5e1" } } } ;
const valueAxis = { type : "value" , scale : ! includesBars ,
. . . ( ! includesBars ? chartValueBounds ( visible , rangeStart , rangeEnd , chartUsesIntegerTicks ( yAxisName ) ) : { } ) ,
. . . ( chartUsesIntegerTicks ( yAxisName ) ? { minInterval : 1 } : { } ) ,
axisLabel : { fontSize : tile ? 10 : 11 , color : "#64748b" , formatter : numberLabel ,
. . . ( tile ? { margin : 4 , lineHeight : 12 } : { } ) } ,
splitLine : { lineStyle : { color : "#e2e8f0" } } } ;
return {
animation : animateInteraction && ! reducedMotion , animationDuration : 180 , animationDurationUpdate : 180 ,
textStyle : { fontFamily : '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' } ,
color : colors.filter ( ( _ , index ) = > ! hiddenSeries . has ( index ) ) , aria : { enabled : true } ,
tooltip : { trigger : composite ? "item" : "axis" , renderMode : composite ? "html" : "richText" , confine : true ,
textStyle : { fontFamily : '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' , fontSize : 12 } ,
. . . ( composite ? { formatter : ( params : unknown ) = > {
const first = ( Array . isArray ( params ) ? params [ 0 ] : params ) as { dataIndex? : number ; seriesIndex? : number } | undefined ;
const focused = composite && typeof first ? . seriesIndex === "numbe r" ? visible . slice ( first . seriesIndex , first . seriesIndex + 1 ) : visible ;
const text = typeof first ? . dataIndex === "number"
? chartPointTooltip ( labels [ first . dataIndex ] , focused , rangeStart + first . dataIndex ) : "" ;
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML.
const content = document . createElement ( "div" ) ;
content . textContent = text ;
content . style . whiteSpace = "pre-line" ;
return content ;
} } : { } ) , axisPointer : { type : type === "ba r" ? "shadow" : "line " } } ,
grid : { top : tile ? verticalAxisName ? 16 : 10 : 28 , right : 24 ,
bottom : tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28 , left : 12 , containLabel : true } ,
xAxis : { . . . ( horizontal ? valueAxis : categoryAxis ) , name : horizontalAxisName ,
nameLocation : "middle" , nameGap : tile ? 16 : 30 ,
. . . ( tile ? { nameTextStyle : { fontSize : 10 , lineHeight : 12 } } : { } ) } ,
yAxis : { . . . ( horizontal ? { . . . categoryAxis , inverse : true } : valueAxis ) , name : verticalAxisName ,
. . . ( tile ? { nameGap : 4 , nameTextStyle : { fontSize : 10 , lineHeight : 12 , align : "left" } } : { } ) } ,
series : visible.map ( ( series ) = > ( { name : series.name , type : seriesType ( series ) ,
data : series.data.slice ( rangeStart , rangeEnd + 1 ) . map ( ( value , index ) = > type === "bar" && categoryColors
? { value , itemStyle : { color : colors [ ( rangeStart + index ) % colors . length ] } } : value ) ,
barMaxWidth : 32 , barCategoryGap : "35%" , connectNulls : false ,
showSymbol : labels.length <= 24 , symbolSize : seriesType ( series ) === "scatter" ? 8 : 6 , emphasis : { focus : "series" } } ) )
} ;
} ;
} ;
const metadata = < >
{ ! props . embedded && view !== "table" ? < div className = "rounded-lg bg-muted/40 p-2.5" role = "group" aria-label = "当前区间摘要" >
< div className = "mb-2 flex flex-wrap items-center justify-between gap-1" >
< span className = "text-[11px] font-medium" > 区 间 摘 要 < span className = "font-normal text-muted-foreground" > · 本 地 统 计 < / span > < / span >
< / div >
< div className = "space-y-2" >
{ summaries . map ( ( item ) = > < div key = { item . name } className = "answer-chart-range-summary text-[11px] leading-5" >
< span className = "min-w-0 break-words text-muted-foreground" > { item . name } < / span >
< span className = "tabular-nums" > { numberLabel ( item . first ) } → { numberLabel ( item . last ) } < / span >
< span className = "text-muted-foreground" > 最 小 { numberLabel ( item . min ) } · 最 大 { numberLabel ( item . max ) } < / span >
< / div > ) }
< / div >
< / div > : null }
{ view !== "table" && inspecting ? < motion.section aria-label = "数据点详情" className = "rounded-lg border border-border p-2.5"
return { line : option ( "line" ) , bar : option ( "bar" ) } ;
} , [ animateInteraction , categoryColors , composite , fitted , hiddenSeries , horizontalBars , labels , numberLabel , plotWidth ,
props . chart_type , rangeStart , rangeEnd , reducedMotion , tile , view , visible , xAxisName , yAxisName ] ) ;
const pointDetails = view !== "table" && inspecting ? < motion.section aria-label = "数据点详情" className = "rounded-lg border border-border p-2.5"
initial = { pointer . current && ! reducedMotion ? { opacity : 0 , y : 4 } : false }
animate = { { opacity : 1 , y : 0 } } transition = { { duration : 0.18 , ease : agentEase } } >
< div className = "mb-2 flex items-center justify-between gap-2" >
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
< / dd >
< / div > ) }
< / dl >
< / motion.section > : null }
< / motion.section > : null ;
const metadata = < >
{ ! props . embedded && ! tile && view !== "table" ? < div className = "rounded-lg bg-muted/40 p-2.5" role = "group" aria-label = "当前区间摘要" >
< div className = "mb-2 flex flex-wrap items-center justify-between gap-1" >
< span className = "text-[11px] font-medium" > 区 间 摘 要 < span className = "font-normal text-muted-foreground" > · 本 地 统 计 < / span > < / span >
< / div >
< div className = "space-y-2" >
{ summaries . map ( ( item ) = > < div key = { item . name } className = "answer-chart-range-summary text-[11px] leading-5" >
< span className = "min-w-0 break-words text-muted-foreground" > { item . name } < / span >
< span className = "tabular-nums" > { numberLabel ( item . first ) } → { numberLabel ( item . last ) } < / span >
< span className = "text-muted-foreground" > 最 小 { numberLabel ( item . min ) } · 最 大 { numberLabel ( item . max ) } < / span >
< / div > ) }
< / div >
< / div > : null }
{ pointDetails }
< p className = "flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground" >
< Database size = { 12 } className = "mt-1 shrink-0" / > < span className = "min-w-0 break-words [overflow-wrap:anywhere]" > { fullRange ? labels . length : ` ${ labels . length } / ${ props . x_data . length } ` } 个 数 据 点 · { visible . length } 组 序 列 < br / > 来 源 : { props . source } < / span >
< Database size = { 12 } className = "mt-1 shrink-0" / > < span className = "min-w-0 break-words [overflow-wrap:anywhere]" > { fullRange ? labels . length : ` ${ labels . length } / ${ props . x_data . length } ` } 个 数 据 点 · { visible . length } 组 序 列 { props . embedded ? "" : < > < br / > 来 源 : { props . source } < / > } < / span >
< / p >
< / > ;
return < Collapsible asChild > < Card className = { ` answer-ui-chart overflow-hidden ${ props . embedded || props . expanded ? "border-0 shadow-none" : "" } ${ props . expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : "" } ` } role = { props . embedded ? undefined : "group" } aria-label = { props . embedded ? undefined : props . title }
onPointerDownCapture = { ( ) = > { pointer . current = true ; } }
onKeyDownCapture = { ( ) = > {
pointer . current = false ;
setAnimateInteraction ( false ) ;
contentMotion . stop ( ) ;
contentMotion . set ( { opacity : 1 , y : 0 } ) ;
} } >
< CardHeader className = "shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5" >
< CardTitle className = "flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal" > < TrendingUp size = { 15 } className = "shrink-0" aria - hidden / > { props . embedded || props . expanded ? "图形与数据" : props . title } < / CardTitle >
< div className = "flex shrink-0 items-center gap-1" >
{ ! props . embedded && ! props . expanded ? < FloatingWindow title = { props . title } fillContent
openRequestKey = { chartWindowKey === undefined ? undefined : JSON . stringify ( [ chartWindowKey , props ] ) } >
{ ( ) = > < InteractiveChart { ...props } expanded / > }
< / FloatingWindow > : null }
< CollapsibleTrigger asChild >
< Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "筛选数据范围" title = "筛选数据范围"
className = "shrink-0 text-muted-foreground" >
< SlidersHorizontal size = { 14 } aria - hidden / >
< / Button >
< / CollapsibleTrigger >
< / div >
< / CardHeader >
< CardContent className = { ` ${ props . expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3" } px-3.5 pb-3.5 pt-0 ` } >
{ migrationNotice ? < p role = "status" className = "text-xs leading-5 text-muted-foreground" > { migrationNotice } < / p > : null }
< Tabs value = { view } onValueChange = { setView } className = { props . expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined } >
< TabsList aria-label = { ` ${ props . title } 显示方式 ` } className = { ` ${ props . expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3" } relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none ` } >
< motion.span aria - hidden className = "pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style = { { width : "calc((100% - 0.5rem) / 3)" } }
initial = { false } animate = { { x : view === "line" ? "0%" : view === "bar" ? "100%" : "200%" } }
transition = { { duration : animateInteraction && ! reducedMotion ? 0.18 : 0 , ease : agentEase } } / >
< TabsTrigger value = "line" > < TrendingUp size = { 13 } / > { composite ? "复合" : "折线" } < / TabsTrigger >
< TabsTrigger value = "bar" > < BarChart3 size = { 13 } / > 柱 状 < / TabsTrigger >
< TabsTrigger value = "table" > < Table2 size = { 13 } / > 数 据 < / TabsTrigger >
< / TabsList >
< aside className = { props . expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined } >
< div className = { ` answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${ props . expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : "" } ` } aria-label = "显示的数据序列" >
{ props . series . map ( ( series , index ) = > < Button key = { index } size = "sm" variant = "outline"
className = { ` h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${ props . expanded ? "justify-start" : "" } ` } aria-pressed = { ! hidden . includes ( index ) }
disabled = { visible . length === 1 && ! hidden . includes ( index ) }
onClick = { ( ) = > { setAnimateInteraction ( pointer . current && ! reducedMotion ) ; patch ( { hidden : hidden.includes ( index ) ? hidden . filter ( ( item ) = > item !== index ) : [ . . . hidden , index ] ,
table : { query : "" , . . . table , sort : null , selection : [ ] } } , [ "hidden" , "table/sort" ] ) ; } } >
< span className = "size-2 shrink-0 rounded-full" style = { { background : hidden.includes ( index ) ? "#cbd5e1" : colors [ index ] } } / > < span className = "min-w-0 break-words" > { series . name } < / span >
< / Button > ) }
< / div >
{ hidden . length ? < Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) = > patch ( { hidden : [ ] } , [ "hidden" ] ) } > 显 示 全 部 序 列 < / Button > : null }
{ view === "bar" && categoryColors ? < div aria-label = "类别图例" className = { ` mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${ props . expanded ? "" : "max-h-40 overflow-y-auto" } ` } style = { { gridTemplateColumns : ` repeat( ${ props . expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1 } ,minmax(0,1fr)) ` } } >
{ labels . map ( ( label , index ) = > < div key = { index } className = "flex min-w-0 items-start gap-2 text-[11px] leading-5" >
< span className = "mt-1.5 size-2 shrink-0 rounded-sm" style = { { background : colors [ ( range [ 0 ] + index ) % colors . length ] } } / >
< span className = "min-w-0 break-words" > { label } < / span >
< span className = "ml-auto shrink-0 tabular-nums" > { numberLabel ( visible [ 0 ] . data [ range [ 0 ] + index ] ) } < / span >
< / div > ) }
< / div > : null }
{ props . expanded ? metadata : null }
< / aside >
< div className = { props . expanded ? "col-start-1 row-start-2 min-w-0" : undefined } >
{ ! fullRange ? < p className = "mb-2 text-[11px] text-muted-foreground" > { props . x_data [ range [ 0 ] ] } 至 { props . x_data [ range [ 1 ] ] } < / p > : null }
< CollapsibleContent className = "mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5" >
const rangeControls = < div className = "space-y-2" >
< div className = "flex items-center gap-2" >
< PointSelect label = "区间起点" value = { range [ 0 ] } labels = { props . x_data }
onChange = { ( start ) = > setRange ( [ start , Math . max ( start , range [ 1 ] ) ] ) } / >
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
< / div >
< Button type = "button" variant = "ghost" size = "sm" disabled = { fullRange }
onClick = { ( ) = > setRange ( [ 0 , props . x_data . length - 1 ] ) } > 重 置 范 围 < / Button >
< / CollapsibleContent >
< / div > ;
return < Collapsible asChild > < Card className = { ` answer-ui-chart overflow-hidden ${ props . embedded || props . expanded ? "border-0 shadow-none" : "" } ${ fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : "" } ` } data-chart-tile = { tile || undefined } role = { props . embedded ? undefined : "group" } aria-label = { props . embedded ? undefined : props . title }
onPointerDownCapture = { ( ) = > { pointer . current = true ; } }
onKeyDownCapture = { ( ) = > {
pointer . current = false ;
setAnimateInteraction ( false ) ;
contentMotion . stop ( ) ;
contentMotion . set ( { opacity : 1 , y : 0 } ) ;
} } >
{ ! props . embedded ? < CardHeader className = { ` shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${ tile ? "px-2 py-1.5" : "px-3.5 py-2.5" } ` } >
< CardTitle className = "flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal" > < TrendingUp size = { 15 } className = "shrink-0" aria - hidden / > { props . expanded ? "图形与数据" : props . title } < / CardTitle >
< div className = { ` flex shrink-0 items-center gap-1 ${ tile ? "[&>button]:size-6" : "" } ` } >
{ ! props . embedded && ! props . expanded ? < FloatingWindow title = { props . title } fillContent >
{ ( ) = > < InteractiveChart { ...props } expanded tile = { false } / > }
< / FloatingWindow > : null }
{ tile ? < Popover > < PopoverTrigger asChild >
< Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "查看图表来源" title = "查看图表来源" > < FileText size = { 14 } aria - hidden / > < / Button >
< / PopoverTrigger > < PopoverContent align = "end" className = "workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5" > { props . source } < / PopoverContent > < / Popover > : < CollapsibleTrigger asChild >
< Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "筛选数据范围" title = "筛选数据范围" className = "shrink-0 text-muted-foreground" > < SlidersHorizontal size = { 14 } aria - hidden / > < / Button >
< / CollapsibleTrigger > }
< / div >
< / CardHeader > : null }
< CardContent className = { ` ${ fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3" } ${ props . embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5" } pt-0 ` } >
{ migrationNotice ? < p role = "status" className = "text-xs leading-5 text-muted-foreground" > { migrationNotice } < / p > : null }
< Tabs value = { view } onValueChange = { setView } className = { fitted ? ` grid min-h-0 flex-1 grid-cols-1 ${ tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2" } ` : undefined } >
< div className = { ` flex min-w-0 items-center gap-2 ${ fitted ? "col-start-1 row-start-1" : "mb-3" } ` } >
< TabsList aria-label = { ` ${ props . title } 显示方式 ` } className = { ` relative grid min-w-0 flex-1 grid-cols-3 ${ tile ? "min-h-7 p-0.5" : "" } [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none ` } >
< motion.span aria - hidden className = "pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style = { { width : "calc((100% - 0.5rem) / 3)" } }
initial = { false } animate = { { x : view === "line" ? "0%" : view === "bar" ? "100%" : "200%" } }
transition = { { duration : animateInteraction && ! reducedMotion ? 0.18 : 0 , ease : agentEase } } / >
< TabsTrigger value = "line" className = { tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined } > < TrendingUp size = { 13 } / > { composite ? "复合" : "折线" } < / TabsTrigger >
< TabsTrigger value = "bar" className = { tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined } > < BarChart3 size = { 13 } / > 柱 状 < / TabsTrigger >
< TabsTrigger value = "table" className = { tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined } > < Table2 size = { 13 } / > 数 据 < / TabsTrigger >
< / TabsList >
{ tile ? < >
< Popover > < PopoverTrigger asChild > < Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "筛选数据范围" title = "筛选数据范围" className = { ` h-7 shrink-0 ${ fullRange ? "w-7" : "w-auto gap-1 px-1.5" } ` } > < SlidersHorizontal size = { 14 } aria - hidden / >
{ ! fullRange ? < span className = "max-w-20 truncate text-[10px]" title = { ` ${ props . x_data [ range [ 0 ] ] } 至 ${ props . x_data [ range [ 1 ] ] } ` } > { props . x_data [ range [ 0 ] ] } 至 { props . x_data [ range [ 1 ] ] } < / span > : null }
< / Button > < / PopoverTrigger >
< PopoverContent align = "end" className = "workbench-shadcn-surface z-[90] p-3" > { rangeControls } < / PopoverContent >
< / Popover >
{ inspecting && view !== "table" ? < Popover open = { inspecting } onOpenChange = { setInspecting } > < PopoverTrigger asChild > < Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "数据点详情" title = "数据点详情" className = "size-7 shrink-0" > < Database size = { 14 } aria - hidden / > < / Button > < / PopoverTrigger >
< PopoverContent align = "end" className = "workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus = { ( event ) = > event . preventDefault ( ) } > { pointDetails } < / PopoverContent >
< / Popover > : null }
< / > : props . embedded ? < CollapsibleTrigger asChild >
< Button type = "button" variant = "ghost" size = "icon-sm" aria-label = "筛选数据范围" title = "筛选数据范围" > < SlidersHorizontal size = { 14 } aria - hidden / > < / Button >
< / CollapsibleTrigger > : null }
< / div >
< motion.div className = { props . expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined } data-chart-view = { view } initial = { false } animate = { contentMotion } >
{ ( [ "line" , "bar" ] as const ) . map ( ( type ) = > < TabsContent key = { type } value = { type } className = { props . expanded ? "h-full min-h -0" : undefined } >
{ ! tile ? < div className = { fitted ? "col-start-1 row-start-2 min-w -0" : undefined } >
{ ! fullRange ? < p className = "mb-2 text-[11px] text-muted-foreground" > { props . x_data [ range [ 0 ] ] } 至 { props . x_data [ range [ 1 ] ] } < / p > : null }
{ ! tile ? < CollapsibleContent className = "mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5" > { rangeControls } < / CollapsibleContent > : null }
< / div > : null }
< motion.div className = { fitted ? ` col-start-1 ${ tile ? "row-start-2" : "row-start-3" } min-h-0 min-w-0 ` : undefined } data-chart-view = { view } initial = { false } animate = { contentMotion } >
{ ( [ "line" , "bar" ] as const ) . map ( ( type ) = > < TabsContent key = { type } value = { type } className = { fitted ? "h-full min-h-0" : undefined } >
< div ref = { view === type ? plot : undefined } role = "group" aria-label = { ` ${ props . title } ,按回车查看数据点,方向键切换数据点 ` } tabIndex = { 0 }
className = { ` ${ props . expanded ? ( type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden" ) : "" } rounded-md focus-visible:outline-2 focus-visible:outline-ring ` }
className = { ` ${ fitted ? ( type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden" ) : "" } rounded-md focus-visible:outline-2 focus-visible:outline-ring ` }
onKeyDown = { ( event ) = > {
if ( event . target !== event . currentTarget ) return ;
if ( [ "Enter" , "ArrowLeft" , "ArrowRight" , "Home" , "End" ] . includes ( event . key ) ) {
@@ -259,13 +275,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
if ( event . key === "Escape" ) setInspecting ( false ) ;
} } >
< span className = "sr-only" > 点 击 图 中 数 据 点 查 看 详 情 , 也 可 使 用 回 车 和 左 右 方 向 键 。 < / span >
< div role = "img" aria-label = { props . title } style = { { height : props.expanded ? "100%" : props . plotHeight ? ? 220 ,
minHeight : props.expanded && type === "bar" && horizontalBars ? labels . length * 36 + 48 : undefined } } >
< ResponsiveChart option = { option ( type ) } height = "100%" onPointSelect = { ( index ) = > patch ( { point : range [ 0 ] + index , inspecting : true } ) } / >
< div role = "img" aria-label = { props . title } style = { { height : fitted ? "100%" : props . plotHeight ? ? 220 ,
minHeight : fitted && type === "bar" && horizontalBars ? labels . length * ( tile ? 28 : 36 ) + 48 : undefined } } >
< ResponsiveChart option = { options [ type ] } height = "100%" onPointSelect = { ( index ) = > patch ( { point : range [ 0 ] + index , inspecting : true } ) } / >
< / div >
< / div >
< / TabsContent > ) }
< TabsContent value = "table" className = { props . expanded ? "h-full overflow-auto" : undefined } forceMount style = { view === "table" ? undefined : { display : "none" } } >
< TabsContent value = "table" className = { fitted ? "h-full overflow-auto" : undefined } forceMount style = { view === "table" ? undefined : { display : "none" } } >
< InteractiveDataTable title = { props . title } columns = { [ "时间或类别" , . . . visible . map ( ( series ) = > series . name ) ] }
rows = { labels . map ( ( label , index ) = > [ label , . . . visible . map ( ( series ) = > {
const offset = range [ 0 ] + index , time = chartSampleTime ( series . sample_times ? . [ offset ] ) ;
@@ -274,7 +290,27 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
state = { table } onStateChange = { ( updates ) = > patch ( { table : { query : "" , sort : null , selection : [ ] , . . . table , . . . updates } } , Object . keys ( updates ) . map ( ( key ) = > ` table/ ${ key } ` ) ) } / >
< / TabsContent >
< / motion.div >
{ ! props . expanded ? metadata : null }
< div aria-label = "图表图例" className = { fitted ? ` col-start-1 ${ tile ? "row-start-3 min-h-8 max-h-14" : ` row-start-4 min-h-0 ${ props . embedded ? "max-h-56" : "max-h-40" } ` } min-w-0 overflow-y-auto border-t border-border ${ tile ? "pt-1" : "pt-2" } ` : "border-t border-border pt-3" } >
< div className = { ` answer-ui-chart-series flex flex-wrap ${ tile ? "gap-1" : "mb-2 gap-2" } ` } aria-label = "显示的数据序列" >
{ props . series . map ( ( series , index ) = > < Button key = { index } size = "sm" variant = "outline"
className = { ` h-auto ${ tile ? "min-h-6 px-2 py-0.5" : "min-h-8" } min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ` } aria-pressed = { ! hidden . includes ( index ) }
disabled = { visible . length === 1 && ! hidden . includes ( index ) }
onClick = { ( ) = > { setAnimateInteraction ( pointer . current && ! reducedMotion && ! tile ) ; patch ( { hidden : hidden.includes ( index ) ? hidden . filter ( ( item ) = > item !== index ) : [ . . . hidden , index ] ,
table : { query : "" , . . . table , sort : null , selection : [ ] } } , [ "hidden" , "table/sort" ] ) ; } } >
< span className = "size-2 shrink-0 rounded-full" style = { { background : hidden.includes ( index ) ? "#cbd5e1" : colors [ index ] } } / > < span className = "min-w-0 break-words" > { series . name } < / span >
< / Button > ) }
< / div >
{ hidden . length ? < Button type = "button" variant = "ghost" size = "sm" onClick = { ( ) = > patch ( { hidden : [ ] } , [ "hidden" ] ) } > 显 示 全 部 序 列 < / Button > : null }
{ view === "bar" && categoryColors ? < div aria-label = "类别图例" className = { ` mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${ fitted ? "" : "max-h-40 overflow-y-auto" } ` } style = { { gridTemplateColumns : ` repeat( ${ fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1 } ,minmax(0,1fr)) ` } } >
{ labels . map ( ( label , index ) = > < div key = { index } className = "flex min-w-0 items-start gap-2 text-[11px] leading-5" >
< span className = "mt-1.5 size-2 shrink-0 rounded-sm" style = { { background : colors [ ( range [ 0 ] + index ) % colors . length ] } } / >
< span className = "min-w-0 break-words" > { label } < / span >
< span className = "ml-auto shrink-0 tabular-nums" > { numberLabel ( visible [ 0 ] . data [ range [ 0 ] + index ] ) } < / span >
< / div > ) }
< / div > : null }
{ props . expanded && ! tile ? metadata : null }
< / div >
{ ! fitted ? metadata : null }
< / Tabs >
< / CardContent >
< / Card > < / Collapsible > ;