Skip to content

Commit

Permalink
WIP: Matplotlib for plotting (#645)
Browse files Browse the repository at this point in the history
* remove the heavy seaborn dependency in favor of matplotlib
* remove pie charts
* bar charts
  • Loading branch information
chrisclark authored Jul 13, 2024
1 parent ac228fa commit e405efc
Show file tree
Hide file tree
Showing 7 changed files with 80 additions and 154 deletions.
11 changes: 6 additions & 5 deletions docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,18 @@ Pivot Table
Displaying query results as charts
----------------------------------

If the results table adheres to a certain format, the results can be displayed as a pie chart or a line chart.
If the results table has numeric columns, they can be displayed in a bar chart. The first column will always be used
as the x-axis labels. This is quite basic, but can be useful for quick visualization.

To enable this feature, set ``EXPLORER_CHARTS_ENABLED`` setting to ``True`` and install the plotting libraries ``matplotlib`` and ``seaborn`` with
To enable this feature, set ``EXPLORER_CHARTS_ENABLED`` setting to ``True`` and install the plotting library
``matplotlib`` with:

.. code-block:: console
pip install "django-sql-explorer[charts]"
This will add the "Pie chart" and the "Line chart" tabs alongside the "Preview" and the "Pivot" tabs in the query results view.

The tabs show the respective charts if the query result table adheres to a format which the chart widget can read. Otherwise a message explaining the required format together with an example query is displayed.
This will add the "Line chart" and "Bar chart" tabs alongside the "Preview" and the "Pivot" tabs in the query results
view.

Query Logs
----------
Expand Down
99 changes: 35 additions & 64 deletions explorer/charts.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,24 @@
from io import BytesIO
from typing import Iterable, Optional

from django.core.exceptions import ImproperlyConfigured

from explorer import app_settings


if app_settings.EXPLORER_CHARTS_ENABLED:
try:
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.figure import Figure
except ImportError as err:
raise ImproperlyConfigured(
"If `EXPLORER_CHARTS_ENABLED` is enabled, `matplotlib` and `seaborn` must be installed."
) from err

from .models import QueryResult

BAR_WIDTH = 0.2

def get_pie_chart(result: QueryResult) -> Optional[str]:
"""
Return a pie chart in SVG format if the result table adheres to the expected format.
A pie chart is rendered if
* there is at least on row of in the result table
* the result table has at least two columns
* the second column is of a numeric type

The first column is used as labels for the pie sectors.
The second column is used to determine the size of the sectors
(hence the requirement of it being numeric).
All other columns are ignored.
def get_chart(result: QueryResult, chart_type: str) -> Optional[str]:
import matplotlib.pyplot as plt
"""
if len(result.data) < 1 or len(result.data[0]) < 2:
return None
not_none_rows = [row for row in result.data if row[0] is not None and row[1] is not None]
labels = [row[0] for row in not_none_rows]
values = [row[1] for row in not_none_rows]
if not is_numeric(values):
return None
fig, ax = plt.subplots(figsize=(4.5, 4.5))
ax.pie(values, labels=labels)
return get_svg(fig)


def get_line_chart(result: QueryResult) -> Optional[str]:
"""
Return a line chart in SVG format if the result table adheres to the expected format.
A line chart is rendered if
* there is at least on row of in the result table
* there is at least one numeric column (the first column (with index 0) does not count)
The first column is used as x-axis labels.
All other numeric columns represent a line on the chart.
The name of the column is used as the name of the line in the legend.
Not numeric columns (except the first on) are ignored.
Return a line or bar chart in SVG format if the result table adheres to the expected format.
A line chart is rendered if
* there is at least on row of in the result table
* there is at least one numeric column (the first column (with index 0) does not count)
The first column is used as x-axis labels.
All other numeric columns represent a line on the chart.
The name of the column is used as the name of the line in the legend.
Not numeric columns (except the first on) are ignored.
"""
if chart_type not in ("bar", "line"):
return
if len(result.data) < 1:
return None
numeric_columns = [
Expand All @@ -68,20 +29,33 @@ def get_line_chart(result: QueryResult) -> Optional[str]:
return None
labels = [row[0] for row in result.data]
fig, ax = plt.subplots(figsize=(10, 3.8))
for col_num in numeric_columns:
sns.lineplot(ax=ax,
x=labels,
y=[row[col_num] for row in result.data],
label=result.headers[col_num])
bars = []
bar_positions = []
for idx, col_num in enumerate(numeric_columns):
if chart_type == "bar":
values = [row[col_num] for row in result.data]
bar_container = ax.bar([x + idx * BAR_WIDTH
for x in range(len(labels))], values, BAR_WIDTH, label=result.headers[col_num])
bars.append(bar_container)
bar_positions.append([(rect.get_x(), rect.get_height()) for rect in bar_container])
if chart_type == "line":
ax.plot(labels, [row[col_num] for row in result.data], label=result.headers[col_num])

ax.set_xlabel(result.headers[0])
# Rotate x-axis labels by 20 degrees to reduce overlap

if chart_type == "bar":
ax.set_xticks([x + BAR_WIDTH * (len(numeric_columns) / 2 - 0.5) for x in range(len(labels))])
ax.set_xticklabels(labels)

ax.legend()
for label in ax.get_xticklabels():
label.set_rotation(20)
label.set_rotation(20) # makes labels fit better
label.set_ha("right")
return get_svg(fig)
svg_str = get_svg(fig)
return svg_str


def get_svg(fig: "Figure") -> str:
def get_svg(fig) -> str:
buffer = BytesIO()
fig.savefig(buffer, format="svg")
buffer.seek(0)
Expand All @@ -91,7 +65,4 @@ def get_svg(fig: "Figure") -> str:


def is_numeric(column: Iterable) -> bool:
"""
Indicate if all the values in the given column are numeric or None.
"""
return all([isinstance(value, (int, float)) or value is None for value in column])
106 changes: 27 additions & 79 deletions explorer/templates/explorer/preview_pane.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
{% endif %}
{% if data %}
<button class="nav-link" id="nav-pivot-tab" data-bs-toggle="tab" data-bs-target="#nav-pivot" type="button" role="tab" area-controls="nav-pivot" aria-selected="false">{% translate "Pivot" %}</button>
{% if charts_enabled %}
<button class="nav-link" id="nav-piechart-tab" data-bs-toggle="tab" data-bs-target="#nav-piechart" type="button" role="tab" area-controls="nav-piechart" aria-selected="false">{% translate "Pie Chart" %}</button>
{% if charts_enabled and line_chart_svg %}
<button class="nav-link" id="nav-linechart-tab" data-bs-toggle="tab" data-bs-target="#nav-linechart" type="button" role="tab" area-controls="nav-linechart" aria-selected="false">{% translate "Line Chart" %}</button>
<button class="nav-link" id="nav-barchart-tab" data-bs-toggle="tab" data-bs-target="#nav-barchart" type="button" role="tab" area-controls="nav-barchart" aria-selected="false">{% translate "Bar Chart" %}</button>
{% endif %}
{% endif %}
</div>
Expand Down Expand Up @@ -129,89 +129,37 @@ <h3>{{ snapshots|length }} Snapshots <small>(oldest first)</small></h3>

{% if data %}
<div class="tab-pane" id="nav-pivot" role="tabpanel" area-labelledby="nav-pivot-tab">
<div class="card p-3">
<ul class="nav justify-content-end">
<li class="nav-item">
<a id="pivot-bookmark" class="nav-link"
data-baseurl="{% url 'explorer_playground' %}?querylog_id={{ ql_id }}"
href="#">
<i class="bi-link"></i> Link to this
</a>
</li>
<li class="nav-item">
<a id="button-excel" class="nav-link" href="#"><i class="bi-download"></i> Download CSV</a>
</li>
</ul>
<div class="overflow-wrapper">
<div class="pivot-table"></div>
<div class="card p-3">
<ul class="nav justify-content-end">
<li class="nav-item">
<a id="pivot-bookmark" class="nav-link"
data-baseurl="{% url 'explorer_playground' %}?querylog_id={{ ql_id }}"
href="#">
<i class="bi-link"></i> Link to this
</a>
</li>
<li class="nav-item">
<a id="button-excel" class="nav-link" href="#"><i class="bi-download"></i> Download CSV</a>
</li>
</ul>
<div class="overflow-wrapper">
<div class="pivot-table"></div>
</div>
</div>
</div>
</div>
{% if charts_enabled %}
<div class="tab-pane" id="nav-piechart" role="tabpanel" area-labelledby="nav-piechart-tab">
{% if charts_enabled and line_chart_svg %}
<div class="tab-pane" id="nav-linechart" role="tabpanel" area-labelledby="nav-linechart-tab">
<div class="overflow-wrapper">
{% if pie_chart_svg %}
<div style="margin: 2em;">
{{ pie_chart_svg | safe }}
</div>
{% else %}
<div style="margin: 6em;">
<p>{% blocktranslate trimmed %}
This query result table is not formatted in a way
which could be displayed as a pie chart.
{% endblocktranslate %}</p>
<p>{% blocktranslate trimmed %}
Query results can be displayed as a pie chart as follows:
each row represents one sector of the pie;
the first column will be used as a label
while the second column is used to determine the size of the sector.
Thus the second column must be of a numeric type.
Rows which contain <code>NULL</code>s will be ignored.
{% endblocktranslate %}</p>
<p>{% blocktranslate trimmed %}
Use this sample query to see it in action:
{% endblocktranslate %}</p>
<pre>
SELECT *
FROM (VALUES ('apple', 7),
('orange', 8),
('grape', 9),
('peppermint', 1))
AS fruit_salad_proportions;</pre>
</div>
{% endif %}
<div style="margin: 2em;">
{{ line_chart_svg | safe }}
</div>
</div>
</div>
<div class="tab-pane" id="nav-linechart" role="tabpanel" area-labelledby="nav-linechart-tab">
<div class="tab-pane" id="nav-barchart" role="tabpanel" area-labelledby="nav-barchart-tab">
<div class="overflow-wrapper">
{% if line_chart_svg %}
<div style="margin: 2em;">
{{ line_chart_svg | safe }}
</div>
{% else %}
<div style="margin: 6em;">
<p>{% blocktranslate trimmed %}
This query result table is not formatted in a way
which could be displayed as a line chart.
{% endblocktranslate %}</p>
<p>{% blocktranslate trimmed %}
Query results can be displayed as a line chart as follows:
the first column represents the values on the x-axis (e.g. dates).
All other numeric columns represent one line on the chart.
Other columns will be ignored.
{% endblocktranslate %}</p>
<p>{% blocktranslate trimmed %}
Use this sample query to see it in action:
{% endblocktranslate %}</p>
<pre>
SELECT *
FROM (VALUES ('2019-01-01'::date, 500,550,530),
('2020-01-01'::date, 530, 580, 570),
('2021-01-01'::date, 580, 590, 670),
('2022-01-01'::date, 700, 620, 780))
AS fruit_salad_proportions(date, generosity, joy, happiness);</pre>
</div>
{% endif %}
<div style="margin: 2em;">
{{ bar_chart_svg | safe }}
</div>
</div>
</div>
{% endif %}
Expand Down
1 change: 1 addition & 0 deletions explorer/tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
EXPLORER_ENABLE_ANONYMOUS_STATS = False
EXPLORER_TASKS_ENABLED = True # set to true to test async tasks
EXPLORER_AI_API_KEY = None # set to any value to enable assistant
EXPLORER_CHARTS_ENABLED = False
CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_TASK_ALWAYS_EAGER = True
TEST_MODE = True
Expand Down
13 changes: 9 additions & 4 deletions explorer/views/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.db import DatabaseError

from explorer import app_settings
from explorer.charts import get_line_chart, get_pie_chart
from explorer.charts import get_chart
from explorer.models import QueryFavorite
from explorer.schema import schema_json_info

Expand Down Expand Up @@ -42,6 +42,13 @@ def query_viewmodel(request, query, title=None, form=None, message=None,
if user.is_authenticated and query.pk:
is_favorite = QueryFavorite.objects.filter(user=user, query=query).exists()

charts = {"line_chart_svg": None,
"bar_chart_svg": None}

if app_settings.EXPLORER_CHARTS_ENABLED and has_valid_results:
charts["line_chart_svg"] = get_chart(res,"line")
charts["bar_chart_svg"] = get_chart(res,"bar")

ret = {
"tasks_enabled": app_settings.ENABLE_TASKS,
"params": query.available_params_w_labels(),
Expand All @@ -65,10 +72,8 @@ def query_viewmodel(request, query, title=None, form=None, message=None,
"unsafe_rendering": app_settings.UNSAFE_RENDERING,
"fullscreen_params": fullscreen_params.urlencode(),
"charts_enabled": app_settings.EXPLORER_CHARTS_ENABLED,
"pie_chart_svg": get_pie_chart(res) if app_settings.EXPLORER_CHARTS_ENABLED and has_valid_results else None,
"line_chart_svg": get_line_chart(res) if app_settings.EXPLORER_CHARTS_ENABLED and has_valid_results else None,
"is_favorite": is_favorite,
"show_sql_by_default": app_settings.EXPLORER_SHOW_SQL_BY_DEFAULT,
"schema_json": schema_json_info(query.connection if query else None),
}
return ret
return {**ret, **charts}
3 changes: 1 addition & 2 deletions requirements/extra/charts.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
matplotlib<4
seaborn<0.12
matplotlib>=3.9
1 change: 1 addition & 0 deletions test_project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,4 @@
EXPLORER_ASSISTANT_BASE_URL = os.environ.get("AI_BASE_URL")
EXPLORER_DB_CONNECTIONS_ENABLED = True
EXPLORER_USER_UPLOADS_ENABLED = True
EXPLORER_CHARTS_ENABLED = True

0 comments on commit e405efc

Please sign in to comment.