Skip to content

Commit e0e00b0

Browse files
Add categorical colors (#64)
Add categorical colors - Color mode can be switched between threshold (default/fallback) and categories - Categories are based on the datapoint's locationName field
1 parent 19a69ba commit e0e00b0

File tree

6 files changed

+222
-33
lines changed

6 files changed

+222
-33
lines changed

src/model.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,14 @@ export const MapCenters = [
186186
data: { mapCenterLatitude: 59.32549, mapCenterLongitude: 18.07109, initialZoom: 11 },
187187
},
188188
];
189+
190+
export const ColorModes = {
191+
threshold: {
192+
id: 'threshold',
193+
label: 'threshold',
194+
},
195+
categories: {
196+
id: 'categories',
197+
label: 'categories',
198+
},
199+
};

src/partials/editor.html

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ <h5>Mapping table data</h5>
283283
<script type="text/ng-template" id="help_location_fields">
284284
<li>
285285
<b>Label field (optional)</b>: Enter the name of the location name column. Used to label each circle on the
286-
map. If it is empty then the geohash value is used as the label.</li>
286+
map. If it is empty then the geohash value is used as the label and can also be used to colorize the circle
287+
by mapping the value against configured categories.</li>
287288
<li ng-show="ctrl.panel.locationData == 'table+json' || ctrl.panel.locationData == 'table+jsonp'">
288289
<b>Label location key field (optional)</b>: Enter the name of the location key column. The value from this column
289290
will get used to lookup the location name by matching it against the value of the key attribute of the locations
@@ -420,12 +421,30 @@ <h5>Circle parameters</h5>
420421
</div>
421422

422423
<div class="gf-form-group" style="overflow-x: visible">
423-
<h5>Map thresholds to colors</h5>
424+
<h5>Map data to colors</h5>
425+
424426
<div class="gf-form">
427+
<label class="gf-form-label width-10">Color mode</label>
428+
<div class="gf-form-select-wrapper max-width-10">
429+
<select class="input-small gf-form-input"
430+
ng-model="ctrl.panel.colorMode"
431+
ng-options="option.id as option.label for (unused, option) in ctrl.getColorModeChoices()"
432+
ng-change="ctrl.changeColorMode()">
433+
</select>
434+
</div>
435+
</div>
436+
437+
<div class="gf-form" ng-show="ctrl.panel.colorMode === 'categories'">
438+
<label class="gf-form-label width-10">Categories</label>
439+
<input type="text" class="input-small gf-form-input width-18" ng-model="ctrl.panel.categories" ng-change="ctrl.changeColorMode()"
440+
placeholder="a,b" ng-model-onblur />
441+
</div>
442+
<div class="gf-form" ng-show="ctrl.panel.colorMode === 'threshold' || !ctrl.panel.colorMode">
425443
<label class="gf-form-label width-10">Threshold values</label>
426-
<input type="text" class="input-small gf-form-input width-18" ng-model="ctrl.panel.thresholds" ng-change="ctrl.changeThresholds()"
444+
<input type="text" class="input-small gf-form-input width-18" ng-model="ctrl.panel.thresholds" ng-change="ctrl.changeColorMode()"
427445
placeholder="0,10" ng-model-onblur />
428446
</div>
447+
429448
<div class="gf-form" style2="overflow-x: visible">
430449
<label class="gf-form-label width-10">Colors</label>
431450
<div class="width-24" style="display: inline-flex">

src/worldmap.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import $ from 'jquery';
44
import PluginSettings from './settings';
55
import { TemplateSrv } from 'grafana/app/features/templating/template_srv';
66
import DataFormatter from './data_formatter';
7+
import { ColorModes } from './model';
78

89
describe('Worldmap', () => {
910
let worldMap;
@@ -150,6 +151,56 @@ describe('Worldmap', () => {
150151
});
151152
});
152153

154+
describe('when the data has three points and color mode is threshold', () => {
155+
beforeEach(() => {
156+
ctrl.data = new DataBuilder()
157+
.withCountryAndValue('SE', 1)
158+
.withCountryAndValue('IE', 2)
159+
.withCountryAndValue('US', 3)
160+
.withDataRange(1, 3, 2)
161+
.withThresholdValues([2])
162+
.build();
163+
ctrl.panel.circleMinSize = '2';
164+
ctrl.panel.circleMaxSize = '10';
165+
ctrl.panel.colorMode = ColorModes.threshold.id;
166+
worldMap.drawCircles();
167+
});
168+
169+
it('should set red color on values under threshold', () => {
170+
expect(worldMap.circles[0].options.color).toBe('red');
171+
});
172+
173+
it('should set blue color on values equal to or over threshold', () => {
174+
expect(worldMap.circles[1].options.color).toBe('blue');
175+
expect(worldMap.circles[2].options.color).toBe('blue');
176+
});
177+
});
178+
179+
describe('when the data has three points and color mode is categories', () => {
180+
beforeEach(() => {
181+
ctrl.data = new DataBuilder()
182+
.withCountryAndValue('SE', 1)
183+
.withCountryAndValue('IE', 2)
184+
.withCountryAndValue('US', 3)
185+
.withDataRange(1, 3, 2)
186+
.withCategories(['Sweden'])
187+
.build();
188+
ctrl.panel.circleMinSize = '2';
189+
ctrl.panel.circleMaxSize = '10';
190+
ctrl.panel.colorMode = ColorModes.categories.id;
191+
worldMap.drawCircles();
192+
});
193+
194+
it('should set red color on locations not defined in categories', () => {
195+
expect(worldMap.circles[1].options.color).toBe('red');
196+
expect(worldMap.circles[2].options.color).toBe('red');
197+
});
198+
199+
it('should set blue color on defined categories', () => {
200+
expect(worldMap.circles[0].options.color).toBe('blue');
201+
});
202+
});
203+
153204
describe('when the data has empty values and hideEmpty is true', () => {
154205
beforeEach(() => {
155206
ctrl.data = new DataBuilder()
@@ -323,6 +374,42 @@ describe('Worldmap', () => {
323374
});
324375
});
325376

377+
describe('when three thresholds are set and color mode is threshold', () => {
378+
beforeEach(() => {
379+
ctrl.panel.colorMode = ColorModes.threshold.id;
380+
ctrl.data = new DataBuilder().withThresholdValues([2, 4, 6]).build();
381+
worldMap.createLegend();
382+
});
383+
384+
it('should create a legend with four legend values', () => {
385+
expect(worldMap.legend).toBeDefined();
386+
expect(worldMap.legend._div.outerHTML).toBe(
387+
'<div class="info legend leaflet-control"><div class="legend-item">' +
388+
'<i style="background:red"></i> &lt; 2</div><div class="legend-item"><i style="background:blue"></i> 2–4</div>' +
389+
'<div class="legend-item"><i style="background:green"></i> 4–6</div>' +
390+
'<div class="legend-item"><i style="background:undefined"></i> 6+</div></div>'
391+
);
392+
});
393+
});
394+
395+
describe('when three thresholds are set and color mode is categories', () => {
396+
beforeEach(() => {
397+
ctrl.panel.colorMode = ColorModes.categories.id;
398+
ctrl.data = new DataBuilder().withCategories(['some cat', 'other cat', 'asdf']).build();
399+
worldMap.createLegend();
400+
});
401+
402+
it('should create a legend with four legend values', () => {
403+
expect(worldMap.legend).toBeDefined();
404+
expect(worldMap.legend._div.outerHTML).toBe(
405+
'<div class="info legend leaflet-control"><div class="legend-item">' +
406+
'<i style="background:red"></i> *</div><div class="legend-item"><i style="background:blue"></i> some cat</div>' +
407+
'<div class="legend-item"><i style="background:green"></i> other cat</div>' +
408+
'<div class="legend-item"><i style="background:undefined"></i> asdf</div></div>'
409+
);
410+
});
411+
});
412+
326413
describe('when the legend should be displayed out-of-band', () => {
327414
/*
328415
* Optimizations for small maps

src/worldmap.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as _ from 'lodash';
22
import $ from 'jquery';
33
import * as L from './libs/leaflet';
44
import WorldmapCtrl from './worldmap_ctrl';
5+
import { ColorModes } from './model';
56

67
const tileServers = {
78
'CartoDB Positron': {
@@ -101,6 +102,35 @@ export default class WorldMap {
101102
return zoomLevel;
102103
}
103104

105+
private getLegendUpdateFunction() {
106+
switch (this.ctrl.settings.colorMode) {
107+
case ColorModes.categories.id:
108+
return () => {
109+
const legendHtml = this.ctrl.data.categories.reduce((html, cat, idx) => {
110+
return html + '<div class="legend-item"><i style="background:' + this.ctrl.settings.colors[idx + 1] + '"></i> ' + cat + '</div>';
111+
}, '<div class="legend-item"><i style="background:' + this.ctrl.settings.colors[0] + '"></i> *</div>');
112+
this.legend._div.innerHTML = legendHtml;
113+
};
114+
case ColorModes.threshold.id:
115+
default:
116+
return () => {
117+
const thresholds = this.ctrl.data.thresholds;
118+
let legendHtml = '';
119+
legendHtml +=
120+
'<div class="legend-item"><i style="background:' + this.ctrl.settings.colors[0] + '"></i> ' + '&lt; ' + thresholds[0] + '</div>';
121+
for (let index = 0; index < thresholds.length; index += 1) {
122+
legendHtml +=
123+
'<div class="legend-item"><i style="background:' +
124+
this.ctrl.settings.colors[index + 1] +
125+
'"></i> ' +
126+
thresholds[index] +
127+
(thresholds[index + 1] ? '&ndash;' + thresholds[index + 1] + '</div>' : '+');
128+
}
129+
this.legend._div.innerHTML = legendHtml;
130+
};
131+
}
132+
}
133+
104134
createLegend() {
105135
this.legend = (window as any).L.control({ position: 'bottomleft' });
106136
this.legend.onAdd = () => {
@@ -109,20 +139,7 @@ export default class WorldMap {
109139
return this.legend._div;
110140
};
111141

112-
this.legend.update = () => {
113-
const thresholds = this.ctrl.data.thresholds;
114-
let legendHtml = '';
115-
legendHtml += '<div class="legend-item"><i style="background:' + this.ctrl.settings.colors[0] + '"></i> ' + '&lt; ' + thresholds[0] + '</div>';
116-
for (let index = 0; index < thresholds.length; index += 1) {
117-
legendHtml +=
118-
'<div class="legend-item"><i style="background:' +
119-
this.ctrl.settings.colors[index + 1] +
120-
'"></i> ' +
121-
thresholds[index] +
122-
(thresholds[index + 1] ? '&ndash;' + thresholds[index + 1] + '</div>' : '+');
123-
}
124-
this.legend._div.innerHTML = legendHtml;
125-
};
142+
this.legend.update = this.getLegendUpdateFunction();
126143
this.legend.addTo(this.map);
127144

128145
// Optionally display legend in different DOM element.
@@ -234,8 +251,8 @@ export default class WorldMap {
234251
createCircle(dataPoint) {
235252
const circle = (window as any).L.circleMarker([dataPoint.locationLatitude, dataPoint.locationLongitude], {
236253
radius: this.calcCircleSize(dataPoint.value || 0),
237-
color: this.getColor(dataPoint.value),
238-
fillColor: this.getColor(dataPoint.value),
254+
color: this.getColor(dataPoint),
255+
fillColor: this.getColor(dataPoint),
239256
fillOpacity: 0.5,
240257
location: dataPoint.key,
241258
stroke: Boolean(this.ctrl.settings.circleOptions.strokeEnabled),
@@ -257,8 +274,8 @@ export default class WorldMap {
257274
if (circle) {
258275
circle.setRadius(this.calcCircleSize(dataPoint.value || 0));
259276
circle.setStyle({
260-
color: this.getColor(dataPoint.value),
261-
fillColor: this.getColor(dataPoint.value),
277+
color: this.getColor(dataPoint),
278+
fillColor: this.getColor(dataPoint),
262279
fillOpacity: 0.5,
263280
location: dataPoint.key,
264281
});
@@ -374,7 +391,16 @@ export default class WorldMap {
374391
return label;
375392
}
376393

377-
getColor(value) {
394+
private getCategoryColor(label) {
395+
for (let index = 0; index !== this.ctrl.data.categories.length; index += 1) {
396+
if (this.ctrl.data.categories[index] === label) {
397+
return this.ctrl.settings.colors[index + 1];
398+
}
399+
}
400+
return _.first(this.ctrl.settings.colors);
401+
}
402+
403+
private getThresholdColor(value) {
378404
for (let index = this.ctrl.data.thresholds.length; index > 0; index -= 1) {
379405
if (value >= this.ctrl.data.thresholds[index - 1]) {
380406
return this.ctrl.settings.colors[index];
@@ -383,6 +409,16 @@ export default class WorldMap {
383409
return _.first(this.ctrl.settings.colors);
384410
}
385411

412+
getColor(dataPoint) {
413+
switch (this.ctrl.settings.colorMode) {
414+
case ColorModes.categories.id:
415+
return this.getCategoryColor(dataPoint.locationName);
416+
case ColorModes.threshold.id:
417+
default:
418+
return this.getThresholdColor(dataPoint.value);
419+
}
420+
}
421+
386422
resize() {
387423
this.map.invalidateSize();
388424
}

src/worldmap_ctrl.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import './styles/worldmap-panel.css';
55
import './styles/leaflet.css';
66
import PluginSettings from './settings';
77
import WorldMap from './worldmap';
8-
import { LocationSources, MapCenters } from './model';
8+
import { ColorModes, LocationSources, MapCenters } from './model';
99
import { WorldmapCore } from './core';
1010
import { WorldmapChrome } from './chrome';
1111
import { ErrorManager } from './errors';
@@ -31,6 +31,8 @@ const panelDefaults = {
3131
locationData: null,
3232
thresholds: '0,10',
3333
colors: ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)'],
34+
categories: 'a,b',
35+
colorMode: ColorModes.threshold.id,
3436
unitSingular: '',
3537
unitPlural: '',
3638
showLegend: true,
@@ -280,7 +282,7 @@ export default class WorldmapCtrl extends MetricsPanelCtrl {
280282

281283
this.processData(dataList);
282284

283-
this.updateThresholdData();
285+
this.updateColorMode();
284286

285287
const autoCenterMap = this.settings.mapCenter === 'First GeoHash' || this.settings.mapCenter === 'Last GeoHash' || this.settings.mapFitData;
286288

@@ -337,22 +339,45 @@ export default class WorldmapCtrl extends MetricsPanelCtrl {
337339
}
338340
}
339341

340-
updateThresholdData() {
341-
// FIXME: Isn't `this.data` actually an array?
342-
this.data.thresholds = this.settings.thresholds.split(',').map(strValue => {
343-
return Number(strValue.trim());
344-
});
345-
while (_.size(this.settings.colors) > _.size(this.data.thresholds) + 1) {
342+
private adjustColorCount(target: any[]) {
343+
const targetSize = _.size(target) + 1; // +1 for catch-all case
344+
while (_.size(this.settings.colors) > targetSize) {
346345
// too many colors. remove the last one.
347346
this.settings.colors.pop();
348347
}
349-
while (_.size(this.settings.colors) < _.size(this.data.thresholds) + 1) {
348+
while (_.size(this.settings.colors) < targetSize) {
350349
// not enough colors. add one.
351350
const newColor = 'rgba(50, 172, 45, 0.97)';
352351
this.settings.colors.push(newColor);
353352
}
354353
}
355354

355+
updateThresholdData() {
356+
// FIXME: Isn't `this.data` actually an array?
357+
this.data.thresholds = this.settings.thresholds.split(',').map(strValue => {
358+
return Number(strValue.trim());
359+
});
360+
this.adjustColorCount(this.data.thresholds);
361+
}
362+
363+
updateCategoricalData() {
364+
// FIXME: Isn't `this.data` actually an array?
365+
this.data.categories = this.settings.categories.split(',');
366+
this.adjustColorCount(this.data.categories);
367+
}
368+
369+
updateColorMode() {
370+
switch (this.settings.colorMode) {
371+
case ColorModes.categories.id:
372+
this.updateCategoricalData();
373+
break;
374+
case ColorModes.threshold.id:
375+
default:
376+
// support existing legacy configurations (i.e. color mode is not set)
377+
this.updateThresholdData();
378+
}
379+
}
380+
356381
onPanelTeardown() {
357382
this.teardownMap();
358383
}
@@ -548,8 +573,8 @@ export default class WorldmapCtrl extends MetricsPanelCtrl {
548573
this.render();
549574
}
550575

551-
changeThresholds() {
552-
this.updateThresholdData();
576+
changeColorMode() {
577+
this.updateColorMode();
553578
this.map.legend.update();
554579
this.render();
555580
}
@@ -580,6 +605,10 @@ export default class WorldmapCtrl extends MetricsPanelCtrl {
580605
return MapCenters;
581606
}
582607

608+
getColorModeChoices() {
609+
return ColorModes;
610+
}
611+
583612
getSelectedMapCenter() {
584613
const mapCenter: any = _.find(MapCenters, { id: this.settings.mapCenter });
585614
return mapCenter && mapCenter.data;

0 commit comments

Comments
 (0)