Add n body simulation
This commit is contained in:
@@ -66,6 +66,7 @@ func NewSimulationEngine() (*SimulationEngine, error) {
|
||||
newFlightSimInfo,
|
||||
newSinewaveInfo,
|
||||
newTankSimInfo,
|
||||
newNBodySimInfo,
|
||||
}
|
||||
|
||||
for _, init := range initializers {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type nbodySim struct {
|
||||
key simulationKey
|
||||
cfg nbodyConfig
|
||||
state nbodyState
|
||||
random *rand.Rand
|
||||
}
|
||||
|
||||
var (
|
||||
_ Simulation = (*nbodySim)(nil)
|
||||
)
|
||||
|
||||
type nbodyConfig struct {
|
||||
N int `json:"n"` // number of bodies
|
||||
Width float64 `json:"width"` // boundary width in pixels
|
||||
Height float64 `json:"height"` // boundary height in pixels
|
||||
Seed int64 `json:"seed"` // random seed for reproducibility
|
||||
}
|
||||
|
||||
type circle struct {
|
||||
x float64 // x position
|
||||
y float64 // y position
|
||||
vx float64 // x velocity
|
||||
vy float64 // y velocity
|
||||
radius float64 // radius
|
||||
mass float64 // mass (proportional to radius^2 for simplicity)
|
||||
}
|
||||
|
||||
type nbodyState struct {
|
||||
lastTime time.Time
|
||||
circles []circle
|
||||
}
|
||||
|
||||
func (s *nbodySim) GetState() simulationState {
|
||||
return simulationState{
|
||||
Key: s.key,
|
||||
Config: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) SetConfig(vals map[string]any) error {
|
||||
oldCfg := s.cfg
|
||||
err := updateConfigObjectFromJSON(&s.cfg, vals)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If configuration changed, reinitialize the simulation
|
||||
if oldCfg.N != s.cfg.N || oldCfg.Width != s.cfg.Width || oldCfg.Height != s.cfg.Height || oldCfg.Seed != s.cfg.Seed {
|
||||
s.initialize()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *nbodySim) initialize() {
|
||||
s.random = rand.New(rand.NewSource(s.cfg.Seed))
|
||||
s.state.circles = make([]circle, s.cfg.N)
|
||||
s.state.lastTime = time.Time{}
|
||||
|
||||
// Generate random circles
|
||||
for i := 0; i < s.cfg.N; i++ {
|
||||
// Random radius between 5 and 30 pixels
|
||||
radius := 5.0 + s.random.Float64()*25.0
|
||||
|
||||
// Random position ensuring the circle is within bounds
|
||||
x := radius + s.random.Float64()*(s.cfg.Width-2*radius)
|
||||
y := radius + s.random.Float64()*(s.cfg.Height-2*radius)
|
||||
|
||||
// Random velocity between -50 and 50 pixels/second
|
||||
vx := (s.random.Float64()*2.0 - 1.0) * 250.0
|
||||
vy := (s.random.Float64()*2.0 - 1.0) * 250.0
|
||||
|
||||
// Mass proportional to area (radius squared)
|
||||
mass := radius * radius
|
||||
|
||||
s.state.circles[i] = circle{
|
||||
x: x,
|
||||
y: y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
radius: radius,
|
||||
mass: mass,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) NewFrame(size int) *data.Frame {
|
||||
frame := data.NewFrame("")
|
||||
|
||||
// Time field - create with length=size for pre-allocated storage
|
||||
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
|
||||
|
||||
// For each circle, add position, bounding box, size, and velocity fields with pre-allocated storage
|
||||
for i := 0; i < s.cfg.N; i++ {
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_x", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_y", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_left", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_top", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_diameter", i), nil, make([]float64, size)),
|
||||
)
|
||||
frame.Fields = append(frame.Fields,
|
||||
data.NewField(fmt.Sprintf("circle_%d_velocity", i), nil, make([]float64, size)),
|
||||
)
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func (s *nbodySim) GetValues(t time.Time) map[string]any {
|
||||
// Initialize if this is the first call
|
||||
if s.state.lastTime.IsZero() {
|
||||
s.state.lastTime = t
|
||||
if len(s.state.circles) == 0 {
|
||||
s.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate elapsed time in seconds
|
||||
if t.After(s.state.lastTime) {
|
||||
dt := t.Sub(s.state.lastTime).Seconds()
|
||||
s.simulate(dt)
|
||||
s.state.lastTime = t
|
||||
} else if t.Before(s.state.lastTime) {
|
||||
// Can't go backwards - reinitialize
|
||||
s.initialize()
|
||||
s.state.lastTime = t
|
||||
}
|
||||
|
||||
// Build result map
|
||||
result := map[string]any{
|
||||
"time": t,
|
||||
}
|
||||
|
||||
for i := 0; i < len(s.state.circles); i++ {
|
||||
c := s.state.circles[i]
|
||||
// Calculate velocity magnitude: sqrt(vx^2 + vy^2)
|
||||
velocity := math.Sqrt(c.vx*c.vx + c.vy*c.vy)
|
||||
|
||||
// Center position
|
||||
result[fmt.Sprintf("circle_%d_x", i)] = c.x
|
||||
result[fmt.Sprintf("circle_%d_y", i)] = c.y
|
||||
|
||||
// Top-left corner of bounding box (for easier canvas positioning)
|
||||
result[fmt.Sprintf("circle_%d_left", i)] = c.x - c.radius
|
||||
result[fmt.Sprintf("circle_%d_top", i)] = c.y - c.radius
|
||||
|
||||
// Size and velocity
|
||||
result[fmt.Sprintf("circle_%d_diameter", i)] = c.radius * 2.0
|
||||
result[fmt.Sprintf("circle_%d_velocity", i)] = velocity
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *nbodySim) simulate(dt float64) {
|
||||
// Don't simulate too large time steps
|
||||
if dt > 1.0 {
|
||||
dt = 1.0
|
||||
}
|
||||
|
||||
// Use smaller sub-steps for more accurate collision detection
|
||||
steps := int(math.Ceil(dt * 60)) // 60 sub-steps per second
|
||||
if steps < 1 {
|
||||
steps = 1
|
||||
}
|
||||
subDt := dt / float64(steps)
|
||||
|
||||
for step := 0; step < steps; step++ {
|
||||
// Update positions
|
||||
for i := range s.state.circles {
|
||||
s.state.circles[i].x += s.state.circles[i].vx * subDt
|
||||
s.state.circles[i].y += s.state.circles[i].vy * subDt
|
||||
}
|
||||
|
||||
// Handle wall collisions
|
||||
for i := range s.state.circles {
|
||||
c := &s.state.circles[i]
|
||||
|
||||
// Left/right walls (perfectly elastic - no energy loss)
|
||||
if c.x-c.radius < 0 {
|
||||
c.x = c.radius
|
||||
c.vx = math.Abs(c.vx)
|
||||
} else if c.x+c.radius > s.cfg.Width {
|
||||
c.x = s.cfg.Width - c.radius
|
||||
c.vx = -math.Abs(c.vx)
|
||||
}
|
||||
|
||||
// Top/bottom walls (perfectly elastic - no energy loss)
|
||||
if c.y-c.radius < 0 {
|
||||
c.y = c.radius
|
||||
c.vy = math.Abs(c.vy)
|
||||
} else if c.y+c.radius > s.cfg.Height {
|
||||
c.y = s.cfg.Height - c.radius
|
||||
c.vy = -math.Abs(c.vy)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle circle-to-circle collisions
|
||||
for i := 0; i < len(s.state.circles); i++ {
|
||||
for j := i + 1; j < len(s.state.circles); j++ {
|
||||
c1 := &s.state.circles[i]
|
||||
c2 := &s.state.circles[j]
|
||||
|
||||
// Calculate distance between centers
|
||||
dx := c2.x - c1.x
|
||||
dy := c2.y - c1.y
|
||||
distSq := dx*dx + dy*dy
|
||||
minDist := c1.radius + c2.radius
|
||||
|
||||
// Check for collision
|
||||
if distSq < minDist*minDist && distSq > 0 {
|
||||
dist := math.Sqrt(distSq)
|
||||
|
||||
// Normalize collision vector
|
||||
nx := dx / dist
|
||||
ny := dy / dist
|
||||
|
||||
// Separate the circles so they don't overlap
|
||||
overlap := minDist - dist
|
||||
c1.x -= nx * overlap * 0.5
|
||||
c1.y -= ny * overlap * 0.5
|
||||
c2.x += nx * overlap * 0.5
|
||||
c2.y += ny * overlap * 0.5
|
||||
|
||||
// Calculate relative velocity
|
||||
dvx := c2.vx - c1.vx
|
||||
dvy := c2.vy - c1.vy
|
||||
|
||||
// Calculate relative velocity in collision normal direction
|
||||
dvn := dvx*nx + dvy*ny
|
||||
|
||||
// Do not resolve if velocities are separating
|
||||
if dvn > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate impulse scalar (perfectly elastic collision)
|
||||
restitution := 1.0 // coefficient of restitution (1.0 = perfectly elastic, no energy loss)
|
||||
impulse := (1 + restitution) * dvn / (1/c1.mass + 1/c2.mass)
|
||||
|
||||
// Apply impulse
|
||||
c1.vx += impulse * nx / c1.mass
|
||||
c1.vy += impulse * ny / c1.mass
|
||||
c2.vx -= impulse * nx / c2.mass
|
||||
c2.vy -= impulse * ny / c2.mass
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *nbodySim) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newNBodySimInfo() simulationInfo {
|
||||
defaultCfg := nbodyConfig{
|
||||
N: 10,
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Seed: 12345,
|
||||
}
|
||||
|
||||
// Create config frame that describes the available configuration fields
|
||||
df := data.NewFrame("")
|
||||
df.Fields = append(df.Fields, data.NewField("n", nil, []int64{int64(defaultCfg.N)}))
|
||||
df.Fields = append(df.Fields, data.NewField("width", nil, []float64{defaultCfg.Width}).SetConfig(&data.FieldConfig{
|
||||
Unit: "px",
|
||||
}))
|
||||
df.Fields = append(df.Fields, data.NewField("height", nil, []float64{defaultCfg.Height}).SetConfig(&data.FieldConfig{
|
||||
Unit: "px",
|
||||
}))
|
||||
df.Fields = append(df.Fields, data.NewField("seed", nil, []int64{defaultCfg.Seed}))
|
||||
|
||||
return simulationInfo{
|
||||
Type: "nbody",
|
||||
Name: "N-Body",
|
||||
Description: "N-body collision simulation with circles bouncing in a bounded space",
|
||||
ConfigFields: df,
|
||||
OnlyForward: false,
|
||||
create: func(cfg simulationState) (Simulation, error) {
|
||||
s := &nbodySim{
|
||||
key: cfg.Key,
|
||||
cfg: defaultCfg,
|
||||
}
|
||||
err := updateConfigObjectFromJSON(&s.cfg, cfg.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if s.cfg.N <= 0 {
|
||||
return nil, fmt.Errorf("n must be positive")
|
||||
}
|
||||
if s.cfg.Width <= 0 || s.cfg.Height <= 0 {
|
||||
return nil, fmt.Errorf("width and height must be positive")
|
||||
}
|
||||
if s.cfg.N > 100 {
|
||||
return nil, fmt.Errorf("n is too large (max 100)")
|
||||
}
|
||||
|
||||
s.initialize()
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package sims
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNBodyQuery(t *testing.T) {
|
||||
s, err := NewSimulationEngine()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("simple nbody simulation", func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = map[string]any{
|
||||
"n": 5,
|
||||
"width": 400.0,
|
||||
"height": 300.0,
|
||||
"seed": 42,
|
||||
}
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Date(2020, time.January, 10, 23, 0, 0, 0, time.UTC)
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second * 2),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 20,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
|
||||
// Verify we got a response
|
||||
dr, ok := rsp.Responses["A"]
|
||||
require.True(t, ok)
|
||||
require.NoError(t, dr.Error)
|
||||
require.Len(t, dr.Frames, 1)
|
||||
|
||||
frame := dr.Frames[0]
|
||||
// Should have time + (x, y, left, top, diameter, velocity) for each of 5 circles = 31 fields
|
||||
require.Equal(t, 31, len(frame.Fields))
|
||||
|
||||
// Check field names
|
||||
require.Equal(t, "time", frame.Fields[0].Name)
|
||||
require.Equal(t, "circle_0_x", frame.Fields[1].Name)
|
||||
require.Equal(t, "circle_0_y", frame.Fields[2].Name)
|
||||
require.Equal(t, "circle_0_left", frame.Fields[3].Name)
|
||||
require.Equal(t, "circle_0_top", frame.Fields[4].Name)
|
||||
require.Equal(t, "circle_0_diameter", frame.Fields[5].Name)
|
||||
require.Equal(t, "circle_0_velocity", frame.Fields[6].Name)
|
||||
|
||||
// Verify we have data points
|
||||
require.Greater(t, frame.Fields[0].Len(), 0)
|
||||
})
|
||||
|
||||
t.Run("nbody with different configurations", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
n int
|
||||
width float64
|
||||
height float64
|
||||
seed int64
|
||||
}{
|
||||
{"small", 3, 200, 200, 1},
|
||||
{"medium", 10, 800, 600, 2},
|
||||
{"large", 20, 1000, 800, 3},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = map[string]any{
|
||||
"n": tc.n,
|
||||
"width": tc.width,
|
||||
"height": tc.height,
|
||||
"seed": tc.seed,
|
||||
}
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 10,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
|
||||
dr, ok := rsp.Responses["A"]
|
||||
require.True(t, ok)
|
||||
require.NoError(t, dr.Error)
|
||||
require.Len(t, dr.Frames, 1)
|
||||
|
||||
frame := dr.Frames[0]
|
||||
// Should have time + (x, y, left, top, diameter, velocity) for each of N circles = 1 + 6*N fields
|
||||
expectedFields := 1 + 6*tc.n
|
||||
require.Equal(t, expectedFields, len(frame.Fields))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nbody validates configuration", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
shouldError bool
|
||||
}{
|
||||
{"valid", map[string]any{"n": 5, "width": 400.0, "height": 300.0, "seed": 42}, false},
|
||||
{"zero n", map[string]any{"n": 0, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
{"negative n", map[string]any{"n": -5, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
{"zero width", map[string]any{"n": 5, "width": 0.0, "height": 300.0, "seed": 42}, true},
|
||||
{"negative height", map[string]any{"n": 5, "width": 400.0, "height": -300.0, "seed": 42}, true},
|
||||
{"too many bodies", map[string]any{"n": 150, "width": 400.0, "height": 300.0, "seed": 42}, true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sq := &simulationQuery{}
|
||||
sq.Key = simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
}
|
||||
sq.Config = tc.config
|
||||
|
||||
sb, err := json.Marshal(map[string]any{
|
||||
"sim": sq,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
qr := &backend.QueryDataRequest{
|
||||
Queries: []backend.DataQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
TimeRange: backend.TimeRange{
|
||||
From: start,
|
||||
To: start.Add(time.Second),
|
||||
},
|
||||
Interval: 100 * time.Millisecond,
|
||||
MaxDataPoints: 10,
|
||||
JSON: sb,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rsp, err := s.QueryData(context.Background(), qr)
|
||||
|
||||
if tc.shouldError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rsp)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNBodyCollisions(t *testing.T) {
|
||||
// Test that circles actually collide and bounce
|
||||
info := newNBodySimInfo()
|
||||
cfg := simulationState{
|
||||
Key: simulationKey{
|
||||
Type: "nbody",
|
||||
TickHZ: 10,
|
||||
},
|
||||
Config: map[string]any{
|
||||
"n": 2,
|
||||
"width": 200.0,
|
||||
"height": 200.0,
|
||||
"seed": 12345,
|
||||
},
|
||||
}
|
||||
|
||||
sim, err := info.create(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sim)
|
||||
|
||||
// Get initial values
|
||||
t0 := time.Now()
|
||||
v0 := sim.GetValues(t0)
|
||||
|
||||
// Simulate for 2 seconds
|
||||
t1 := t0.Add(2 * time.Second)
|
||||
v1 := sim.GetValues(t1)
|
||||
|
||||
// Verify that positions have changed (circles are moving)
|
||||
require.NotEqual(t, v0["circle_0_x"], v1["circle_0_x"])
|
||||
require.NotEqual(t, v0["circle_0_y"], v1["circle_0_y"])
|
||||
|
||||
// Verify diameters remain constant
|
||||
require.Equal(t, v0["circle_0_diameter"], v1["circle_0_diameter"])
|
||||
require.Equal(t, v0["circle_1_diameter"], v1["circle_1_diameter"])
|
||||
|
||||
sim.Close()
|
||||
}
|
||||
|
||||
+19
-2
@@ -27,7 +27,8 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
const simQuery = query.sim ?? ({} as SimulationQuery);
|
||||
const simKey = simQuery.key ?? {};
|
||||
// keep track of updated config state to pass down to form
|
||||
const [cfgValue, setCfgValue] = useState<Config>({});
|
||||
// Initialize from saved query config if it exists
|
||||
const [cfgValue, setCfgValue] = useState<Config>(simQuery.config ?? {});
|
||||
|
||||
// This only changes once
|
||||
const info = useAsync(async () => {
|
||||
@@ -50,6 +51,19 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
}, [info.value, simKey?.type]);
|
||||
|
||||
let config = useAsync(async () => {
|
||||
// If we have a saved config in the query, use that and update server
|
||||
if (simQuery.config && Object.keys(simQuery.config).length > 0) {
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Update server with saved config
|
||||
ds.postResource<SimInfo>('sim/' + path, simQuery.config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
return simQuery.config;
|
||||
}
|
||||
// Otherwise fetch default config from server
|
||||
let path = simKey.type + '/' + simKey.tick + 'hz';
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
@@ -57,7 +71,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
let config = (await ds.getResource('sim/' + path))?.config;
|
||||
setCfgValue(config.value);
|
||||
return config;
|
||||
}, [simKey.type, simKey.tick, simKey.uid]);
|
||||
}, [simKey.type, simKey.tick, simKey.uid, simQuery.config]);
|
||||
|
||||
const onUpdateKey = (key: typeof simQuery.key) => {
|
||||
onChange({ ...query, sim: { ...simQuery, key } });
|
||||
@@ -90,6 +104,9 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => {
|
||||
if (simKey.uid) {
|
||||
path += '/' + simKey.uid;
|
||||
}
|
||||
// Save config to query JSON so it persists in dashboard
|
||||
onChange({ ...query, sim: { ...simQuery, config } });
|
||||
// Also update server state
|
||||
ds.postResource<SimInfo>('sim/' + path, config).then((res) => {
|
||||
setCfgValue(res.config);
|
||||
});
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ const renderInput = (field: FieldSchema, onChange: SchemaFormProps['onChange'],
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={config?.[field.name]}
|
||||
value={config?.[field.name]}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => {
|
||||
const newValue = e.currentTarget.valueAsNumber;
|
||||
onChange({ ...config, [field.name]: newValue });
|
||||
@@ -76,7 +76,7 @@ export const SimulationSchemaForm = ({ config, schema, onChange }: SchemaFormPro
|
||||
onChange={() => setJsonView(!jsonView)}
|
||||
/>
|
||||
{jsonView ? (
|
||||
<TextArea defaultValue={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
<TextArea value={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
|
||||
) : (
|
||||
<>
|
||||
{schema.fields.map((field) => (
|
||||
|
||||
Reference in New Issue
Block a user