mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
17 Commits
docs/add-a
...
drew08t/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b982190f1 | ||
|
|
953f08ed55 | ||
|
|
8ed233e867 | ||
|
|
e326306485 | ||
|
|
632f47e367 | ||
|
|
3d7358c4ce | ||
|
|
fc8893bc53 | ||
|
|
2723a719aa | ||
|
|
52d4c928e2 | ||
|
|
efa577e186 | ||
|
|
2627df30d6 | ||
|
|
bf2870c8c8 | ||
|
|
c53d239aae | ||
|
|
cdde735f41 | ||
|
|
7be8de044c | ||
|
|
34305670c5 | ||
|
|
308bc56d0f |
@@ -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.
|
||||
|
||||
13
packages/grafana-schema/src/common/common.gen.ts
generated
13
packages/grafana-schema/src/common/common.gen.ts
generated
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'> {}
|
||||
|
||||
@@ -66,6 +66,8 @@ func NewSimulationEngine() (*SimulationEngine, error) {
|
||||
newFlightSimInfo,
|
||||
newSinewaveInfo,
|
||||
newTankSimInfo,
|
||||
newNBodySimInfo,
|
||||
newGrot3dSimInfo,
|
||||
}
|
||||
|
||||
for _, init := range initializers {
|
||||
|
||||
560
pkg/tsdb/grafana-testdata-datasource/sims/grot3d.go
Normal file
560
pkg/tsdb/grafana-testdata-datasource/sims/grot3d.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
BIN
pkg/tsdb/grafana-testdata-datasource/sims/grot_base_color.png
Normal file
BIN
pkg/tsdb/grafana-testdata-datasource/sims/grot_base_color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
1
pkg/tsdb/grafana-testdata-datasource/sims/grot_mesh.json
Normal file
1
pkg/tsdb/grafana-testdata-datasource/sims/grot_mesh.json
Normal file
File diff suppressed because one or more lines are too long
429
pkg/tsdb/grafana-testdata-datasource/sims/nbody.go
Normal file
429
pkg/tsdb/grafana-testdata-datasource/sims/nbody.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
238
pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go
Normal file
238
pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go
Normal 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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
260
public/app/features/canvas/elements/svg.tsx
Normal file
260
public/app/features/canvas/elements/svg.tsx
Normal 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: {},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
}),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,11 +696,47 @@ 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())}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,7 +78,10 @@ const generateTargetElements = (rootElements: ElementState[]): HTMLDivElement[]
|
||||
const currentElement = stack.shift();
|
||||
|
||||
if (currentElement && currentElement.div) {
|
||||
targetElements.push(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 : [];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
60
public/app/features/dimensions/position.ts
Normal file
60
public/app/features/dimensions/position.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface TextDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export interface PositionDimensionOptions {
|
||||
// anything?
|
||||
}
|
||||
|
||||
export const defaultTextConfig: TextDimensionConfig = Object.freeze({
|
||||
fixed: '',
|
||||
mode: TextDimensionMode.Field,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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];
|
||||
element.applyLayoutStylesToDiv();
|
||||
settings.scene.clearCurrentSelection(true);
|
||||
reselectElementAfterChange();
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
14
public/app/plugins/panel/canvas/panelcfg.gen.ts
generated
14
public/app/plugins/panel/canvas/panelcfg.gen.ts
generated
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user