Compare commits

...

9 Commits

Author SHA1 Message Date
Bel LaPointe
94fb1a97fe add legend and series coloring 2024-02-21 13:36:57 -07:00
Bel LaPointe
ac5f3bdc1c log env 2024-02-21 13:34:14 -07:00
Bel LaPointe
3ff2bf0c91 sort by x 2024-02-21 13:28:45 -07:00
Bel LaPointe
0dc484047c upgrade DATA_POINTS to require 3d array but now multi series is ok 2024-02-21 13:19:01 -07:00
Bel LaPointe
83579698c0 accept -graph-type 2024-02-21 12:56:54 -07:00
Bel LaPointe
3aa9f635be dont start at zero 2024-02-21 12:47:05 -07:00
bel
bf0c27efca fix title 2024-02-14 10:08:43 -07:00
bel
ba824be243 hover 2024-02-14 09:58:11 -07:00
bel
b084195a6f better test too 2024-02-14 09:50:56 -07:00
2 changed files with 153 additions and 67 deletions

117
config.go
View File

@@ -7,6 +7,7 @@ import (
"log" "log"
"net/url" "net/url"
"os" "os"
"sort"
"slices" "slices"
@@ -16,10 +17,13 @@ import (
type Config struct { type Config struct {
Data Data Data Data
Output url.URL Output url.URL
Graph struct {
Type string
}
} }
type Data struct { type Data struct {
Points [][]float64 Points [][][]float64
Labels []string Labels []string
Weight struct { Weight struct {
Floor int Floor int
@@ -30,22 +34,24 @@ type Data struct {
} }
func newConfig() (Config, error) { func newConfig() (Config, error) {
dataPoints := envOr("DATA_POINTS", `[[1, 1], [2, 2], [2, 2]]`) dataPoints := envOr("DATA_POINTS", `[[[11, 21], [12, 22], [12, 21], [14, 23]]]`)
dataLabels := envOr("DATA_LABELS", ``) dataLabels := envOr("DATA_LABELS", ``)
dataWeightFloor := envOr("DATA_WEIGHT_FLOOR", `12`)
dataWeightRange := envOr("DATA_WEIGHT_RANGE", `20`) dataWeightRange := envOr("DATA_WEIGHT_RANGE", `20`)
dataWeightFloor := envOr("DATA_WEIGHT_FLOOR", `12`)
dataTitle := envOr("DATA_TITLE", ``) dataTitle := envOr("DATA_TITLE", ``)
dataSubtitle := envOr("DATA_SUBTITLE", ``) dataSubtitle := envOr("DATA_SUBTITLE", ``)
output := envOr("OUTPUT", `file:///tmp/f`) output := envOr("OUTPUT", `file:///tmp/f.html`)
graphType := envOr("GRAPH_TYPE", `scatter`)
fs := flag.NewFlagSet("cmd", flag.ContinueOnError) fs := flag.NewFlagSet("cmd", flag.ContinueOnError)
fs.StringVar(&dataPoints, "data-points", dataPoints, `JSON [[x, y, z, ...]]`) fs.StringVar(&dataPoints, "data-points", dataPoints, `JSON [[[x, y, z, ...]]]`)
fs.StringVar(&dataLabels, "data-labels", dataLabels, `(optional) JSON ["foo", "bar", "baz"]`) fs.StringVar(&dataLabels, "data-labels", dataLabels, `(optional) JSON ["foo", "bar", "baz"]`)
fs.StringVar(&dataWeightFloor, "data-weight-floor", dataWeightFloor, `int min point size`) 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(&dataWeightRange, "data-weight-range", dataWeightRange, `int max point scale up`)
fs.StringVar(&dataTitle, "data-title", dataTitle, `chart title`) fs.StringVar(&dataTitle, "data-title", dataTitle, `chart title`)
fs.StringVar(&dataSubtitle, "data-subtitle", dataSubtitle, `chart subtitle`) fs.StringVar(&dataSubtitle, "data-subtitle", dataSubtitle, `chart subtitle`)
fs.StringVar(&output, "output", output, `scheme://location`) fs.StringVar(&output, "output", output, `scheme://location`)
fs.StringVar(&graphType, "graph-type", graphType, `[scatter, line]`)
if err := fs.Parse(os.Args[1:]); err != nil { if err := fs.Parse(os.Args[1:]); err != nil {
return Config{}, err return Config{}, err
} }
@@ -56,13 +62,16 @@ func newConfig() (Config, error) {
return result, err return result, err
} }
if len(result.Data.Points) > 0 { 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 i := range result.Data.Points {
if want := len(result.Data.Points[0]); want != len(result.Data.Points[i]) { for j := range result.Data.Points[i] {
return result, fmt.Errorf("point [%d] expected %d dimensions but found %d", i, want, len(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, len(result.Data.Points[0])) result.Data.Labels = make([]string, expect)
for i := range result.Data.Labels { for i := range result.Data.Labels {
result.Data.Labels[i] = fmt.Sprintf("%c", byte(int('A')+i)) result.Data.Labels[i] = fmt.Sprintf("%c", byte(int('A')+i))
} }
@@ -70,8 +79,8 @@ func newConfig() (Config, error) {
if dataLabels == "" { if dataLabels == "" {
} else if err := json.Unmarshal([]byte(dataLabels), &result.Data.Labels); err != nil { } else if err := json.Unmarshal([]byte(dataLabels), &result.Data.Labels); err != nil {
return result, err return result, err
} else if len(result.Data.Labels) < len(result.Data.Points[0]) { } else if len(result.Data.Labels) < expect {
return result, fmt.Errorf("expected at least %d labels but got %d", len(result.Data.Points[0]), len(result.Data.Labels)) return result, fmt.Errorf("expected at least %d labels but got %d", expect, len(result.Data.Labels))
} }
} }
@@ -87,6 +96,10 @@ func newConfig() (Config, error) {
return result, fmt.Errorf("found negative weight floor %d", result.Data.Weight.Floor) 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 { if u, err := url.Parse(output); err != nil {
return result, fmt.Errorf("failed to parse $OUTPUT (%s): %w", output, err) return result, fmt.Errorf("failed to parse $OUTPUT (%s): %w", output, err)
} else { } else {
@@ -98,27 +111,42 @@ func newConfig() (Config, error) {
func envOr(k, v string) string { func envOr(k, v string) string {
if s := os.Getenv(k); s != "" { if s := os.Getenv(k); s != "" {
log.Printf("found $%s = %s", k, s)
return s return s
} }
return v return v
} }
func (d Data) AsScatterData() (string, []float64, string, []opts.ScatterData, error) { func scatterAsLineData(data [][]opts.ScatterData) [][]opts.LineData {
if !(2 <= len(d.Labels) && len(d.Labels) <= 3) { result := make([][]opts.LineData, len(data))
return "", nil, "", nil, fmt.Errorf("cannot map %d dimensions to [2,3]", len(d.Labels)) 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,
}
}
} }
d = d.weighXYasZ() return result
}
xs := make([]float64, 0) func (d Data) AsScatterData() (string, string, [][]opts.ScatterData, error) {
for i := range d.Points { if !(2 <= len(d.Labels) && len(d.Labels) <= 3) {
xs = append(xs, d.Points[i][0]) return "", "", nil, fmt.Errorf("cannot map %d dimensions to [2,3]", len(d.Labels))
} }
slices.Sort(xs) d2 := d
xs = slices.Compact(xs) 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) zs := make([]float64, 0)
for i := range d.Points { for i := range d.Points {
zs = append(zs, d.Points[i][2]) for j := range d.Points[i] {
zs = append(zs, d.Points[i][j][2])
}
} }
slices.Sort(zs) slices.Sort(zs)
zmin, zmax := zs[0], zs[len(zs)-1] zmin, zmax := zs[0], zs[len(zs)-1]
@@ -128,47 +156,48 @@ func (d Data) AsScatterData() (string, []float64, string, []opts.ScatterData, er
} }
log.Printf("d=%+v zmin=%v zmax=%v zrange=%v", d.Points, zmin, zmax, zrange) log.Printf("d=%+v zmin=%v zmax=%v zrange=%v", d.Points, zmin, zmax, zrange)
ys := make([]opts.ScatterData, 0) result := make([][]opts.ScatterData, 0)
for i := range xs { for _, series := range d.Points {
for j := range d.Points { subresult := make([]opts.ScatterData, 0)
x := d.Points[j][0] for i := range series {
if x == xs[i] { x := series[i][0]
ys = append(ys, opts.ScatterData{ y := series[i][1]
Value: d.Points[j][1], z := series[i][2]
SymbolSize: d.Weight.Floor + int((float64(d.Weight.Range)*(d.Points[j][2]-zmin))/zrange), 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], xs, d.Labels[1], ys, nil return d.Labels[0], d.Labels[1], result, nil
} }
func (d Data) weighXYasZ() Data { func weighXYasZ(input [][]float64) [][]float64 {
xyz := map[float64]map[float64]float64{} xyz := map[float64]map[float64]float64{}
for i := range d.Points { for i := range input {
x := d.Points[i][0] x := input[i][0]
if _, ok := xyz[x]; !ok { if _, ok := xyz[x]; !ok {
xyz[x] = map[float64]float64{} xyz[x] = map[float64]float64{}
} }
y := d.Points[i][1] y := input[i][1]
z := 1.0 z := 1.0
if len(d.Labels) > 2 { if len(input[i]) > 2 {
z = d.Points[i][2] z = input[i][2]
} }
if _, ok := xyz[x][y]; !ok { if _, ok := xyz[x][y]; !ok {
xyz[x][y] = 0.0 xyz[x][y] = 0.0
} }
xyz[x][y] += z xyz[x][y] += z
} }
result := Data{ result := [][]float64{}
Labels: d.Labels[:],
Points: [][]float64{},
Weight: d.Weight,
}
for x := range xyz { for x := range xyz {
for y, z := range xyz[x] { for y, z := range xyz[x] {
result.Points = append(result.Points, []float64{x, y, z}) result = append(result, []float64{x, y, z})
} }
} }
sort.Slice(result, func(i, j int) bool {
return result[i][0] < result[j][0]
})
return result return result
} }

103
main.go
View File

@@ -27,35 +27,92 @@ func run(ctx context.Context) error {
} }
log.Printf("%+v", config) log.Printf("%+v", config)
x, xs, y, ys, err := config.Data.AsScatterData() x, y, data, err := config.Data.AsScatterData()
if err != nil { if err != nil {
return err return err
} }
log.Printf("%s: %v", x, xs) log.Printf("%s: (%s, %s) = %v", config.Data.Title, x, y, data)
log.Printf("%s: %v", y, ys)
scatter := charts.NewScatter() minX, maxX := data[0][0].Value.([]float64)[0], data[0][0].Value.([]float64)[0]
scatter.SetGlobalOptions(charts.WithTitleOpts(opts.Title{ minY, maxY := data[0][0].Value.([]float64)[1], data[0][0].Value.([]float64)[1]
Title: config.Data.Title, for _, series := range data {
Subtitle: config.Data.Subtitle, for _, datum := range series {
})) x := datum.Value.([]float64)[0]
scatter.SetGlobalOptions(charts.WithLegendOpts(opts.Legend{Show: false})) if x < minX {
scatter.SetGlobalOptions(charts.WithXAxisOpts(opts.XAxis{ minX = x
Name: x, } else if x > maxX {
NameLocation: "middle", maxX = x
NameGap: config.Data.Weight.Floor + config.Data.Weight.Range, }
})) y := datum.Value.([]float64)[1]
scatter.SetGlobalOptions(charts.WithYAxisOpts(opts.YAxis{ if y < minY {
Name: y, minY = y
NameLocation: "middle", } else if y > maxY {
NameGap: config.Data.Weight.Floor + config.Data.Weight.Range, maxY = y
})) }
scatter.SetXAxis(xs). }
AddSeries(y, ys) }
if 0 <= minX && minX <= 1 {
minX = 0
} else if 1 <= minX {
minX -= 1
maxX += 1
}
if 0 <= minY && minY <= 1 {
minY = 0
} else if 1 <= minY {
minY -= 1
maxY += 1
}
log.Printf("x=[%v, %v] y=[%v, %v]", minX, maxX, minY, maxY)
globalOpts := []charts.GlobalOpts{
charts.WithTitleOpts(opts.Title{
Title: config.Data.Title,
Subtitle: config.Data.Subtitle,
}),
charts.WithTooltipOpts(opts.Tooltip{
Show: true,
Formatter: `{c}`,
}),
charts.WithLegendOpts(opts.Legend{Show: true}),
charts.WithXAxisOpts(opts.XAxis{
Name: x,
NameLocation: "middle",
NameGap: config.Data.Weight.Floor + config.Data.Weight.Range,
Min: minX,
Max: maxX,
}),
charts.WithYAxisOpts(opts.YAxis{
Name: y,
NameLocation: "middle",
NameGap: config.Data.Weight.Floor + config.Data.Weight.Range,
Min: minY,
Max: maxY,
}),
}
buff := bytes.NewBuffer(nil) buff := bytes.NewBuffer(nil)
if err := scatter.Render(buff); err != nil {
return err switch config.Graph.Type {
case "line":
line := charts.NewLine()
line.SetGlobalOptions(globalOpts...)
for i, series := range scatterAsLineData(data) {
line.AddSeries(fmt.Sprintf("%s[%d]", y, i), series)
}
if err := line.Render(buff); err != nil {
return err
}
case "scatter":
scatter := charts.NewScatter()
scatter.SetGlobalOptions(globalOpts...)
for i, series := range data {
scatter.AddSeries(fmt.Sprintf("%s[%d]", y, i), series)
}
if err := scatter.Render(buff); err != nil {
return err
}
} }
switch config.Output.Scheme { switch config.Output.Scheme {