From 59451fbeb4239c67ded1b9d6a5843d8240f9ded4 Mon Sep 17 00:00:00 2001 From: Justin Kromlinger Date: Tue, 19 Feb 2019 19:51:41 +0100 Subject: [PATCH 1/2] Add type classes on class output (#106) * Add type classes on class output Without this it is quite difficult to differentiate between fill and stroke elements, f.e. with basic charts with fillings or legends generally: `svg path:nth-last-of-type(2).legend` Text elements needed to be accessed with text.classname which isn't really best practise. This way they can be accessed easier: `svg .legend.fill` * Add type classes to examples * Fix import in custom_stylesheets example --- _examples/css_classes/main.go | 15 ++++++++------- _examples/custom_stylesheets/main.go | 14 +++++++------- vector_renderer.go | 19 ++++++++++++++++--- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/_examples/css_classes/main.go b/_examples/css_classes/main.go index 5046b72..9718de5 100644 --- a/_examples/css_classes/main.go +++ b/_examples/css_classes/main.go @@ -17,7 +17,8 @@ func inlineSVGWithClasses(res http.ResponseWriter, req *http.Request) { "")) pie := chart.PieChart{ - // Note that setting ClassName will cause all other inline styles to be dropped! + // Notes: * Setting ClassName will cause all other inline styles to be dropped! + // * The following type classes may be added additionally: stroke, fill, text Background: chart.Style{ClassName: "background"}, Canvas: chart.Style{ ClassName: "canvas", @@ -42,12 +43,12 @@ func css(res http.ResponseWriter, req *http.Request) { res.Header().Set("Content-Type", "text/css") res.Write([]byte("svg .background { fill: white; }" + "svg .canvas { fill: white; }" + - "svg path.blue { fill: blue; stroke: lightblue; }" + - "svg path.green { fill: green; stroke: lightgreen; }" + - "svg path.gray { fill: gray; stroke: lightgray; }" + - "svg text.blue { fill: white; }" + - "svg text.green { fill: white; }" + - "svg text.gray { fill: white; }")) + "svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" + + "svg .green.fill.stroke { fill: green; stroke: lightgreen; }" + + "svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" + + "svg .blue.text { fill: white; }" + + "svg .green.text { fill: white; }" + + "svg .gray.text { fill: white; }")) } func main() { diff --git a/_examples/custom_stylesheets/main.go b/_examples/custom_stylesheets/main.go index 2432b2d..36f6106 100644 --- a/_examples/custom_stylesheets/main.go +++ b/_examples/custom_stylesheets/main.go @@ -2,19 +2,19 @@ package main import ( "fmt" - "github.com/hashworks/go-chart" + "github.com/wcharczuk/go-chart" "log" "net/http" ) const style = "svg .background { fill: white; }" + "svg .canvas { fill: white; }" + - "svg path.blue { fill: blue; stroke: lightblue; }" + - "svg path.green { fill: green; stroke: lightgreen; }" + - "svg path.gray { fill: gray; stroke: lightgray; }" + - "svg text.blue { fill: white; }" + - "svg text.green { fill: white; }" + - "svg text.gray { fill: white; }" + "svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" + + "svg .green.fill.stroke { fill: green; stroke: lightgreen; }" + + "svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" + + "svg .blue.text { fill: white; }" + + "svg .green.text { fill: white; }" + + "svg .gray.text { fill: white; }" func svgWithCustomInlineCSS(res http.ResponseWriter, _ *http.Request) { res.Header().Set("Content-Type", chart.ContentTypeSVG) diff --git a/vector_renderer.go b/vector_renderer.go index 71c6a86..0d7fc76 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -311,15 +311,28 @@ func (c *canvas) getFontFace(s Style) string { // styleAsSVG returns the style as a svg style or class string. func (c *canvas) styleAsSVG(s Style) string { - if s.ClassName != "" { - return fmt.Sprintf("class=\"%s\"", s.ClassName) - } sw := s.StrokeWidth sc := s.StrokeColor fc := s.FillColor fs := s.FontSize fnc := s.FontColor + if s.ClassName != "" { + var classes []string + classes = append(classes, s.ClassName) + if !sc.IsZero() { + classes = append(classes, "stroke") + } + if !fc.IsZero() { + classes = append(classes, "fill") + } + if fs != 0 || s.Font != nil { + classes = append(classes, "text") + } + + return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " ")) + } + var pieces []string if sw != 0 { From 9852fce5a172598e72d16f4306f0f17a53d94eb4 Mon Sep 17 00:00:00 2001 From: Alessandro Date: Tue, 19 Feb 2019 19:52:03 +0100 Subject: [PATCH 2/2] adding donut type chart, like a pie chart with a blank circle on the center and little trick for label position (#111) (some way of improvement) --- _examples/donut_chart/main.go | 54 ++++++ _examples/donut_chart/output.png | Bin 0 -> 24439 bytes _examples/donut_chart/reg.svg | 25 +++ donut_chart.go | 321 +++++++++++++++++++++++++++++++ donut_chart_test.go | 69 +++++++ 5 files changed, 469 insertions(+) create mode 100644 _examples/donut_chart/main.go create mode 100644 _examples/donut_chart/output.png create mode 100644 _examples/donut_chart/reg.svg create mode 100644 donut_chart.go create mode 100644 donut_chart_test.go diff --git a/_examples/donut_chart/main.go b/_examples/donut_chart/main.go new file mode 100644 index 0000000..010dbec --- /dev/null +++ b/_examples/donut_chart/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + pie := chart.DonutChart{ + Width: 512, + Height: 512, + Values: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 4, Label: "Gray"}, + {Value: 4, Label: "Orange"}, + {Value: 3, Label: "Deep Blue"}, + {Value: 3, Label: "test"}, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := pie.Render(chart.PNG, res) + if err != nil { + fmt.Printf("Error rendering pie chart: %v\n", err) + } +} + +func drawChartRegression(res http.ResponseWriter, req *http.Request) { + pie := chart.DonutChart{ + Width: 512, + Height: 512, + Values: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 2, Label: "Two"}, + {Value: 1, Label: "One"}, + }, + } + + res.Header().Set("Content-Type", chart.ContentTypeSVG) + err := pie.Render(chart.SVG, res) + if err != nil { + fmt.Printf("Error rendering pie chart: %v\n", err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + http.HandleFunc("/reg", drawChartRegression) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/_examples/donut_chart/output.png b/_examples/donut_chart/output.png new file mode 100644 index 0000000000000000000000000000000000000000..e682501a9a47b2e151c858a6f409e5c4a569f178 GIT binary patch literal 24439 zcmeFY^;cD2^e((SGbQTA3+d=tEli&1A>sjpU4pAf4>4K zWvd{_R6_Bkw3cty(W+0NCj4IVuSb0ni^^qjCi3$JYm#RRGWyni+8qUXd+-DJ=1cXC zg0(|cX;czvIcm(-PukMO^gOSX>>sZNj5n$*e_8w~+>Jr)%xWJRmlZgbb$PZyr!tk( zA${Yw9mo>Nq=*BiJ*!nC2frX(Oz>g$?EilLpL+oB02wlC@8}Q^H~dk5Mj|XMY*5vF7EcvBFfuYS zH#ZkI{=l4>HQPaz7D@LTG-v`ZZ7Qp_D{EuhsW^GpKad92D>hfPEMbH zS{)n9Q^v}(BsE=Hs4FCn{QPO}LozC=+p}@W7Ff3t=UScR;L&13uKz(|k?sOiWE#eCVby^CN(ug)FzNfx1Pfcl587l*;BF>IP{= z{m=JC(s@?q365O}+!rKR;Us1f$>=To1f1wt~ii*MLmI27FLdy|Eccr*^(TEW5h zzd8eUM>ENZhzh3n=cq*_;Yg7 zZF?v=3E2+1aq0 z{ii-Ym&q+gV5X%e$H~b_<1Q|*ppP#Z7TY{ZUNY>fZ|{!hb_XNcTUsv1vPJbd=7?3r zUc6mu#3tvgG-xV!fiZ`W{pTx&^1|G~Epv*Lq@*zOG?SVK_`GSE1Q||WVq#*B{{ia( zbD7rb&X-MB`}_Npu@U}z>+9<$C&6bnx&4%a&MVu)shS!ZC!`RFVlQuR7EFfc&yRaABTp9kWo->?^=*KZ~%B_YU=8~g8zO0 z{(YKqad9yVPG8r<{cYS@Qg(K%ImImD!|rn$)8`x^hZt4K|}7y71^E?p8d;y}I4gl_Sg-W(`Nm!=YN~|A`0SI}I9X6m%T3hDApCnATv# z&0LjXB+lchdvT#SOA4~bkNu99nrw6YiaW<684?seMn{iw8mg&c2P;R+D&C(WW=YF5ObWXoHq%m+B&O^=n?3p=Y|Ac7HJ^<4ReY5oP6hY0;?ch~un3n? z*V9vgvobR?n^Sn#+~>wEEiJ{-N&X5B;@#NTpop-xvNEG6)?yQVLz*q(RW3y@DO;nMRO)&s+};1PN?_=o0Zdn1LV8mvP>=dF*l;G)DQ`FE!d{3cMBZKGL(X z*=cp#dXpnorp4yy;Gk1wu>I$cWZ>1`n3x#-2AlrDLDM$(qWXIGi9Bh^yT2&#hDYHR z`%^^#?Ua?2l73oQ)cG#dnqQWiueN*1C*&YddRE$rj;fW!+Cw zm1uL{9$IX$6|IH{9))X`yb%W*a;42NeK z-26#&|08Vdur75e_&5m}+0E5e42`Jw{oOT4stzzk4l>x~s?XtE6+o{=07W3kvV=WM z{P&p;W=8>1EYxm*DKPM9Jv}{RqN4-0Vg*Z0C~^A4#KclKjoV=l!Rg97hlgLCS49r0 z3zTN!NW^Gejuz^`=ow;u?t?U;ODgF+%uk>0&Vcw*&CL-HXs)O@Of?N&ygx8>>0~C0 zj;9v>2M`oH{M+819lMJoQak>idPe+-lDfLnuYW{DL>Q{D&rw9O z-{h;QHTaJZpgu|ZDJCwyoO%E5-Mc~BfRXWWt`{#7m=xQLP7V$ndM$8TR!Xc>e_8=7 zoLgJK^Q4PG(o23*VpwM(=kMPxMX%THxestyn0gGXZ4N+01&hB@ffC)rbGaGY``g>I zziu*n-B#+WBqPj?I4|#W55>jA^589Q1dtBA4_|U-X6Ai(IE_T$RkE&$-(gh?Sb@%t zj(=xo@ie0HaJ-*ZJPsJ7MH>1V8dLWTOo}_(+htniapYRhNO;mm%(%@c=D-jW6BE}e zApZ;x50}L|WnVhw?>K8VTtZL`vL&SB_4@}QPM?LPrLmDwsclC`M?qmBF)=al8&AN6 zQF%F`1Azi1jn~;K|CE^pY3kD3#Sgpe*EYUpVbWu3wwcM2p|ot zg-P)Bc71();j8!z<>DVdes~?u35$waQp_za-3(B`SJ z&Zk#aR!G@(z$W-oRFp016X$<_SkwJRRdx658uoCzrp=bTsM>k418x+&b%v#4kzl)DRc`Om5<94tAj! zZ9J1=nbytuw5sIw?)VVltmxhhFy zh#M6(1g_?2u8=rm$7jUZg79-#CKJe9bsJjMR27#C3f;Q;NXo9vqUr4H?Cgt9{<;^K7Q7zw#H93Bak00+WR?Y$-o9**!(vB}b zKOam><3e&0=CB#|?p=Fe@{%meR!ZEst*xyr1oaSrfF~s(c^UNS4>dHC=bYN?vQ|Co zaCyA^D(Jmwy_I6BXk%KwNJiC&}$>ZezW0(nS5db6NDTMIlJ z1vMHY8ylN|Kni_S(oxb!BxwyDEI6OT(urV?7<`Vb?1$}lq?+^$S z6qMniAs$}dNL;GoNjs5~MItIHcCzTQ*rl?D+{C1$7dTXs!5!Q8tqKV&Q>FdNB2%S< z$_7)V%(3m6;}lI_6vy4@z9n8Y!TuC`j3RS#uGhXaGWu?bC3IC4*%~!Wbm|gMKVJDy|QgS-oFx&T>8`xNjli`=0A&Cu>*(kV{=5gc2(kvKj4~u)!GVE+a&mGuHXsG5 zC@UZBKc@ddfG)GDP)dcVjZ$Xc4)1a zPr^FHm#oYOVK*0;+nM7S^t3t+V-dPx#)KS~Yt8%OCGT5({`|VT3&iQWySoDw8D^ZA+ik zm;3!b#v!NVp4+gYeszC!^B4R?`kl!cDWI7hnsL!v85kHWIC)=E85L>ludh#R?-*U0 zb5npFvhPXs!3usbYde;8y;m4t{3d(pG{A}{Jvw?g=mN8$EAx4<(ucCP?=u<>4y??L zRVkfai!@RryQC?d5^-Bk96Gtw#03Xli=gW=a?*RDpak^XvmvJyI3v_ zMxPVE^!;{!1&L!q0Xqi=OLKD<`_CyN+FD!1E>}F78zjl|OZkR)VsM4MCpIP33Wq$~ z)q>j9RK(9eyB0@$EPns77>@)^q_5_Q6iR!@k&&^n@$1*Gy}Z0UJrT7s2R@LzXpS1y0zYukjIaRzud zvGr+w!K+uV$?%9p|2@G9VU~s<>=*mEP4*E(OobdtOvg3aJnI`Xo)L>Lp&W>eB; zSvDGWUPaN-u%^@Q^fV>28t0jUQ)B1L#*}^bL>%p(@5vUeXruSIkS5-gF$TAmUu~3s z)BkQFrq$upxb=np$w7fsH!2e3AHi3OYi$cB-BtO>Hq*py+^}@6;&3QfR#g~?#{N_U z5%d|=yAnkl83}5SR+~MHezV6uc3DzQYtyPzWqj(mdSDs*K*1gq{B8!nIU8C3rgiFl>kLbow1})~cZ)(18C-h>?ORd&% z%v~|OyqO$;#U$Sc6eP7UDpMfu&_g}O3SNcPSFK%l)Rn7E_*&f)PsuZK>9~R%KSpPw ze1}?5kf9a%1CsU**_(RFKd=zdg?gbn2>9D_JjN$33vt_2Hng#YL~ks zi{Xqi&WD(ke`Zl>z3Xp2)hWnYC;mG>%!mhIX8kAYi-TT|5 z_#I@%P{>hud<2`ZHzi>rztD9_^&TDSTd!x#Ouj2+jd!$@-|NIfK9Ov*+V3Tu>QjH5=)H)2#n z3^j)M3ni<3{Swatrj2QQ|N4BxoIv}I8S&5KEv)k09NkK80=-~PQY6gi!m#;~*Yh6A zb-bQ2LbmjsCBcCRr>co;J?@P*>BXk8+88fsWi$O>tPmuG*c?E`oHn5{j0< zbgkmxcr{Qz9qusG1S!3PYRToRSP)9HMe*h+wo*wV;chqcvla0IlN>u&x!XyMyf>$S`hn|_XeS?>r3KA19@UACc4l%`r4R^ zb5Jw~5kLa5Gwj;r65LuPz$YO&*8)vm1M}Lou&h{6TN%GEH}N_VFYF-jdIFsfEHZeV z&wczRuUBOEl^$~XTvjFppEuAnqyc`}>wJffXssfJkvgi8Be$a?nMDH#EB}IzB|cIN zAxs*U_z;KXE}EpJ*ht1;0fpP&^<^}Un+MSO_nJuQSaBzeA5{b#QaEWjd#r!AHI&KV zo9~`ZMTEEGp@Xlt!)>=8(bT=o$EfURrU7P$@s(*x^3Ht8uSS8|1KrBxs-AZHeNV!D zVn&sR{X;pf}j#KuAV}qPlDS_;V8aPn|ePIa_ZM+W=c5c3}KxubEltr30X9bk(3womQkfVk) z*-v#(>9XWkBSY-@1`lTB&bPcaRg&8e{4}d8fs}HgF~Hl}?JJ0zkJzOZ5CxW@w4U7V zT=&SrOdGtU(qW}V6Dc>g@)2^6KMpB*qhF+&E|PO7lxGUQv$8;1COuU7&aOh!_#QRR z69Z@R>s)RDW9*rugz99B?hkwSWXodyp?vZmum!BysKxs+)nFUc_>gS6jhU@qXJ5dMkz z8%<)_)S07ag5I5;&^aM$$aaE%kqDeO?HnsDM9%h5)t;~EdF8c??7?#{=?OgKo_~D% z%UHN0W+)f&{T9|W1ugIrqiG#6&F1rKHE+F0< zn%X-R7s5;Kz9=BRpm|3(fW)N)aL1tmCT)zihrWPa>LSu|uRV~$s-0=)fMBf=Jd&&$ zEBbSTgNkG(EFa6_#qNnf`8p3CS+}W9fvXT6q{3A9#rsukUHF`jISD2dJ!y11_NDxl zF&cC(Gkc@?n`L&Io5D;O*kt}pvZSW{efuM5FT5FElBH%HH0%G%!Nqos9^wd5o3rEb z2Elhutt8^FLQ|*Gem+B=YTyoL7wJ7M2brq^2CVlAM%~;wmdYA zErpjfV;k#S(u5|=!5l?VkS27OV#JH*Ue9PlQjF#B_`koKBsDD^vNf8p6c1I~zOdf6 zUNe>Zv``U1K^B#51?=IYEHsNS7#YM8VA5rTl{41)Rtb z<)YJJym?v$q&vgl)&i}Bdi&-)4{e)>kE$R2L6<_{Z@H~!5|Is9< ziembgos<`kj!7{?eoc_l^0J!ksKVr>pq24hU6jaxHG9ON@<-O>Os86S3CZ5!JRhx$ zu^gz>CM*G*ruq>4H))XvSg5uas-}^Rr5INo+fH#^WL~5Qd{n7;`)b&$jP+pRg_8S) z8%qisrVr%SwS+GJSseq)$!EhdU4S2_SLvDMwzIlrbu}HV>{f zMFffHkxdYTE4@2)t9|O^NE|p9$o27abbYlNvRgYll4`n>|0S0gyOGzYzkL?oAOUP9 z>-(0T1L>M#?JeBQUyje^{tLa+Bh8|iehUT~@TTF2yOkoj=7{E!GscF3yx_?p>JoPs zkvvK6uV;6Hmu@tv!mFKE;@dw4qaf$`xH|X9ChlimnR~dBVR*fO&NX)DB&!`X!Xm22l-Kruh!}Z$w!OjrSh#zcb;^=Y*JV zoe``qg3QeXl+i}z`T5diS{4-GG!M>olfLuH|B*gROPpj+4&gUhSy^#w97#X5*hnMH z*^*zgBoPYK(qZ9=W~`e2n~gDz%R0F0$ZVLNR@%AsZO;@}Jy`Z^!#FQ0G5`9)aKH`E zJ1QvmroCg4;wrytCDE`qITcy{cGVt1r97nf)e*t^;FgW(I=t&LeJUW(Tvb(t*tlGS zNqX+7b(+8cec2gsNk&FyK_TF|Hvu{*Kc=UN`)Y4)ZU7}}+U}`XI0n^EiFK9qAbQ>tE6Fg~1)jXQR!y3FvL3a2CMya&9%YFLXa#j274t}u!)v7EZ zw~B7nL$R_k|5Sn97y3p+!~4KN>o;^Yynv%je(kmS{X~2FP*ihCdCkYRzQ_cN-cS3JVJEPvc<< zOo`vWe?MMsfdQ5lm{0?_&7M${=Z0Z&Jsf$}_Koe5N!91)B@R>>5(^irCtd^2eC&h1yjU_x%*LO$kD;%f*Zrix6dN1s=jWH6o?cg1 z2e@4j2%uxIySw}QHy=NLEVZyZXt4G6_EJQ|(ux}}Bz!3;p%eGlDw!V5;6DU?P;Xb) z9`2F8zQWSd(%M>XVDQOG8|ZwHA;K;wI8b>@1S#g7?==c%^)KqT7$dnd9uFS!9nUjy zP1zg?2#_0I9QPo+j4*Xp9nNoI7tI7X1~A@xqttJC_`VchxyaSaw9v!ArYze^!o|ha zDEmCP<#itnIxU-L0yhytw%{X^B~#GsZL;zwx3Hl$V((_~>jIO&eL-s^li8trCP%<&tmbczeE_+kA4^BL z$zDTLt=T0S>fKxn=F*90S~X*)GhYy@`1YB9qjJL9++sXJiQ5v=ow@%s`i%yS!=dk2 zFowZHPC0+`9iD9O0}6Jp^YRruXNb~pu{C#aL#)g0BI-$YGd`2b&8Fs3N7xr6Hyt=O zM?Us4>!)Lq@9uwl{1OcxgxAGRtLD}^+MX&X7d>6_{37$}$87OC^dR{v&Wbm%LLJ>2 zK871&v7T$#wd$jHbKQeP)Dbjr;}7kRG9e^aO?~arr)&41K=%Mptjd-CXWO|;FKdRq zQTP_e8rEBuWTVHR+pNhwyVsD7?AA5Ikos0Fz5d|*F@L)we>-pZLAg$t9Ov>Yn7HJ6 zzM1)LADIkq)j<;9+s9J{HyC7bsi;-h>z{S8hteEN9U{cqlFrvlDbgCfzaX>TDzug>Ds+x`oh#`~bg zIj^yYwlP^_G=Bmp?Uz~L?bbLPJ@ZP>hwH3FoPUWiI~x$6x)90+@qt z%}USaZK>yda5!H!bb6PqTm?8UZyYsuf8>|{=HLBl;^s1&Yp_hrS3L7HERO(MfM?xh z-^S%tZ}y@h_kN7r#e0Ou4GZPo`xtllk65vAyM?=6+UbKJm+PZ>A8O8*9Z42nS@`Q1 zi?$oSq==v$7ottv7!H}Qk?)9b2e|_>v)N;9<4_o_v|^9bRQ0liHvP|uMHeG!+1t6D zJBQL%v%v@1#WCr3wb#_Sli883Z$p2*Kklr)x-#;xd2XzTQ`L<&N{BDHdFC>sO+RoB zP3+^kxZJIn)lS;eUh;TbeP6TbKl95LNOfkN{11{y8OozMsV_0_^RB_?Ot|MT9oj+c zZiXspDMx^Q!(4ar#CO@lH4Kg{ZPL30!DSv^v>Vo93@HPflboMc9V{B~~qeeoKX-=JI87EM2X=CC)OoUu~8_1wOs&~)Qv`xJxj?O7X=+Y_Ird){)% z&Pvv%AN`LVzwT~5e10QujP_a?KcohxI5O07a4(s{O!gti-^$resw8_$S;V-P(0D_A z%4%b)wkl$anuGe9Z@BETL}>9HuiODS-q1g)n{(L4xg>@%Zus2VKO2#k)R#e6UZl@n zh(L`Zh-E5CLCRaiSWjs1FV*|E)eSR~M}8X9r9W>L)so+pR-1eiMwY(n>sa5{qr-D+ zqVXqg-p{&2OuNVC3myvO!AtP*clq8sd0Szrk-x(!M4^YvU&MbHRM$0s@xoG`GmUI` z+kGTQkbm2r$2-$th;Vk6*Qh4pTY_qS#-d_wz0KCp%f&y!Z^ds|?GXDH(U+1b3TXkz z7=19lu4Cu_2Ge+YX!G)T1ZgXQzGn5fs4<)>1g;(j!*mpr4Bc|LA zjn^M;3W%A^JsNU+QCJ?ya}2iOm15@`H$?azl`Ika;ioa8R&{$t>EA_sQbldO4&s%q zT?_alKEt1vkdvO7VCyo?%qo&CaqS@Q<~O@OAr(BhRjzLoH^_Ywa{8q`!LCA1-vUEu zj3~}mhTzmEYZd-;$y@$MKONO}l=;H-RKN+7+rc8zDaxP5`$sa^Rgx;_U+g=$g<^I; zv=i{9Q9+Taxh@XPtM{L}l3E&bRNnAX#6Cd^2GX`kt*oazH!AC`5rkDmU&eTnNCI$o zKfau-aYe8cJntKFrH;lXjQvYUJ+8SQv(vKvH0Lyv*4wtlfjK{0zmr+cQa31BK<@kI zC^4_6BDd_<5I%#|jCjeG`)gALSD~yy@)1hX7eDUM5+_pc^GC&{4xV>dm1Qnia7|um zbG-1t7yjYG&2iNwqUvp%xc9;K&IYHw0-meh$*iv#Qz|9@cU{3DOt9X`-A{Oxf)Yx< zmRq|^jB0;W;qKw>@BcH9PSvMjS1~A)^D?CCyfIlYb;lQnG&U#!EBpSkWQI`nq)IZa z?EM^F{uzo%F4K|c9Xbj(AB2~6b#}L1QCcBowwE_ibY}bU{SI+1mUS|VWxRTdbvDlx z=velpE)McNbTPg-qPsnyqvZ^pqf1WB-jZ~Kp?Z;Gf>GvR*Fja4gkGdSM*in$krs!7 zLh}EfDg0ICiE%Q<@ohJ`3@@b*U93Zm=BM(!z?2~$kn*(39vV)u|3i_j(-f{8aXyak zcWB>A|98btDe^0oj>nigP?$!ynd-HMVBvwL9iock>EDNlzZ8Ph{oe!re&Z41BmG}h zyd8pE)LO;o&*Q<(zvyEL%4^@TeSd83emT2)Dt!opiHxMFq2(e7TTAx`1Kzed;*EHejfV=B>#a^yQ(aU(fCHeX$5!IY76D#-jHs#UXR4j;uGJc>U*nNf^$OzZpJ!fnHG z-h}zwp4znSq`jF4jG#gDuMWR&EYjSFoB#(}iIlHESd4eYieB(Ft#ry~^l#ytJo~Wr zgNnPZ1OO33nIox(p=pA{}v8x6I89TgBMA zKwg3!#k^TF#s67fT)uR}tEfxOj2CP|OCt$yc+c6sGj8-W4TM#SWc4Bo1n_q$|R~g_ZnNis(QV) zdXK{Xz~3hzZrLm{t;M{}#gyU&L$y0Giex%N?|}Yje(q~mbQ*$zO4G5#j}9};=aO&P z;I^W$#rm^Uvi?=WNO#n`zpuWZQngALQsCnt)UJ-d1^`{<~QehNjSuj5NWqEyqU)Dv~P?t9nFy2AI_=6HI3(2 zS0w2x8P@)&*lfuGvO$yLN0nO3Qc{F@tfn-MP&Wk2HV3>HI+5M@LCVpE+Q%I{p%r&& zrzN+PTlTFtUN{b*Dr1(do(omO0Hjm1Rz3^2VG-rt;_wQz`H3v)f`3Mi<6u1b;=Ttp zhEYZ{J-eKz8HepQJ|pBB`<$q@wJJaNl)Jc6&uW4uq(d92s=J7#?s7x z+kx~n@($Mk7JarQROO~9WgL!}CW0Ke(~zG(_xzn0otc}TKj@L8B1t7_?k24nJuJo=*?8p`(9!KWc|K4bupOD^bm4hn zfes$TA?3mse0k^+fJY;19F=YOjF$8dH^2#RKp9I&Llch%Pa_B93aY=|2kqQHqtelx zV8sB<^Y~w8pZWPgt(+r=M|1RNE4=;Dt>F7z@p*G0FuZZxYqo?q%ldOoA{r4vCD53&_zFjE(N!5M9Cxh&TS?k+Ac#$%5N zq%GpF-LX_Aw$+>3?DjpG;HLaU(Y_Xfw7)a4?cYvD`kvE z+__FN`0hIIJmdxRwP4O+^o<~D3K7}wKPQ`@x@CFvyLHT+;v;y-84Hd1Oo~a{o+v0J z{^9&F9hS#bLMR&2IqM9s#i6e}s#HZit{2wDhkgrOEL^~ddi2sjL6pj`XPEU+7?FGK zIAqP9Aa$7_OW=b|xjpXoc<55vI6Np(8d?BK_zav!cv8m5)67CRvc~U%5_>5^Rzo=H z!N0Lyf`5Yr`7-%EN&G5JDu#SjrO< zm%7JdqqM?IgmWMV{VD1MGyok%BDaUwaX(w2^TJv$_Ei@GE+X(mTEu!s|Cx;+xP78W z3KPmC7(0_Uen<08+DtUxPP0!DA{(@*6I(I*uR%jO66WE{QPVp!@Ls3y9!08RI-j!_ zPgsd&k>mDZaGJ}vp2pl(Et)ssCARs4IL5_;s1r9OSo27;2f9pTK_pKz87=};Xb)Eq z6EZ~ZgFMZ?DXZP$Lxq#?+fhY-pKQjoqq<_CLNsZ$G@lR{&A^&jLZLvZWeqdK2|@Rty3fP|BcOO9P4@IFegyYNvM(cfVRyEa8 zybEY;)YTIrBVSorF%6^JJo-ix(hro@oP*yU+@(x=yrqC4G$G8eSavqHxk~*cAa)k_ z^!AR6jWup|S`0x#;ke=yx8_@f#TY)WglhSzW0);YXWr9#J@M|3A}m$P%kD>(D$}ag zt1Bofl4D5V(64{VK}IJTd^eFVtD>R;RGrN;U(etDw0d|Tufn+qX^?&*b{X-tyyk_IdTMF}l$1kT zs}d&Vz`UCI=zfcYy$hnjogJI7Uit)u_cG@3j{q-3W{WKz(4!pj@U>J4Ld5qYub=Nv zQ$(m_@O^MVyujIVZ*!cl9vvETc>A`WyRx?SFA(!%Vq<5D`TYYTQ!-AYR)^Vgz>oq` z4N$gaRaGiXM1YWT23!dsNWZ$IO9b5jX`ce_a}JXZy?nL62gIdp~Nq2Y*SQCH!ox&_Fwqyk+iio13BK2{n z4(2CMHtr>_0HHQ8FaQ+o(cpayHg@*IE27zzTv4BD(5nD+Ab2u9zDDw<(+9y?91gUB z*|)I?O!@E@AzG9Z&*vBWLeHK(V`OCH=l8leTBL~R9~iKpu$e2)RIiu?Legvnrq{~K zU;bIHw7L~Ps4QQ?;WQF~zVh+|7st!DQxARNqL7lKW*OGIkU09~{LoNNO4rAKxmGM- z-=609BKOq-MSqrv*T<{1$b{zR+w2NIpgRX|eN0TKmBT?iGo0L$$gZMh3Aq(;A@$sv zUXs+3Br+K;Yk_X7Pw7TZ3_3M>rBB^L$yRG?E6~>F2zvn4C%-9>*aFfW2p6ZOr`@RO z1f29zC5dQgj(`951lqRUoB&P^j-$UDeKU)(fKi)~`qpSa4c@E({g4Qtt3TZS9!Lus zs!eR4$Kr@)v_qEH@j~#F_mJy<=eih&*xK9#H=qAtjQ6}o z1WLdFSqK7CxqEw7*Ay2Iuh^E8R>!Rs>Bcl@$zCa);K9>D{%(6DMv;@NG1&(7b{mh#pjSZn+saSd3 z=u(kn{RolEkYrZ)MYl&M{DnDANXo$EM#>3Ha`q+@yiWs&*011&f!{xq#Krt2A7g`g zAs@%xoDJgnQ*NuZ|HHszV2}BL7+MubHg5d)_8_kt8Wg-F~ z^L>20hHF0b1tjuc5uFO1l~8)n{tuI2W)B88b!`?z50JscE86ghJmZLtVag2*n4O)C;p5hgGJ%I zAp7C&Ar~l`+k<)`?ZjV`7vx0Z6{H1x@)FMG3@9j7*Qj{TPF=Ux2u}Gnn^Hc+egoAK z@&+10I}5(_sRsA|E(vDjJw$SHV>(G@neF;7eK8rAH%9i5e!y38rB3Cs#D5`=3Z?cD zibjD0NG_YhfrE7{$jk*co%u#s#8UQ8c3g#U^w0)ALm}yTjjubA1d%Ym2>t+RvxNfe zqdX|Y9J={p{aek}54qw+!tccl{~7It=j2GJAwi+(dFSelW&f0hfFT^Zl)sYyt)|rvL$e#n zsyhl^+xM(U>$}?@KtlF8av}nLL6*3m{usU`0c0Vz?~x_Wktf+|S}RK{!O`NPMsHH5 zz)sdUT>Oj(i-HH^vounO`2ysE504mI8GryOXxfw+nneOD%|dukxiSS1U@MTu9C3roJZVN~n`t?8Y!%YZ5BA6lLd9 zAotMXh5%$qdi~vF>uLOB+PHQCc_D3m_OMRf{oTPdLNgn*kivyt^`pFyvons+;SJ?% zA)ABmbz;@mIr@)>3}B&2#HrHVaysf7g35PQSrtMN* z4kv^B;Zt7l%GVfk$6|3Wq^O3O3SwH_v`LRG=)U!xBXTAWGoQyyL#VbjHaq_%Q zA&r~aM&`Hc-3jXyQv|{!_bj)=(k53`l0%Glz&@k~nlm|1Ua}KWL&oN){H{7uS*;X- z2F0LUQfKyOh;h0P{63XIT01JrYQ9P3A$Dx6%7(qRp6N^u@SHF0-lEW)o0$mxUb`Az z^g0a5>xnXw4|FjytH?(5Xq{sDgo=o=(N#XHBr|&PY!rPoqr-BSD*De^!~S#auWZvf z)Z7s}l*fyIw)weF*Br90#4g|Dx*t(BAVYn%X0YzcI|k6^N1?4A!?t~cg^$Y<@JoSc zc{r(?<9KdN((v_Iyw?Rjd0(uqi;<&~gt|UBb)i50-k~W=K@NqY+_ z(KCVJ!2GMlfdcKeZpYZw)ek1BY4^!)>m@%1gHT;*WyiJ3_`YH*{TJk6qAE@e0XR4z zQXs@QsRsvN;bqd0PhVqhYc$;zRerb7j*owMJJBSbS@&n~N0CNck9+b`n?T`d#R@|w z)=rSYEIlUlb@ycRh{MatIb6|eM+W(hY&2`iuRtqP@d0*S5;wbhelt{5UQEt|7bxnE zmD}G6bLo^aTWIlPcCP{wyOB+1qSt;q6_m%c#IPqAk*F-Dqu`{5>y{$InRY+%b=WoTCSGCkRyvQu8%(4jfrv zB_41UeGcnh(KvOuaCz<@oh=5pmECw?7wQyn$|&NrgDK$L*41*fbqyk%^8V06X$&0C zLc+ID6@g5yuGCz1r(f0EoiF!Vtx&TgB}k}n_TQQ6g^uajy093zQ17O0GYV?NG3T>R z21glVOyWA%K>D*{=mj72Ql=N8<6_A*7wsAJI2~b?MhP6vnI{^Ej~=_%P}#!zW2nT{ zOwm*!yJZ%t_lhO)jza^0JQLw3P(wp(_xqkLm#tF$3MCwHv!GS)6**V|+u49bM zXzF5$V4kzC9+gy_=-yh$8EO=%+ARDl%*~?wBJuZpTfe@$-rsQLw9g+9k|Vewy-$!} z+(&mZ0+}>aH2gstjUnT_0j!$(ml|{V{#8>B{dbdU6RtsjcKgVBt0&RlE#w$}p2sQd zdWP$6Vex?PfkJN=!j(HXyPsa)KQtkok6k?2{#7Vle9cO&YD_qTgR3D}ao(_k?(0q8 z#Rlw*T*UBU_3g`sHxF-36w8S4{9b(+v_TX7sID7|E{hT}Y7vf#WA<%s!sMhH+xEMc z$-ND|is@&QCH0(`((25i*-@D4gZXl;T;Z3ULPF(FPh1u?)rtiu)ycnk=Eo7?jZF}; zMxaAYls#Q2QcMIRA2SJf{h2DJ;%y8V7%e+60oYMKVDVssvnk=i0;oI;1_%1T@d!8BeGLr_DVy? zBHbXav@w8km-=t-C;eZ6w{+1-YX=vsVa{~RJ?3>1l!7unHiew1xI4d)6qq+ZLEQyB z0iq6;W}hAk9=l28oY;)B$F?_JKYjD_AK@$ zG0=P;g9lQxJU*3$D$@D^hcUOf_tko+&3LAQyY663W^puSb~xAd^k zCv`fu&n#Fm*1>u7zncw*!q=jgG}6X$TxeJRTsi#AJ@d;)ez89Rr!^8v8L;rz`Tfl9 zn)D0etlKqKJkqxZ2xij$M-)d*#t%67G{5ZKa0pNsihb zP^IM+TRHMWX2hSdx@-B1&wjLLMk8q8c<+XopL~O2f|EnS43{;2(?Y_&G&Va_Eg-;q zH=P_y>?X7^rtv?`8&Tl;U>$5AV`+~ zYM0tmf*6z+#aP(MNA`cGkj!sf`nFV~Onn}i*6Cg9?bHPH{|z{Rga3yMiQD2w&-Jv0 zv1pK!{X~VyxUhRs8?w5wP9g)BQbKCmSw9x7?2ext4p}ChNyO_;`GBtGjrvM;%lGEL zf-U(Pb|2Kh35ZegU-sy7@>>B!@8TdxhcT_8()S=8Do&>)Vz23bVJOEg;;y?j8jo7c z*(lwKVZ(`7vFW$g*OZ`MpIsCB*Gd;hV;(J-IERMhIvq+3%jpgz^&&MUj((4&X~FUm zZ}88r>i6GkE$deve$%#u)6funa|^6Yf@K-*DU`XiE`-Gz+P^mSw>
    !0)EGIPVf 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 Vm;>Wv5u85s_`y@<5=CV2e*vo_W7+@! literal 0 HcmV?d00001 diff --git a/_examples/donut_chart/reg.svg b/_examples/donut_chart/reg.svg new file mode 100644 index 0000000..f14c2af --- /dev/null +++ b/_examples/donut_chart/reg.svg @@ -0,0 +1,25 @@ +\nBlueTwoOne \ No newline at end of file diff --git a/donut_chart.go b/donut_chart.go new file mode 100644 index 0000000..3472c98 --- /dev/null +++ b/donut_chart.go @@ -0,0 +1,321 @@ +package chart + +import ( + "errors" + "fmt" + "io" + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/util" +) + +// DonutChart is a chart that draws sections of a circle based on percentages with an hole. +type DonutChart struct { + Title string + TitleStyle Style + + ColorPalette ColorPalette + + Width int + Height int + DPI float64 + + Background Style + Canvas Style + SliceStyle Style + + Font *truetype.Font + defaultFont *truetype.Font + + Values []Value + Elements []Renderable +} + +// GetDPI returns the dpi for the chart. +func (pc DonutChart) GetDPI(defaults ...float64) float64 { + if pc.DPI == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultDPI + } + return pc.DPI +} + +// GetFont returns the text font. +func (pc DonutChart) GetFont() *truetype.Font { + if pc.Font == nil { + return pc.defaultFont + } + return pc.Font +} + +// GetWidth returns the chart width or the default value. +func (pc DonutChart) GetWidth() int { + if pc.Width == 0 { + return DefaultChartWidth + } + return pc.Width +} + +// GetHeight returns the chart height or the default value. +func (pc DonutChart) GetHeight() int { + if pc.Height == 0 { + return DefaultChartWidth + } + return pc.Height +} + + + +// Render renders the chart with the given renderer to the given io.Writer. +func (pc DonutChart) Render(rp RendererProvider, w io.Writer) error { + if len(pc.Values) == 0 { + return errors.New("please provide at least one value") + } + + r, err := rp(pc.GetWidth(), pc.GetHeight()) + if err != nil { + return err + } + + if pc.Font == nil { + defaultFont, err := GetDefaultFont() + if err != nil { + return err + } + pc.defaultFont = defaultFont + } + r.SetDPI(pc.GetDPI(DefaultDPI)) + + canvasBox := pc.getDefaultCanvasBox() + canvasBox = pc.getCircleAdjustedCanvasBox(canvasBox) + + pc.drawBackground(r) + pc.drawCanvas(r, canvasBox) + + + finalValues, err := pc.finalizeValues(pc.Values) + if err != nil { + return err + } + pc.drawSlices(r, canvasBox, finalValues) + pc.drawTitle(r) + for _, a := range pc.Elements { + a(r, canvasBox, pc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (pc DonutChart) drawBackground(r Renderer) { + Draw.Box(r, Box{ + Right: pc.GetWidth(), + Bottom: pc.GetHeight(), + }, pc.getBackgroundStyle()) +} + +func (pc DonutChart) drawCanvas(r Renderer, canvasBox Box) { + Draw.Box(r, canvasBox, pc.getCanvasStyle()) +} + +func (pc DonutChart) drawTitle(r Renderer) { + if len(pc.Title) > 0 && pc.TitleStyle.Show { + Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle()) + } +} + +func (pc DonutChart) drawSlices(r Renderer, canvasBox Box, values []Value) { + cx, cy := canvasBox.Center() + diameter := util.Math.MinInt(canvasBox.Width(), canvasBox.Height()) + radius := float64(diameter >> 1) / 1.1 + labelRadius := (radius * 2.83) / 3.0 + + // draw the donut slices + var rads, delta, delta2, total float64 + var lx, ly int + + if len(values) == 1 { + pc.styleDonutChartValue(0).WriteToRenderer(r) + r.MoveTo(cx, cy) + r.Circle(radius, cx, cy) + } else { + for index, v := range values { + v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r) + r.MoveTo(cx, cy) + rads = util.Math.PercentToRadians(total) + delta = util.Math.PercentToRadians(v.Value) + + r.ArcTo(cx, cy, (radius / 1.25), (radius / 1.25), rads, delta) + + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + total = total + v.Value + } + } + + + //making the donut hole + v := Value{Value: 100, Label: "center"} + styletemp := pc.SliceStyle.InheritFrom(Style{ + StrokeColor: ColorWhite, StrokeWidth: 4.0, FillColor: ColorWhite, FontColor: ColorWhite, //Font: pc.GetFont(),//FontSize: pc.getScaledFontSize(), + }) + v.Style.InheritFrom(styletemp).WriteToRenderer(r) + r.MoveTo(cx, cy) + r.ArcTo(cx, cy, (radius / 3.5), (radius / 3.5), util.Math.DegreesToRadians(0), util.Math.DegreesToRadians(359)) + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + + + // draw the labels + total = 0 + for index, v := range values { + v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r) + if len(v.Label) > 0 { + delta2 = util.Math.PercentToRadians(total + (v.Value / 2.0)) + delta2 = util.Math.RadianAdd(delta2, _pi2) + lx, ly = util.Math.CirclePoint(cx, cy, labelRadius, delta2) + + tb := r.MeasureText(v.Label) + lx = lx - (tb.Width() >> 1) + ly = ly + (tb.Height() >> 1) + + r.Text(v.Label, lx, ly) + } + total = total + v.Value + } +} + + +func (pc DonutChart) finalizeValues(values []Value) ([]Value, error) { + finalValues := Values(values).Normalize() + if len(finalValues) == 0 { + return nil, fmt.Errorf("donut chart must contain at least (1) non-zero value") + } + return finalValues, nil +} + +func (pc DonutChart) getDefaultCanvasBox() Box { + return pc.Box() +} + +func (pc DonutChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { + circleDiameter := util.Math.MinInt(canvasBox.Width(), canvasBox.Height()) + + square := Box{ + Right: circleDiameter, + Bottom: circleDiameter, + } + + return canvasBox.Fit(square) +} + +func (pc DonutChart) getBackgroundStyle() Style { + return pc.Background.InheritFrom(pc.styleDefaultsBackground()) +} + +func (pc DonutChart) getCanvasStyle() Style { + return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas()) +} + +func (pc DonutChart) styleDefaultsCanvas() Style { + return Style{ + FillColor: pc.GetColorPalette().CanvasColor(), + StrokeColor: pc.GetColorPalette().CanvasStrokeColor(), + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc DonutChart) styleDefaultsDonutChartValue() Style { + return Style{ + StrokeColor: pc.GetColorPalette().TextColor(), + StrokeWidth: 4.0, + FillColor: pc.GetColorPalette().TextColor(), + } +} + +func (pc DonutChart) styleDonutChartValue(index int) Style { + return pc.SliceStyle.InheritFrom(Style{ + StrokeColor: ColorWhite, + StrokeWidth: 4.0, + FillColor: pc.GetColorPalette().GetSeriesColor(index), + FontSize: pc.getScaledFontSize(), + FontColor: pc.GetColorPalette().TextColor(), + Font: pc.GetFont(), + }) +} + +func (pc DonutChart) getScaledFontSize() float64 { + effectiveDimension := util.Math.MinInt(pc.GetWidth(), pc.GetHeight()) + if effectiveDimension >= 2048 { + return 48.0 + } else if effectiveDimension >= 1024 { + return 24.0 + } else if effectiveDimension > 512 { + return 18.0 + } else if effectiveDimension > 256 { + return 12.0 + } + return 10.0 +} + +func (pc DonutChart) styleDefaultsBackground() Style { + return Style{ + FillColor: pc.GetColorPalette().BackgroundColor(), + StrokeColor: pc.GetColorPalette().BackgroundStrokeColor(), + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc DonutChart) styleDefaultsElements() Style { + return Style{ + Font: pc.GetFont(), + } +} + +func (pc DonutChart) styleDefaultsTitle() Style { + return pc.TitleStyle.InheritFrom(Style{ + FontColor: pc.GetColorPalette().TextColor(), + Font: pc.GetFont(), + FontSize: pc.getTitleFontSize(), + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + }) +} + +func (pc DonutChart) getTitleFontSize() float64 { + effectiveDimension := util.Math.MinInt(pc.GetWidth(), pc.GetHeight()) + if effectiveDimension >= 2048 { + return 48 + } else if effectiveDimension >= 1024 { + return 24 + } else if effectiveDimension >= 512 { + return 18 + } else if effectiveDimension >= 256 { + return 12 + } + return 10 +} + +// GetColorPalette returns the color palette for the chart. +func (pc DonutChart) GetColorPalette() ColorPalette { + if pc.ColorPalette != nil { + return pc.ColorPalette + } + return AlternateColorPalette +} + +// Box returns the chart bounds as a box. +func (pc DonutChart) Box() Box { + dpr := pc.Background.Padding.GetRight(DefaultBackgroundPadding.Right) + dpb := pc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom) + + return Box{ + Top: pc.Background.Padding.GetTop(DefaultBackgroundPadding.Top), + Left: pc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left), + Right: pc.GetWidth() - dpr, + Bottom: pc.GetHeight() - dpb, + } +} diff --git a/donut_chart_test.go b/donut_chart_test.go new file mode 100644 index 0000000..f01b3b9 --- /dev/null +++ b/donut_chart_test.go @@ -0,0 +1,69 @@ +package chart + +import ( + "bytes" + "testing" + + assert "github.com/blend/go-sdk/assert" +) + +func TestDonutChart(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 10, Label: "Blue"}, + {Value: 9, Label: "Green"}, + {Value: 8, Label: "Gray"}, + {Value: 7, Label: "Orange"}, + {Value: 6, Label: "HEANG"}, + {Value: 5, Label: "??"}, + {Value: 2, Label: "!!"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + pie.Render(PNG, b) + assert.NotZero(b.Len()) +} + +func TestDonutChartDropsZeroValues(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 0, Label: "Gray"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + err := pie.Render(PNG, b) + assert.Nil(err) +} + +func TestDonutChartAllZeroValues(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 0, Label: "Blue"}, + {Value: 0, Label: "Green"}, + {Value: 0, Label: "Gray"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + err := pie.Render(PNG, b) + assert.NotNil(err) +}