Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions examples/webgpu-chart/app.gx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package main

func App() (Component) {
// Fetch initial Bitcoin data from Binance with fallback to static data
chartData := GetChartData()
// Get visible window of chart data (managed by scroll manager)
chartData := GetVisibleChartData()

// Get or initialize the data channel for reactive updates
dataChan := InitChartDataChannel()

Div(
ID("app"),
Expand All @@ -13,14 +16,14 @@ func App() (Component) {
"Bitcoin Price Chart (Binance Data)"
}
P(Class("subtitle")) {
"WebGPU-powered candlestick chart with viewport scrolling"
"WebGPU-powered candlestick chart with viewport scrolling (~100 candles visible)"
}
Canvas(
ID("chart-canvas"),
Width(1200),
Height(700)
) {
GPUChart(NewBitcoinChart(chartData))
GPUChart(NewBitcoinChart(chartData, dataChan))
}
Div(Class("info")) {
P() {
Expand Down
5 changes: 3 additions & 2 deletions examples/webgpu-chart/app_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion examples/webgpu-chart/chart.gx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package main

func BitcoinChart(data []OHLCV) (Chart) {
func BitcoinChart(data []OHLCV, dataChan chan []OHLCV) (Chart) {
Chart(ChartBackground(0.08, 0.09, 0.12, 1.0)) {
XAxis(AxisPosition("bottom"), TimeScale(true), GridLines(true), Label("Time"))

YAxis(AxisPosition("right"), GridLines(true), Label("Price (USD)"))

CandlestickSeries(
ChartData(data),
ChartDataChannel(dataChan),
UpColor(0.18, 0.80, 0.44, 1.0),
DownColor(0.91, 0.27, 0.38, 1.0),
WickColor(0.6, 0.6, 0.65, 1.0),
Expand Down
9 changes: 5 additions & 4 deletions examples/webgpu-chart/chart_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions examples/webgpu-chart/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ func main() {
appComponent.BindApp(runtimeApp)
log("[Go] App bound")

// Set global app for state management
log("[Go] Setting global app...")
SetGlobalApp(appComponent)
log("[Go] Global app set")

// Mount
log("[Go] Mounting to #app...")
if err := runtimeApp.Mount("#app"); err != nil {
Expand All @@ -49,6 +54,11 @@ func main() {
scrollManager := NewScrollManager("chart-canvas", initialData)
log("[Go] Scroll manager initialized")

// Set global scroll manager for state management
log("[Go] Setting global scroll manager...")
SetGlobalScrollManager(scrollManager)
log("[Go] Global scroll manager set")

// Store scroll manager for cleanup (not implemented yet)
_ = scrollManager
})
Expand Down
19 changes: 10 additions & 9 deletions examples/webgpu-chart/scroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ type ChartDataManager struct {

// NewChartDataManager creates a new chart data manager
func NewChartDataManager(initialData []chart.OHLCV, symbol, interval string) *ChartDataManager {
visibleSize := len(initialData)
if visibleSize > 200 {
visibleSize = 200 // Show 200 candles at a time
visibleSize := 100 // Show 100 candles at a time (as requested)
if len(initialData) < visibleSize {
visibleSize = len(initialData)
}

// Initialize Markov chain generator from last candle
Expand All @@ -57,7 +57,7 @@ func NewChartDataManager(initialData []chart.OHLCV, symbol, interval string) *Ch
allData: initialData,
visibleStart: 0,
visibleEnd: visibleSize,
prefetchSize: 100, // Prefetch 100 candles when nearing edge
prefetchSize: 50, // Prefetch 50 candles when nearing edge
totalFetched: len(initialData),
symbol: symbol,
interval: interval,
Expand Down Expand Up @@ -269,7 +269,7 @@ func (sm *ScrollManager) attachEventListeners() {
shift = 10 // Scroll right (newer data)
case "Home":
sm.chartData.visibleStart = 0
sm.chartData.visibleEnd = 200
sm.chartData.visibleEnd = 100
sm.updateChart()
return nil
case "End":
Expand All @@ -295,13 +295,14 @@ func (sm *ScrollManager) attachEventListeners() {
sm.app.Call("addEventListener", "keydown", sm.keyCallback)
}

// updateChart triggers a chart rerender with updated data
// updateChart triggers a chart update by sending data through the channel
func (sm *ScrollManager) updateChart() {
// This is a placeholder - in the actual implementation,
// we need to trigger the chart component to rerender
// For now, we'll just log the viewport change
log("[Scroll] Viewport updated:", sm.chartData.visibleStart, "-", sm.chartData.visibleEnd, "of", len(sm.chartData.allData))

// Send new visible data through the channel (non-blocking)
visibleData := sm.chartData.GetVisibleData()
SendChartDataUpdate(visibleData)

// Check if we need to fetch/generate more data
if sm.chartData.visibleEnd > len(sm.chartData.allData)-sm.chartData.prefetchSize {
log("[Scroll] Near end, fetching/generating more data...")
Expand Down
34 changes: 17 additions & 17 deletions examples/webgpu-chart/scroll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ func TestNewChartDataManager(t *testing.T) {
t.Error("Expected generator to be initialized")
}

// Should show 200 candles at a time
if cdm.visibleEnd != 200 {
t.Errorf("Expected visibleEnd=200, got %d", cdm.visibleEnd)
// Should show 100 candles at a time
if cdm.visibleEnd != 100 {
t.Errorf("Expected visibleEnd=100, got %d", cdm.visibleEnd)
}

if cdm.visibleStart != 0 {
Expand All @@ -46,7 +46,7 @@ func TestNewChartDataManager(t *testing.T) {
}

func TestNewChartDataManagerSmallDataset(t *testing.T) {
// Test with fewer than 200 candles
// Test with fewer than 100 candles
initialData := GenerateFallbackData(50)
cdm := NewChartDataManager(initialData, "BTCUSDT", "1h")

Expand Down Expand Up @@ -81,8 +81,8 @@ func TestGetVisibleData(t *testing.T) {

visibleData := cdm.GetVisibleData()

if len(visibleData) != 200 {
t.Errorf("Expected 200 visible candles, got %d", len(visibleData))
if len(visibleData) != 100 {
t.Errorf("Expected 100 visible candles, got %d", len(visibleData))
}

// Visible data should be a slice of the original data
Expand Down Expand Up @@ -120,8 +120,8 @@ func TestShiftViewport(t *testing.T) {
initialData := GenerateFallbackData(500)
cdm := NewChartDataManager(initialData, "BTCUSDT", "1h")

// Initial state: 0-200
if cdm.visibleStart != 0 || cdm.visibleEnd != 200 {
// Initial state: 0-100
if cdm.visibleStart != 0 || cdm.visibleEnd != 100 {
t.Errorf("Initial state incorrect: start=%d, end=%d",
cdm.visibleStart, cdm.visibleEnd)
}
Expand All @@ -132,8 +132,8 @@ func TestShiftViewport(t *testing.T) {
t.Error("Expected viewport to change")
}

if cdm.visibleStart != 50 || cdm.visibleEnd != 250 {
t.Errorf("After shift: expected start=50, end=250, got start=%d, end=%d",
if cdm.visibleStart != 50 || cdm.visibleEnd != 150 {
t.Errorf("After shift: expected start=50, end=150, got start=%d, end=%d",
cdm.visibleStart, cdm.visibleEnd)
}
}
Expand All @@ -150,8 +150,8 @@ func TestShiftViewportLeftBound(t *testing.T) {
t.Errorf("Expected start=0, got %d", cdm.visibleStart)
}

if cdm.visibleEnd != 200 {
t.Errorf("Expected end=200, got %d", cdm.visibleEnd)
if cdm.visibleEnd != 100 {
t.Errorf("Expected end=100, got %d", cdm.visibleEnd)
}

// Since we're already at the boundary, nothing changed
Expand Down Expand Up @@ -235,8 +235,8 @@ func TestChartDataManagerPrefetchSize(t *testing.T) {
initialData := GenerateFallbackData(500)
cdm := NewChartDataManager(initialData, "BTCUSDT", "1h")

if cdm.prefetchSize != 100 {
t.Errorf("Expected prefetchSize=100, got %d", cdm.prefetchSize)
if cdm.prefetchSize != 50 {
t.Errorf("Expected prefetchSize=50, got %d", cdm.prefetchSize)
}
}

Expand Down Expand Up @@ -289,9 +289,9 @@ func TestGetVisibleDataAfterShift(t *testing.T) {

visibleData := cdm.GetVisibleData()

// Should show candles 100-300
if len(visibleData) != 200 {
t.Errorf("Expected 200 visible candles, got %d", len(visibleData))
// Should show candles 100-200
if len(visibleData) != 100 {
t.Errorf("Expected 100 visible candles, got %d", len(visibleData))
}

// First visible candle should be the 100th candle from original data
Expand Down
63 changes: 63 additions & 0 deletions examples/webgpu-chart/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//go:build js && wasm

package main

import "github.com/gaarutyunov/guix/pkg/runtime/chart"

// Global state for chart data management
var (
globalScrollManager *ScrollManager
globalApp *App

Check failure on line 10 in examples/webgpu-chart/state.go

View workflow job for this annotation

GitHub Actions / Lint

var globalApp is unused (unused)
chartDataChannel chan []chart.OHLCV
)

// InitChartDataChannel initializes the global chart data channel
func InitChartDataChannel() chan []chart.OHLCV {
if chartDataChannel == nil {
chartDataChannel = make(chan []chart.OHLCV, 1) // Buffered channel
}
return chartDataChannel
}

// GetChartDataChannel returns the global chart data channel
func GetChartDataChannel() chan []chart.OHLCV {
return chartDataChannel
}

// GetVisibleChartData returns the currently visible chart data
// This is called by the app during rendering to get only the visible window of data
func GetVisibleChartData() []chart.OHLCV {
if globalScrollManager != nil && globalScrollManager.chartData != nil {
return globalScrollManager.chartData.GetVisibleData()
}
// Fallback: return initial data if scroll manager not initialized
return GetChartData()
}

// SetGlobalScrollManager sets the global scroll manager instance
func SetGlobalScrollManager(sm *ScrollManager) {
globalScrollManager = sm
}

// SetGlobalApp sets the global app instance
func SetGlobalApp(app *App) {
globalApp = app
}

// SendChartDataUpdate sends new chart data through the channel
func SendChartDataUpdate(data []chart.OHLCV) {
if chartDataChannel != nil {
// Non-blocking send - if channel is full, drain it first
select {
case chartDataChannel <- data:
// Successfully sent
default:
// Channel full, drain and send new data
select {
case <-chartDataChannel:
default:
}
chartDataChannel <- data
}
}
}
80 changes: 80 additions & 0 deletions pkg/runtime/chart_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ func NewChartRenderer(canvas *GPUCanvas, chart *GPUNode) (*ChartRenderer, error)
return nil, err
}

// Start monitoring data channels for updates
renderer.startDataChannelMonitors()

log("[ChartRenderer] Chart renderer created successfully")
return renderer, nil
}
Expand Down Expand Up @@ -917,3 +920,80 @@ func (cr *ChartRenderer) createLineBindGroup(dataBuffer *GPUBuffer) js.Value {

return cr.Canvas.GPUContext.Device.Call("createBindGroup", bindGroupDesc)
}

// startDataChannelMonitors starts goroutines to monitor data channels for updates
func (cr *ChartRenderer) startDataChannelMonitors() {
log("[ChartRenderer] Starting data channel monitors")

// Monitor candlestick series data channels
for _, series := range cr.CandlestickSeries {
if channelValue, hasChannel := series.Properties["dataChannel"]; hasChannel {
log("[ChartRenderer] Found dataChannel in candlestick series")

// Type assert to the correct channel type
if dataChan, ok := channelValue.(chan []interface{}); ok {
log("[ChartRenderer] Starting monitor for candlestick data channel (generic)")
go cr.monitorCandlestickChannel(series, dataChan)
} else {
// Try reflection to handle the actual type
log("[ChartRenderer] Data channel type:", fmt.Sprintf("%T", channelValue))
go cr.monitorCandlestickChannelReflect(series, channelValue)
}
}
}

log("[ChartRenderer] Data channel monitors started")
}

// monitorCandlestickChannel monitors a candlestick data channel for updates
func (cr *ChartRenderer) monitorCandlestickChannel(series *GPUNode, dataChan chan []interface{}) {
log("[ChartRenderer] Candlestick channel monitor started")

for {
select {
case newData, ok := <-dataChan:
if !ok {
log("[ChartRenderer] Data channel closed")
return
}

log("[ChartRenderer] Received new data through channel:", len(newData), "candles")

// Update the series data property
series.Properties["data"] = newData
}
}
}

// monitorCandlestickChannelReflect monitors a channel using reflection
func (cr *ChartRenderer) monitorCandlestickChannelReflect(series *GPUNode, channelValue interface{}) {
log("[ChartRenderer] Starting reflect-based channel monitor")

// Use reflection to receive from channel
channelVal := reflect.ValueOf(channelValue)
if channelVal.Kind() != reflect.Chan {
logError("[ChartRenderer] Value is not a channel")
return
}

log("[ChartRenderer] Channel monitor loop started")

for {
chosen, recv, recvOK := reflect.Select([]reflect.SelectCase{
{Dir: reflect.SelectRecv, Chan: channelVal},
})

if chosen == 0 {
if !recvOK {
log("[ChartRenderer] Channel closed")
return
}

// Update the series data property with received value
newData := recv.Interface()
log("[ChartRenderer] Received new data through reflect channel:", fmt.Sprintf("%T", newData))

series.Properties["data"] = newData
}
}
}
Loading
Loading