Compare commits

...

17 Commits

Author SHA1 Message Date
Drew Slobodnjak
8b982190f1 Compress mesh file 2025-12-08 17:22:40 -08:00
Drew Slobodnjak
953f08ed55 Flip y for 3d rendering 2025-12-08 14:56:16 -08:00
Drew Slobodnjak
8ed233e867 Double number of colors for 3d demo 2025-12-07 23:15:17 -08:00
Drew Slobodnjak
e326306485 Change default ambient lighting 2025-12-07 16:45:32 -08:00
Drew Slobodnjak
632f47e367 Add lighting 2025-12-07 16:37:46 -08:00
Drew Slobodnjak
3d7358c4ce Add 3d grot simulation 2025-12-07 16:08:33 -08:00
Drew Slobodnjak
fc8893bc53 Add an option to svg element for field driven 2025-12-06 23:38:25 -08:00
Drew Slobodnjak
2723a719aa Add 2 boss particles 2025-12-06 16:39:59 -08:00
Drew Slobodnjak
52d4c928e2 Limit rotation change rate 2025-12-05 13:45:06 -08:00
Drew Slobodnjak
efa577e186 Add a rotation output to nbody sim 2025-12-05 13:21:46 -08:00
Drew Slobodnjak
2627df30d6 Prevent class name collisions 2025-12-05 12:41:07 -08:00
Drew Slobodnjak
bf2870c8c8 Add generic svg element 2025-12-05 12:18:37 -08:00
Drew Slobodnjak
c53d239aae Adjust radius to be a bit smaller 2025-12-05 11:56:49 -08:00
Drew Slobodnjak
cdde735f41 Add attraction between points 2025-12-04 12:13:43 -08:00
Drew Slobodnjak
7be8de044c Fix flicker 2025-12-04 11:07:45 -08:00
Drew Slobodnjak
34305670c5 Add n body simulation 2025-12-04 10:20:24 -08:00
Drew Slobodnjak
308bc56d0f Canvas: Field driven layout 2025-12-02 23:33:19 -08:00
42 changed files with 2416 additions and 314 deletions

View File

@@ -128,6 +128,20 @@ The server element lets you easily represent a single server, a stack of servers
{{< figure src="/media/docs/grafana/canvas-server-element-9-4-0.png" max-width="650px" alt="Canvas server element" >}}
#### SVG
The SVG element lets you add custom SVG graphics to the canvas. You can enter raw SVG markup in the content field, and the element will render it with proper sanitization to prevent XSS attacks. This element is useful for creating custom icons, logos, or complex graphics that aren't available in the standard shape elements.
SVG element features:
- **Sanitized content**: All SVG content is automatically sanitized for security
- **Data binding**: SVG content can be bound to field data using template variables
- **Scalable**: SVG graphics scale cleanly at any size
The SVG element supports the following configuration options:
- **SVG Content**: Enter raw SVG markup. Content will be sanitized automatically.
#### Button
The button element lets you add a basic button to the canvas. Button elements support triggering basic, unauthenticated API calls. [API settings](#button-api-options) are found in the button element editor. You can also pass template variables in the API editor.

View File

@@ -105,6 +105,19 @@ export interface TextDimensionConfig extends BaseDimensionConfig {
mode: TextDimensionMode;
}
export enum PositionDimensionMode {
Field = 'field',
Fixed = 'fixed',
}
/**
* Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping
*/
export interface PositionDimensionConfig extends BaseDimensionConfig {
fixed?: number;
mode: PositionDimensionMode;
}
export enum ResourceDimensionMode {
Field = 'field',
Fixed = 'fixed',

View File

@@ -38,6 +38,15 @@ TextDimensionConfig: {
fixed?: string
}@cuetsy(kind="interface")
PositionDimensionMode: "fixed" | "field" @cuetsy(kind="enum")
// Simple position/coordinate dimension - just fixed value or field value, no scaling/clamping
PositionDimensionConfig: {
BaseDimensionConfig
mode: PositionDimensionMode
fixed?: number
}@cuetsy(kind="interface")
ResourceDimensionMode: "fixed" | "field" | "mapping" @cuetsy(kind="enum")
// Links to a resource (image/svg path)

View File

@@ -34,13 +34,13 @@ export interface Constraint {
}
export interface Placement {
bottom?: number;
height?: number;
left?: number;
right?: number;
rotation?: number;
top?: number;
width?: number;
bottom?: ui.PositionDimensionConfig;
height?: ui.PositionDimensionConfig;
left?: ui.PositionDimensionConfig;
right?: ui.PositionDimensionConfig;
rotation?: ui.ScalarDimensionConfig;
top?: ui.PositionDimensionConfig;
width?: ui.PositionDimensionConfig;
}
export enum BackgroundImageSize {

View File

@@ -29,6 +29,10 @@ export interface ScalarDimensionConfig extends BaseDimensionConfig<number>, Omit
export interface TextDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.TextDimensionConfig, 'fixed'> {}
export interface PositionDimensionConfig
extends BaseDimensionConfig<number>,
Omit<raw.PositionDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}
export interface ColorDimensionConfig extends BaseDimensionConfig<string>, Omit<raw.ColorDimensionConfig, 'fixed'> {}

View File

@@ -66,6 +66,8 @@ func NewSimulationEngine() (*SimulationEngine, error) {
newFlightSimInfo,
newSinewaveInfo,
newTankSimInfo,
newNBodySimInfo,
newGrot3dSimInfo,
}
for _, init := range initializers {

View File

@@ -0,0 +1,560 @@
package sims
import (
_ "embed"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"math"
"time"
"bytes"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
//go:embed grot_mesh.json
var grotMeshData []byte
//go:embed grot_base_color.png
var grotBaseColor []byte
type grot3dSim struct {
key simulationKey
cfg grot3dConfig
state grot3dState
vertices []point3d
uvs [][]float64
indices []int
texture image.Image
}
var (
_ Simulation = (*grot3dSim)(nil)
)
type grot3dConfig struct {
RotationSpeedX float64 `json:"rotationSpeedX"` // Rotation speed around X axis (degrees/second)
RotationSpeedY float64 `json:"rotationSpeedY"` // Rotation speed around Y axis (degrees/second)
RotationSpeedZ float64 `json:"rotationSpeedZ"` // Rotation speed around Z axis (degrees/second)
MinAngleX float64 `json:"minAngleX"` // Minimum rotation angle for X axis (degrees)
MaxAngleX float64 `json:"maxAngleX"` // Maximum rotation angle for X axis (degrees)
MinAngleY float64 `json:"minAngleY"` // Minimum rotation angle for Y axis (degrees)
MaxAngleY float64 `json:"maxAngleY"` // Maximum rotation angle for Y axis (degrees)
MinAngleZ float64 `json:"minAngleZ"` // Minimum rotation angle for Z axis (degrees)
MaxAngleZ float64 `json:"maxAngleZ"` // Maximum rotation angle for Z axis (degrees)
LightX float64 `json:"lightX"` // Light direction X component
LightY float64 `json:"lightY"` // Light direction Y component
LightZ float64 `json:"lightZ"` // Light direction Z component
AmbientLight float64 `json:"ambientLight"` // Ambient light level (0-1)
ViewWidth float64 `json:"viewWidth"` // SVG viewBox width
ViewHeight float64 `json:"viewHeight"` // SVG viewBox height
Perspective float64 `json:"perspective"` // Perspective distance (larger = less perspective)
Scale float64 `json:"scale"` // Overall scale multiplier
}
type grot3dState struct {
lastTime time.Time
angleX float64 // Current rotation around X axis (radians)
angleY float64 // Current rotation around Y axis (radians)
angleZ float64 // Current rotation around Z axis (radians)
directionX float64 // Direction multiplier for X rotation (+1 or -1)
directionY float64 // Direction multiplier for Y rotation (+1 or -1)
directionZ float64 // Direction multiplier for Z rotation (+1 or -1)
}
type point3d struct {
x, y, z float64
}
type point2d struct {
x, y float64
}
type meshData struct {
Vertices [][]float64 `json:"vertices"`
Uvs [][]float64 `json:"uvs"`
Indices []int `json:"indices"`
}
type triangleWithDepth struct {
v0, v1, v2 point2d
depth float64
visible bool
idx0, idx1, idx2 int
normal point3d
}
func (s *grot3dSim) GetState() simulationState {
return simulationState{
Key: s.key,
Config: s.cfg,
}
}
func (s *grot3dSim) SetConfig(vals map[string]any) error {
return updateConfigObjectFromJSON(&s.cfg, vals)
}
func (s *grot3dSim) initialize() error {
s.state.lastTime = time.Time{}
s.state.angleX = 0
s.state.angleY = 0
s.state.angleZ = 0
s.state.directionX = 1
s.state.directionY = 1
s.state.directionZ = 1
// Load mesh data if not already loaded
if len(s.vertices) == 0 {
var mesh meshData
if err := json.Unmarshal(grotMeshData, &mesh); err != nil {
return fmt.Errorf("failed to load grot holiday mesh data: %w", err)
}
// Convert to point3d
s.vertices = make([]point3d, len(mesh.Vertices))
for i, v := range mesh.Vertices {
if len(v) != 3 {
return fmt.Errorf("invalid vertex data at index %d", i)
}
s.vertices[i] = point3d{x: v[0], y: v[1], z: v[2]}
}
s.uvs = mesh.Uvs
if len(s.uvs) != len(s.vertices) {
return fmt.Errorf("UV count mismatch: %d vs %d", len(s.uvs), len(s.vertices))
}
s.indices = mesh.Indices
}
// Load texture
img, err := png.Decode(bytes.NewReader(grotBaseColor))
if err != nil {
return fmt.Errorf("failed to decode texture: %w", err)
}
s.texture = img
return nil
}
func (s *grot3dSim) NewFrame(size int) *data.Frame {
frame := data.NewFrame("")
// Time field
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
// SVG content field (string)
frame.Fields = append(frame.Fields, data.NewField("svg_content", nil, make([]string, size)))
// Also add rotation angles for reference/debugging
frame.Fields = append(frame.Fields, data.NewField("angle_x", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_y", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_z", nil, make([]float64, size)))
return frame
}
func (s *grot3dSim) GetValues(t time.Time) map[string]any {
// Initialize if this is the first call
if s.state.lastTime.IsZero() {
s.state.lastTime = t
}
// Calculate elapsed time and update rotation
if t.After(s.state.lastTime) {
dt := t.Sub(s.state.lastTime).Seconds()
s.updateRotation(dt)
s.state.lastTime = t
} else if t.Before(s.state.lastTime) {
// Can't go backwards - reinitialize
s.initialize()
s.state.lastTime = t
}
// Generate the SVG content for the current rotation
svgContent := s.generateSVG()
return map[string]any{
"time": t,
"svg_content": svgContent,
"angle_x": s.state.angleX * 180 / math.Pi, // Convert to degrees for display
"angle_y": s.state.angleY * 180 / math.Pi,
"angle_z": s.state.angleZ * 180 / math.Pi,
}
}
func (s *grot3dSim) updateRotation(dt float64) {
// Update X rotation
if s.cfg.MinAngleX == 0 && s.cfg.MaxAngleX == 0 {
// No limits - continuous rotation
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180
s.state.angleX = math.Mod(s.state.angleX, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleX := s.cfg.MinAngleX * math.Pi / 180
maxAngleX := s.cfg.MaxAngleX * math.Pi / 180
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180 * s.state.directionX
if s.state.angleX >= maxAngleX {
s.state.angleX = maxAngleX
s.state.directionX = -1
} else if s.state.angleX <= minAngleX {
s.state.angleX = minAngleX
s.state.directionX = 1
}
}
// Update Y rotation
if s.cfg.MinAngleY == 0 && s.cfg.MaxAngleY == 0 {
// No limits - continuous rotation
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180
s.state.angleY = math.Mod(s.state.angleY, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleY := s.cfg.MinAngleY * math.Pi / 180
maxAngleY := s.cfg.MaxAngleY * math.Pi / 180
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180 * s.state.directionY
if s.state.angleY >= maxAngleY {
s.state.angleY = maxAngleY
s.state.directionY = -1
} else if s.state.angleY <= minAngleY {
s.state.angleY = minAngleY
s.state.directionY = 1
}
}
// Update Z rotation
if s.cfg.MinAngleZ == 0 && s.cfg.MaxAngleZ == 0 {
// No limits - continuous rotation
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180
s.state.angleZ = math.Mod(s.state.angleZ, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleZ := s.cfg.MinAngleZ * math.Pi / 180
maxAngleZ := s.cfg.MaxAngleZ * math.Pi / 180
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180 * s.state.directionZ
if s.state.angleZ >= maxAngleZ {
s.state.angleZ = maxAngleZ
s.state.directionZ = -1
} else if s.state.angleZ <= minAngleZ {
s.state.angleZ = minAngleZ
s.state.directionZ = 1
}
}
}
// rotatePoint3D applies 3D rotation around X, Y, and Z axes
func (s *grot3dSim) rotatePoint3D(p point3d) point3d {
// Rotate around X axis
cosX, sinX := math.Cos(s.state.angleX), math.Sin(s.state.angleX)
y := p.y*cosX - p.z*sinX
z := p.y*sinX + p.z*cosX
p.y, p.z = y, z
// Rotate around Y axis
cosY, sinY := math.Cos(s.state.angleY), math.Sin(s.state.angleY)
x := p.x*cosY + p.z*sinY
z = -p.x*sinY + p.z*cosY
p.x, p.z = x, z
// Rotate around Z axis
cosZ, sinZ := math.Cos(s.state.angleZ), math.Sin(s.state.angleZ)
x = p.x*cosZ - p.y*sinZ
y = p.x*sinZ + p.y*cosZ
p.x, p.y = x, y
return p
}
// project3DTo2D converts 3D point to 2D using perspective projection
func (s *grot3dSim) project3DTo2D(p point3d) point2d {
// Apply scale
scaledP := point3d{
x: p.x * s.cfg.Scale,
y: p.y * s.cfg.Scale,
z: p.z * s.cfg.Scale,
}
// Apply perspective projection
scale := s.cfg.Perspective / (s.cfg.Perspective + scaledP.z)
return point2d{
x: scaledP.x*scale + s.cfg.ViewWidth/2,
y: -scaledP.y*scale + s.cfg.ViewHeight/2, // Flip Y vertically (negative Y goes up)
}
}
func (s *grot3dSim) generateSVG() string {
// Rotate all vertices
rotatedVertices := make([]point3d, len(s.vertices))
for i, v := range s.vertices {
rotatedVertices[i] = s.rotatePoint3D(v)
}
// Project to 2D
projectedVertices := make([]point2d, len(rotatedVertices))
for i, v := range rotatedVertices {
projectedVertices[i] = s.project3DTo2D(v)
}
// Process triangles for depth sorting and backface culling
triangles := make([]triangleWithDepth, 0, len(s.indices)/3)
// Calculate near plane for clipping
nearPlane := -s.cfg.Perspective * 0.9 / s.cfg.Scale
for i := 0; i < len(s.indices); i += 3 {
idx0 := s.indices[i]
idx1 := s.indices[i+1]
idx2 := s.indices[i+2]
v0 := rotatedVertices[idx0]
v1 := rotatedVertices[idx1]
v2 := rotatedVertices[idx2]
// Near-plane clipping: skip triangles too close to camera
if v0.z < nearPlane || v1.z < nearPlane || v2.z < nearPlane {
continue
}
// Calculate triangle center depth for sorting
centerZ := (v0.z + v1.z + v2.z) / 3
// Calculate face normal for backface culling
// Two edges of the triangle
edge1 := point3d{v1.x - v0.x, v1.y - v0.y, v1.z - v0.z}
edge2 := point3d{v2.x - v0.x, v2.y - v0.y, v2.z - v0.z}
// Cross product gives normal
normal := point3d{
x: edge1.y*edge2.z - edge1.z*edge2.y,
y: edge1.z*edge2.x - edge1.x*edge2.z,
z: edge1.x*edge2.y - edge1.y*edge2.x,
}
// Normalize the normal vector
normalMag := math.Sqrt(normal.x*normal.x + normal.y*normal.y + normal.z*normal.z)
if normalMag > 0 {
normal.x /= normalMag
normal.y /= normalMag
normal.z /= normalMag
}
// View vector (camera is looking along -Z axis)
viewVector := point3d{0, 0, -1}
// Dot product of normal and view vector (now both are unit vectors)
dotProduct := normal.x*viewVector.x + normal.y*viewVector.y + normal.z*viewVector.z
// Only render triangles facing the camera (backface culling)
// Use small tolerance to catch edge-on triangles (dot product is now -1 to 1)
visible := dotProduct < 0.2
triangles = append(triangles, triangleWithDepth{
v0: projectedVertices[idx0],
v1: projectedVertices[idx1],
v2: projectedVertices[idx2],
depth: centerZ,
visible: visible,
idx0: idx0,
idx1: idx1,
idx2: idx2,
normal: normal,
})
}
// Sort triangles by depth (painter's algorithm - draw furthest first)
for i := 0; i < len(triangles); i++ {
for j := i + 1; j < len(triangles); j++ {
if triangles[i].depth > triangles[j].depth {
triangles[i], triangles[j] = triangles[j], triangles[i]
}
}
}
// Build SVG string
svg := fmt.Sprintf("<svg viewBox='0 0 %.0f %.0f' xmlns='http://www.w3.org/2000/svg' stroke='none'>",
s.cfg.ViewWidth, s.cfg.ViewHeight)
// Calculate colors for all visible triangles and group by color
type triangleWithColor struct {
tri triangleWithDepth
color string
opacity string
}
coloredTriangles := make([]triangleWithColor, 0, len(triangles))
bounds := s.texture.Bounds()
for _, tri := range triangles {
if !tri.visible {
continue
}
// Calculate lighting intensity
// Normalize light direction
lightDir := point3d{x: s.cfg.LightX, y: s.cfg.LightY, z: s.cfg.LightZ}
lightMag := math.Sqrt(lightDir.x*lightDir.x + lightDir.y*lightDir.y + lightDir.z*lightDir.z)
if lightMag > 0 {
lightDir.x /= lightMag
lightDir.y /= lightMag
lightDir.z /= lightMag
}
// Diffuse lighting (Lambert) - dot product of normal and light direction
diffuse := math.Max(0, -(tri.normal.x*lightDir.x + tri.normal.y*lightDir.y + tri.normal.z*lightDir.z))
// Combine ambient and diffuse
intensity := s.cfg.AmbientLight + (1.0-s.cfg.AmbientLight)*diffuse
intensity = math.Max(0, math.Min(1, intensity))
// Get centroid UV
uv0 := s.uvs[tri.idx0]
uv1 := s.uvs[tri.idx1]
uv2 := s.uvs[tri.idx2]
centU := (uv0[0] + uv1[0] + uv2[0]) / 3
centV := (uv0[1] + uv1[1] + uv2[1]) / 3
// Clamp UVs to 0-1
centU = math.Max(0, math.Min(1, centU))
centV = math.Max(0, math.Min(1, centV))
// Sample texture - no V flip
x := int(centU * float64(bounds.Dx()-1))
y := int(centV * float64(bounds.Dy()-1))
c := s.texture.At(x, y).(color.RGBA)
// Apply depth intensity to the sampled color
r := int(float64(c.R) * intensity)
g := int(float64(c.G) * intensity)
b := int(float64(c.B) * intensity)
// Quantize colors to reduce palette (round to nearest 8)
r = (r / 8) * 8
g = (g / 8) * 8
b = (b / 8) * 8
colorStr := fmt.Sprintf("#%02x%02x%02x", r, g, b)
opacityStr := ""
if c.A < 255 {
opacityStr = fmt.Sprintf("%.2f", float64(c.A)/255)
}
coloredTriangles = append(coloredTriangles, triangleWithColor{
tri: tri,
color: colorStr,
opacity: opacityStr,
})
}
// Group triangles by color and render
i := 0
for i < len(coloredTriangles) {
currentColor := coloredTriangles[i].color
currentOpacity := coloredTriangles[i].opacity
// Build path data for all triangles with the same color
pathData := ""
for i < len(coloredTriangles) &&
coloredTriangles[i].color == currentColor &&
coloredTriangles[i].opacity == currentOpacity {
tri := coloredTriangles[i].tri
pathData += fmt.Sprintf(
"M%.2f,%.2fL%.2f,%.2fL%.2f,%.2fZ",
tri.v0.x, tri.v0.y,
tri.v1.x, tri.v1.y,
tri.v2.x, tri.v2.y,
)
i++
}
// Output single path with all triangles
if currentOpacity != "" {
svg += fmt.Sprintf("<path fill='%s' opacity='%s' d='%s'/>", currentColor, currentOpacity, pathData)
} else {
svg += fmt.Sprintf("<path fill='%s' d='%s'/>", currentColor, pathData)
}
}
svg += "</svg>"
return svg
}
func (s *grot3dSim) Close() error {
return nil
}
func newGrot3dSimInfo() simulationInfo {
return simulationInfo{
Type: "grot3d",
Name: "Rotating 3D Grot",
Description: "Renders a rotating 3D grot model using SVG triangles",
OnlyForward: false,
ConfigFields: data.NewFrame("config",
data.NewField("rotationSpeedX", nil, []float64{0}),
data.NewField("rotationSpeedY", nil, []float64{5}),
data.NewField("rotationSpeedZ", nil, []float64{30}),
data.NewField("minAngleX", nil, []float64{-45}),
data.NewField("maxAngleX", nil, []float64{45}),
data.NewField("minAngleY", nil, []float64{-45}),
data.NewField("maxAngleY", nil, []float64{45}),
data.NewField("minAngleZ", nil, []float64{0}),
data.NewField("maxAngleZ", nil, []float64{0}),
data.NewField("lightX", nil, []float64{-1}),
data.NewField("lightY", nil, []float64{-1}),
data.NewField("lightZ", nil, []float64{1}),
data.NewField("ambientLight", nil, []float64{0.3}),
data.NewField("viewWidth", nil, []float64{800}),
data.NewField("viewHeight", nil, []float64{800}),
data.NewField("perspective", nil, []float64{1000}),
data.NewField("scale", nil, []float64{5.0}),
),
create: func(state simulationState) (Simulation, error) {
sim := &grot3dSim{
key: state.Key,
cfg: grot3dConfig{
RotationSpeedX: 0,
RotationSpeedY: 5,
RotationSpeedZ: 30,
MinAngleX: -45,
MaxAngleX: 45,
MinAngleY: -45,
MaxAngleY: 45,
MinAngleZ: 0,
MaxAngleZ: 0,
LightX: -1,
LightY: -1,
LightZ: -1,
AmbientLight: 0.5,
ViewWidth: 800,
ViewHeight: 800,
Perspective: 1000,
Scale: 5.0,
},
}
if state.Config != nil {
vals, ok := state.Config.(map[string]any)
if ok {
err := sim.SetConfig(vals)
if err != nil {
return nil, err
}
}
}
if err := sim.initialize(); err != nil {
return nil, err
}
return sim, nil
},
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,429 @@
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)
rotation float64 // current rotation angle in degrees (0-360)
}
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{}
const maxRadius = 30.0
const bossRadius = maxRadius * 2.0 // Boss is twice the max radius (60 pixels)
// Generate random circles (first one is the boss, rest are normal)
for i := 0; i < s.cfg.N; i++ {
var radius float64
// First circle is always the "boss" with double radius
if i == 0 || i == 1 {
radius = bossRadius
} else {
// Random radius between 5 and 30 pixels for normal circles
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 -250 and 250 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
// Initial rotation based on initial velocity
rotation := math.Atan2(vy, vx) * 180.0 / math.Pi
if rotation < 0 {
rotation += 360.0
}
s.state.circles[i] = circle{
x: x,
y: y,
vx: vx,
vy: vy,
radius: radius,
mass: mass,
rotation: rotation,
}
}
}
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, velocity, and rotation 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)),
)
frame.Fields = append(frame.Fields,
data.NewField(fmt.Sprintf("circle_%d_rotation", 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, velocity, and rotation (smoothed rotation from simulate)
result[fmt.Sprintf("circle_%d_diameter", i)] = c.radius * 2.0
result[fmt.Sprintf("circle_%d_velocity", i)] = velocity
result[fmt.Sprintf("circle_%d_rotation", i)] = c.rotation
}
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++ {
// Calculate and apply gravitational forces between all pairs
// G scaled for pixel world: smaller masses, pixel distances
const G = 5000.0 // Gravitational constant scaled for our pixel world
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
// Avoid division by zero and extremely strong forces at close range
const minDist = 10.0 // Minimum distance to prevent extreme forces
if distSq < minDist*minDist {
distSq = minDist * minDist
}
dist := math.Sqrt(distSq)
// Calculate gravitational force magnitude: F = G * m1 * m2 / r^2
force := G * c1.mass * c2.mass / distSq
// Calculate force components (normalized direction * force)
fx := (dx / dist) * force
fy := (dy / dist) * force
// Apply acceleration to both particles (F = ma -> a = F/m)
// c1 is attracted to c2 (positive direction)
c1.vx += (fx / c1.mass) * subDt
c1.vy += (fy / c1.mass) * subDt
// c2 is attracted to c1 (negative direction, by Newton's 3rd law)
c2.vx -= (fx / c2.mass) * subDt
c2.vy -= (fy / c2.mass) * subDt
}
}
// 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
}
}
}
// Update rotations smoothly based on velocity direction
// Maximum rotation change per sub-step (in degrees)
// At 60 sub-steps/sec, 1.5 degrees/step = 90 degrees/second max
const maxRotationChange = 5
for i := range s.state.circles {
c := &s.state.circles[i]
// Calculate target rotation from velocity vector
targetRotation := math.Atan2(c.vy, c.vx) * 180.0 / math.Pi
if targetRotation < 0 {
targetRotation += 360.0
}
// Calculate the shortest angular difference (handles wrap-around)
diff := targetRotation - c.rotation
if diff > 180.0 {
diff -= 360.0
} else if diff < -180.0 {
diff += 360.0
}
// Clamp the rotation change
if diff > maxRotationChange {
diff = maxRotationChange
} else if diff < -maxRotationChange {
diff = -maxRotationChange
}
// Apply the clamped rotation change
c.rotation += diff
// Keep rotation in 0-360 range
if c.rotation >= 360.0 {
c.rotation -= 360.0
} else if c.rotation < 0 {
c.rotation += 360.0
}
}
}
}
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
},
}
}

View File

@@ -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()
}

View File

@@ -81,6 +81,12 @@ export interface CanvasElementProps<TConfig = unknown, TData = unknown> {
isSelected?: boolean;
}
/** Simple numeric size for element defaults - not persisted, just for initial sizing */
export interface DefaultElementSize {
width?: number;
height?: number;
}
/**
* Canvas item builder
*
@@ -89,7 +95,7 @@ export interface CanvasElementProps<TConfig = unknown, TData = unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface CanvasElementItem<TConfig = any, TData = any> extends RegistryItem {
/** The default width/height to use when adding */
defaultSize?: Placement;
defaultSize?: DefaultElementSize;
prepareData?: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<TConfig>) => TData;

View File

@@ -3,7 +3,7 @@ import { useState } from 'react';
import { GrafanaTheme2, PluginState } from '@grafana/data';
import { t } from '@grafana/i18n';
import { TextDimensionMode } from '@grafana/schema';
import { ScalarDimensionMode, PositionDimensionMode, TextDimensionMode } from '@grafana/schema';
import { Button, Spinner, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -122,11 +122,11 @@ export const buttonItem: CanvasElementItem<ButtonConfig, ButtonData> = {
},
},
placement: {
width: options?.placement?.width ?? 32,
height: options?.placement?.height ?? 78,
top: options?.placement?.top ?? 100,
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 32, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 78, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
}),

View File

@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -94,11 +95,11 @@ export const cloudItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 110,
height: options?.placement?.height ?? 70,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 110, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 70, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionConfig } from '@grafana/schema';
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
@@ -89,11 +89,11 @@ export const droneFrontItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 26,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionConfig } from '@grafana/schema';
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
@@ -88,11 +88,11 @@ export const droneSideItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 26,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 26, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -101,11 +102,11 @@ export const ellipseItem: CanvasElementItem<CanvasElementConfig, CanvasElementDa
},
},
placement: {
width: options?.placement?.width ?? 160,
height: options?.placement?.height ?? 138,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 160, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 138, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -4,7 +4,13 @@ import { CSSProperties } from 'react';
import { LinkModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
ResourceDimensionMode,
ScalarDimensionMode,
PositionDimensionMode,
} from '@grafana/schema';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -76,11 +82,11 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
},
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 100,
top: options?.placement?.top ?? 100,
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -5,7 +5,7 @@ import { of } from 'rxjs';
import { DataFrame, FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { TextDimensionMode } from '@grafana/schema';
import { TextDimensionMode, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { usePanelContext, useStyles2 } from '@grafana/ui';
import { FieldNamePicker, frameHasName, getFrameFieldsDisplayNames } from '@grafana/ui/internal';
import { DimensionContext } from 'app/features/dimensions/context';
@@ -171,9 +171,9 @@ export const metricValueItem: CanvasElementItem<TextConfig, TextData> = {
placement: {
width: options?.placement?.width,
height: options?.placement?.height,
top: options?.placement?.top ?? 100,
left: options?.placement?.left ?? 100,
rotation: options?.placement?.rotation ?? 0,
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -94,11 +95,11 @@ export const parallelogramItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 250,
height: options?.placement?.height ?? 150,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 250, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 150, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -2,7 +2,12 @@ import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ColorDimensionConfig, ScalarDimensionConfig } from '@grafana/schema';
import {
ColorDimensionConfig,
ScalarDimensionConfig,
ScalarDimensionMode,
PositionDimensionMode,
} from '@grafana/schema';
import config from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -76,11 +81,11 @@ export const serverItem: CanvasElementItem<ServerConfig, ServerData> = {
},
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 100,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
config: {
type: ServerType.Single,

View File

@@ -0,0 +1,260 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { FieldNamePickerConfigSettings, GrafanaTheme2, StandardEditorsRegistryItem, textUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { PositionDimensionMode, ScalarDimensionMode, TextDimensionConfig, TextDimensionMode } from '@grafana/schema';
import { CodeEditor, InlineField, InlineFieldRow, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/internal';
import { DimensionContext } from 'app/features/dimensions/context';
import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../element';
// eslint-disable-next-line
const dummyFieldSettings: StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings> = {
settings: {},
} as StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings>;
// Simple hash function to generate unique scope IDs
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
// Scope CSS classes to avoid conflicts between multiple SVG elements
function scopeSvgClasses(content: string, scopeId: string): string {
// Replace class definitions in style blocks (.classname)
let scoped = content.replace(/\.([a-zA-Z_-][\w-]*)/g, (match, className) => {
return `.${className}-${scopeId}`;
});
// Replace class attributes (class="name1 name2")
scoped = scoped.replace(/class="([^"]+)"/g, (match, classNames) => {
const scopedNames = classNames
.split(/\s+/)
.map((name: string) => (name ? `${name}-${scopeId}` : ''))
.join(' ');
return `class="${scopedNames}"`;
});
return scoped;
}
export interface SvgConfig {
content?: TextDimensionConfig;
}
interface SvgData {
content: string;
}
export function SvgDisplay(props: CanvasElementProps<SvgConfig, SvgData>) {
const { data } = props;
const styles = useStyles2(getStyles);
// Generate unique scope ID based on content hash
const scopeId = useMemo(() => {
if (!data?.content) {
return '';
}
return hashString(data.content);
}, [data?.content]);
if (!data?.content) {
return (
<div className={styles.placeholder}>{t('canvas.svg-element.placeholder', 'Double click to add SVG content')}</div>
);
}
// Check if content already has an SVG wrapper
const hasSvgWrapper = data.content.trim().toLowerCase().startsWith('<svg');
// Prepare content (wrap if needed)
let contentToScope = data.content;
if (!hasSvgWrapper) {
contentToScope = `<svg width="100%" height="100%">${data.content}</svg>`;
}
// Scope class names to prevent conflicts
const scopedContent = scopeSvgClasses(contentToScope, scopeId);
// Sanitize the scoped content
const sanitizedContent = textUtil.sanitizeSVGContent(scopedContent);
return <div className={styles.container} dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
width: '100%',
height: '100%',
},
}),
placeholder: css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
textAlign: 'center',
padding: theme.spacing(1),
border: `1px dashed ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
export const svgItem: CanvasElementItem<SvgConfig, SvgData> = {
id: 'svg',
name: 'SVG',
description: 'Generic SVG element with sanitized content',
display: SvgDisplay,
hasEditMode: false,
defaultSize: {
width: 100,
height: 100,
},
getNewOptions: (options) => ({
...options,
config: {
content: {
mode: TextDimensionMode.Fixed,
fixed: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="currentColor" /></svg>',
},
},
background: {
color: {
fixed: 'transparent',
},
},
placement: {
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, mode: ScalarDimensionMode.Clamped, min: 0, max: 360 },
},
links: options?.links ?? [],
}),
prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions<SvgConfig>) => {
const svgConfig = elementOptions.config;
const data: SvgData = {
content: svgConfig?.content ? dimensionContext.getText(svgConfig.content).value() : '',
};
return data;
},
registerOptionsUI: (builder) => {
const category = [t('canvas.svg-element.category', 'SVG')];
builder.addCustomEditor({
category,
id: 'svgContent',
path: 'config.content',
name: t('canvas.svg-element.content', 'SVG Content'),
description: t('canvas.svg-element.content-description', 'Enter SVG content or select a field.'),
editor: ({ value, onChange, context }) => {
const mode = value?.mode ?? TextDimensionMode.Fixed;
const labelWidth = 9;
const modeOptions = [
{
label: t('canvas.svg-element.mode-fixed', 'Fixed'),
value: TextDimensionMode.Fixed,
description: t('canvas.svg-element.mode-fixed-description', 'Manually enter SVG content'),
},
{
label: t('canvas.svg-element.mode-field', 'Field'),
value: TextDimensionMode.Field,
description: t('canvas.svg-element.mode-field-description', 'SVG content from data source field'),
},
];
const onModeChange = (newMode: TextDimensionMode) => {
onChange({
...value,
mode: newMode,
});
};
const onFieldChange = (field?: string) => {
onChange({
...value,
field,
});
};
const onFixedChange = (newValue: string) => {
onChange({
...value,
mode: TextDimensionMode.Fixed,
fixed: newValue,
});
};
return (
<>
<InlineFieldRow>
<InlineField label={t('canvas.svg-element.source', 'Source')} labelWidth={labelWidth} grow={true}>
<RadioButtonGroup value={mode} options={modeOptions} onChange={onModeChange} fullWidth />
</InlineField>
</InlineFieldRow>
{mode === TextDimensionMode.Field && (
<InlineFieldRow>
<InlineField label={t('canvas.svg-element.field', 'Field')} labelWidth={labelWidth} grow={true}>
<FieldNamePicker
context={context}
value={value?.field ?? ''}
onChange={onFieldChange}
item={dummyFieldSettings}
/>
</InlineField>
</InlineFieldRow>
)}
{mode === TextDimensionMode.Fixed && (
<div style={{ marginTop: '8px' }}>
<CodeEditor
value={value?.fixed || ''}
language="xml"
height="200px"
onBlur={onFixedChange}
monacoOptions={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
folding: false,
renderLineHighlight: 'none',
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
overviewRulerLanes: 0,
}}
/>
</div>
)}
</>
);
},
settings: {},
});
},
};

View File

@@ -6,6 +6,7 @@ import { of } from 'rxjs';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { Input, usePanelContext, useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -145,11 +146,11 @@ export const textItem: CanvasElementItem<TextConfig, TextData> = {
size: 16,
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 100,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
@@ -95,11 +96,11 @@ export const triangleItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 160,
height: options?.placement?.height ?? 138,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 160, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 138, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { ScalarDimensionConfig } from '@grafana/schema';
import { ScalarDimensionConfig, ScalarDimensionMode, PositionDimensionMode } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { DimensionContext } from 'app/features/dimensions/context';
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
@@ -85,11 +85,11 @@ export const windTurbineItem: CanvasElementItem = {
},
},
placement: {
width: options?.placement?.width ?? 100,
height: options?.placement?.height ?? 155,
top: options?.placement?.top,
left: options?.placement?.left,
rotation: options?.placement?.rotation ?? 0,
width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed },
height: options?.placement?.height ?? { fixed: 155, mode: PositionDimensionMode.Fixed },
top: options?.placement?.top ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
left: options?.placement?.left ?? { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: options?.placement?.rotation ?? { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
},
links: options?.links ?? [],
}),

View File

@@ -12,6 +12,7 @@ import { metricValueItem } from './elements/metricValue';
import { parallelogramItem } from './elements/parallelogram';
import { rectangleItem } from './elements/rectangle';
import { serverItem } from './elements/server/server';
import { svgItem } from './elements/svg';
import { textItem } from './elements/text';
import { triangleItem } from './elements/triangle';
import { windTurbineItem } from './elements/windTurbine';
@@ -33,6 +34,7 @@ export const defaultElementItems = [
triangleItem,
cloudItem,
parallelogramItem,
svgItem,
];
export const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem];

View File

@@ -14,7 +14,12 @@ import {
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { TooltipDisplayMode } from '@grafana/schema';
import {
PositionDimensionConfig,
PositionDimensionMode,
ScalarDimensionMode,
TooltipDisplayMode,
} from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
@@ -74,6 +79,40 @@ export class ElementState implements LayerElement {
showActionVarsModal = false;
actionVars: ActionVariableInput = {};
// Cached values resolved from dimension context
private cachedRotation = 0;
private cachedTop = 0;
private cachedLeft = 0;
private cachedWidth = 100;
private cachedHeight = 100;
private cachedRight?: number;
private cachedBottom?: number;
/** Check if a position property is field-driven (not fixed) */
isPositionFieldDriven(prop: 'top' | 'left' | 'width' | 'height' | 'right' | 'bottom'): boolean {
const pos = this.options.placement?.[prop];
return pos?.mode === PositionDimensionMode.Field && !!pos?.field;
}
/** Check if rotation is field-driven (has a field binding) */
isRotationFieldDriven(): boolean {
const rot = this.options.placement?.rotation;
return !!rot?.field;
}
/** Check if ANY position/size property is field-driven - if so, element can't be moved in editor */
hasFieldDrivenPosition(): boolean {
return (
this.isPositionFieldDriven('top') ||
this.isPositionFieldDriven('left') ||
this.isPositionFieldDriven('width') ||
this.isPositionFieldDriven('height') ||
this.isPositionFieldDriven('right') ||
this.isPositionFieldDriven('bottom') ||
this.isRotationFieldDriven()
);
}
setActionVars = (vars: ActionVariableInput) => {
this.actionVars = vars;
this.forceUpdate();
@@ -93,7 +132,13 @@ export class ElementState implements LayerElement {
vertical: VerticalConstraint.Top,
horizontal: HorizontalConstraint.Left,
};
options.placement = options.placement ?? { width: 100, height: 100, top: 0, left: 0, rotation: 0 };
options.placement = options.placement ?? {
width: { fixed: 100, mode: PositionDimensionMode.Fixed },
height: { fixed: 100, mode: PositionDimensionMode.Fixed },
top: { fixed: 0, mode: PositionDimensionMode.Fixed },
left: { fixed: 0, mode: PositionDimensionMode.Fixed },
rotation: { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped },
};
options.background = options.background ?? { color: { fixed: 'transparent' } };
options.border = options.border ?? { color: { fixed: 'dark-green' } };
@@ -121,6 +166,18 @@ export class ElementState implements LayerElement {
return this.options.name;
}
/** Get the current rotation value (resolved from dimension context) */
getRotation(): number {
return this.cachedRotation;
}
/** Set the fixed value of a PositionDimensionConfig */
private setPositionFixed(pos: PositionDimensionConfig | undefined, value: number): void {
if (pos) {
pos.fixed = value;
}
}
/** Use the configured options to update CSS style properties directly on the wrapper div **/
applyLayoutStylesToDiv(disablePointerEvents?: boolean) {
if (config.featureToggles.canvasPanelPanZoom) {
@@ -134,7 +191,6 @@ export class ElementState implements LayerElement {
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const placement: Placement = this.options.placement ?? {};
const editingEnabled = this.getScene()?.isEditingEnabled;
@@ -145,95 +201,64 @@ export class ElementState implements LayerElement {
// Minimum element size is 10x10
minWidth: '10px',
minHeight: '10px',
rotate: `${placement.rotation ?? 0}deg`,
rotate: `${this.cachedRotation}deg`,
};
const translate = ['0px', '0px'];
switch (vertical) {
case VerticalConstraint.Top:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
style.top = `${placement.top}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
style.top = `${this.cachedTop}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Bottom:
placement.bottom = placement.bottom ?? 0;
placement.height = placement.height ?? 100;
style.bottom = `${placement.bottom}px`;
style.height = `${placement.height}px`;
delete placement.top;
style.bottom = `${this.cachedBottom ?? 0}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.TopBottom:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
style.top = `${placement.top}px`;
style.bottom = `${placement.bottom}px`;
delete placement.height;
style.top = `${this.cachedTop}px`;
style.bottom = `${this.cachedBottom ?? 0}px`;
style.height = '';
break;
case VerticalConstraint.Center:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
translate[1] = '-50%';
style.top = `calc(50% - ${placement.top}px)`;
style.height = `${placement.height}px`;
delete placement.bottom;
style.top = `calc(50% - ${this.cachedTop}px)`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Scale:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
style.top = `${placement.top}%`;
style.bottom = `${placement.bottom}%`;
delete placement.height;
style.top = `${this.cachedTop}%`;
style.bottom = `${this.cachedBottom ?? 0}%`;
style.height = '';
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
style.left = `${placement.left}px`;
style.width = `${placement.width}px`;
delete placement.right;
style.left = `${this.cachedLeft}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Right:
placement.right = placement.right ?? 0;
placement.width = placement.width ?? 100;
style.right = `${placement.right}px`;
style.width = `${placement.width}px`;
delete placement.left;
style.right = `${this.cachedRight ?? 0}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.LeftRight:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
style.left = `${placement.left}px`;
style.right = `${placement.right}px`;
delete placement.width;
style.left = `${this.cachedLeft}px`;
style.right = `${this.cachedRight ?? 0}px`;
style.width = '';
break;
case HorizontalConstraint.Center:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
translate[0] = '-50%';
style.left = `calc(50% - ${placement.left}px)`;
style.width = `${placement.width}px`;
delete placement.right;
style.left = `calc(50% - ${this.cachedLeft}px)`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Scale:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
style.left = `${placement.left}%`;
style.right = `${placement.right}%`;
delete placement.width;
style.left = `${this.cachedLeft}%`;
style.right = `${this.cachedRight ?? 0}%`;
style.width = '';
break;
}
style.transform = `translate(${translate[0]}, ${translate[1]})`;
this.options.placement = placement;
this.sizeStyle = style;
if (this.div) {
@@ -267,7 +292,6 @@ export class ElementState implements LayerElement {
const { constraint } = this.options;
const { vertical, horizontal } = constraint ?? {};
const placement: Placement = this.options.placement ?? {};
const editingEnabled = scene?.isEditingEnabled;
@@ -275,7 +299,6 @@ export class ElementState implements LayerElement {
cursor: editingEnabled ? 'grab' : 'auto',
pointerEvents: disablePointerEvents ? 'none' : 'auto',
position: 'absolute',
// Minimum element size is 10x10
minWidth: '10px',
minHeight: '10px',
};
@@ -285,81 +308,50 @@ export class ElementState implements LayerElement {
switch (vertical) {
case VerticalConstraint.Top:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${placement.top ?? 0}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
transformY = `${this.cachedTop}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Bottom:
placement.bottom = placement.bottom ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! - (placement.bottom ?? 0) - (placement.height ?? 100)}px`;
style.height = `${placement.height}px`;
delete placement.top;
transformY = `${sceneHeight! - (this.cachedBottom ?? 0) - this.cachedHeight}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.TopBottom:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${placement.top ?? 0}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) - (placement.bottom ?? 0)}px`;
delete placement.height;
transformY = `${this.cachedTop}px`;
style.height = `${sceneHeight! - this.cachedTop - (this.cachedBottom ?? 0)}px`;
break;
case VerticalConstraint.Center:
placement.top = placement.top ?? 0;
placement.height = placement.height ?? 100;
transformY = `${sceneHeight! / 2 - (placement.top ?? 0) - (placement.height ?? 0) / 2}px`;
style.height = `${placement.height}px`;
delete placement.bottom;
transformY = `${sceneHeight! / 2 - this.cachedTop - this.cachedHeight / 2}px`;
style.height = `${this.cachedHeight}px`;
break;
case VerticalConstraint.Scale:
placement.top = placement.top ?? 0;
placement.bottom = placement.bottom ?? 0;
transformY = `${(placement.top ?? 0) * (sceneHeight! / 100)}px`;
style.height = `${sceneHeight! - (placement.top ?? 0) * (sceneHeight! / 100) - (placement.bottom ?? 0) * (sceneHeight! / 100)}px`;
delete placement.height;
transformY = `${this.cachedTop * (sceneHeight! / 100)}px`;
style.height = `${sceneHeight! - this.cachedTop * (sceneHeight! / 100) - (this.cachedBottom ?? 0) * (sceneHeight! / 100)}px`;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${placement.left ?? 0}px`;
style.width = `${placement.width}px`;
delete placement.right;
transformX = `${this.cachedLeft}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Right:
placement.right = placement.right ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! - (placement.right ?? 0) - (placement.width ?? 100)}px`;
style.width = `${placement.width}px`;
delete placement.left;
transformX = `${sceneWidth! - (this.cachedRight ?? 0) - this.cachedWidth}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.LeftRight:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${placement.left ?? 0}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) - (placement.right ?? 0)}px`;
delete placement.width;
transformX = `${this.cachedLeft}px`;
style.width = `${sceneWidth! - this.cachedLeft - (this.cachedRight ?? 0)}px`;
break;
case HorizontalConstraint.Center:
placement.left = placement.left ?? 0;
placement.width = placement.width ?? 100;
transformX = `${sceneWidth! / 2 - (placement.left ?? 0) - (placement.width ?? 0) / 2}px`;
style.width = `${placement.width}px`;
delete placement.right;
transformX = `${sceneWidth! / 2 - this.cachedLeft - this.cachedWidth / 2}px`;
style.width = `${this.cachedWidth}px`;
break;
case HorizontalConstraint.Scale:
placement.left = placement.left ?? 0;
placement.right = placement.right ?? 0;
transformX = `${(placement.left ?? 0) * (sceneWidth! / 100)}px`;
style.width = `${sceneWidth! - (placement.left ?? 0) * (sceneWidth! / 100) - (placement.right ?? 0) * (sceneWidth! / 100)}px`;
delete placement.width;
transformX = `${this.cachedLeft * (sceneWidth! / 100)}px`;
style.width = `${sceneWidth! - this.cachedLeft * (sceneWidth! / 100) - (this.cachedRight ?? 0) * (sceneWidth! / 100)}px`;
break;
}
this.options.placement = placement;
style.transform = `translate(${transformX}, ${transformY}) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${transformX}, ${transformY}) rotate(${this.cachedRotation}deg)`;
this.sizeStyle = style;
if (this.div) {
@@ -415,8 +407,8 @@ export class ElementState implements LayerElement {
// TODO: Fix behavior for top+bottom, left+right, center, and scale constraints
let rotationTopOffset = 0;
let rotationLeftOffset = 0;
if (this.options.placement?.rotation && this.options.placement?.width && this.options.placement?.height) {
const rotationDegrees = this.options.placement.rotation;
if (this.cachedRotation && this.options.placement?.width && this.options.placement?.height) {
const rotationDegrees = this.cachedRotation;
const rotationRadians = (Math.PI / 180) * rotationDegrees;
let rotationOffset = rotationRadians;
@@ -438,8 +430,8 @@ export class ElementState implements LayerElement {
const calculateDelta = (dimension1: number, dimension2: number) =>
(dimension1 / 2) * Math.sin(rotationOffset) + (dimension2 / 2) * (Math.cos(rotationOffset) - 1);
rotationTopOffset = calculateDelta(this.options.placement.width, this.options.placement.height);
rotationLeftOffset = calculateDelta(this.options.placement.height, this.options.placement.width);
rotationTopOffset = calculateDelta(this.cachedWidth, this.cachedHeight);
rotationLeftOffset = calculateDelta(this.cachedHeight, this.cachedWidth);
}
const relativeTop =
@@ -463,67 +455,103 @@ export class ElementState implements LayerElement {
transformScale
: 0;
const placement: Placement = {};
// Don't update placement if any position is field-driven
if (this.hasFieldDrivenPosition()) {
this.applyLayoutStylesToDiv();
this.revId++;
return;
}
const width = (elementContainer?.width ?? 100) / transformScale;
const height = (elementContainer?.height ?? 100) / transformScale;
// Helper to create a position dimension config
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const placement: Placement = {};
switch (vertical) {
case VerticalConstraint.Top:
placement.top = relativeTop;
placement.height = height;
placement.top = fixedPosition(relativeTop);
placement.height = fixedPosition(height);
this.cachedTop = relativeTop;
this.cachedHeight = height;
break;
case VerticalConstraint.Bottom:
placement.bottom = relativeBottom;
placement.height = height;
placement.bottom = fixedPosition(relativeBottom);
placement.height = fixedPosition(height);
this.cachedBottom = relativeBottom;
this.cachedHeight = height;
break;
case VerticalConstraint.TopBottom:
placement.top = relativeTop;
placement.bottom = relativeBottom;
placement.top = fixedPosition(relativeTop);
placement.bottom = fixedPosition(relativeBottom);
this.cachedTop = relativeTop;
this.cachedBottom = relativeBottom;
break;
case VerticalConstraint.Center:
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
const parentCenter = parentContainer ? parentContainer.height / 2 : 0;
const distanceFromCenter = parentCenter - elementCenter;
placement.top = distanceFromCenter;
placement.height = height;
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
const parentCenterV = parentContainer ? parentContainer.height / 2 : 0;
const distanceFromCenterV = parentCenterV - elementCenterV;
placement.top = fixedPosition(distanceFromCenterV);
placement.height = fixedPosition(height);
this.cachedTop = distanceFromCenterV;
this.cachedHeight = height;
break;
case VerticalConstraint.Scale:
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.top = fixedPosition(scaleTop);
placement.bottom = fixedPosition(scaleBottom);
this.cachedTop = scaleTop;
this.cachedBottom = scaleBottom;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = relativeLeft;
placement.width = width;
placement.left = fixedPosition(relativeLeft);
placement.width = fixedPosition(width);
this.cachedLeft = relativeLeft;
this.cachedWidth = width;
break;
case HorizontalConstraint.Right:
placement.right = relativeRight;
placement.width = width;
placement.right = fixedPosition(relativeRight);
placement.width = fixedPosition(width);
this.cachedRight = relativeRight;
this.cachedWidth = width;
break;
case HorizontalConstraint.LeftRight:
placement.left = relativeLeft;
placement.right = relativeRight;
placement.left = fixedPosition(relativeLeft);
placement.right = fixedPosition(relativeRight);
this.cachedLeft = relativeLeft;
this.cachedRight = relativeRight;
break;
case HorizontalConstraint.Center:
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenter = parentContainer ? parentContainer.width / 2 : 0;
const distanceFromCenter = parentCenter - elementCenter;
placement.left = distanceFromCenter;
placement.width = width;
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenterH = parentContainer ? parentContainer.width / 2 : 0;
const distanceFromCenterH = parentCenterH - elementCenterH;
placement.left = fixedPosition(distanceFromCenterH);
placement.width = fixedPosition(width);
this.cachedLeft = distanceFromCenterH;
this.cachedWidth = width;
break;
case HorizontalConstraint.Scale:
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.left = fixedPosition(scaleLeft);
placement.right = fixedPosition(scaleRight);
this.cachedLeft = scaleLeft;
this.cachedRight = scaleRight;
break;
}
// Preserve rotation
if (this.options.placement?.rotation) {
placement.rotation = this.options.placement.rotation;
placement.width = this.options.placement.width;
placement.height = this.options.placement.height;
}
this.options.placement = placement;
@@ -554,71 +582,109 @@ export class ElementState implements LayerElement {
const relativeLeft = Math.round(elementRect.left);
const relativeRight = Math.round(scene.width - elementRect.left - elementRect.width);
const placement: Placement = {};
// Don't update placement if any position is field-driven
if (this.hasFieldDrivenPosition()) {
this.applyLayoutStylesToDiv();
this.revId++;
return;
}
const width = elementRect.width;
const height = elementRect.height;
// INFO: calculate it anyway to be able to use it for pan&zoom
placement.top = relativeTop;
placement.left = relativeLeft;
// Helper to create a position dimension config
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const placement: Placement = {};
// INFO: calculate for pan&zoom
placement.top = fixedPosition(relativeTop);
placement.left = fixedPosition(relativeLeft);
this.cachedTop = relativeTop;
this.cachedLeft = relativeLeft;
switch (vertical) {
case VerticalConstraint.Top:
placement.top = relativeTop;
placement.height = height;
placement.top = fixedPosition(relativeTop);
placement.height = fixedPosition(height);
this.cachedTop = relativeTop;
this.cachedHeight = height;
break;
case VerticalConstraint.Bottom:
placement.bottom = relativeBottom;
placement.height = height;
placement.bottom = fixedPosition(relativeBottom);
placement.height = fixedPosition(height);
this.cachedBottom = relativeBottom;
this.cachedHeight = height;
break;
case VerticalConstraint.TopBottom:
placement.top = relativeTop;
placement.bottom = relativeBottom;
placement.top = fixedPosition(relativeTop);
placement.bottom = fixedPosition(relativeBottom);
this.cachedTop = relativeTop;
this.cachedBottom = relativeBottom;
break;
case VerticalConstraint.Center:
const elementCenter = elementContainer ? relativeTop + height / 2 : 0;
const parentCenter = scene.height / 2; // Use scene height instead of scaled viewport height
const distanceFromCenter = parentCenter - elementCenter;
placement.top = distanceFromCenter;
placement.height = height;
const elementCenterV = elementContainer ? relativeTop + height / 2 : 0;
const parentCenterV = scene.height / 2;
const distanceFromCenterV = parentCenterV - elementCenterV;
placement.top = fixedPosition(distanceFromCenterV);
placement.height = fixedPosition(height);
this.cachedTop = distanceFromCenterV;
this.cachedHeight = height;
break;
case VerticalConstraint.Scale:
placement.top = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.bottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleTop = (relativeTop / (parentContainer?.height ?? height)) * 100 * transformScale;
const scaleBottom = (relativeBottom / (parentContainer?.height ?? height)) * 100 * transformScale;
placement.top = fixedPosition(scaleTop);
placement.bottom = fixedPosition(scaleBottom);
this.cachedTop = scaleTop;
this.cachedBottom = scaleBottom;
break;
}
switch (horizontal) {
case HorizontalConstraint.Left:
placement.left = relativeLeft;
placement.width = width;
placement.left = fixedPosition(relativeLeft);
placement.width = fixedPosition(width);
this.cachedLeft = relativeLeft;
this.cachedWidth = width;
break;
case HorizontalConstraint.Right:
placement.right = relativeRight;
placement.width = width;
placement.right = fixedPosition(relativeRight);
placement.width = fixedPosition(width);
this.cachedRight = relativeRight;
this.cachedWidth = width;
break;
case HorizontalConstraint.LeftRight:
placement.left = relativeLeft;
placement.right = relativeRight;
placement.left = fixedPosition(relativeLeft);
placement.right = fixedPosition(relativeRight);
this.cachedLeft = relativeLeft;
this.cachedRight = relativeRight;
break;
case HorizontalConstraint.Center:
const elementCenter = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenter = scene.width / 2; // Use scene width instead of scaled viewport width
const distanceFromCenter = parentCenter - elementCenter;
placement.left = distanceFromCenter;
placement.width = width;
const elementCenterH = elementContainer ? relativeLeft + width / 2 : 0;
const parentCenterH = scene.width / 2;
const distanceFromCenterH = parentCenterH - elementCenterH;
placement.left = fixedPosition(distanceFromCenterH);
placement.width = fixedPosition(width);
this.cachedLeft = distanceFromCenterH;
this.cachedWidth = width;
break;
case HorizontalConstraint.Scale:
placement.left = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.right = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleLeft = (relativeLeft / (parentContainer?.width ?? width)) * 100 * transformScale;
const scaleRight = (relativeRight / (parentContainer?.width ?? width)) * 100 * transformScale;
placement.left = fixedPosition(scaleLeft);
placement.right = fixedPosition(scaleRight);
this.cachedLeft = scaleLeft;
this.cachedRight = scaleRight;
break;
}
// Preserve rotation
if (this.options.placement?.rotation) {
placement.rotation = this.options.placement.rotation;
placement.width = this.options.placement.width;
placement.height = this.options.placement.height;
}
this.options.placement = placement;
@@ -630,10 +696,46 @@ export class ElementState implements LayerElement {
}
updateData(ctx: DimensionContext) {
const previousData = this.data;
if (this.item.prepareData) {
this.data = this.item.prepareData(ctx, this.options);
this.revId++; // rerender
// Only increment revId if data actually changed (not just position)
// This prevents flickering when only position updates
if (JSON.stringify(this.data) !== JSON.stringify(previousData)) {
this.revId++;
}
}
// Update placement values from dimension context
const placement = this.options.placement;
if (placement) {
if (placement.rotation) {
this.cachedRotation = ctx.getScalar(placement.rotation).value();
}
if (placement.top) {
this.cachedTop = ctx.getPosition(placement.top).value();
}
if (placement.left) {
this.cachedLeft = ctx.getPosition(placement.left).value();
}
if (placement.width) {
this.cachedWidth = ctx.getPosition(placement.width).value();
}
if (placement.height) {
this.cachedHeight = ctx.getPosition(placement.height).value();
}
if (placement.right) {
this.cachedRight = ctx.getPosition(placement.right).value();
}
if (placement.bottom) {
this.cachedBottom = ctx.getPosition(placement.bottom).value();
}
}
// Apply updated positions without forcing a remount
this.applyLayoutStylesToDiv();
const scene = this.getScene();
const frames = scene?.data?.series;
@@ -793,6 +895,11 @@ export class ElementState implements LayerElement {
};
applyDrag = (event: OnDrag) => {
// Don't allow dragging if any position is field-driven
if (this.hasFieldDrivenPosition()) {
return;
}
const hasHorizontalCenterConstraint = this.options.constraint?.horizontal === HorizontalConstraint.Center;
const hasVerticalCenterConstraint = this.options.constraint?.vertical === VerticalConstraint.Center;
if (hasHorizontalCenterConstraint || hasVerticalCenterConstraint) {
@@ -813,18 +920,31 @@ export class ElementState implements LayerElement {
applyRotate = (event: OnRotate) => {
const rotationDelta = event.delta;
const placement = this.options.placement!;
const placementRotation = placement.rotation ?? 0;
const placementRotation = this.cachedRotation;
const calculatedRotation = placementRotation + rotationDelta;
// Ensure rotation is between 0 and 360
placement.rotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
const newRotation = calculatedRotation - Math.floor(calculatedRotation / 360) * 360;
// Update the config value as fixed
if (!placement.rotation) {
placement.rotation = { fixed: newRotation, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
} else {
placement.rotation.fixed = newRotation;
}
this.cachedRotation = newRotation;
event.target.style.transform = event.transform;
};
// kinda like:
// https://github.com/grafana/grafana-edge-app/blob/main/src/panels/draw/WrapItem.tsx#L44
applyResize = (event: OnResize) => {
// Don't allow resizing if any position is field-driven
if (this.hasFieldDrivenPosition()) {
return;
}
const placement = this.options.placement!;
const style = event.target.style;
@@ -834,8 +954,8 @@ export class ElementState implements LayerElement {
let dirTB = event.direction[1];
// Handle case when element is rotated
if (placement.rotation) {
const rotation = placement.rotation ?? 0;
if (this.cachedRotation) {
const rotation = this.cachedRotation;
const rotationInRadians = (rotation * Math.PI) / 180;
const originalDirLR = dirLR;
const originalDirTB = dirTB;
@@ -845,31 +965,37 @@ export class ElementState implements LayerElement {
}
if (dirLR === 1) {
placement.width = event.width;
style.width = `${placement.width}px`;
this.setPositionFixed(placement.width, event.width);
this.cachedWidth = event.width;
style.width = `${this.cachedWidth}px`;
} else if (dirLR === -1) {
placement.left! -= deltaX;
placement.width = event.width;
this.cachedLeft -= deltaX;
this.setPositionFixed(placement.left, this.cachedLeft);
this.cachedWidth = event.width;
this.setPositionFixed(placement.width, this.cachedWidth);
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
} else {
style.left = `${placement.left}px`;
style.left = `${this.cachedLeft}px`;
}
style.width = `${placement.width}px`;
style.width = `${this.cachedWidth}px`;
}
if (dirTB === -1) {
placement.top! -= deltaY;
placement.height = event.height;
this.cachedTop -= deltaY;
this.setPositionFixed(placement.top, this.cachedTop);
this.cachedHeight = event.height;
this.setPositionFixed(placement.height, this.cachedHeight);
if (config.featureToggles.canvasPanelPanZoom) {
style.transform = `translate(${placement.left}px, ${placement.top}px) rotate(${placement.rotation ?? 0}deg)`;
style.transform = `translate(${this.cachedLeft}px, ${this.cachedTop}px) rotate(${this.cachedRotation}deg)`;
} else {
style.top = `${placement.top}px`;
style.top = `${this.cachedTop}px`;
}
style.height = `${placement.height}px`;
style.height = `${this.cachedHeight}px`;
} else if (dirTB === 1) {
placement.height = event.height;
style.height = `${placement.height}px`;
this.cachedHeight = event.height;
this.setPositionFixed(placement.height, this.cachedHeight);
style.height = `${this.cachedHeight}px`;
}
};
@@ -880,7 +1006,8 @@ export class ElementState implements LayerElement {
!scene?.isEditingEnabled && (!scene?.tooltipPayload?.isOpen || scene?.tooltipPayload?.element === this);
if (shouldHandleTooltip) {
this.handleTooltip(event);
} else if (!isSelected) {
} else if (!isSelected && !this.hasFieldDrivenPosition()) {
// Don't show connection anchors for field-driven elements
scene?.connections.handleMouseEnter(event);
}
@@ -1090,6 +1217,25 @@ export class ElementState implements LayerElement {
);
};
// Track if this field-driven element is selected (for showing outline)
isFieldDrivenSelected = false;
setFieldDrivenSelected(selected: boolean) {
if (this.hasFieldDrivenPosition()) {
this.isFieldDrivenSelected = selected;
// Update the outline style
if (this.div) {
if (selected) {
this.div.style.outline = '2px solid #3274d9';
this.div.style.outlineOffset = '2px';
} else {
this.div.style.outline = '';
this.div.style.outlineOffset = '';
}
}
}
}
renderElement() {
const { item, div } = this;
const scene = this.getScene();
@@ -1112,7 +1258,7 @@ export class ElementState implements LayerElement {
key={`${this.UID}/${this.revId}`}
config={this.options.config}
data={this.data}
isSelected={isSelected}
isSelected={isSelected || this.isFieldDrivenSelected}
/>
</div>
{this.showActionConfirmation && this.renderActionsConfirmModal(this.getPrimaryAction())}

View File

@@ -9,6 +9,7 @@ import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
PositionDimensionConfig,
ResourceDimensionConfig,
ScalarDimensionConfig,
ScaleDimensionConfig,
@@ -21,6 +22,7 @@ import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import {
getColorDimensionFromData,
getPositionDimensionFromData,
getResourceDimensionFromData,
getScalarDimensionFromData,
getScaleDimensionFromData,
@@ -109,6 +111,22 @@ export class Scene {
targetsToSelect = new Set<HTMLDivElement>();
// Track currently selected field-driven element (these aren't in Selecto)
private fieldDrivenSelectedElement?: ElementState;
clearFieldDrivenSelection = () => {
if (this.fieldDrivenSelectedElement) {
this.fieldDrivenSelectedElement.setFieldDrivenSelected(false);
this.fieldDrivenSelectedElement = undefined;
}
};
setFieldDrivenSelection = (element: ElementState) => {
this.clearFieldDrivenSelection();
this.fieldDrivenSelectedElement = element;
element.setFieldDrivenSelected(true);
};
constructor(
options: Options,
public onSave: (cfg: CanvasFrameOptions) => void,
@@ -211,6 +229,7 @@ export class Scene {
getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color),
getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale),
getScalar: (scalar: ScalarDimensionConfig) => getScalarDimensionFromData(this.data, scalar),
getPosition: (pos: PositionDimensionConfig) => getPositionDimensionFromData(this.data, pos),
getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text),
getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res),
getDirection: (direction: DirectionDimensionConfig) => getDirectionDimensionFromData(this.data, direction),
@@ -267,6 +286,8 @@ export class Scene {
clearCurrentSelection(skipNextSelectionBroadcast = false) {
this.skipNextSelectionBroadcast = skipNextSelectionBroadcast;
// Clear field-driven selection
this.clearFieldDrivenSelection();
let event: MouseEvent = new MouseEvent('click');
if (config.featureToggles.canvasPanelPanZoom) {
this.selecto?.clickTarget(event, this.viewportDiv);
@@ -324,6 +345,9 @@ export class Scene {
select = (selection: SelectionParams) => {
if (this.selecto) {
// Clear any field-driven selection when selecting via Selecto
this.clearFieldDrivenSelection();
this.selecto.setSelectedTargets(selection.targets);
this.updateSelection(selection);
this.editModeEnabled.next(false);

View File

@@ -69,6 +69,7 @@ const isTargetAlreadySelected = (selectedTarget: HTMLElement, scene: Scene) => {
};
// Generate HTML element divs for every canvas element to configure selecto / moveable
// Excludes elements with field-driven positions (they can't be moved in editor)
const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[] => {
let targetElements: HTMLDivElement[] = [];
@@ -77,8 +78,11 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[]
const currentElement = stack.shift();
if (currentElement && currentElement.div) {
// Skip elements with field-driven positions - they can't be moved
if (!currentElement.hasFieldDrivenPosition()) {
targetElements.push(currentElement.div);
}
}
const nestedElements = currentElement instanceof FrameState ? currentElement.elements : [];
for (const nestedElement of nestedElements) {

View File

@@ -1,6 +1,7 @@
import { PanelData } from '@grafana/data';
import {
ColorDimensionConfig,
PositionDimensionConfig,
ResourceDimensionConfig,
ScalarDimensionConfig,
ScaleDimensionConfig,
@@ -18,6 +19,8 @@ export interface DimensionContext {
getScalar(scalar: ScalarDimensionConfig): DimensionSupplier<number>;
getPosition(position: PositionDimensionConfig): DimensionSupplier<number>;
getText(text: TextDimensionConfig): DimensionSupplier<string>;
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;

View File

@@ -0,0 +1,131 @@
import { useCallback, useId, useMemo } from 'react';
import { FieldType, SelectableValue, StandardEditorProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { InlineField, InlineFieldRow, RadioButtonGroup, Select } from '@grafana/ui';
import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/internal';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
import { PositionDimensionOptions } from '../types';
type Props = StandardEditorProps<PositionDimensionConfig, PositionDimensionOptions>;
export const PositionDimensionEditor = ({ value, context, onChange }: Props) => {
const positionOptions = useMemo(
() => [
{
label: t('dimensions.position-dimension-editor.label-fixed', 'Fixed'),
value: PositionDimensionMode.Fixed,
description: t('dimensions.position-dimension-editor.description-fixed', 'Fixed value'),
},
{
label: t('dimensions.position-dimension-editor.label-field', 'Field'),
value: PositionDimensionMode.Field,
description: t('dimensions.position-dimension-editor.description-field', 'Use field value'),
},
],
[]
);
const fixedValueOption: SelectableValue<string> = useMemo(
() => ({
label: t('dimensions.position-dimension-editor.fixed-value-option.label', 'Fixed value'),
value: '_____fixed_____',
}),
[]
);
const labelWidth = 9;
const fieldName = value?.field;
const names = useFieldDisplayNames(context.data);
// Filter to only show number fields for position values
const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number);
const onModeChange = useCallback(
(mode: PositionDimensionMode) => {
onChange({
...value,
mode,
});
},
[onChange, value]
);
const onFieldChange = useCallback(
(selection: SelectableValue<string>) => {
const field = selection.value;
if (field && field !== fixedValueOption.value) {
onChange({
...value,
field,
});
} else {
onChange({
...value,
field: undefined,
});
}
},
[onChange, value, fixedValueOption.value]
);
const onFixedChange = useCallback(
(fixed?: number) => {
onChange({
...value,
fixed: fixed ?? 0,
});
},
[onChange, value]
);
const fieldInputId = useId();
const valueInputId = useId();
const mode = value?.mode ?? PositionDimensionMode.Fixed;
const selectedOption =
mode === PositionDimensionMode.Field ? selectOptions.find((v) => v.value === fieldName) : fixedValueOption;
return (
<>
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-source', 'Source')}
labelWidth={labelWidth}
grow={true}
>
<RadioButtonGroup value={mode} options={positionOptions} onChange={onModeChange} fullWidth />
</InlineField>
</InlineFieldRow>
{mode === PositionDimensionMode.Field && (
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-field', 'Field')}
labelWidth={labelWidth}
grow={true}
>
<Select
inputId={fieldInputId}
value={selectedOption}
options={selectOptions}
onChange={onFieldChange}
noOptionsMessage={t('dimensions.position-dimension-editor.no-fields', 'No number fields found')}
/>
</InlineField>
</InlineFieldRow>
)}
{mode === PositionDimensionMode.Fixed && (
<InlineFieldRow>
<InlineField
label={t('dimensions.position-dimension-editor.label-value', 'Value')}
labelWidth={labelWidth}
grow={true}
>
<NumberInput id={valueInputId} value={value?.fixed ?? 0} onChange={onFixedChange} />
</InlineField>
</InlineFieldRow>
)}
</>
);
};

View File

@@ -0,0 +1,60 @@
import { DataFrame, Field } from '@grafana/data';
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { DimensionSupplier } from './types';
import { findField, getLastNotNullFieldValue } from './utils';
//---------------------------------------------------------
// Position dimension - simple fixed or field value
//---------------------------------------------------------
export function getPositionDimension(
frame: DataFrame | undefined,
config: PositionDimensionConfig
): DimensionSupplier<number> {
return getPositionDimensionForField(findField(frame, config?.field), config);
}
export function getPositionDimensionForField(
field: Field | undefined,
config: PositionDimensionConfig
): DimensionSupplier<number> {
const v = config.fixed ?? 0;
const mode = config.mode ?? PositionDimensionMode.Fixed;
if (mode === PositionDimensionMode.Fixed) {
return {
isAssumed: !config.fixed,
fixed: v,
value: () => v,
get: () => v,
};
}
// Field mode
if (!field) {
return {
isAssumed: true,
fixed: v,
value: () => v,
get: () => v,
};
}
const get = (i: number) => {
const val = field.values[i];
if (val === null || typeof val !== 'number') {
return 0;
}
return val;
};
return {
field,
get,
value: () => {
const val = getLastNotNullFieldValue(field);
return typeof val === 'number' ? val : 0;
},
};
}

View File

@@ -46,6 +46,10 @@ export interface TextDimensionOptions {
// anything?
}
export interface PositionDimensionOptions {
// anything?
}
export const defaultTextConfig: TextDimensionConfig = Object.freeze({
fixed: '',
mode: TextDimensionMode.Field,

View File

@@ -6,12 +6,14 @@ import {
TextDimensionConfig,
ColorDimensionConfig,
ScalarDimensionConfig,
PositionDimensionConfig,
DirectionDimensionConfig,
ConnectionDirection,
} from '@grafana/schema';
import { getColorDimension } from './color';
import { getDirectionDimension } from './direction';
import { getPositionDimension } from './position';
import { getResourceDimension } from './resource';
import { getScalarDimension } from './scalar';
import { getScaledDimension } from './scale';
@@ -78,6 +80,21 @@ export function getScalarDimensionFromData(
return getScalarDimension(undefined, cfg);
}
export function getPositionDimensionFromData(
data: PanelData | undefined,
cfg: PositionDimensionConfig
): DimensionSupplier<number> {
if (data?.series && cfg.field) {
for (const frame of data.series) {
const d = getPositionDimension(frame, cfg);
if (!d.isAssumed || data.series.length === 1) {
return d;
}
}
}
return getPositionDimension(undefined, cfg);
}
export function getResourceDimensionFromData(
data: PanelData | undefined,
cfg: ResourceDimensionConfig

View File

@@ -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);
});

View File

@@ -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) => (

View File

@@ -1,10 +1,12 @@
import { useObservable } from 'react-use';
import { Subject } from 'rxjs';
import { SelectableValue, StandardEditorProps } from '@grafana/data';
import { SelectableValue, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { PositionDimensionConfig, ScalarDimensionConfig, ScalarDimensionMode } from '@grafana/schema';
import { Field, Icon, InlineField, InlineFieldRow, Select, Stack } from '@grafana/ui';
import { NumberInput } from 'app/core/components/OptionsUI/NumberInput';
import { PositionDimensionEditor } from 'app/features/dimensions/editors/PositionDimensionEditor';
import { ScalarDimensionEditor } from 'app/features/dimensions/editors/ScalarDimensionEditor';
import { HorizontalConstraint, Options, Placement, VerticalConstraint } from '../../panelcfg.gen';
@@ -12,7 +14,7 @@ import { ConstraintSelectionBox } from './ConstraintSelectionBox';
import { QuickPositioning } from './QuickPositioning';
import { CanvasEditorOptions } from './elementEditor';
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height', 'rotation'];
const places: Array<keyof Placement> = ['top', 'left', 'bottom', 'right', 'width', 'height'];
type Props = StandardEditorProps<unknown, CanvasEditorOptions, Options>;
@@ -61,8 +63,9 @@ export function PlacementEditor({ item }: Props) {
const { options } = element;
const { placement, constraint: layout } = options;
if (placement) {
placement.rotation = placement?.rotation ?? 0;
// Initialize rotation if not set
if (placement && !placement.rotation) {
placement.rotation = { fixed: 0, min: 0, max: 360, mode: ScalarDimensionMode.Clamped };
}
const reselectElementAfterChange = () => {
@@ -95,20 +98,34 @@ export function PlacementEditor({ item }: Props) {
reselectElementAfterChange();
};
const onPositionChange = (value: number | undefined, placement: keyof Placement) => {
element.options.placement![placement] = value ?? element.options.placement![placement];
const onPositionChange = (value: PositionDimensionConfig | undefined, key: keyof Placement) => {
if (value && key !== 'rotation') {
element.options.placement![key] = value as any;
element.updateData(settings.scene.context);
element.applyLayoutStylesToDiv();
settings.scene.clearCurrentSelection(true);
reselectElementAfterChange();
}
};
const onRotationChange = (value?: ScalarDimensionConfig) => {
if (value) {
element.options.placement!.rotation = value;
element.updateData(settings.scene.context);
element.applyLayoutStylesToDiv();
settings.scene.clearCurrentSelection(true);
reselectElementAfterChange();
}
};
const constraint = element.tempConstraint ?? layout ?? {};
const editorContext = { ...settings.scene.context, data: settings.scene.context.getPanelData()?.series ?? [] };
return (
<div>
<QuickPositioning onPositionChange={onPositionChange} settings={settings} element={element} />
<br />
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')}>
<Field label={t('canvas.placement-editor.label-constraints', 'Constraints')} noMargin>
<Stack direction="row">
<ConstraintSelectionBox
onVerticalConstraintChange={onVerticalConstraintChange}
@@ -134,7 +151,7 @@ export function PlacementEditor({ item }: Props) {
<br />
<Field label={t('canvas.placement-editor.label-position', 'Position')}>
<Field label={t('canvas.placement-editor.label-position', 'Position')} noMargin>
<>
{places.map((p) => {
const v = placement![p];
@@ -142,18 +159,40 @@ export function PlacementEditor({ item }: Props) {
return null;
}
// Need to set explicit min/max for rotation as logic only can handle 0-360
const min = p === 'rotation' ? 0 : undefined;
const max = p === 'rotation' ? 360 : undefined;
return (
<InlineFieldRow key={p}>
<InlineField label={p} labelWidth={8} grow={true}>
<NumberInput min={min} max={max} value={v} onChange={(v) => onPositionChange(v, p)} />
<PositionDimensionEditor
value={v as PositionDimensionConfig}
context={editorContext}
onChange={(val) => onPositionChange(val, p)}
item={{} as any}
/>
</InlineField>
</InlineFieldRow>
);
})}
{placement?.rotation && (
<InlineFieldRow>
<InlineField label={t('canvas.placement-editor.label-rotation', 'rotation')} labelWidth={8} grow={true}>
<ScalarDimensionEditor
value={placement.rotation}
context={editorContext}
onChange={onRotationChange}
item={
{
id: 'rotation',
name: 'Rotation',
settings: {
min: 0,
max: 360,
},
} as StandardEditorsRegistryItem<ScalarDimensionConfig>
}
/>
</InlineField>
</InlineFieldRow>
)}
</>
</Field>
</div>

View File

@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { IconButton, useStyles2 } from '@grafana/ui';
import { ElementState } from 'app/features/canvas/runtime/element';
import { QuickPlacement } from 'app/features/canvas/types';
@@ -11,7 +12,7 @@ import { HorizontalConstraint, VerticalConstraint, Placement } from '../../panel
import { CanvasEditorOptions } from './elementEditor';
type Props = {
onPositionChange: (value: number | undefined, placement: keyof Placement) => void;
onPositionChange: (value: PositionDimensionConfig | undefined, placement: keyof Placement) => void;
element: ElementState;
settings: CanvasEditorOptions;
};
@@ -19,6 +20,17 @@ type Props = {
export const QuickPositioning = ({ onPositionChange, element, settings }: Props) => {
const styles = useStyles2(getStyles);
// Helper to get numeric value from PositionDimensionConfig
const getPositionValue = (config: PositionDimensionConfig | undefined): number => {
return config?.fixed ?? 0;
};
// Helper to create a fixed PositionDimensionConfig
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
const onQuickPositioningChange = (position: QuickPlacement) => {
const defaultConstraint = { vertical: VerticalConstraint.Top, horizontal: HorizontalConstraint.Left };
const originalConstraint = { ...element.options.constraint };
@@ -26,24 +38,27 @@ export const QuickPositioning = ({ onPositionChange, element, settings }: Props)
element.options.constraint = defaultConstraint;
element.setPlacementFromConstraint();
const height = getPositionValue(element.options.placement?.height);
const width = getPositionValue(element.options.placement?.width);
switch (position) {
case QuickPlacement.Top:
onPositionChange(0, 'top');
onPositionChange(fixedPosition(0), 'top');
break;
case QuickPlacement.Bottom:
onPositionChange(getRightBottomPosition(element.options.placement?.height ?? 0, 'bottom'), 'top');
onPositionChange(fixedPosition(getRightBottomPosition(height, 'bottom')), 'top');
break;
case QuickPlacement.VerticalCenter:
onPositionChange(getCenterPosition(element.options.placement?.height ?? 0, 'v'), 'top');
onPositionChange(fixedPosition(getCenterPosition(height, 'v')), 'top');
break;
case QuickPlacement.Left:
onPositionChange(0, 'left');
onPositionChange(fixedPosition(0), 'left');
break;
case QuickPlacement.Right:
onPositionChange(getRightBottomPosition(element.options.placement?.width ?? 0, 'right'), 'left');
onPositionChange(fixedPosition(getRightBottomPosition(width, 'right')), 'left');
break;
case QuickPlacement.HorizontalCenter:
onPositionChange(getCenterPosition(element.options.placement?.width ?? 0, 'h'), 'left');
onPositionChange(fixedPosition(getCenterPosition(width, 'h')), 'left');
break;
}

View File

@@ -1,7 +1,19 @@
import { PanelModel, OneClickMode } from '@grafana/data';
import { PositionDimensionMode, ScalarDimensionMode } from '@grafana/schema';
import { Options } from './panelcfg.gen';
// Helper to migrate a position value from number to PositionDimensionConfig
const migratePositionValue = (value: number | undefined) => {
if (value === undefined) {
return undefined;
}
return {
fixed: value,
mode: PositionDimensionMode.Fixed,
};
};
export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
const pluginVersion = panel?.pluginVersion ?? '';
@@ -99,5 +111,52 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial<Options> => {
}
}
// migrate placement values from numbers to dimension configs
if (parseFloat(pluginVersion) <= 12.4) {
const root = panel.options?.root;
if (root?.elements) {
for (const element of root.elements) {
if (element.placement) {
// Migrate rotation from number to ScalarDimensionConfig
if (typeof element.placement.rotation === 'number') {
element.placement.rotation = {
fixed: element.placement.rotation,
min: 0,
max: 360,
mode: ScalarDimensionMode.Clamped,
};
} else if (!element.placement.rotation) {
element.placement.rotation = {
fixed: 0,
min: 0,
max: 360,
mode: ScalarDimensionMode.Clamped,
};
}
// Migrate position values from numbers to PositionDimensionConfig
if (typeof element.placement.top === 'number') {
element.placement.top = migratePositionValue(element.placement.top);
}
if (typeof element.placement.left === 'number') {
element.placement.left = migratePositionValue(element.placement.left);
}
if (typeof element.placement.width === 'number') {
element.placement.width = migratePositionValue(element.placement.width);
}
if (typeof element.placement.height === 'number') {
element.placement.height = migratePositionValue(element.placement.height);
}
if (typeof element.placement.right === 'number') {
element.placement.right = migratePositionValue(element.placement.right);
}
if (typeof element.placement.bottom === 'number') {
element.placement.bottom = migratePositionValue(element.placement.bottom);
}
}
}
}
}
return panel.options;
};

View File

@@ -35,15 +35,15 @@ composableKinds: PanelCfg: {
} @cuetsy(kind="interface")
Placement: {
top?: float64
left?: float64
right?: float64
bottom?: float64
top?: ui.PositionDimensionConfig
left?: ui.PositionDimensionConfig
right?: ui.PositionDimensionConfig
bottom?: ui.PositionDimensionConfig
width?: float64
height?: float64
width?: ui.PositionDimensionConfig
height?: ui.PositionDimensionConfig
rotation?: float64
rotation?: ui.ScalarDimensionConfig
} @cuetsy(kind="interface")
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")

View File

@@ -32,13 +32,13 @@ export interface Constraint {
}
export interface Placement {
bottom?: number;
height?: number;
left?: number;
right?: number;
rotation?: number;
top?: number;
width?: number;
bottom?: ui.PositionDimensionConfig;
height?: ui.PositionDimensionConfig;
left?: ui.PositionDimensionConfig;
right?: ui.PositionDimensionConfig;
rotation?: ui.ScalarDimensionConfig;
top?: ui.PositionDimensionConfig;
width?: ui.PositionDimensionConfig;
}
export enum BackgroundImageSize {

View File

@@ -1,7 +1,7 @@
import { isNumber, isString } from 'lodash';
import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data';
import { ConnectionDirection } from '@grafana/schema';
import { ConnectionDirection, PositionDimensionConfig, PositionDimensionMode } from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { hasAlphaPanels, config } from 'app/core/config';
import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element';
@@ -15,6 +15,9 @@ import { AnchorPoint, ConnectionState, LineStyle, StrokeDasharray } from './type
export function doSelect(scene: Scene, element: ElementState | FrameState) {
try {
// Clear any previous field-driven selection
scene.clearFieldDrivenSelection?.();
let selection: SelectionParams = { targets: [] };
if (element instanceof FrameState) {
const targetElements: HTMLDivElement[] = [];
@@ -22,6 +25,14 @@ export function doSelect(scene: Scene, element: ElementState | FrameState) {
selection.targets = targetElements;
selection.frame = element;
scene.select(selection);
} else if (element.hasFieldDrivenPosition()) {
// Field-driven elements can't be selected via Selecto, show custom selection
scene.currentLayer = element.parent;
scene.setFieldDrivenSelection(element);
// Clear Selecto selection and broadcast this element as selected
scene.selecto?.setSelectedTargets([]);
scene.moveable!.target = [];
scene.selection.next([element]);
} else {
scene.currentLayer = element.parent;
selection.targets = [element?.div!];
@@ -81,12 +92,30 @@ export function onAddItem(sel: SelectableValue<string>, rootLayer: FrameState |
name: '',
};
// Helper to create a fixed PositionDimensionConfig
const fixedPosition = (value: number): PositionDimensionConfig => ({
fixed: value,
mode: PositionDimensionMode.Fixed,
});
if (anchorPoint) {
newElementOptions.placement = { ...newElementOptions.placement, top: anchorPoint.y, left: anchorPoint.x };
newElementOptions.placement = {
...newElementOptions.placement,
top: fixedPosition(anchorPoint.y),
left: fixedPosition(anchorPoint.x),
};
}
if (newItem.defaultSize) {
newElementOptions.placement = { ...newElementOptions.placement, ...newItem.defaultSize };
// defaultSize uses simple numbers, convert to PositionDimensionConfig
const sizeConfig: Partial<typeof newElementOptions.placement> = {};
if (newItem.defaultSize.width !== undefined) {
sizeConfig.width = fixedPosition(newItem.defaultSize.width);
}
if (newItem.defaultSize.height !== undefined) {
sizeConfig.height = fixedPosition(newItem.defaultSize.height);
}
newElementOptions.placement = { ...newElementOptions.placement, ...sizeConfig };
}
if (rootLayer) {