You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

379 lines
9.2 KiB

// SPDX-License-Identifier: Unlicense OR MIT
package main
import (
"fmt"
"image"
"image/color"
"log"
"math"
"strconv"
"time"
"golang.org/x/exp/rand"
"golang.org/x/exp/shiny/materialdesign/icons"
"gonum.org/v1/gonum/spatial/r2"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
)
// distribution tracks useful minimum and maximum information about
// the stars.
type distribution struct {
min, max r2.Vec
maxSpeed float64
meanSpeed float64
minMass, maxMass float64
speedSum float64
speedSamples int
}
// Update ensures that the distribution contains accurate min/max
// data for the slice of stars provided.
func (d *distribution) Update(stars []*mass) {
var (
speedSum float64
speedSamples int
)
for i, s := range stars {
speed := distance(s.v, s.d)
if i == 0 {
d.minMass = s.m
}
if s.d.X < d.min.X {
d.min.X = s.d.X
}
if s.d.Y < d.min.Y {
d.min.Y = s.d.Y
}
if s.d.X > d.max.X {
d.max.X = s.d.X
}
if s.d.Y > d.max.Y {
d.max.Y = s.d.Y
}
if s.m > d.maxMass {
d.maxMass = s.m
}
if s.m < d.minMass {
d.minMass = s.m
}
if speed > d.maxSpeed {
d.maxSpeed = speed
}
speedSamples++
speedSum += speed
}
d.meanSpeed = speedSum / float64(speedSamples)
}
// EnsureSquare adjusts the distribution so that the min and max
// coordinates are the corners of a square (by padding one axis
// equally across the top and bottom). This helps to prevent visual
// distortion during the visualization, though it does not stop it
// completely.
func (d *distribution) EnsureSquare() {
diff := d.max.Sub(d.min)
if diff.X > diff.Y {
padding := (diff.X - diff.Y) / 2
d.max.Y += padding
d.min.Y -= padding
} else if diff.Y > diff.X {
padding := (diff.Y - diff.X) / 2
d.max.X += padding
d.min.X -= padding
}
}
// String describes the distribution in text form.
func (d distribution) String() string {
return fmt.Sprintf("distance: (min: %v max: %v), mass: (min: %v, max: %v)", d.min, d.max, d.minMass, d.maxMass)
}
// Scale uses the min/max data within the distribution to compute the
// position, speed, and size of the star.
func (d distribution) Scale(star *mass) Star {
s := Star{}
s.X = float32((star.d.X - d.min.X) / (d.max.X - d.min.X))
s.Y = float32((star.d.Y - d.min.Y) / (d.max.Y - d.min.Y))
speed := math.Log(distance(star.v, star.d)) / math.Log(d.maxSpeed)
s.Speed = float32(speed)
s.Size = unit.Dp(float32(1 + ((star.m / (d.maxMass - d.minMass)) * 10)))
return s
}
// distance implements the simple two-dimensional euclidean distance function.
func distance(a, b r2.Vec) float64 {
return math.Sqrt((b.X-a.X)*(b.X-a.X) + (b.Y-a.Y)*(b.Y-a.Y))
}
var PlayIcon = func() *widget.Icon {
ic, _ := widget.NewIcon(icons.AVPlayArrow)
return ic
}()
var PauseIcon = func() *widget.Icon {
ic, _ := widget.NewIcon(icons.AVPause)
return ic
}()
var ClearIcon = func() *widget.Icon {
ic, _ := widget.NewIcon(icons.ContentClear)
return ic
}()
// viewport models a region of a larger space. Offset is the location
// of the upper-left corner of the view within the larger space. size
// is the dimensions of the viewport within the larger space.
type viewport struct {
offset f32.Point
size f32.Point
}
// subview modifies v to describe a smaller region by zooming into the
// space described by v using other.
func (v *viewport) subview(other *viewport) {
v.offset.X += other.offset.X * v.size.X
v.offset.Y += other.offset.Y * v.size.Y
v.size.X *= other.size.X
v.size.Y *= other.size.Y
}
// ensureSquare returns a copy of the rectangle that has been padded to
// be square by increasing the maximum coordinate.
func ensureSquare(r image.Rectangle) image.Rectangle {
dx := r.Dx()
dy := r.Dy()
if dx > dy {
r.Max.Y = r.Min.Y + dx
} else if dy > dx {
r.Max.X = r.Min.X + dy
}
return r
}
var (
ops op.Ops
play, clear widget.Clickable
playing = false
th = material.NewTheme(gofont.Collection())
selected image.Rectangle
selecting = false
view *viewport
)
func main() {
th.Palette.Fg, th.Palette.Bg = th.Palette.Bg, th.Palette.Fg
dist := distribution{}
seed := time.Now().UnixNano()
rnd := rand.New(rand.NewSource(uint64(seed)))
// Make 1000 stars in random locations.
stars, plane := galaxy(1000, rnd)
dist.Update(stars)
desiredSize := unit.Dp(800)
window := app.NewWindow(
app.Size(desiredSize, desiredSize),
app.Title("Seed: "+strconv.Itoa(int(seed))),
)
iterateSim := func() {
if !playing {
return
}
simulate(stars, plane, &dist)
window.Invalidate()
}
for {
select {
case ev := <-window.Events():
switch ev := ev.(type) {
case system.DestroyEvent:
if ev.Err != nil {
log.Fatal(ev.Err)
}
return
case system.FrameEvent:
gtx := layout.NewContext(&ops, ev)
paint.Fill(gtx.Ops, th.Palette.Bg)
layout.Center.Layout(gtx, func(gtx C) D {
return widget.Border{
Color: th.Fg,
Width: unit.Dp(1),
}.Layout(gtx, func(gtx C) D {
if gtx.Constraints.Max.X > gtx.Constraints.Max.Y {
gtx.Constraints.Max.X = gtx.Constraints.Max.Y
} else {
gtx.Constraints.Max.Y = gtx.Constraints.Max.X
}
gtx.Constraints.Min = gtx.Constraints.Max
if clear.Clicked() {
view = nil
}
if play.Clicked() {
playing = !playing
}
layoutSelectionLayer(gtx)
for _, s := range stars {
dist.Scale(s).Layout(gtx, view)
}
layoutControls(gtx)
return D{Size: gtx.Constraints.Max}
})
})
ev.Frame(gtx.Ops)
iterateSim()
}
}
}
}
func layoutControls(gtx C) D {
layout.N.Layout(gtx, func(gtx C) D {
return material.Body1(th, "Click and drag to zoom in on a region").Layout(gtx)
})
layout.S.Layout(gtx, func(gtx C) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D {
return layout.Flex{
Spacing: layout.SpaceEvenly,
}.Layout(gtx,
layout.Rigid(func(gtx C) D {
var btn material.IconButtonStyle
if playing {
btn = material.IconButton(th, &play, PauseIcon, "Pause Simulation")
} else {
btn = material.IconButton(th, &play, PlayIcon, "Play Simulation")
}
return btn.Layout(gtx)
}),
layout.Rigid(func(gtx C) D {
if view == nil {
gtx = gtx.Disabled()
}
return material.IconButton(th, &clear, ClearIcon, "Reset Viewport").Layout(gtx)
}),
)
})
})
return D{}
}
func layoutSelectionLayer(gtx C) D {
for _, event := range gtx.Events(&selected) {
switch event := event.(type) {
case pointer.Event:
var intPt image.Point
intPt.X = int(event.Position.X)
intPt.Y = int(event.Position.Y)
switch event.Type {
case pointer.Press:
selecting = true
selected.Min = intPt
selected.Max = intPt
case pointer.Drag:
if intPt.X >= selected.Min.X && intPt.Y >= selected.Min.Y {
selected.Max = intPt
} else {
selected.Min = intPt
}
selected = ensureSquare(selected)
case pointer.Release:
selecting = false
newView := &viewport{
offset: f32.Point{
X: float32(selected.Min.X) / float32(gtx.Constraints.Max.X),
Y: float32(selected.Min.Y) / float32(gtx.Constraints.Max.Y),
},
size: f32.Point{
X: float32(selected.Dx()) / float32(gtx.Constraints.Max.X),
Y: float32(selected.Dy()) / float32(gtx.Constraints.Max.Y),
},
}
if view == nil {
view = newView
} else {
view.subview(newView)
}
case pointer.Cancel:
selecting = false
selected = image.Rectangle{}
}
}
}
if selecting {
paint.FillShape(gtx.Ops, color.NRGBA{R: 255, A: 100}, clip.Rect(selected).Op())
}
pr := clip.Rect(image.Rectangle{Max: gtx.Constraints.Max}).Push(gtx.Ops)
pointer.CursorNameOp{Name: pointer.CursorCrossHair}.Add(gtx.Ops)
pointer.InputOp{
Tag: &selected,
Types: pointer.Press | pointer.Release | pointer.Drag,
}.Add(gtx.Ops)
pr.Pop()
return D{Size: gtx.Constraints.Max}
}
// Star represents a point of mass rendered within a specific region of a canvas.
type Star struct {
X, Y float32
Speed float32
Size unit.Value
}
type (
C = layout.Context
D = layout.Dimensions
)
// Layout renders the star into the gtx assuming that it is visible within the
// provided viewport. Stars outside of the viewport will be skipped.
func (s Star) Layout(gtx layout.Context, view *viewport) layout.Dimensions {
px := gtx.Px(s.Size)
if view != nil {
if s.X < view.offset.X || s.X > view.offset.X+view.size.X {
return D{}
}
if s.Y < view.offset.Y || s.Y > view.offset.Y+view.size.Y {
return D{}
}
s.X = (s.X - view.offset.X) / view.size.X
s.Y = (s.Y - view.offset.Y) / view.size.Y
}
rr := float32(px / 2)
x := s.X*float32(gtx.Constraints.Max.X) - rr
y := s.Y*float32(gtx.Constraints.Max.Y) - rr
defer op.Offset(f32.Pt(x, y)).Push(gtx.Ops).Pop()
rect := f32.Rectangle{
Max: f32.Pt(float32(px), float32(px)),
}
fill := color.NRGBA{R: 0xff, G: 128, B: 0xff, A: 50}
fill.R = 255 - uint8(255*s.Speed)
fill.B = uint8(255 * s.Speed)
paint.FillShape(gtx.Ops, fill, clip.UniformRRect(rect, rr).Op(gtx.Ops))
return D{}
}