22import * as React from "react"
33import { useMemo , forwardRef , useRef , useImperativeHandle } from "react"
44import StreamXYFrame from "../../stream/StreamXYFrame"
5- import type { StreamXYFrameProps , StreamXYFrameHandle } from "../../stream/types"
5+ import type { StreamXYFrameProps , StreamXYFrameHandle , SceneNode , StreamScales , StreamLayout } from "../../stream/types"
66import type { RealtimeFrameHandle } from "../../realtime/types"
77import type { BaseChartProps , AxisConfig , ChartAccessor } from "../shared/types"
88import { normalizeTooltip , type TooltipProp } from "../../Tooltip/Tooltip"
@@ -129,20 +129,40 @@ export const ConnectedScatterplot = forwardRef(function ConnectedScatterplot<TDa
129129
130130 const rawData = ( data || [ ] ) as Record < string , any > [ ]
131131
132- // Sort by orderAccessor if provided (bounded data mode only)
132+ // Sort by orderAccessor if provided, then stamp __orderIndex/__orderTotal
133+ // on each datum so pointStyle can read the index directly (order-independent).
133134 const safeData = useMemo ( ( ) => {
134- if ( ! orderAccessor || rawData . length === 0 ) return rawData
135- const getOrder = typeof orderAccessor === "function"
136- ? orderAccessor as ( d : any ) => number | Date
137- : ( d : any ) => d [ orderAccessor ]
138- return [ ...rawData ] . sort ( ( a , b ) => {
139- const va = getOrder ( a )
140- const vb = getOrder ( b )
141- const na = va instanceof Date ? va . getTime ( ) : + va
142- const nb = vb instanceof Date ? vb . getTime ( ) : + vb
143- return na - nb
135+ const xAcc = typeof xAccessor === "function" ? xAccessor : ( d : any ) => d [ xAccessor ]
136+ const yAcc = typeof yAccessor === "function" ? yAccessor : ( d : any ) => d [ yAccessor ]
137+ let sorted = rawData
138+ if ( orderAccessor && rawData . length > 0 ) {
139+ const getOrder = typeof orderAccessor === "function"
140+ ? orderAccessor as ( d : any ) => number | Date
141+ : ( d : any ) => d [ orderAccessor ]
142+ sorted = [ ...rawData ] . sort ( ( a , b ) => {
143+ const va = getOrder ( a )
144+ const vb = getOrder ( b )
145+ const na = va instanceof Date ? va . getTime ( ) : + va
146+ const nb = vb instanceof Date ? vb . getTime ( ) : + vb
147+ return na - nb
148+ } )
149+ }
150+ // Count renderable points and stamp ordering metadata
151+ const renderable = sorted . filter ( ( p : any ) => {
152+ const x = xAcc ( p ) ; const y = yAcc ( p )
153+ return x != null && y != null && isFinite ( x ) && isFinite ( y )
144154 } )
145- } , [ rawData , orderAccessor ] )
155+ const total = renderable . length
156+ let idx = 0
157+ for ( const d of sorted ) {
158+ const x = xAcc ( d ) ; const y = yAcc ( d )
159+ if ( x != null && y != null && isFinite ( x ) && isFinite ( y ) ) {
160+ ( d as any ) . __orderIndex = idx ++ ;
161+ ( d as any ) . __orderTotal = total
162+ }
163+ }
164+ return sorted
165+ } , [ rawData , orderAccessor , xAccessor , yAccessor ] )
146166
147167 // ── Dev-mode warnings ─────────────────────────────────────────────────
148168 warnMissingField ( "ConnectedScatterplot" , safeData , "xAccessor" , xAccessor )
@@ -221,35 +241,48 @@ export const ConnectedScatterplot = forwardRef(function ConnectedScatterplot<TDa
221241 [ connectingLineRenderer ]
222242 )
223243
244+ // ── SVG pre-renderer for SSR (same logic as canvas, produces SVG elements) ──
245+ const connectingLineSVGRenderer = useMemo ( ( ) => {
246+ return ( nodes : SceneNode [ ] , _scales : StreamScales , _layout : StreamLayout ) : React . ReactNode => {
247+ const pts = nodes . filter ( ( n ) => n . type === "point" ) as Array < { x : number ; y : number ; datum ?: any } >
248+ if ( pts . length < 2 ) return null
249+ const count = pts . length
250+ const halo = count < 100
251+ const elements : React . ReactElement [ ] = [ ]
252+
253+ for ( let i = 0 ; i < count - 1 ; i ++ ) {
254+ const p0 = pts [ i ]
255+ const p1 = pts [ i + 1 ]
256+ const color = viridisColor ( i , count )
257+ if ( halo ) {
258+ elements . push (
259+ < line key = { `halo-${ i } ` } x1 = { p0 . x } y1 = { p0 . y } x2 = { p1 . x } y2 = { p1 . y }
260+ stroke = "white" strokeWidth = { pointRadius + 2 } strokeLinecap = "round" opacity = { 0.5 } />
261+ )
262+ }
263+ elements . push (
264+ < line key = { `seg-${ i } ` } x1 = { p0 . x } y1 = { p0 . y } x2 = { p1 . x } y2 = { p1 . y }
265+ stroke = { color } strokeWidth = { pointRadius } strokeLinecap = "round" />
266+ )
267+ }
268+ return < > { elements } </ >
269+ }
270+ } , [ pointRadius ] )
271+
272+ const svgPreRenderers = useMemo (
273+ ( ) => [ connectingLineSVGRenderer ] ,
274+ [ connectingLineSVGRenderer ]
275+ )
276+
224277 // ── Point style — viridis colored, fixed radius ───────────────────────
225278 //
226- // pointStyle is called once per datum during scene build, in data order.
227- // We use a call counter (reset each time the function reference changes)
228- // so each datum gets the correct viridis position. The total count comes
229- // from getData() which reflects the pipeline's current windowed data.
230-
231- const pointCallCounter = useRef ( { idx : 0 , total : 0 } )
279+ // Each datum has __orderIndex and __orderTotal stamped during sorting,
280+ // so pointStyle reads the index directly without relying on call order.
232281
233282 const basePointStyle = useMemo ( ( ) => {
234- const xAcc = typeof xAccessor === "function" ? xAccessor : ( d : any ) => d [ xAccessor ]
235- const yAcc = typeof yAccessor === "function" ? yAccessor : ( d : any ) => d [ yAccessor ]
236283 return ( d : Record < string , any > ) => {
237- const counter = pointCallCounter . current
238- // On first call of a batch, snapshot the renderable point count
239- // (filter out invalid x/y so gradient matches the connecting-line renderer)
240- if ( counter . idx === 0 ) {
241- const allData = frameRef . current ?. getData ( ) ?? safeData
242- counter . total = allData . filter ( ( p : any ) => {
243- const x = xAcc ( p ) ; const y = yAcc ( p )
244- return x != null && y != null && isFinite ( x ) && isFinite ( y )
245- } ) . length
246- }
247- const n = counter . total
248- const i = counter . idx
249- counter . idx ++
250- // Reset for next scene build cycle
251- if ( counter . idx >= n ) counter . idx = 0
252-
284+ const i = ( d as any ) . __orderIndex ?? 0
285+ const n = ( d as any ) . __orderTotal ?? 1
253286 return {
254287 fill : n > 0 ? viridisColor ( i , n ) : "#6366f1" ,
255288 stroke : "white" ,
@@ -258,7 +291,7 @@ export const ConnectedScatterplot = forwardRef(function ConnectedScatterplot<TDa
258291 fillOpacity : 1 ,
259292 }
260293 }
261- } , [ pointRadius , safeData . length , xAccessor , yAccessor ] )
294+ } , [ pointRadius ] )
262295
263296 const pointStyle = useMemo (
264297 ( ) => wrapStyleWithSelection ( basePointStyle , effectiveSelectionHook , selection ) ,
@@ -321,6 +354,7 @@ export const ConnectedScatterplot = forwardRef(function ConnectedScatterplot<TDa
321354 ...( ( linkedHover || onObservation ) && { customHoverBehavior } ) ,
322355 ...( pointIdAccessor && { pointIdAccessor } ) ,
323356 canvasPreRenderers,
357+ svgPreRenderers,
324358 ...( annotations && annotations . length > 0 && { annotations } ) ,
325359 ...frameProps
326360 }
0 commit comments