Skip to content

Commit

Permalink
feat: add choropleth chart to plotly, refactor, add more tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mutantsan committed Jan 16, 2025
1 parent 998e0e4 commit 87cf995
Show file tree
Hide file tree
Showing 44 changed files with 3,553 additions and 1,161 deletions.
3 changes: 2 additions & 1 deletion ckanext/charts/chart_builders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from .base import BaseChartBuilder
from .chartjs import ChartJSBarBuilder
from .observable import ObservableBuilder
from .plotly import PlotlyBarForm, PlotlyBuilder
from .plotly import PlotlyBuilder
from .plotly.bar import PlotlyBarForm

DEFAULT_CHART_FORM = PlotlyBarForm

Expand Down
50 changes: 32 additions & 18 deletions ckanext/charts/chart_builders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ckan import types

from ckanext.charts import const, fetchers
from ckanext.charts.exception import ChartTypeNotImplementedError
from ckanext.charts.exception import ChartTypeNotImplementedError, ChartBuildError


class FilterDecoder:
Expand Down Expand Up @@ -176,11 +176,19 @@ def get_unique_values(self, column: pd.Series, sort: bool = True) -> list[Any]:
class BaseChartForm(ABC):
name = ""

def __init__(self, resource_id: str) -> None:
try:
self.df = fetchers.DatastoreDataFetcher(resource_id).fetch_data()
except tk.ValidationError:
return
def __init__(
self, resource_id: str | None = None, dataframe: pd.DataFrame | None = None
) -> None:
if dataframe is not None:
self.df = dataframe
else:
if not resource_id:
raise ChartBuildError("Resource ID is required")

try:
self.df = fetchers.DatastoreDataFetcher(resource_id).fetch_data()
except tk.ValidationError:
return

def get_validator(self, name: str) -> types.ValueValidator:
"""Get the validator by name. Replaces the tk.get_validator to get rid
Expand Down Expand Up @@ -242,15 +250,20 @@ def drop_validators(self, fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
def get_validation_schema(self, for_show: bool = False) -> dict[str, Any]:
fields = self.get_form_fields()

return {
field["field_name"]: (
field["validators"]
if not for_show
else field.get("output_validators", field["validators"])
)
for field in fields
if "validators" in field
}
try:
validation_schema = {
field["field_name"]: (
field["validators"]
if not for_show
else field.get("output_validators", field["validators"])
)
for field in fields
if "validators" in field
}
except KeyError:
raise ChartBuildError("Form field missing 'field_name' key")

return validation_schema

def get_fields_by_tab(self, tab: str) -> list[dict[str, Any]]:
fields = self.get_expanded_form_fields()
Expand Down Expand Up @@ -280,9 +293,10 @@ def title_field(self) -> dict[str, Any]:
"form_placeholder": "Chart title",
"group": "General",
"type": "str",
"default": "Chart",
"help_text": "Title of the chart view",
"validators": [
self.get_validator("ignore_empty"),
self.get_validator("default")("Chart"),
self.get_validator("unicode_safe"),
],
}
Expand Down Expand Up @@ -737,7 +751,7 @@ def names_field(self, choices: list[dict[str, str]]) -> dict[str, Any]:
return field

def width_field(self) -> dict[str, Any]:
"""The limit field represent an amount of rows to show in the chart."""
"""Width of the chart."""
return {
"field_name": "width",
"label": "Width",
Expand All @@ -755,7 +769,7 @@ def width_field(self) -> dict[str, Any]:
}

def height_field(self) -> dict[str, Any]:
"""The limit field represent an amount of rows to show in the chart."""
"""Height of the chart."""
return {
"field_name": "height",
"label": "Height",
Expand Down
3 changes: 3 additions & 0 deletions ckanext/charts/chart_builders/plotly/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from __future__ import annotations

from .base import PlotlyBuilder # noqa
128 changes: 128 additions & 0 deletions ckanext/charts/chart_builders/plotly/bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from __future__ import annotations

from typing import Any

import plotly.express as px

from .base import PlotlyBuilder, BasePlotlyForm


class PlotlyBarBuilder(PlotlyBuilder):
def to_json(self) -> str:
return self.build_bar_chart()

def build_bar_chart(self) -> Any:
if self.settings.get("skip_null_values"):
self.df = self.df[self.df[self.settings["y"]].notna()]

fig = px.bar(
data_frame=self.df,
x=self.settings["x"],
y=self.settings["y"],
log_x=self.settings.get("log_x", False),
log_y=self.settings.get("log_y", False),
opacity=self.settings.get("opacity"),
animation_frame=self.settings.get("animation_frame"),
color=self.settings.get("color"),
)

fig.update_xaxes(
type="category",
)

if chart_title := self.settings.get("chart_title"):
fig.update_layout(title_text=chart_title)

if x_axis_label := self.settings.get("x_axis_label"):
fig.update_xaxes(title_text=x_axis_label)
else:
fig.update_xaxes(title_text=self.settings["x"])

if y_axis_label := self.settings.get("y_axis_label"):
fig.update_yaxes(title_text=y_axis_label)
else:
fig.update_yaxes(title_text=self.settings["y"][0])

return fig.to_json()


class PlotlyBarForm(BasePlotlyForm):
name = "Bar"
builder = PlotlyBarBuilder

def get_form_fields(self):
"""Get the form fields for the Plotly bar chart."""
columns = [{"value": col, "label": col} for col in self.df.columns]
chart_types = [
{"value": form.name, "label": form.name}
for form in self.builder.get_supported_forms()
]

return [
self.title_field(),
self.description_field(),
self.engine_field(),
self.type_field(chart_types),
self.engine_details_field(),
self.x_axis_field(columns),
self.y_axis_field(columns),
self.more_info_button_field(),
self.log_x_field(),
self.log_y_field(),
self.sort_x_field(),
self.sort_y_field(),
self.skip_null_values_field(),
self.limit_field(maximum=1000000),
self.chart_title_field(),
self.x_axis_label_field(),
self.y_axis_label_field(),
self.color_field(columns),
self.animation_frame_field(columns),
self.opacity_field(),
self.filter_field(columns),
]


class PlotlyHorizontalBarBuilder(PlotlyBuilder):
def to_json(self) -> Any:
return self.build_horizontal_bar_chart()

def build_horizontal_bar_chart(self) -> Any:
if self.settings.get("skip_null_values"):
self.df = self.df[self.df[self.settings["y"]].notna()]

fig = px.bar(
data_frame=self.df,
x=self.settings["y"],
y=self.settings["x"],
log_x=self.settings.get("log_y", False),
log_y=self.settings.get("log_x", False),
opacity=self.settings.get("opacity"),
animation_frame=self.settings.get("animation_frame"),
color=self.settings.get("color"),
orientation="h",
)

fig.update_yaxes(
type="category",
)

if chart_title := self.settings.get("chart_title"):
fig.update_layout(title_text=chart_title)

if x_axis_label := self.settings.get("x_axis_label"):
fig.update_xaxes(title_text=x_axis_label)
else:
fig.update_xaxes(title_text=self.settings["y"])

if y_axis_label := self.settings.get("y_axis_label"):
fig.update_yaxes(title_text=y_axis_label)
else:
fig.update_yaxes(title_text=self.settings["x"])

return fig.to_json()


class PlotlyHorizontalBarForm(PlotlyBarForm):
name = "Horizontal Bar"
builder = PlotlyHorizontalBarBuilder
38 changes: 38 additions & 0 deletions ckanext/charts/chart_builders/plotly/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from typing import Any

from ckanext.charts.chart_builders.base import BaseChartBuilder, BaseChartForm


class PlotlyBuilder(BaseChartBuilder):
"""Base class for Plotly chart builders.
Defines supported chart types for Plotly engine.
"""

DEFAULT_NAN_FILL_VALUE = 0

@classmethod
def get_supported_forms(cls) -> list[type[Any]]:
from ckanext.charts.chart_builders.plotly.choropleth import PlotlyChoroplethForm
from ckanext.charts.chart_builders.plotly.line import PlotlyLineForm
from ckanext.charts.chart_builders.plotly.pie import PlotlyPieForm
from ckanext.charts.chart_builders.plotly.scatter import PlotlyScatterForm
from ckanext.charts.chart_builders.plotly.bar import (
PlotlyBarForm,
PlotlyHorizontalBarForm,
)

return [
PlotlyBarForm,
PlotlyHorizontalBarForm,
PlotlyPieForm,
PlotlyLineForm,
PlotlyScatterForm,
PlotlyChoroplethForm,
]


class BasePlotlyForm(BaseChartForm):
pass
Loading

0 comments on commit 87cf995

Please sign in to comment.