-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathheat_map.go
314 lines (273 loc) · 9 KB
/
heat_map.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
package charts
import (
"errors"
"strconv"
"github.com/golang/freetype/truetype"
"github.com/go-analyze/charts/chartdraw"
)
// HeatMapOption contains configuration options for a heat map chart. Render the chart using Painter.HeatMapChart.
type HeatMapOption struct {
// Theme specifies the color palette used for rendering the heat map.
Theme ColorPalette
// BaseColorIndex specifies which color from the theme palette to use as the base for gradients.
BaseColorIndex int
// Padding specifies the padding around the heat map chart.
Padding Box
// Deprecated: Font is deprecated, instead the font needs to be set on the SeriesLabel, or other specific elements.
Font *truetype.Font
// Title options for rendering the chart title, including text and font styling.
Title TitleOption
// Values provides the 2D slice of float64 values representing the data for the heat map.
// The outer slice represents the rows (Y-axis) and the inner slice represents the columns (X-axis).
Values [][]float64
// XAxis specifies the configuration options for the X-axis.
XAxis HeatMapAxis
// YAxis specifies the configuration options for the Y-axis.
YAxis HeatMapAxis
// ScaleMinValue overrides the minimum value used for color gradient calculation. If nil, calculated from data.
ScaleMinValue *float64
// ScaleMaxValue overrides the maximum value used for color gradient calculation. If nil, calculated from data.
ScaleMaxValue *float64
// ValuesLabel configuration for displaying numeric values on top of heat map cells.
ValuesLabel SeriesLabel
}
// HeatMapAxis contains configuration options for an axis on a heat map chart.
type HeatMapAxis struct {
// Title specifies the title to display next to the axis, if any.
Title string
// TitleFontStyle specifies the font style for the axis title.
TitleFontStyle FontStyle
// Labels specifies custom labels to display along the axis. If empty or nil, numeric indices are used. Must match the size of Values for the given axis.
Labels []string
// LabelFontStyle specifies the font style for the axis labels.
LabelFontStyle FontStyle
// LabelRotation are the radians for rotating the label. Convert from degrees using DegreesToRadians(float64).
LabelRotation float64
// LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions.
LabelCount int
// LabelCountAdjustment specifies a relative influence on how many labels should be rendered.
// Typically, this is negative to result in cleaner graphs, positive values may result in text collisions.
LabelCountAdjustment int
}
type heatMap struct {
p *Painter
opt *HeatMapOption
}
// newHeatMapChart returns a heat map chart renderer.
func newHeatMapChart(p *Painter, opt HeatMapOption) *heatMap {
return &heatMap{
p: p,
opt: &opt,
}
}
// NewHeatMapOptionWithData returns an initialized HeatMapOption with the provided data.
func NewHeatMapOptionWithData(data [][]float64) HeatMapOption {
return HeatMapOption{
Padding: defaultPadding,
Values: data,
}
}
func (h *heatMap) renderChart(result *defaultRenderResult) (Box, error) {
opt := h.opt
if len(opt.Values) == 0 {
return BoxZero, errors.New("empty values")
}
numRows := len(opt.Values)
numCols := sliceMaxLen(opt.Values...)
if numCols == 0 {
return BoxZero, errors.New("no columns in heat map values")
}
seriesPainter := result.seriesPainter.Child(PainterPaddingOption(NewBoxEqual(1)))
// determine scale for map colors
minVal, maxVal := computeMinMax(opt.Values, numCols)
if opt.ScaleMinValue != nil {
minVal = *opt.ScaleMinValue
}
if opt.ScaleMaxValue != nil {
maxVal = *opt.ScaleMaxValue
}
if minVal == maxVal { // ensure a non-zero range
minVal = 0
maxVal = 1
}
valueRange := maxVal - minVal
baseColor := opt.Theme.GetSeriesColor(opt.BaseColorIndex)
cellWidth := seriesPainter.Width() / numCols
cellHeight := seriesPainter.Height() / numRows
if cellWidth < 2 || cellHeight < 2 {
return BoxZero, errors.New("insufficient space for heat map cells")
}
// Draw each cell, using the ratio to adjust the lightness of the base color.
for y := range opt.Values {
for x := 0; x < numCols; x++ {
var value float64
if x < len(opt.Values[y]) {
value = opt.Values[y][x]
}
ratio := (value - minVal) / valueRange
lightDelta := (1 - ratio) * 0.4
satDelta := (1 - ratio) * 0.1
if opt.Theme.IsDark() {
lightDelta *= -1
}
cellColor := baseColor.WithAdjustHSL(0, satDelta, lightDelta)
x1 := x * cellWidth
y1 := y * cellHeight
x2 := x1 + cellWidth
y2 := y1 + cellHeight
seriesPainter.FilledRect(x1, y1, x2, y2, cellColor, cellColor, 0)
}
}
if flagIs(true, opt.ValuesLabel.Show) {
opt.ValuesLabel.FontStyle =
fillFontStyleDefaults(opt.ValuesLabel.FontStyle, defaultLabelFontSize, opt.Theme.GetLabelTextColor(), opt.Font)
labelPainter := newSeriesLabelPainter(seriesPainter, []string{""}, opt.ValuesLabel, opt.Theme)
for y := range opt.Values {
for x := 0; x < numCols; x++ {
var value float64
if x < len(opt.Values[y]) {
value = opt.Values[y][x]
}
xCenter := x*cellWidth + cellWidth/2
yCenter := y*cellHeight + cellHeight/2
labelPainter.Add(labelValue{
index: 0,
value: value,
x: xCenter,
y: yCenter,
fontStyle: opt.ValuesLabel.FontStyle,
})
}
}
if _, err := labelPainter.Render(); err != nil {
return BoxZero, err
}
}
return seriesPainter.box, nil
}
func computeMinMax(values [][]float64, numCol int) (float64, float64) {
if len(values) == 0 || numCol == 0 {
return 0, 0
}
var min, max float64
if len(values[0]) != 0 {
min = values[0][0]
max = values[0][0]
}
for _, row := range values {
rowMin, rowMax := chartdraw.MinMax(row...)
if rowMin < min {
min = rowMin
}
if rowMax > max {
max = rowMax
}
if len(row) < numCol { // ensure range considers potential default values
if min < 0 {
min = 0
}
if max < 0 {
max = 0
}
}
}
return min, max
}
func (h *heatMap) Render() (Box, error) {
p := h.p
opt := h.opt
if opt.Theme == nil {
opt.Theme = getPreferredTheme(p.theme)
}
numRows := len(opt.Values)
numCols := sliceMaxLen(opt.Values...)
// Ensure X-axis labels cover all columns.
for len(opt.XAxis.Labels) < numCols {
opt.XAxis.Labels = append(opt.XAxis.Labels, strconv.Itoa(len(opt.XAxis.Labels)))
}
xAxisOption := XAxisOption{
Title: opt.XAxis.Title,
TitleFontStyle: opt.XAxis.TitleFontStyle,
Labels: opt.XAxis.Labels,
LabelFontStyle: opt.XAxis.LabelFontStyle,
LabelRotation: opt.XAxis.LabelRotation,
LabelCount: opt.XAxis.LabelCount,
LabelCountAdjustment: opt.XAxis.LabelCountAdjustment,
}
// Ensure y-axis labels cover all columns.
for len(opt.YAxis.Labels) < numRows {
opt.YAxis.Labels = append(opt.YAxis.Labels, strconv.Itoa(len(opt.YAxis.Labels)))
}
yAxisOption := []YAxisOption{{
Title: opt.YAxis.Title,
TitleFontStyle: opt.YAxis.TitleFontStyle,
Labels: opt.YAxis.Labels,
LabelFontStyle: opt.YAxis.LabelFontStyle,
LabelRotation: opt.YAxis.LabelRotation,
LabelCountAdjustment: opt.YAxis.LabelCountAdjustment,
LabelCount: opt.YAxis.LabelCount,
Min: Ptr(0.0),
Max: Ptr(float64(numRows - 1)),
RangeValuePaddingScale: Ptr(0.0),
isCategoryAxis: true,
}}
renderResult, err := defaultRender(p, defaultRenderOption{
theme: opt.Theme,
padding: opt.Padding,
seriesList: heatMapFakeSeries{
rows: numRows,
},
stackSeries: false,
xAxis: &xAxisOption,
yAxis: yAxisOption,
title: opt.Title,
legend: &LegendOption{Show: Ptr(false)},
})
if err != nil {
return BoxZero, err
}
return h.renderChart(renderResult)
}
// heatMapFakeSeries is a dummy series type used solely to satisfy defaultRender's needs and notably drive axis rendering.
type heatMapFakeSeries struct {
rows int
}
func (h heatMapFakeSeries) len() int {
return 1
}
func (h heatMapFakeSeries) getSeries(_ int) series {
return h
}
func (h heatMapFakeSeries) getSeriesName(_ int) string {
return h.names()[0]
}
func (h heatMapFakeSeries) getSeriesValues(_ int) []float64 {
return nil // not used, current usage is just in sumSeries, not used by defaultRender
}
func (h heatMapFakeSeries) getSeriesLen(_ int) int {
return 0 // not used, current usage in getSeriesMaxDataCount, which is only used when axisReverse is true
}
func (h heatMapFakeSeries) names() []string {
return []string{"Heat Map"}
}
func (h heatMapFakeSeries) hasMarkPoint() bool {
return false
}
func (h heatMapFakeSeries) setSeriesName(_ int, _ string) {
// ignored
}
func (h heatMapFakeSeries) sortByNameIndex(_ map[string]int) {
// no-op
}
func (h heatMapFakeSeries) getSeriesSymbol(_ int) Symbol {
return ""
}
func (h heatMapFakeSeries) getType() string {
return ChartTypeHeatMap
}
func (h heatMapFakeSeries) getYAxisIndex() int {
return 0
}
func (h heatMapFakeSeries) getValues() []float64 {
return []float64{0, float64(h.rows)} // fake series data to get y-axis values set correctly
}