package main import ( "encoding/json" "flag" "fmt" "log" "net/url" "os" "sort" "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], [12, 22], [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 && len(result.Data.Points[0]) > 0 { expect := len(result.Data.Points[0][0]) for i := range result.Data.Points { for j := range result.Data.Points[i] { if got := len(result.Data.Points[i][j]); expect != got { return result, fmt.Errorf("point [%d] expected %d dimensions but found %d", i, expect, got) } } } result.Data.Labels = make([]string, expect) 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) < expect { return result, fmt.Errorf("expected at least %d labels but got %d", expect, 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 != "" { log.Printf("found $%s = %s", 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] = make([]opts.LineData, len(data[i])) for j := range data[i] { result[i][j] = opts.LineData{ Value: data[i][j].Value, SymbolSize: data[i][j].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)) } d2 := d d2.Points = make([][][]float64, len(d.Points)) for i := range d.Points { d2.Points[i] = weighXYasZ(d.Points[i]) } d = d2 zs := make([]float64, 0) for i := range d.Points { for j := range d.Points[i] { zs = append(zs, d.Points[i][j][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 _, series := range d.Points { subresult := make([]opts.ScatterData, 0) for i := range series { x := series[i][0] y := series[i][1] z := series[i][2] subresult = append(subresult, opts.ScatterData{ Value: []float64{x, y}, SymbolSize: d.Weight.Floor + int((float64(d.Weight.Range)*(z-zmin))/zrange), }) } result = append(result, subresult) } return d.Labels[0], d.Labels[1], result, nil } func weighXYasZ(input [][]float64) [][]float64 { xyz := map[float64]map[float64]float64{} for i := range input { x := input[i][0] if _, ok := xyz[x]; !ok { xyz[x] = map[float64]float64{} } y := input[i][1] z := 1.0 if len(input[i]) > 2 { z = input[i][2] } if _, ok := xyz[x][y]; !ok { xyz[x][y] = 0.0 } xyz[x][y] += z } result := [][]float64{} for x := range xyz { for y, z := range xyz[x] { result = append(result, []float64{x, y, z}) } } sort.Slice(result, func(i, j int) bool { return result[i][0] < result[j][0] }) return result }