diff --git a/go.mod b/go.mod index cef2305852..951f3161fe 100644 --- a/go.mod +++ b/go.mod @@ -164,6 +164,8 @@ require ( github.com/docker/cli v26.0.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d // indirect + github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect @@ -184,6 +186,7 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-zookeeper/zk v1.0.3 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect @@ -200,6 +203,7 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/flatbuffers/go v0.0.0-20230110200425-62e4d2e5b215 // indirect github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 // indirect + github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect @@ -229,6 +233,8 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect + github.com/kenshaw/colors v0.1.6 // indirect + github.com/kenshaw/snaker v0.2.0 // indirect github.com/klauspost/asmfmt v1.3.2 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect @@ -289,6 +295,8 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/echartsgoja v0.1.1 // indirect + github.com/xo/resvg v0.6.0 // indirect github.com/xwb1989/sqlparser v0.0.0-20180606152119-120387863bf2 // indirect github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 5829221e24..9b655ba9f2 100644 --- a/go.sum +++ b/go.sum @@ -833,9 +833,12 @@ github.com/chaisql/chai v0.16.1-0.20240218103834-23e406360fd2 h1:vLtnPMqbTuTQcnf github.com/chaisql/chai v0.16.1-0.20240218103834-23e406360fd2/go.mod h1:ix/NVvPO+dv6wsmln8R5H22yJb0iIj6KrGpouHaJmrE= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -899,6 +902,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= @@ -912,6 +917,13 @@ github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= +github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= +github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= +github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c h1:hLoodLRD4KLWIH8eyAQCLcH8EqIrjac7fCkp/fHnvuQ= +github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c/go.mod h1:bhGPmCgCCTSRfiMYWjpS46IDo9EUZXlsuUaPXSWGbv0= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -1001,6 +1013,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-zookeeper/zk v1.0.3 h1:7M2kwOsc//9VeeFiPtf+uSJlVpU66x9Ba5+8XK7/TDg= @@ -1130,6 +1144,7 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= @@ -1206,6 +1221,7 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM= github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -1258,8 +1274,12 @@ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kenshaw/colors v0.1.6 h1:saSe9+ubchrnwWqwfNIP5CLEQcGcFp3vgiag2g538jk= +github.com/kenshaw/colors v0.1.6/go.mod h1:+Nx5c4s70YO6elThBFOk0/9InGOvV1+uAak7T29sCKk= github.com/kenshaw/rasterm v0.1.10 h1:cMCTpBHfqmftt/VqeT6B+9Td+mYi+ZtziN+XBdrTQfA= github.com/kenshaw/rasterm v0.1.10/go.mod h1:kL4DCN+wOlQ4BPBCxA+itiVwiObRAj0Hkze7SbCyYaw= +github.com/kenshaw/snaker v0.2.0 h1:DPlxCtAv9mw1wSsvIN1khUAPJUIbFJUckMIDWSQ7TC8= +github.com/kenshaw/snaker v0.2.0/go.mod h1:DNyRUqHMZ18/zioxr6R7m4kSxxf2+QmB0BXoORsXRaY= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= @@ -1518,6 +1538,10 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/dburl v0.23.2 h1:Fl88cvayrgE56JA/sqhNMLljCW/b7RmG1mMkKMZUFgA= github.com/xo/dburl v0.23.2/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4= +github.com/xo/echartsgoja v0.1.1 h1:9ZH7rhjovE/caGzWTFw1X5GItCsPw5eFV2Qwfgmjt7M= +github.com/xo/echartsgoja v0.1.1/go.mod h1:u2iiKyIA1H3URjh9+8Nltj8cP33R19JHasXhSz/tWHI= +github.com/xo/resvg v0.6.0 h1:GsovErv9JuOnGttOA8RhQcBI7DEEVpEiIEKBuJVRS4g= +github.com/xo/resvg v0.6.0/go.mod h1:xsIgOmL6UD2xRHIm2Laepjm/b4auoPMxAAqOHkvbSes= github.com/xo/tblfmt v0.0.0-20190609041254-28c54ec42ce8/go.mod h1:3U5kKQdIhwACye7ml3acccHmjGExY9WmUGU7rnDWgv0= github.com/xo/tblfmt v0.13.2 h1:GzHsRYduJxKqwNekczv2ZJwpznsSU89Dr2L+qVyW63Y= github.com/xo/tblfmt v0.13.2/go.mod h1:BLPC+dRy68cgSK/mPgQRfFQ/xLg231Fyic178ybjB34= @@ -1872,6 +1896,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/handler/handler.go b/handler/handler.go index c8572d5a1f..0b03c72254 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -31,12 +31,15 @@ import ( "github.com/go-git/go-billy/v5" "github.com/xo/dburl" "github.com/xo/dburl/passfile" + "github.com/xo/echartsgoja" + "github.com/xo/resvg" "github.com/xo/tblfmt" "github.com/xo/usql/drivers" "github.com/xo/usql/drivers/completer" "github.com/xo/usql/drivers/metadata" "github.com/xo/usql/env" "github.com/xo/usql/metacmd" + "github.com/xo/usql/metacmd/charts" "github.com/xo/usql/rline" "github.com/xo/usql/stmt" ustyles "github.com/xo/usql/styles" @@ -1041,6 +1044,78 @@ func (h *Handler) doExecWatch(ctx context.Context, w io.Writer, opt metacmd.Opti // doExecChart executes a single query against the database, displaying its output as a chart. func (h *Handler) doExecChart(ctx context.Context, w io.Writer, opt metacmd.Option, prefix, sqlstr string, qtyp bool, bind []interface{}) error { + stdout := h.l.Stdout() + typ := env.TermGraphics() + if !typ.Available() { + return text.ErrGraphicsNotSupported + } + if _, ok := opt.Params["help"]; ok { + fmt.Fprintln(stdout, text.ChartUsage) + return nil + } + cfg, err := charts.ParseArgs(opt.Params) + if err != nil { + return err + } + start := time.Now() + // query + rows, err := h.DB().QueryContext(ctx, sqlstr, bind...) + if err != nil { + return err + } + defer rows.Close() + // get cols + cols, err := drivers.Columns(h.u, rows) + if err != nil { + return err + } + // process row(s) + transposed := make([][]string, len(cols)) + clen, tfmt := len(cols), env.GoTime() + for rows.Next() { + row, err := h.scan(rows, clen, tfmt) + if err != nil { + return err + } + for i := range row { + transposed[i] = append(transposed[i], row[i]) + } + } + // display + c, err := charts.MakeChart(cfg, cols, transposed) + if err != nil { + return err + } + data, err := c.ToEcharts() + if err != nil { + return err + } + echarts := echartsgoja.New(echartsgoja.WithWidthHeight(cfg.W, cfg.H)) + res, err := echarts.RenderOptions(ctx, data) + if err != nil { + return err + } + if cfg.File != "" { + fmt.Println("writing to", cfg.File) + return os.WriteFile(cfg.File, []byte(res), 0o644) + } + img, err := resvg.Render([]byte(res), resvg.WithBackground(cfg.Background)) + if err != nil { + return err + } + if err := typ.Encode(stdout, img); err != nil { + return err + } + if h.timing { + d := time.Since(start) + s := text.TimingDesc + v := []interface{}{float64(d.Microseconds()) / 1000} + if d > 1*time.Second { + s += " (%v)" + v = append(v, d.Round(1*time.Millisecond)) + } + fmt.Fprintln(h.l.Stdout(), fmt.Sprintf(s, v...)) + } return nil } @@ -1076,6 +1151,7 @@ func (h *Handler) doExecSet(ctx context.Context, w io.Writer, opt metacmd.Option if err != nil { return err } + defer rows.Close() // get cols cols, err := drivers.Columns(h.u, rows) if err != nil { @@ -1116,6 +1192,7 @@ func (h *Handler) doExecExec(ctx context.Context, w io.Writer, _ metacmd.Option, if err != nil { return err } + defer rows.Close() // exec resulting rows if err := h.doExecRows(ctx, w, rows); err != nil { return err diff --git a/metacmd/charts/charts.go b/metacmd/charts/charts.go new file mode 100644 index 0000000000..734ba4b57f --- /dev/null +++ b/metacmd/charts/charts.go @@ -0,0 +1,300 @@ +package charts + +import ( + "encoding/json" + "fmt" + "image/color" + "math" + "strconv" + "strings" + + "github.com/kenshaw/colors" + "github.com/xo/usql/text" +) + +type ChartConfig struct { + Title string + Subtitle string + W, H int + Background color.Color + Type string + Prec int + + File string +} + +func ParseArgs(opts map[string]string) (ChartConfig, error) { + cfg := ChartConfig{ + Title: opts["title"], + Subtitle: opts["subtitle"], + W: 800, + H: 600, + Background: color.White, + Type: opts["type"], + } + if size, ok := opts["size"]; ok { + b, a, ok := strings.Cut(size, "x") + if !ok { + return ChartConfig{}, fmt.Errorf(text.ChartParseFailed, "size", "provide size as NxN") + } + var err error + cfg.W, err = strconv.Atoi(b) + if err != nil { + return ChartConfig{}, fmt.Errorf(text.ChartParseFailed, "size", err) + } + cfg.H, err = strconv.Atoi(a) + if err != nil { + return ChartConfig{}, fmt.Errorf(text.ChartParseFailed, "size", err) + } + } + if c, ok := opts["bg"]; ok { + var err error + cfg.Background, err = colors.Parse(c) + if err != nil { + return ChartConfig{}, fmt.Errorf(text.ChartParseFailed, "bg", err) + } + } + if prec, ok := opts["prec"]; ok { + p, err := strconv.Atoi(prec) + if err != nil { + return ChartConfig{}, fmt.Errorf(text.ChartParseFailed, "prec", err) + } + cfg.Prec = p + } + if file, ok := opts["file"]; ok { + cfg.File = file + } + return cfg, nil +} + +type Chart struct { + Title string + Subtitle string + Legend []string + XAxis Series + YAxis Series + Series []Series +} + +type Series struct { + Name string + Type string + Data any +} + +func MakeChart(cfg ChartConfig, cols []string, transposed [][]string) (*Chart, error) { + numCols := make([][]float64, len(cols)) + for i, col := range transposed { + for _, v := range col { + f, err := parseFloat(v, cfg.Prec) + if err != nil { + numCols[i] = nil + break + } + if numCols[i] == nil { + // don't allocate slice unless we have at least some valid data + numCols[i] = make([]float64, 0, len(col)) + } + numCols[i] = append(numCols[i], f) + } + } + firstReg, firstNumeric := -1, -1 + for i, c := range numCols { + if firstReg == -1 && c == nil { + firstReg = i + } + if firstNumeric == -1 && c != nil { + firstNumeric = i + } + } + c := &Chart{ + Title: cfg.Title, + Subtitle: cfg.Subtitle, + } + var x int + var chartType string + switch { + case firstNumeric == -1: + return nil, text.ErrNoNumericColumns + case firstReg >= 0: + x = firstReg + chartType = "bar" + default: + x = firstNumeric + chartType = "line" + } + if cfg.Type != "" { + chartType = cfg.Type + } + c.XAxis = Series{ + Name: cols[x], + Type: "category", + Data: transposed[x], + } + c.YAxis = Series{ + Type: "value", + } + for i, col := range cols { + if i == x { + continue + } + c.Legend = append(c.Legend, col) + c.Series = append(c.Series, Series{ + Name: col, + Type: chartType, + Data: numCols[i], + }) + } + return c, nil +} + +/* echarts */ + +type echarts struct { + Title *echartsTitle `json:"title,omitempty"` + Legend *echartsLegend `json:"legend,omitempty"` + XAxis *echartsAxis `json:"xAxis,omitempty"` + YAxis *echartsAxis `json:"yAxis,omitempty"` + Series []echartsAxis `json:"series,omitempty"` +} + +type echartsTitle struct { + Title string `json:"text,omitempty"` + Subtext string `json:"subtext,omitempty"` +} + +type echartsLegend struct { + Data []string `json:"data,omitempty"` +} + +type echartsAxis struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Data any `json:"data,omitempty"` +} + +func (c Chart) ToEcharts() (string, error) { + ec := echarts{} + if c.Title != "" || c.Subtitle != "" { + ec.Title = &echartsTitle{c.Title, c.Subtitle} + } + if len(c.Legend) > 0 { + ec.Legend = &echartsLegend{c.Legend} + } + if c.XAxis.Data != nil || c.YAxis.Type != "" { + ec.XAxis = &echartsAxis{ + Name: c.XAxis.Name, + Type: c.XAxis.Type, + Data: c.XAxis.Data, + } + } + if c.YAxis.Data != nil || c.YAxis.Type != "" { + ec.YAxis = &echartsAxis{ + Name: c.YAxis.Name, + Type: c.YAxis.Type, + Data: c.YAxis.Data, + } + } + if len(c.Series) > 0 { + ec.Series = make([]echartsAxis, 0, len(c.Series)) + for _, s := range c.Series { + ec.Series = append(ec.Series, echartsAxis{ + Name: s.Name, + Type: s.Type, + Data: s.Data, + }) + } + } + buf, err := json.Marshal(ec) + if err != nil { + return "", err + } + return string(buf), nil +} + +func parseFloat(v string, prec int) (f float64, err error) { + f, err = strconv.ParseFloat(v, 64) + if err != nil || prec == 0 { + return + } + r := math.Pow(10, float64(prec)) + return math.Round(f*r) / r, nil +} + +const basicBarTemplate = ` +{ + "title": { + "text": {{ printf "%q" .Title }}, + "subtext": {{ printf "%q" .Subtitle }} + }, + {{- if .Legend }} + "legend": { + "data": [ + {{ range .Legend }}{{ printf "%q" . }}{{ end }} + ] + }, + {{- end }} + "xAxis": [ + { + "type": "category", + "data": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + ], + "yAxis": [ + { + "type": "value" + } + ], + "series": [ + { + "name": "Rainfall", + "type": "bar", + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ], + }, + { + "name": "Evaporation", + "type": "bar", + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ], + } + ] +} +` diff --git a/metacmd/cmds.go b/metacmd/cmds.go index 825b0ea519..4c6df90d73 100644 --- a/metacmd/cmds.go +++ b/metacmd/cmds.go @@ -298,6 +298,30 @@ func init() { } case "chart": p.Option.Exec = ExecChart + if p.Option.Params == nil { + p.Option.Params = make(map[string]string, 1) + } + params, err := p.GetAll(true) + if err != nil { + return err + } + for i := 0; i < len(params); i++ { + param := params[i] + if param == "help" { + p.Option.Params["help"] = "" + return nil + } + equal := strings.IndexByte(param, '=') + switch { + case equal == -1 && i >= len(params)-1: + return text.ErrWrongNumberOfArguments + case equal == -1: + i++ + p.Option.Params[param] = params[i] + default: + p.Option.Params[param[:equal]] = param[equal+1:] + } + } case "watch": p.Option.Exec = ExecWatch p.Option.Watch = 2 * time.Second diff --git a/text/errors.go b/text/errors.go index ac1dd080b2..832917064c 100644 --- a/text/errors.go +++ b/text/errors.go @@ -57,6 +57,10 @@ var ( ErrInvalidFormatBorderLineStyle = errors.New(`\pset: allowed Unicode border line styles are single, double`) // ErrInvalidTimezoneLocation is the invalid timezone location error. ErrInvalidTimezoneLocation = errors.New(`\pset: invalid timezone location`) + // ErrGraphicsNotSupported is the graphics not supported error. + ErrGraphicsNotSupported = errors.New(`\chart: graphics not supported in terminal`) + // ErrNoNumericColumns is the no numeric columns error. + ErrNoNumericColumns = errors.New(`\chart: no numeric columns found`) // ErrInvalidQuotedString is the invalid quoted string error. ErrInvalidQuotedString = errors.New(`invalid quoted string`) // ErrInvalidFormatOption is the invalid format option error. diff --git a/text/text.go b/text/text.go index c7c8207a13..59154f25b8 100644 --- a/text/text.go +++ b/text/text.go @@ -40,6 +40,7 @@ var ( ConfirmPassword = `Confirm password: ` PasswordChangeFailed = `\password for %q failed: %v` CouldNotSetVariable = `could not set variable %q` + ChartParseFailed = `\chart: invalid argument for %q: %v` // PasswordChangeSucceeded = `\password succeeded for %q` HelpDesc string HelpDescShort = `Use \? for help or press control-C to clear the input buffer.` @@ -106,6 +107,19 @@ Arguments: Flags: {{.LocalFlags.FlagUsages | trimTrailingWhitespaces}} ` + ChartUsage = `\chart: create and display charts from SQL data +usage: \chart [opts] + +available options: + +help +title [title] chart title +subtitle [subtitle] chart subtitle +size NxN chart size (width x height) +bg [color] chart background color +type [bar|line] chart type +prec [num] data decimal precision +file [path] write chart to file (svg)` ) func init() {