package main import ( "encoding/json" "flag" "fmt" "log" "net/url" "os" "slices" "github.com/go-echarts/go-echarts/v2/opts" ) type Config struct { Data Data Output url.URL Graph struct { Type string } } type Data struct { Points [][]float64 Labels []string Weight struct { Floor int Range int } Title string Subtitle string } func newConfig() (Config, error) { dataPoints := envOr("DATA_POINTS", `[[11, 21], [11, 22], [12, 21], [12, 21], [14, 23]]`) dataLabels := envOr("DATA_LABELS", ``) dataWeightRange := envOr("DATA_WEIGHT_RANGE", `20`) dataWeightFloor := envOr("DATA_WEIGHT_FLOOR", `12`) dataTitle := envOr("DATA_TITLE", ``) dataSubtitle := envOr("DATA_SUBTITLE", ``) output := envOr("OUTPUT", `file:///tmp/f.html`) graphType := envOr("GRAPH_TYPE", `scatter`) fs := flag.NewFlagSet("cmd", flag.ContinueOnError) fs.StringVar(&dataPoints, "data-points", dataPoints, `JSON [[x, y, z, ...]]`) fs.StringVar(&dataLabels, "data-labels", dataLabels, `(optional) JSON ["foo", "bar", "baz"]`) fs.StringVar(&dataWeightFloor, "data-weight-floor", dataWeightFloor, `int min point size`) fs.StringVar(&dataWeightRange, "data-weight-range", dataWeightRange, `int max point scale up`) fs.StringVar(&dataTitle, "data-title", dataTitle, `chart title`) fs.StringVar(&dataSubtitle, "data-subtitle", dataSubtitle, `chart subtitle`) fs.StringVar(&output, "output", output, `scheme://location`) fs.StringVar(&graphType, "graph-type", graphType, `[scatter, line]`) if err := fs.Parse(os.Args[1:]); err != nil { return Config{}, err } var result Config if err := json.Unmarshal([]byte(dataPoints), &result.Data.Points); err != nil { return result, err } if len(result.Data.Points) > 0 { for i := range result.Data.Points { if want := len(result.Data.Points[0]); want != len(result.Data.Points[i]) { return result, fmt.Errorf("point [%d] expected %d dimensions but found %d", i, want, len(result.Data.Points[i])) } } result.Data.Labels = make([]string, len(result.Data.Points[0])) for i := range result.Data.Labels { result.Data.Labels[i] = fmt.Sprintf("%c", byte(int('A')+i)) } if dataLabels == "" { } else if err := json.Unmarshal([]byte(dataLabels), &result.Data.Labels); err != nil { return result, err } else if len(result.Data.Labels) < len(result.Data.Points[0]) { return result, fmt.Errorf("expected at least %d labels but got %d", len(result.Data.Points[0]), len(result.Data.Labels)) } } if err := json.Unmarshal([]byte(dataWeightRange), &result.Data.Weight.Range); err != nil { return result, err } else if result.Data.Weight.Range < 0 { return result, fmt.Errorf("found negative weight range %d", result.Data.Weight.Range) } if err := json.Unmarshal([]byte(dataWeightFloor), &result.Data.Weight.Floor); err != nil { return result, err } else if result.Data.Weight.Floor < 0 { return result, fmt.Errorf("found negative weight floor %d", result.Data.Weight.Floor) } result.Data.Title = dataTitle result.Data.Subtitle = dataSubtitle result.Graph.Type = graphType if u, err := url.Parse(output); err != nil { return result, fmt.Errorf("failed to parse $OUTPUT (%s): %w", output, err) } else { result.Output = *u } return result, nil } func envOr(k, v string) string { if s := os.Getenv(k); s != "" { return s } return v } func scatterAsLineData(data []opts.ScatterData) []opts.LineData { result := make([]opts.LineData, len(data)) for i := range data { result[i] = opts.LineData{ Value: data[i].Value, SymbolSize: data[i].SymbolSize, } } return result } func (d Data) AsScatterData() (string, string, []opts.ScatterData, error) { if !(2 <= len(d.Labels) && len(d.Labels) <= 3) { return "", "", nil, fmt.Errorf("cannot map %d dimensions to [2,3]", len(d.Labels)) } d = d.weighXYasZ() zs := make([]float64, 0) for i := range d.Points { zs = append(zs, d.Points[i][2]) } slices.Sort(zs) zmin, zmax := zs[0], zs[len(zs)-1] zrange := zmax - zmin if zrange == 0 { zrange = 1 } log.Printf("d=%+v zmin=%v zmax=%v zrange=%v", d.Points, zmin, zmax, zrange) result := make([]opts.ScatterData, 0) for i := range d.Points { x := d.Points[i][0] y := d.Points[i][1] z := d.Points[i][2] result = append(result, opts.ScatterData{ Value: []float64{x, y}, SymbolSize: d.Weight.Floor + int((float64(d.Weight.Range)*(z-zmin))/zrange), }) } return d.Labels[0], d.Labels[1], result, nil } func (d Data) weighXYasZ() Data { xyz := map[float64]map[float64]float64{} for i := range d.Points { x := d.Points[i][0] if _, ok := xyz[x]; !ok { xyz[x] = map[float64]float64{} } y := d.Points[i][1] z := 1.0 if len(d.Labels) > 2 { z = d.Points[i][2] } if _, ok := xyz[x][y]; !ok { xyz[x][y] = 0.0 } xyz[x][y] += z } result := d result.Points = [][]float64{} for x := range xyz { for y, z := range xyz[x] { result.Points = append(result.Points, []float64{x, y, z}) } } return result }