Skip to content

Commit

Permalink
DATASHADES-327 / implement observable plot chart support
Browse files Browse the repository at this point in the history
  • Loading branch information
mutantsan committed May 28, 2024
1 parent 38f01db commit a028a5e
Show file tree
Hide file tree
Showing 20 changed files with 1,004 additions and 427 deletions.
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,131 @@ To register new fetchers, you need to create a new class that inherits from `Dat
The `fetch_data` method should return a `pandas` `DataFrame` object with the data that should be displayed in the chart.
The `make_cache_key` method should return a unique string that will be used as a key to store the data in the cache.

## Implementing new chart engines support

Implementing support for a new chart engine includes multiple steps and changes in Python, HTML, and JavaScript. Starting from the Python code:

1. Create a new builder class at `ckanext.charts.chart_builder` that inherits from `BaseChartBuilder` and implements the `get_supported_forms` method. This method must return a list of classes that represent supported chart types forms.

2. Each form type builder must be connected with a respective chart type builder.

3. The chart type builder must implement a `to_json` method that will return a dumped JSON data, which will be passed to a JS script.

4. The form type builder must implement a `get_form_fields` method that will return a list of all form fields that will be rendered for the user, allowing them to provide all the necessary information for a chart.

5. Register your chart engine by adding the builder class to `get_chart_engines` in `ckanext.charts.chart_builder.__init__.py`.

A full example of an implementation of `bar` chart for `obvervable plot` library.

```py
from __future__ import annotations

import json
from typing import Any

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


class ObservableBuilder(BaseChartBuilder):
@classmethod
def get_supported_forms(cls) -> list[type[Any]]:
return [ObservableBarForm]


class ObservableBarBuilder(ObservableBuilder):
def to_json(self) -> str:
return json.dumps(
{
"type": "bar",
"data": self.df.to_dict(orient="records"),
"settings": self.settings,
}
)


class ObservableBarForm(BaseChartForm):
name = "Bar"
builder = ObservableBarBuilder

def fill_field(self, choices: list[dict[str, str]]) -> dict[str, str]:
field = self.color_field(choices)
field.update({"field_name": "fill", "label": "Fill"})

return field

def get_form_fields(self):
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.x_axis_field(columns),
self.y_axis_field(columns),
self.fill_field(columns),
self.opacity_field(),
self.limit_field(),
]
```

Another step is to register JS/CSS vendor libraries of the chart you want to use. Refer to (CKAN documentation)[https://docs.ckan.org/en/latest/theming/webassets.html] to read about adding CSS and JavaScript files using Webassets.

You also will need a CKAN JS module, that will be responsible for rendering the Chart. This module must be registered inside a `webassets.yml` as well.
```js
ckan.module("charts-render-observable", function ($, _) {
"use strict";

return {
options: {
config: null
},

initialize: function () {
$.proxyAll(this, /_/);

if (!this.options.config) {
console.error("No configuration provided");
return;
}

var plot;

switch (this.options.config.type) {
case "bar":
plot = Plot.barY(this.options.config.data, this.options.config.settings).plot();
break;
default:
return;
}

this.el[0].replaceChildren(plot);
}
};
});
```

And an HTML file, that will provide a proper container and include your JS module with `data-module`.

```html
{% asset "charts/observable" %}

{% if chart %}
<div id="chart-container" data-module="charts-render-observable" data-module-config="{{ chart }}"></div>
{% else %}
<p class="text-muted">
{{ _("Cannot build chart with current settings") }}
</p>
{% endif %}
```

Note, that we should add `{% asset "charts/observable" %}` not only here, but in `charts_form.html` too.

## Developer installation

To install ckanext-charts for development, activate your CKAN virtualenv and
Expand Down
3 changes: 0 additions & 3 deletions ckanext/charts/assets/js/charts-render-chartjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ ckan.module("charts-render-chartjs", function ($, _) {
initialize: function () {
$.proxyAll(this, /_/);

console.log(this.options.config);


if (!this.options.config) {
console.error("No configuration provided");
return;
Expand Down
144 changes: 144 additions & 0 deletions ckanext/charts/assets/js/charts-render-observable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
ckan.module("charts-render-observable", function ($, _) {
"use strict";

return {
options: {
config: null
},

initialize: function () {
$.proxyAll(this, /_/);

if (!this.options.config) {
console.error("No configuration provided");
return;
}

var plot;

switch (this.options.config.type) {
case "bar":
plot = Plot.barY(this.options.config.data, this.options.config.settings).plot();
break;
case "horizontal-bar":
plot = Plot.barX(this.options.config.data, this.options.config.settings).plot();
break;
case "scatter":
plot = Plot.dot(this.options.config.data, this.options.config.settings).plot();
break;
case "line":
plot = Plot.line(this.options.config.data, this.options.config.settings).plot();
break;
case "pie":
plot = PieChart(this.options.config.data, this.options.config.settings);
break;
case "auto":
plot = Plot.auto(this.options.config.data, this.options.config.settings).plot();
break;
default:
return;
}

this.el[0].replaceChildren(plot);
}
};
});

// Copyright 2018-2023 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/pie-chart

function PieChart(data, {
names, // given d in data, returns the (ordinal) label
values, // given d in data, returns the (quantitative) value
title, // given d in data, returns the title text
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
innerRadius = 0, // inner radius of pie, in pixels (non-zero for donut)
outerRadius = Math.min(width, height) / 2, // outer radius of pie, in pixels
labelRadius = (innerRadius * 0.2 + outerRadius * 0.8), // center radius of labels
format = ",", // a format specifier for values (in the label)
// names, // array of names (the domain of the color scale)
colors, // array of colors for names
stroke = innerRadius > 0 ? "none" : "white", // stroke separating widths
strokeWidth = 1, // width of stroke separating wedges
strokeLinejoin = "round", // line join of stroke separating wedges
padAngle = stroke === "none" ? 1 / outerRadius : 0, // angular separation between wedges, in radians
opacity = 1, // opacity of svg
fontSize = 12 // font size of labels
} = {}) {
// Compute values.
console.log(strokeWidth);
const N = d3.map(data, (data) => data[names]);
const V = d3.map(data, (data) => data[values]);

const I = d3.range(N.length).filter(i => !isNaN(V[i]));

// Unique the names.
if (names === undefined) names = N;
names = new d3.InternSet(names);

// Chose a default color scheme based on cardinality.
if (colors === undefined) colors = d3.schemeSpectral[names.size];
if (colors === undefined) colors = d3.quantize(t => d3.interpolateSpectral(t * 0.8 + 0.1), names.size);

// Construct scales.
const color = d3.scaleOrdinal(names, colors);

// Compute titles.
if (title === undefined) {
const formatValue = d3.format(format);
title = i => `${N[i]}\n${formatValue(V[i])}`;
} else {
const O = d3.map(data, d => d);
const T = title;
title = i => T(O[i], i, data);
}

// Construct arcs.
const arcs = d3.pie().padAngle(padAngle).sort(null).value(i => V[i])(I);
const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);
const arcLabel = d3.arc().innerRadius(labelRadius).outerRadius(labelRadius);

const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");

svg.append("g")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-linejoin", strokeLinejoin)
.selectAll("path")
.data(arcs)
.join("path")
.attr("fill", d => color(N[d.data]))
.attr("d", arc)
.append("title")
.text(d => title(d.data));

svg.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", fontSize)
.attr("text-anchor", "middle")
.selectAll("text")
.data(arcs)
.join("text")
.attr("transform", d => `translate(${arcLabel.centroid(d)})`)
.selectAll("tspan")
.data(d => {
const lines = `${title(d.data)}`.split(/\n/);
return (d.endAngle - d.startAngle) > 0.25 ? lines : lines.slice(0, 1);
})
.join("tspan")
.attr("x", 0)
.attr("y", (_, i) => `${i * 1.1}em`)
.attr("font-weight", (_, i) => i ? null : "bold")
.text(d => d);

const resultSvg = Object.assign(svg.node(), { scales: { color } });
resultSvg.setAttribute("opacity", opacity);

return resultSvg;
}
2 changes: 0 additions & 2 deletions ckanext/charts/assets/js/charts-render-plotly.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ ckan.module("charts-render-plotly", function ($, _) {
initialize: function () {
$.proxyAll(this, /_/);

console.log(this.options.config);

if (!this.options.config) {
console.error("No configuration provided");
return;
Expand Down
7 changes: 0 additions & 7 deletions ckanext/charts/assets/js/vendor/chartjs.min.js

This file was deleted.

2 changes: 2 additions & 0 deletions ckanext/charts/assets/js/vendor/d3.min.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions ckanext/charts/assets/js/vendor/observable.min.js

Large diffs are not rendered by default.

33 changes: 21 additions & 12 deletions ckanext/charts/assets/webassets.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
chartjs:
# chartjs:
# filter: rjsmin
# output: ckanext-charts/%(version)s-chartjs.js
# contents:
# - js/vendor/chartjs.min.js

# - js/charts-chartjs.js
# - js/charts-render-chartjs.js
# extra:
# preload:
# - base/main

plotly:
filter: rjsmin
output: ckanext-charts/%(version)s-chartjs.js
output: ckanext-charts/%(version)s-plotly.js
contents:
- js/vendor/chartjs.min.js

- js/charts-chartjs.js
- js/charts-render-chartjs.js
- js/vendor/plotly.min.js
- js/charts-render-plotly.js
extra:
preload:
- base/main

plotly:
observable:
filter: rjsmin
output: ckanext-charts/%(version)s-chartjs-plotly.js
output: ckanext-charts/%(version)s-observable.js
contents:
- js/vendor/plotly.min.js

# - js/charts-form.js
- js/charts-render-plotly.js
- js/vendor/d3.min.js
- js/vendor/observable.min.js
- js/charts-render-observable.js
extra:
preload:
- base/main
Expand Down
6 changes: 5 additions & 1 deletion ckanext/charts/chart_builders/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from __future__ import annotations

from .base import BaseChartBuilder
from .chartjs import ChartJSBuilder
from .plotly import PlotlyBuilder, PlotlyBarForm
from .observable import ObservableBuilder


DEFAULT_CHART_FORM = PlotlyBarForm


def get_chart_engines() -> dict[str, type[BaseChartBuilder]]:
return {
"plotly": PlotlyBuilder,
"observable": ObservableBuilder,
}
Loading

0 comments on commit a028a5e

Please sign in to comment.