Skip to content

Commit

Permalink
Add animation slider example
Browse files Browse the repository at this point in the history
AND some missing utility functions
  • Loading branch information
MetalBlueberry committed Aug 28, 2024
1 parent dbbe562 commit d0adbe3
Show file tree
Hide file tree
Showing 4 changed files with 405 additions and 1 deletion.
298 changes: 298 additions & 0 deletions examples/animation_slider/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
package main

import (
"log"
"net/http"
"sort"

grob "github.com/MetalBlueberry/go-plotly/generated/v2.19.0/graph_objects"
"github.com/MetalBlueberry/go-plotly/pkg/offline"
"github.com/MetalBlueberry/go-plotly/pkg/types"
"github.com/go-gota/gota/dataframe"
"golang.org/x/exp/constraints"
)

// https://plotly.com/javascript/gapminder-example/

func readCSVData() dataframe.DataFrame {
// country,year,pop,continent,lifeExp,gdpPercap
response, err := http.Get("https://raw.githubusercontent.com/plotly/datasets/master/gapminderDataFiveYear.csv")
if err != nil {
log.Fatalf("Unable to fetch csv data, %s", err)
}
defer response.Body.Close()

df := dataframe.ReadCSV(response.Body)

if err != nil {
log.Fatalf("Unable to import CSV data, %s", err)
}
return df
}

func main() {
df := readCSVData()

country := df.Col("country")
year := df.Col("year")
population := df.Col("pop")
continent := df.Col("continent")
lifeExp := df.Col("lifeExp")
gdpPercap := df.Col("gdpPercap")

continentClassifications, continentKey := split(continent.Records(), [][]string{
year.Records(),
country.Records(),
population.Records(),
lifeExp.Records(),
gdpPercap.Records(),
})

indexYearContinent := make(map[string]map[string][][]string)
for _, continent := range continentKey {
continentClassification := continentClassifications[continent]
year := continentClassification[0]
yearClassification, yearKeys := split(year, continentClassification[1:])
for _, year := range yearKeys {
if indexYearContinent[year] == nil {
indexYearContinent[year] = make(map[string][][]string)
}
indexYearContinent[year][continent] = yearClassification[year]
}
}

frames := []grob.Frame{}
sliderSteps := []grob.LayoutSliderStep{}

years := SortedKeys(indexYearContinent)

for _, year := range years {
indexContinent := indexYearContinent[year]
data := []types.Trace{}

continents := SortedKeys(indexContinent)
for _, continent := range continents {
records := indexContinent[continent]
data = append(data, &grob.Scatter{
Name: types.S(continent),
X: types.DataArray(records[2]), // life expectancy
Y: types.DataArray(records[3]), // gdp per capita
Ids: types.DataArray(records[0]), // country
Text: types.ArrayOKArray(types.SA(records[0])...), // country
Marker: &grob.ScatterMarker{
// Sizemode: grob.ScatterMarkerSizemodeArea,
Size: types.ArrayOKArray(types.NSA(records[1])...), // population
// Sizeref: types.N(200000),
},
})
}

frameName := types.S(year)
frames = append(frames, grob.Frame{
Name: frameName,
Data: data,
})

sliderSteps = append(sliderSteps, grob.LayoutSliderStep{
Method: grob.LayoutSliderStepMethodAnimate,
Label: frameName,
Args: []interface{}{
[]interface{}{frameName},
&ButtonArgs{
Mode: "immediate",
Transition: map[string]interface{}{"duration": 300},
Frame: map[string]interface{}{"duration": 300, "redraw": false},
},
},
})
}

indexContinent := indexYearContinent[years[0]]
data := []types.Trace{}

continents := SortedKeys(indexContinent)
for _, continent := range continents {
records := indexContinent[continent]
data = append(data, &grob.Scatter{
Name: types.S(continent),
X: types.DataArray(records[2]), // life expectancy
Y: types.DataArray(records[3]), // gdp per capita
Ids: types.DataArray(records[0]), // country
Text: types.ArrayOKArray(types.SA(records[0])...), // country
Mode: grob.ScatterModeMarkers,
Marker: &grob.ScatterMarker{
Sizemode: grob.ScatterMarkerSizemodeArea,
Size: types.ArrayOKArray(types.NSA(records[1])...), // population
Sizeref: types.N(200000),
},
})
}

fig := &grob.Fig{
Data: data,
Layout: &grob.Layout{
Xaxis: &grob.LayoutXaxis{
Title: &grob.LayoutXaxisTitle{
Text: "Life Expectancy",
},
Range: []int{30, 85},
},
Yaxis: &grob.LayoutYaxis{
Title: &grob.LayoutYaxisTitle{
Text: "GDP per Capita",
},
Type: grob.LayoutYaxisTypeLog,
},
Hovermode: grob.LayoutHovermodeClosest,
Updatemenus: []grob.LayoutUpdatemenu{
{
X: types.N(0),
Y: types.N(0),
Xanchor: grob.LayoutUpdatemenuXanchorLeft,
Yanchor: grob.LayoutUpdatemenuYanchorTop,
Showactive: types.False,
Direction: grob.LayoutUpdatemenuDirectionLeft,
Type: grob.LayoutUpdatemenuTypeButtons,
Pad: &grob.LayoutUpdatemenuPad{
T: types.N(87),
R: types.N(10),
},
Buttons: []grob.LayoutUpdatemenuButton{
{
Label: types.S("Play"),
Method: grob.LayoutUpdatemenuButtonMethodAnimate,
Args: []*ButtonArgs{
nil,
{
Mode: "immediate",
FromCurrent: true,
Transition: map[string]interface{}{"duration": 300},
Frame: map[string]interface{}{"duration": 500, "redraw": false},
},
},
},
{
Label: types.S("Pause"),
Method: grob.LayoutUpdatemenuButtonMethodAnimate,
Args: []interface{}{
[]interface{}{nil},
&ButtonArgs{
Mode: "immediate",
FromCurrent: true,
Transition: map[string]interface{}{"duration": 0},
Frame: map[string]interface{}{"duration": 0, "redraw": false},
},
},
},
},
},
},
Sliders: []grob.LayoutSlider{
{
Pad: &grob.LayoutSliderPad{
L: types.N(130),
T: types.N(55),
},
Currentvalue: &grob.LayoutSliderCurrentvalue{
Visible: types.True,
Prefix: types.S("Year:"),
Xanchor: grob.LayoutSliderCurrentvalueXanchorRight,
Font: &grob.LayoutSliderCurrentvalueFont{
Size: types.N(20),
},
},
Steps: sliderSteps,
},
},
},
Frames: frames,
Animation: &grob.Animation{
Transition: &grob.AnimationTransition{
Duration: types.N(500),
Easing: grob.AnimationTransitionEasingCubicInOut,
},
Frame: &grob.AnimationFrame{
Duration: types.N(500),
Redraw: types.True,
},
},
}

offline.Serve(fig)
}

type ButtonArgs struct {
Frame map[string]interface{} `json:"frame,omitempty"`
Transition map[string]interface{} `json:"transition,omitempty"`
FromCurrent bool `json:"fromcurrent,omitempty"`
Mode string `json:"mode,omitempty"`
}

// given a reference slice, it will split the other slices in the same way
// so if reface is ["a","b","a","b"] and slices is [[1,2,3,4],["s1","s2","s3","s4"]]
// it will return
// {
// "a":[[1,3],["s1","s3"]],
// "b":[[2,4],["s2","s4"]]
// }
func split[T constraints.Ordered, Y any](reference []T, slices [][]Y) (map[T][][]Y, []T) {
indices, keys := findIndices(reference)

result := map[T][][]Y{}
for i, slice := range slices {
sections := splitByIndices(slice, indices)
for j, key := range keys {
if result[key] == nil {
result[key] = make([][]Y, len(slices))
}
result[key][i] = sections[j]
}
}
return result, keys
}

// given an slice, it will return the indices and the keys you can use to classify it by its types
// so ["a","b","a","b"] will return [[0,2],[1,3]] and ["a","b"]
func findIndices[T constraints.Ordered](input []T) ([][]int, []T) {
indexMap := make(map[T][]int)
var keys []T

// Populate the map with indices grouped by the value
for i, val := range input {
if _, found := indexMap[val]; !found {
keys = append(keys, val)
}
indexMap[val] = append(indexMap[val], i)
}

// Collect the grouped indices into a result slice in the order of first appearance
var result [][]int
for _, key := range keys {
result = append(result, indexMap[key])
}

return result, keys
}

// given a slice, it will classify it by the given indices.
// so ["a","b","c","d"] with [[0,2],[1,3]] will return [["a","c"],["b","d"]]
func splitByIndices[T any](orginal []T, indices [][]int) [][]T {
result := [][]T{}
for i, section := range indices {
result = append(result, []T{})
for _, value := range section {
result[i] = append(result[i], orginal[value])
}
}

return result
}

func SortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
84 changes: 84 additions & 0 deletions examples/animation_slider/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"reflect"
"testing"
)

func TestSplit(t *testing.T) {
// Test case 1: Basic functionality
reference := []string{"a", "b", "a", "b"}
slices := [][]interface{}{
{1, 2, 3, 4},
{"s1", "s2", "s3", "s4"},
}
expected := map[string][][]interface{}{
"a": {
{1, 3},
{"s1", "s3"},
},
"b": {
{2, 4},
{"s2", "s4"},
},
}

result, _ := split(reference, slices)

if !reflect.DeepEqual(result, expected) {
t.Errorf("Test case 1 failed. Expected %v, got %v", expected, result)
}

// Test case 2: Single element in reference
reference = []string{"a"}
slices = [][]interface{}{
{5},
{"single"},
}
expected = map[string][][]interface{}{
"a": {
{5},
{"single"},
},
}

result, _ = split(reference, slices)

if !reflect.DeepEqual(result, expected) {
t.Errorf("Test case 2 failed. Expected %v, got %v", expected, result)
}

// Test case 3: Empty slices
reference = []string{}
slices = [][]interface{}{}
expected = map[string][][]interface{}{}

result, _ = split(reference, slices)

if !reflect.DeepEqual(result, expected) {
t.Errorf("Test case 3 failed. Expected %v, got %v", expected, result)
}

// Test case 4: Multiple same keys
reference = []string{"x", "x", "y", "y"}
slices = [][]interface{}{
{10, 20, 30, 40},
{"a", "b", "c", "d"},
}
expected = map[string][][]interface{}{
"x": {
{10, 20},
{"a", "b"},
},
"y": {
{30, 40},
{"c", "d"},
},
}

result, _ = split(reference, slices)

if !reflect.DeepEqual(result, expected) {
t.Errorf("Test case 4 failed. Expected %v, got %v", expected, result)
}
}
2 changes: 1 addition & 1 deletion examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ require (
github.com/go-gota/gota v0.12.0
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
)

require (
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.23.0 // indirect
gonum.org/v1/gonum v0.15.0 // indirect
Expand Down
Loading

0 comments on commit d0adbe3

Please sign in to comment.