Skip to content

Commit

Permalink
feat: implement scores distributions charts using vega
Browse files Browse the repository at this point in the history
Currently implemented in frontend, I will work on a new PR to put the logic in the API.

Basic vega implementation 
Ref #157
  • Loading branch information
Marc-AntoineA authored Jun 19, 2024
1 parent 17fabbb commit 837a658
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 3 deletions.
3 changes: 2 additions & 1 deletion frontend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
{
"argsIgnorePattern": "^_"
}
]
],
"@typescript-eslint/ban-ts-comment": "off"
},
"overrides": [
{
Expand Down
1 change: 1 addition & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ The project is currently composed of several widgets
* it can be used to replace the default button
* searchalicious-button-transparent is a transparent button with defined style
* it can be used to replace the default button
* searchalicious-chart renders vega chart, currently only for distribution. Requires [vega](https://vega.github.io/).

You can give a specific `name` attribute to your search bar.
Then all other component that needs to connect with this search must use the same value in `search-name` attribute
Expand Down
9 changes: 8 additions & 1 deletion frontend/public/off.html
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@
<searchalicious-facet-terms name="ecoscore_grade"></searchalicious-facet-terms>
</searchalicious-facets>
</div>
<div class="large-10 columns">
<div class="large-8 columns">
<div id="search_results" style="clear: left">
<div id="products_tabs_content" class="tabs-content">
<div class="tabs content active" id="products_all">
Expand Down Expand Up @@ -308,6 +308,11 @@
</searchalicious-pages>
</div>
</div>
<div class="large-2 columns">
<searchalicious-chart search-name="off" label="Nutri-score distribution" key="nutriscore_grade", categories='["a", "b", "c", "d", "e", "unknown", "not-applicable"]''></searchalicious-chart>
<searchalicious-chart search-name="off" label="Nova score distribution" key="nova_group", categories='["1", "2", "3", "4", "undefined", "not-applicable"]'></searchalicious-chart>
<searchalicious-chart search-name="off" label="Eco score distribution" key="ecoscore_grade", categories='["a", "b", "c", "d", "e", "unknown", "not-applicable"]''></searchalicious-chart>
</div>
</div>
</div>
</div>
Expand All @@ -323,6 +328,8 @@
<script src="https://static.openfoodfacts.org/js/dist/stikelem.js"></script>
<script src="https://static.openfoodfacts.org/js/dist/scrollNav.js"></script>

<script src="vega5.js"></script>

<script
src="https://static.openfoodfacts.org/js/dist/foundation.js"
data-base-layout="true"
Expand Down
2 changes: 2 additions & 0 deletions frontend/public/vega5.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/src/mixins/search-results-ctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface SearchaliciousResultsCtlInterface
searchName: string;
searchLaunched: Boolean;

// sub class must override this one to dislpay results
// sub class must override this one to display results
handleResults(event: SearchResultEvent): void;
// this is the registered handler
_handleResults(event: Event): void;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/search-a-licious.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {SearchaliciousAutocomplete} from './search-autocomplete';
export {SearchaliciousSecondaryButton} from './secondary-button';
export {SearchaliciousButtonTransparent} from './button-transparent';
export {SearchaliciousIconCross} from './icons/cross';
export {SearchaliciousChart} from './search-chart';
216 changes: 216 additions & 0 deletions frontend/src/search-chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';

import {SearchaliciousResultCtlMixin} from './mixins/search-results-ctl';

import {SearchResultEvent} from './events';

// eslint-disable-next-line
declare const vega: any;

@customElement('searchalicious-chart')
export class SearchaliciousChart extends SearchaliciousResultCtlMixin(
LitElement
) {
// All these properties will change when vega logic
// will be moved in API.
// TODO: fail if some required properties are unset
// (eg. key)
@property()
key?: string;

@property()
label?: string;

@property({type: Array})
categories: Array<string> = [];

@property({attribute: false})
// eslint-disable-next-line
vegaRepresentation: any = undefined;

@property({attribute: false})
vegaInstalled: boolean;

constructor() {
super();
this.vegaInstalled = this.testVegaInstalled();
}

override render() {
if (!this.vegaInstalled) {
return html`<p>Please install vega to use searchalicious-chart</p>`;
}

if (this.vegaRepresentation === undefined) {
return html`<slot name="no-data"><p>no data</p></slot>`;
}

return html`<div id="${this.key!}"></div>`;
}

// Computes the vega representation for given results
// The logic will be partially moved in API in a following
// PR.
// Vega function assumes that rendered had been previously
// called.
override handleResults(event: SearchResultEvent) {
if (event.detail.results.length === 0 || !this.vegaInstalled) {
this.vegaRepresentation = undefined;
return;
}

// Compute the distribution
const values = Object.fromEntries(
this.categories.map((category) => [category, 0])
);

for (const result of event.detail.results) {
// We use ts-ignore here but it will be removed as soon as
// vega logic will be moved in the api
// @ts-ignore
values[result[this.key]] += 1;
}

// Vega is used as a JSON visualization grammar
// Doc: https://vega.github.io/vega/docs/
// It would have been possible to use higher lever vega-lite API,
// which is able to write vega specifications but it's probably too
// much for our usage
// Inspired by: https://vega.github.io/vega/examples/bar-chart/

// I recommend to search on Internet for specific uses like:
// * How to make vega responsive:
// Solution: using signals and auto-size
// https://gist.github.com/donghaoren/023b2246569e8f0615017507b473e55e
// * How to hide vertical axis: do not add { scale: yscale, ...} in axes section

this.vegaRepresentation = {
$schema: 'https://vega.github.io/schema/vega/v5.json',
title: this.label,
// @ts-ignore
// width: container.offsetWidth,
autosize: {type: 'fit', contains: 'padding'},
signals: [
{
name: 'width',
init: 'containerSize()[0]',
on: [{events: 'window:resize', update: 'containerSize()[0]'}],
},
{
name: 'tooltip',
value: {},
on: [
{events: 'rect:pointerover', update: 'datum'},
{events: 'rect:pointerout', update: '{}'},
],
},
],
height: 140,
padding: 5,
data: [
{
name: 'table',
values: Array.from(Object.entries(values), ([key, value]) => ({
category: key,
amount: value,
})),
},
],

scales: [
{
name: 'xscale',
type: 'band',
domain: {data: 'table', field: 'category'},
range: 'width',
padding: 0.05,
round: true,
},
{
name: 'yscale',
domain: {data: 'table', field: 'amount'},
nice: true,
range: 'height',
},
],
axes: [{orient: 'bottom', scale: 'xscale', domain: false, ticks: false}],
marks: [
{
type: 'rect',
from: {data: 'table'},
encode: {
enter: {
x: {scale: 'xscale', field: 'category'},
width: {scale: 'xscale', band: 1},
y: {scale: 'yscale', field: 'amount'},
y2: {scale: 'yscale', value: 0},
},
update: {
fill: {value: 'steelblue'},
},
hover: {
fill: {value: 'red'},
},
},
},
{
type: 'text',
encode: {
enter: {
align: {value: 'center'},
baseline: {value: 'bottom'},
fill: {value: '#333'},
},
update: {
x: {scale: 'xscale', signal: 'tooltip.category', band: 0.5},
y: {scale: 'yscale', signal: 'tooltip.amount', offset: -2},
text: {signal: 'tooltip.amount'},
fillOpacity: [{test: 'datum === tooltip', value: 0}, {value: 1}],
},
},
},
],
};
}

testVegaInstalled() {
try {
vega;
return true;
} catch (e) {
if (e instanceof ReferenceError) {
console.error(
'Vega is required to use searchalicious-chart, you can import it using \
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>'
);
return false;
}
throw e;
}
}

// vega rendering requires an html component with id == this.key
// and consequently must be called AFTER render
// Method updated is perfect for that
// See lit.dev components lifecycle: https://lit.dev/docs/components/lifecycle/
override updated() {
if (this.vegaRepresentation === undefined) return;

const container = this.renderRoot.querySelector(`#${this.key}`);

// How to display a vega chart: https://vega.github.io/vega/usage/#view
const view = new vega.View(vega.parse(this.vegaRepresentation), {
renderer: 'svg',
container: container,
hover: true,
});
view.runAsync();
}
}

declare global {
interface HTMLElementTagNameMap {
'searchalicious-chart': SearchaliciousChart;
}
}

0 comments on commit 837a658

Please sign in to comment.