Repository: rclement/datasette-dashboards (language: python)

Issue:
Add support for Vega charts
Only Vega-Lite charts are currently supported using the `vega` library. It would be useful to support both specifications.


Discussion / hints:
Here are some examples of Vega charts: https://vega.github.io/vega/examples/
Here are some examples of Vega-Lite charts: https://vega.github.io/vega-lite/examples/
It can be safe to say the current `vega` library should be renamed to `vega-lite` to avoid confusion...

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13970fa..0b73a33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added
 - Select filter type with static and dynamic values
+- BREAKING: Support for Vega charts with the new `vega` library
+
+### Changed
+- BREAKING: Renamed `vega` library to `vega-lite`
 
 ### Fixed
 - Allow quotes in chart SQL query
diff --git a/README.md b/README.md
index 31d9d1c..95ea4b5 100644
--- a/README.md
+++ b/README.md
@@ -111,13 +111,13 @@ Dashboard filters:
 
 Common chart properties for all chart types:
 
-| Property  | Type     | Description                                              |
-| --------- | -------- | -------------------------------------------------------- |
-| `title`   | `string` | Chart title                                              |
-| `db`      | `string` | Database name against which to run the query             |
-| `query`   | `string` | SQL query to run and extract data from                   |
-| `library` | `string` | One of supported libraries: `vega`, `markdown`, `metric` |
-| `display` | `object` | Chart display specification (depend on the used library) |
+| Property  | Type     | Description                                                           |
+| --------- | -------- | --------------------------------------------------------------------- |
+| `title`   | `string` | Chart title                                                           |
+| `db`      | `string` | Database name against which to run the query                          |
+| `query`   | `string` | SQL query to run and extract data from                                |
+| `library` | `string` | One of supported libraries: `vega`, `vega-lite`, `markdown`, `metric` |
+| `display` | `object` | Chart display specification (depend on the used library)              |
 
 To define SQL queries using dashboard filters:
 
@@ -140,8 +140,24 @@ Available configuration for `vega` charts:
 
 Notes about the `display` property:
 
-- Requires a valid [Vega specification object](https://vega.github.io/vega-lite/docs/)
-- Some fields are pre-defined: `$schema`, `title`, `width`, `view`, `config`, `data`
+- Requires a valid [Vega specification object](https://vega.github.io/vega/docs/)
+- Some fields are pre-defined: `$schema`, `description`, `autosize`, `data`, `signals`
+- All fields are passed along as-is (overriding pre-defined fields if any)
+- Only `mark` and `encoding` fields are required as the bare-minimum
+
+#### Vega-Lite properties
+
+Available configuration for `vega-lite` charts:
+
+| Property  | Type     | Description                |
+| --------- | -------- | -------------------------- |
+| `library` | `string` | Must be set to `vega-lite` |
+| `display` | `object` | Vega specification object  |
+
+Notes about the `display` property:
+
+- Requires a valid [Vega-Lite specification object](https://vega.github.io/vega-lite/docs/)
+- Some fields are pre-defined: `$schema`, `description`, `width`, `view`, `config`, `data`
 - All fields are passed along as-is (overriding pre-defined fields if any)
 - Only `mark` and `encoding` fields are required as the bare-minimum
 
diff --git a/datasette_dashboards/static/dashboards.js b/datasette_dashboards/static/dashboards.js
index 2e1f490..cf71f90 100644
--- a/datasette_dashboards/static/dashboards.js
+++ b/datasette_dashboards/static/dashboards.js
@@ -1,11 +1,56 @@
-function renderVegaChart(el, chart, query_string, height_style = undefined) {
+function renderVegaChart(el, chart, query_string, full_height) {
+  const query = encodeURIComponent(chart.query)
+  let defaultSignals = [
+    {
+      'name': 'width',
+      'init': 'isFinite(containerSize()[0]) ? containerSize()[0] : 200',
+      'on': [
+        {
+          'update': 'isFinite(containerSize()[0]) ? containerSize()[0] : 200',
+          'events': 'window:resize'
+        }
+      ]
+    }
+  ]
+  if (full_height) {
+    defaultSignals.push({
+      'name': 'height',
+      'init': 'isFinite(containerSize()[1]) ? containerSize()[1] : 200',
+      'on': [
+        {
+          'update': 'isFinite(containerSize()[1]) ? containerSize()[1] : 200',
+          'events': 'window:resize'
+        }
+      ]
+    })
+  }
+
+  const spec = {
+    $schema: 'https://vega.github.io/schema/vega/v5.json',
+    description: chart.title,
+    autosize: { 'type': 'fit', 'resize': true },
+    data: [
+      {
+        name: 'table',
+        url: `/${chart.db}.csv?sql=${query}&${query_string}`,
+        format: { 'type': 'csv', 'parse': 'auto' }
+      }
+    ],
+    signals: defaultSignals,
+    ...chart.display
+  };
+
+  vegaEmbed(el, spec);
+}
+
+function renderVegaLiteChart(el, chart, query_string, full_height) {
   const query = encodeURIComponent(chart.query)
   const spec = {
     $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
     description: chart.title,
     width: 'container',
-    height: height_style,
-    view: {stroke: null},
+    height: full_height ? 'container' : undefined,
+    view: { stroke: null },
     config: {
       background: '#00000000',
       arc: {
@@ -17,7 +62,7 @@ function renderVegaChart(el, chart, query_string, height_style = undefined) {
     },
     data: {
       url: `/${chart.db}.csv?sql=${query}&${query_string}`,
-      format: {'type': 'csv'}
+      format: { 'type': 'csv' }
     },
     ...chart.display
   };
@@ -25,7 +70,7 @@ function renderVegaChart(el, chart, query_string, height_style = undefined) {
   vegaEmbed(el, spec);
 }
 
-async function renderMetricChart(el, chart, query_string) {
+async function renderMetricChart(el, chart, query_string, full_height) {
   const query = encodeURIComponent(chart.query)
   const results = await fetch(`/${chart.db}.json?sql=${query}&${query_string}&_shape=array`)
   const data = await results.json()
@@ -54,3 +99,15 @@ async function renderMetricChart(el, chart, query_string) {
 
   document.querySelector(el).appendChild(wrapper)
 }
+
+async function renderChart(el, chart, query_string, full_height = false) {
+  renderers = new Map()
+  renderers.set('vega', renderVegaChart)
+  renderers.set('vega-lite', renderVegaLiteChart)
+  renderers.set('metric', renderMetricChart)
+
+  render = renderers.get(chart.library)
+  if (render) {
+    await render(el, chart, query_string, full_height)
+  }
+}
diff --git a/datasette_dashboards/templates/dashboard_chart.html b/datasette_dashboards/templates/dashboard_chart.html
index 3cf578e..bde0517 100644
--- a/datasette_dashboards/templates/dashboard_chart.html
+++ b/datasette_dashboards/templates/dashboard_chart.html
@@ -43,15 +43,11 @@ <h1>{{ dashboard.title }}</h1>
       </div>
     </div>
 
-    <script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
-    <script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
-    <script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script>
+    <script src="https://cdn.jsdelivr.net/npm/vega@5.24.0"></script>
+    <script src="https://cdn.jsdelivr.net/npm/vega-lite@5.6.1"></script>
+    <script src="https://cdn.jsdelivr.net/npm/vega-embed@6.21.3"></script>
     <script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script>
     <script type="text/javascript">
-      {% if chart.library == 'vega' %}
-      renderVegaChart('#chart', {{ chart|tojson }}, '{{ query_string|safe }}', 'container')
-      {% elif chart.library == 'metric' %}
-      renderMetricChart('#chart', {{ chart|tojson }}, '{{ query_string|safe }}')
-      {% endif %}
+      renderChart('#chart', {{ chart|tojson }}, '{{ query_string|safe }}', true)
     </script>
 {% endblock %}
diff --git a/datasette_dashboards/templates/dashboard_view.html b/datasette_dashboards/templates/dashboard_view.html
index 9af8c49..640b2f7 100644
--- a/datasette_dashboards/templates/dashboard_view.html
+++ b/datasette_dashboards/templates/dashboard_view.html
@@ -93,11 +93,7 @@ <h1>{{ dashboard.title }}</h1>
     <script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script>
     <script type="text/javascript">
       {% for chart_slug, chart in dashboard.charts.items() %}
-      {% if chart.library == 'vega' %}
-      renderVegaChart('#chart-{{ chart_slug }}', {{ chart|tojson }}, '{{ query_string|safe }}')
-      {% elif chart.library == 'metric' %}
-      renderMetricChart('#chart-{{ chart_slug }}', {{ chart|tojson }}, '{{ query_string|safe }}')
-      {% endif %}
+      renderChart('#chart-{{ chart_slug }}', {{ chart|tojson }}, '{{ query_string|safe }}')
       {% endfor %}
     </script>
 {% endblock %}
diff --git a/demo/metadata.yml b/demo/metadata.yml
index bba02e2..1bc5074 100644
--- a/demo/metadata.yml
+++ b/demo/metadata.yml
@@ -12,11 +12,12 @@ plugins:
       layout:
         - [analysis-note, offers-day, offers-day, offers-count]
         - [analysis-note, offers-source, offers-day-source, offers-region]
+        - [job-titles, job-titles, job-titles, job-titles]
       filters:
         date_start:
           name: Date Start
           type: date
-          default: '2021-01-01'
+          default: "2021-01-01"
         date_end:
           name: Date End
           type: date
@@ -77,7 +78,7 @@ plugins:
           title: Number of offers by day
           db: jobs
           query: SELECT date(date) as day, count(*) as count FROM offers_view WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] [[ AND source = :source ]] [[ AND region = :region ]] GROUP BY day ORDER BY day
-          library: vega
+          library: vega-lite
           display:
             mark: { type: line, tooltip: true }
             encoding:
@@ -88,7 +89,7 @@ plugins:
           title: Number of offers by source
           db: jobs
           query: SELECT source, count(*) as count FROM offers_view WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] [[ AND region = :region ]] GROUP BY source ORDER BY count DESC
-          library: vega
+          library: vega-lite
           display:
             mark: { type: arc, tooltip: true }
             params:
@@ -106,7 +107,7 @@ plugins:
           title: Offers by day by source
           db: jobs
           query: SELECT date(date) as day, source, count(*) as count FROM offers_view WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] [[ AND region = :region ]] GROUP BY day, source ORDER BY day
-          library: vega
+          library: vega-lite
           display:
             mark: { type: bar, tooltip: true }
             params:
@@ -125,7 +126,7 @@ plugins:
           title: Offers by region
           db: jobs
           query: SELECT region, count(*) as count FROM offers_view WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] [[ AND source = :source ]] GROUP BY region ORDER BY count DESC
-          library: vega
+          library: vega-lite
           display:
             mark: geoshape
             projection: { type: mercator }
@@ -146,3 +147,52 @@ plugins:
               tooltip:
                 - { field: region, type: nominal }
                 - { field: count, type: quantitative }
+
+        job-titles:
+          title: Word cloud of job titles
+          db: jobs
+          query: >-
+            SELECT
+              b.value AS word,
+              COUNT(*) AS frequency
+            FROM (
+                SELECT '["' || lower(replace(trim(title), ' ', '", "')) || '"]' AS array
+                FROM offers_view
+                WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] [[ AND source = :source ]] [[ AND region = :region ]]
+              ) a,
+              json_each(a.array) b
+            WHERE length(b.value) > 3
+            GROUP BY b.value
+            ORDER BY frequency DESC
+            LIMIT 100
+          library: vega
+          display:
+            height: 150
+            scales:
+              - name: color
+                type: ordinal
+                domain: { data: table, field: word }
+                range: ["#d5a928", "#652c90", "#939597"]
+            marks:
+              - type: text
+                from: { data: table }
+                encode:
+                  enter:
+                    text: { field: word }
+                    align: { value: center }
+                    baseline: { value: alphabetic }
+                    fill: { scale: color, field: "word" }
+                  update:
+                    fillOpacity: { value: 1 }
+                  hover:
+                    fillOpacity: { value: 0.5 }
+                transform:
+                  - type: wordcloud
+                    size: [{ signal: width }, { signal: height }]
+                    text: { field: word }
+                    rotate: 0
+                    font: Helvetica Neue, Arial
+                    fontSize: { field: datum.frequency }
+                    fontWeight: "300"
+                    fontSizeRange: [12, 56]
+                    padding: 2


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 9c2b09d..4df29c6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -123,7 +123,7 @@ def datasette_metadata():
                             "title": "Number of offers by day",
                             "db": "test",
                             "query": "SELECT date(date) as day, count(*) as count FROM offers_view WHERE TRUE [[ AND date >= date(:date_start) ]] [[ AND date <= date(:date_end) ]] GROUP BY day ORDER BY day",
-                            "library": "vega",
+                            "library": "vega-lite",
                             "display": {
                                 "mark": {
                                     "type": "line",
@@ -139,7 +139,7 @@ def datasette_metadata():
                             "title": "Number of offers by source",
                             "db": "test",
                             "query": "SELECT source, count(*) as count FROM jobs GROUP BY source ORDER BY count DESC",
-                            "library": "vega",
+                            "library": "vega-lite",
                             "display": {
                                 "chart": {
                                     "type": "bar",
diff --git a/tests/test_dashboard_view.py b/tests/test_dashboard_view.py
index 4a8e0d7..e3892b3 100644
--- a/tests/test_dashboard_view.py
+++ b/tests/test_dashboard_view.py
@@ -50,14 +50,10 @@ async def test_dashboard_views(datasette):
                 in response.text
             )
 
-            if chart["library"] == "vega":
+            if chart["library"] != "markdown":
                 assert f'<a href="/-/dashboards/{slug}/{chart_slug}?' in response.text
                 assert f'>{chart["title"]}</a>' in response.text
-                assert f"renderVegaChart('#chart-{chart_slug}', " in response.text
-            elif chart["library"] == "metric":
-                assert f'<a href="/-/dashboards/{slug}/{chart_slug}?' in response.text
-                assert f'>{chart["title"]}</a>' in response.text
-                assert f"renderMetricChart('#chart-{chart_slug}', " in response.text
+            assert f"renderChart('#chart-{chart_slug}', " in response.text
 
 
 @pytest.mark.asyncio