diff --git a/hub/fixtures/mps.json b/hub/fixtures/mps.json index c267d645a..d709eed7b 100644 --- a/hub/fixtures/mps.json +++ b/hub/fixtures/mps.json @@ -41,6 +41,29 @@ "last_update": "2022-10-10T13:20:30+00:00" } }, + { + "model": "hub.dataset", + "pk": 11, + "fields": { + "name": "party", + "data_type": "text", + "last_update": "2022-10-10T13:20:30+00:00", + "options": [ + { "title": "blue party", "shader": "#0000ff" }, + { "title": "red party", "shader": "#ff0000" } + ] + } + }, + { + "model": "hub.datatype", + "pk": 11, + "fields": { + "data_set": 11, + "name": "party", + "data_type": "text", + "last_update": "2022-10-10T13:20:30+00:00" + } + }, { "model": "hub.persondata", "pk": 1, @@ -49,6 +72,24 @@ "data_type": 1, "data": 1 } + }, + { + "model": "hub.persondata", + "pk": 100, + "fields": { + "person": 1, + "data_type": 11, + "data": "blue party" + } + }, + { + "model": "hub.persondata", + "pk": 101, + "fields": { + "person": 2, + "data_type": 11, + "data": "red party" + } } ] diff --git a/hub/mixins.py b/hub/mixins.py index aa3d5096a..bb891529a 100644 --- a/hub/mixins.py +++ b/hub/mixins.py @@ -218,4 +218,7 @@ def shader(self): try: return DataSet.objects.get(name=name) except DataSet.DoesNotExist: - return None + try: + return DataType.objects.get(name=name) + except DataType.DoesNotExist: + return None diff --git a/hub/models.py b/hub/models.py index d50d82db1..036f94b77 100644 --- a/hub/models.py +++ b/hub/models.py @@ -86,7 +86,153 @@ def value_col(self): return "data" -class DataSet(TypeMixin, models.Model): +class ShaderMixin: + shades = [ + "#ffffd9", + "#edf8b1", + "#c7e9b4", + "#7fcdbb", + "#41b6c4", + "#1d91c0", + "#225ea8", + "#253494", + "#081d58", + ] + + COLOUR_NAMES = { + "red-500": "#CC3517", + "orange-500": "#ED6832", + "yellow-500": "#FEC835", + "teal-600": "#068670", + "blue-500": "#21A8E0", + "purple-500": "#6F42C1", + "gray-500": "#ADB5BD", + "gray-300": "#DEE2E6", + } + + @property + def shader_table(self): + return self.table + + @property + def shader_filter(self): + return {"data_type__data_set": self} + + def shade(self, val, cmin, cmax): + if val == "": + return None + try: + x = float(val - cmin) / (cmax - cmin) + except ZeroDivisionError: + x = 0.5 # cmax == cmin + + shade = int(x * 9) - 1 + if shade < 0: + shade = 0 + return self.shades[shade] + + def colours_for_areas(self, areas): + if len(areas) == 0: + return {"properties": {"no_areas": True}} + + values, mininimum, maximum = self.shader_value(areas) + legend = {} + if hasattr(self, "options"): + for option in self.options: + if option.get("shader", None) is not None: + legend[option["title"]] = self.COLOUR_NAMES.get( + option["shader"], option["shader"] + ) + + if len(legend) > 0: + props = {"properties": {"legend": legend}} + else: + d_max = maximum + d_min = mininimum + if self.is_float: + d_max = round(maximum, 1) + d_min = round(mininimum, 1) + if self.is_percentage: + d_max = f"{d_max}%" + d_min = f"{d_min}%" + + props = { + "properties": { + "maximum": d_max, + "minimum": d_min, + "shades": self.shades, + } + } + colours = {} + for value in values: + data = value.value() + if hasattr(self, "options"): + for option in self.options: + if option["title"] == data: + colours[value.gss] = { + "colour": self.COLOUR_NAMES.get( + option["shader"], option["shader"] + ), + "opacity": value.opacity(mininimum, maximum) or 0.7, + "value": data, + "label": self.label, + } + + if colours.get(value.gss, None) is None: + shade = self.shade(data, mininimum, maximum) + if shade is not None: + colours[value.gss] = { + "colour": shade, + "opacity": 0.7, + "label": self.label, + "value": data, + } + + # if there is no data for an area then need to set the shader to opacity 0 otherwise + # they will end up as the default + missing = {} + for area in areas: + if colours.get(area.gss, None) is None: + missing[area.gss] = {"colour": "#ed6832", "opacity": 0} + + return {**colours, **missing, **props} + + def shader_value(self, area): + if self.shader_table == "areadata": + min_max = AreaData.objects.filter( + area__in=area, **self.shader_filter + ).aggregate( + max=models.Max(self.value_col), + min=models.Min(self.value_col), + ) + + data = ( + AreaData.objects.filter(area__in=area, **self.shader_filter) + .select_related("area", "data_type") + .annotate( + gss=models.F("area__gss"), + ) + ) + return data, min_max["min"], min_max["max"] + else: + min_max = PersonData.objects.filter( + person__area__in=area, **self.shader_filter + ).aggregate( + max=models.Max(self.value_col), + min=models.Min(self.value_col), + ) + + data = ( + PersonData.objects.filter(person__area__in=area, **self.shader_filter) + .select_related("person__area", "data_type") + .annotate(gss=models.F("person__area__gss")) + ) + return data, min_max["min"], min_max["max"] + + return None, None, None + + +class DataSet(TypeMixin, ShaderMixin, models.Model): SOURCE_CHOICES = [ ("csv", "CSV File"), ("xlxs", "Excel File"), @@ -251,144 +397,8 @@ class Meta: def filter(self, query, **kwargs): return Filter(self, query).run(**kwargs) - shades = [ - "#ffffd9", - "#edf8b1", - "#c7e9b4", - "#7fcdbb", - "#41b6c4", - "#1d91c0", - "#225ea8", - "#253494", - "#081d58", - ] - - COLOUR_NAMES = { - "red-500": "#CC3517", - "orange-500": "#ED6832", - "yellow-500": "#FEC835", - "teal-600": "#068670", - "blue-500": "#21A8E0", - "purple-500": "#6F42C1", - "gray-500": "#ADB5BD", - "gray-300": "#DEE2E6", - } - - def shade(self, val, cmin, cmax): - if val == "": - return None - try: - x = float(val - cmin) / (cmax - cmin) - except ZeroDivisionError: - x = 0.5 # cmax == cmin - - shade = int(x * 9) - 1 - if shade < 0: - shade = 0 - return self.shades[shade] - - def colours_for_areas(self, areas): - if len(areas) == 0: - return {"properties": {"no_areas": True}} - - values, mininimum, maximum = self.shader_value(areas) - legend = {} - for option in self.options: - if option.get("shader", None) is not None: - legend[option["title"]] = self.COLOUR_NAMES.get( - option["shader"], option["shader"] - ) - - if len(legend) > 0: - props = {"properties": {"legend": legend}} - else: - d_max = maximum - d_min = mininimum - if self.is_float: - d_max = round(maximum, 1) - d_min = round(mininimum, 1) - if self.is_percentage: - d_max = f"{d_max}%" - d_min = f"{d_min}%" - - props = { - "properties": { - "maximum": d_max, - "minimum": d_min, - "shades": self.shades, - } - } - colours = {} - for value in values: - data = value.value() - for option in self.options: - if option["title"] == data: - colours[value.gss] = { - "colour": self.COLOUR_NAMES.get( - option["shader"], option["shader"] - ), - "opacity": value.opacity(mininimum, maximum) or 0.7, - "value": data, - "label": self.label, - } - if colours.get(value.gss, None) is None: - shade = self.shade(data, mininimum, maximum) - if shade is not None: - colours[value.gss] = { - "colour": shade, - "opacity": 0.7, - "label": self.label, - "value": data, - } - - # if there is no data for an area then need to set the shader to opacity 0 otherwise - # they will end up as the default - missing = {} - for area in areas: - if colours.get(area.gss, None) is None: - missing[area.gss] = {"colour": "#ed6832", "opacity": 0} - - return {**colours, **missing, **props} - - def shader_value(self, area): - if self.table == "areadata": - min_max = AreaData.objects.filter( - area__in=area, data_type__data_set=self - ).aggregate( - max=models.Max(self.value_col), - min=models.Min(self.value_col), - ) - - data = ( - AreaData.objects.filter(area__in=area, data_type__data_set=self) - .select_related("area", "data_type") - .annotate( - gss=models.F("area__gss"), - ) - ) - return data, min_max["min"], min_max["max"] - else: - min_max = PersonData.objects.filter( - person__area__in=area, data_type__data_set=self - ).aggregate( - max=models.Max(self.value_col), - min=models.Min(self.value_col), - ) - - data = ( - PersonData.objects.filter( - person__area__in=area, data_type__data_set=self - ) - .select_related("person__area", "data_type") - .annotate(gss=models.F("person__area__gss")) - ) - return data, min_max["min"], min_max["max"] - - return None, None, None - - -class DataType(TypeMixin, models.Model): +class DataType(TypeMixin, ShaderMixin, models.Model): data_set = models.ForeignKey(DataSet, on_delete=models.CASCADE) name = models.CharField(max_length=50) data_type = models.CharField(max_length=20, choices=TypeMixin.TYPE_CHOICES) @@ -406,6 +416,14 @@ def __str__(self): return self.name + @property + def shader_table(self): + return self.data_set.table + + @property + def shader_filter(self): + return {"data_type": self} + class UserDataSets(models.Model): data_set = models.ForeignKey(DataSet, on_delete=models.CASCADE) diff --git a/hub/static/js/explore.esm.js b/hub/static/js/explore.esm.js index aba8a6cdc..b450a3c2c 100644 --- a/hub/static/js/explore.esm.js +++ b/hub/static/js/explore.esm.js @@ -192,6 +192,10 @@ const app = createApp({ addShader(datasetName) { this.shader = this.getDataset(datasetName) + if (!this.shader.selectedType && this.shader.types) { + this.shader.selectedType = this.shader.types[0].name + } + trackEvent('explore_shader_added', { 'dataset': datasetName }); @@ -220,7 +224,13 @@ const app = createApp({ } }) - if (this.shader) { state['shader'] = this.shader.name } + if (this.shader) { + if (this.shader.selectedType) { + state['shader'] = this.shader.selectedType + } else { + state['shader'] = this.shader.name + } + } // don’t bother saving view unless it’s been changed from default if ( this.view != 'map' ) { state['view'] = this.view } @@ -500,7 +510,7 @@ const app = createApp({ case 'filter': return dataset.is_filterable case 'shader': - return dataset.is_shadable + return ["party", "constituency_ruc"].includes(dataset.name) || !["text", "json", "date", "profile_id"].includes(dataset.data_type) default: return true } diff --git a/hub/templates/hub/explore.html b/hub/templates/hub/explore.html index 31bcceece..452b5f238 100644 --- a/hub/templates/hub/explore.html +++ b/hub/templates/hub/explore.html @@ -102,6 +102,11 @@