Automated listing visualization in custom views
Based on the listing generator, this version will fit perfectly into a custom view by reusing dataset filters and search on the left. Embed the view to see how this template activated it’s own filters and search bar..
Dataset in use: custom-view-generator-demo
(See it on userclub domain)
Fields in use:
purpose | description_en | reference_url | name_of_the_initiative_en | language | country |
---|---|---|---|---|---|
Efficiency | This page allows citizens to analyse the region’s energy consumption. It’s divided into electricity, natural gas and diesel supplies. | https://analisis.datosabiertos.jcyl.es/pages/eren/ | Spanish | Spain | |
Transparency | Map displaying the locations of the various Covid-19 Testing Centers in Jersey City as well as other useful information and how to reach tthem. | https://data.jerseycitynj.gov/pages/covid19-testing-centers/ | English | United States of America | |
Attractivity | Heatmap highlighting data on hourly traffic in areas with public WiFi, where traffic means how many devices have entered the area every hour of the day. | https://opendata.comune.bologna.it/explore/dataset/iperbole-wifi-affluenza/custom/ | Italian | Italy | |
Attractivity | You just arrived or you live in Salinas and you need to identify the closest school for your children? Here is a map with dedicated filters in English and Spanish to find the school in two clicks. | https://cityofsalinas.opendatasoft.com/pages/school-map/ | English | United States of America |
<!-- V1 :
- listing generator adapted to custom view :
- no more context to init, and using existing one "ctx"
- filters are hidden in /explore/datasets/ view, but visible in embed mode
-->
<!-- IMPORTANT ******** MUST READ !
In the following settings declaration :
A common error is forget to escape (protect) apostrophe with a leading backslash
As apostrophes are used to declare values of variable it will break the settings
Ex:
wrongVariable = 'I'll be freed from apostrophes'
correctVariable = 'I\'ll be freed from apostrophes'
-->
<!-- SETTINGS START HERE -->
<div class="container custom-view-generator"
ng-init="fieldDate = '';
resetFiltersButton = true;
resetFiltersButtonLabel = 'Supprimer tous les filtres';
fieldDefaultRangeStartsNow = true;
view = 'cards';
fieldsList = ['purpose','description_en'];
fieldLink = 'reference_url';
fieldLinkLabel = 'See it live';
cardTitle = 'name_of_the_initiative_en';
fieldPhoto = '';
imagePosition = 'left';
itemsPerRow = '2';
kpis = [
{
'title': 'Number of initiatives',
'function': 'COUNT'
}
];
filters = [
{'id':'language','multiple':true},
{'id':'country','multiple':true},
{'id':'purpose','multiple':true}
]; DO_NOT_MODIFY_BELOW;
ctxfields = {};
values = {};
activeFilters = {};
">
<!-- ### GENERAL SETTINGS ### -->
<!-- ### FILTERS SETTINGS ### -->
<!-- filters (Filters) : List of object that contains the IDs to generate the filters pannel.
and multiple true or false to allow the user to select multiple values in the filter.
NB: the field must be a facet in the dataset
NB: alphanumerical sort is applied in the filter view
ex: [
{'id':'filterid','multiple':true},
{'id':'filterid2','multiple':false}
]
IMPORTANT: for a custom view, filters are only available when the view is embeded; In /explore view, custom view filters are hidden, as they are already available in left sidebar.
-->
<!-- resetFiltersButton (boolean) : add a reset filters button after filters block -->
<!-- resetFiltersButtonLabel (Label of the button) : test to display when a filter is selected
ex: 'Clear all filters'
-->
<!-- ### DATE SETTINGS -->
<!-- fieldDate : a date type field to display a date-range-slider -->
<!-- resetFiltersButton : reset date period button true/false -->
<!-- fieldDefaultRangeStartsNow : false make the default range selection start from the first date, and end to the last date, if set to true, the range starts from now, to the last date -->
<!-- ### LIST VIEW SETTINGS ### -->
<!-- view (List view type) : Type of the view to list results, can be 'table' or 'cards'
-->
<!-- fieldsList (List configuration) : Set the list of field IDs
ex: ['title','category','genre','date']
-->
<!-- fieldLink (Link to an external resource) : If available, the field ID of some external resource as a web URL
ex: 'link'
-->
<!-- fieldLinkLabel (The label of that link) : Label of the link button
ex: 'Read more here'
-->
<!-- Specific to the 'cards' view mode, set a title and a background image if any -->
<!-- cardTitle (Title of the card) : Field id of the card title
ex: 'title'
-->
<!-- fieldPhoto (Field id of the image field if any)
ex: 'image'
-->
<!-- imagePosition (Image position) : Image position in the card, can be 'top' or 'left'
ex: 'left'
-->
<!-- itemsPerRow (Number of columns) : Modify the columns layout. The division by 12 must be a whole number, ie it can be 1, 2, 3, 4, 6 or 12. But 6 and 12 will generaly be two norrow
ex: '3'
-->
<!-- ### KPIS SETTINGS ### -->
<!-- KPIS settings is a list of object that describes each KPI
List of available keys are :
- title (Name of the KPI) ex: 'Average # of citizens'
- function (function of the aggregation) ex: 'SUM'
- expression (field id that contains numerical values to aggregate) ex: 'population'
- precision (Decimal precision of the KPI) ex: 2
- unit (KPI unit) ex: 'citizens'
- faicon (FontAwesome icon id) ex: 'square-o'
title, function, expression are MANDATORY
the others are optionnal
Available functions are SUM, AVG, COUNT, STD, MAX, MIN.
Please see the documentation for more information
https://help.opendatasoft.com/widgets/#/api/ods-widgets.directive:odsAggregation
Please see all available icons here
https://fontawesome.com/v4.7.0/icons/
ex:
kpis = [
{
'title': 'Taille moyenne',
'function': 'AVG',
'expression': 'surface',
'precision': 2,
'unit': 'm2',
'faicon': 'square-o'
},
{
'title': 'Nombre d\'oeuvre référencées',
'function': 'COUNT'
}
];
-->
<!-- DO NOT MODIFY -->
<!-- Technical fields, do not modify please -->
<ods-dataset-context context="ctxdate"
ctxdate-dataset="{{ ctx.dataset.datasetid }}"
ctxdate-parameters="ctx.parameters">
<span ng-if="fieldDefaultRangeStartsNow">
{{ctxdate.parameters={'q.date': fieldDate + '>#now()'};''}}
</span>
<span ng-repeat="field in ctx.dataset.fields">
{{ ctxfields[field.name] = field.label; '' }}
</span>
<div ng-if="!$parent.$parent.$parent">
<span ng-repeat="filter in filters">
{{ ctx.parameters['refine.' + filter.id] = activeFilters[filter.id] ; '' }}
</span>
</div>
<div ng-if="!$parent.$parent.$parent" class="content-card search-module-container">
<!-- SEARCH -->
<div class="search-module">
<i class="fa fa-search search-module-icon" aria-hidden="true"></i>
<input placeholder="Rechercher"
ng-model="ctx.parameters['q']"
ng-model-options="{ updateOn: 'keyup', debounce: { 'default': 300, 'blur': 0 }}"
class="search-module-input"
type="text"/>
<button class="search-module-clear"
ng-if="ctx.parameters['q']"
ng-click="ctx.parameters['q'] = undefined">
<i class="fa fa-times-circle" aria-hidden="true"></i>
</button>
</div>
<!-- FILTERS Search & Select -->
<div class="filter-list"
ng-init="dropdown.open = '';
select = {}">
<div ng-repeat="filter in filters">
{{ ctx.parameters['disjunctive.' + filter.id] = true; '' }}
<div ods-facet-results="categories"
ods-facet-results-facet-name="{{ filter.id }}"
ods-facet-results-context="ctx"
ods-facet-results-sort="alphanum">
<ods-select ng-if="categories"
selected-values="activeFilters[filter.id]"
multiple="filter.multiple"
options="categories"
label-modifier="name"
value-modifier="name"
placeholder="{{ ctxfields[filter.id] }}"></ods-select>
</div>
</div>
<div class="clear-filters"
ng-show="(activeFilters | values).join('')">
<div class="clear-filters-button"
role="button"
ng-click="activeFilters = {}">
{{ resetFiltersButtonLabel }}
<i class="fa fa-times-circle" aria-hidden="true"></i>
</div>
</div>
</div>
<!-- FILTERS date (if any) -->
<div class="filter-date"
ng-if="fieldDate">
<!--
On récupère la plage de date du jeu de données. On fait une analyse pour récupérer la première et dernière date du champs date_de_restitution
pour le min: conditionnelle pour ajouter le 0 si le mois ou le jour est inférieur à 10 pour avoir une date iso
[dateminmax.results.length-1] : permet de récupérer dynamiquement le dernier élément de l'array du results => donc ici on calcule le nombre d'éléments dans l'array pour la date, donc 2, et 2-1= 1 donc 2019
Pareil que précédemment, ne pas hésiter à enlever le '' pour voir le comportement
-->
<span ods-analysis="dateminmax"
ods-analysis-context="ctxdate"
ods-analysis-x-year="{{ fieldDate }}.year"
ods-analysis-x-month="{{ fieldDate }}.month"
ods-analysis-x-day="{{ fieldDate }}.day"
ods-analysis-serie-c="COUNT()"
ods-analysis-sort="x.{{ fieldDate }}.year,x.{{ fieldDate }}.month,x.{{ fieldDate }}.day">
<span ng-if="dateminmax.results">
{{ values['periode']['min'] = dateminmax.results[0].x.year + '-' + (10 > dateminmax.results[0].x.month?'0':'') + dateminmax.results[0].x.month + '-' + (10 > dateminmax.results[0].x.day?'0':'') + dateminmax.results[0].x.day;
values['periode']['max'] = dateminmax.results[dateminmax.results.length-1].x.year + '-' + (10 > dateminmax.results[dateminmax.results.length-1].x.month?'0':'') + dateminmax.results[dateminmax.results.length-1].x.month + '-' + (10 > dateminmax.results[dateminmax.results.length-1].x.day?'0':'') + dateminmax.results[dateminmax.results.length-1].x.day;
''}}
</span>
</span>
<!-- Affichage du range slider: les dates récupérées dans l'analyse pour initialiser les bounds
la sélection période par défaut avec le mois + 1
le min/maxselection qui correspondent aux bornes choisies par le user -->
<ods-date-range-slider ng-if="values.periode.min && values.periode.max"
context="ctx"
initial-from="{{ values.periode.min }}"
initial-to="{{ values.periode.max }}"
start-bound="values.periode.min"
end-bound="values.periode.max"
date-field="{{ fieldDate }}"
precision="day"
from="values.periode.minselection"
to="values.periode.maxselection">
</ods-date-range-slider>
<!-- le click sur toute la période resette la période choisie sur la date min et max -->
<div ng-if="resetFiltersButton" class="filter-date-button"
ng-class="{'filter-date-button-disabled':
values.periode.minselection == values.periode.min &&
values.periode.maxselection == values.periode.max}"
ng-click="values.periode.minselection = values.periode.min;
values.periode.maxselection = values.periode.max">
Toute la période <i class="fa fa-arrows-h" aria-hidden="true"></i>
</div>
</div>
</div>
<!-- KPIs -->
<section class="kpis-container row row-equal-height">
<div class="{{ 'col-md-' + (12/itemsPerRow) }} margin-bottom-20"
ng-repeat="kpi in kpis">
<!-- KPI box component -->
<div class="kpi-card"
ods-aggregation="agg"
ods-aggregation-context="ctx"
ods-aggregation-function="{{ kpi.function }}"
ods-aggregation-expression="{{ kpi.expression }}">
<i class="kpi-icon fa fa-{{ kpi.faicon || 'gitlab' }}" aria-hidden="true"></i>
<p class="kpi-title">
{{ (agg || 0) | number : (kpi.precision || 0) }}
<span ng-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</p>
<p class="kpi-description">
{{ kpi.title }}
</p>
</div>
</div>
</section>
<!-- TABLE -->
<section ng-if="view == 'table'"
class="content-card">
<div class="table-module">
<table class="table-basic"
ods-results="items"
ods-results-context="ctx"
ods-results-max="20">
<thead>
<tr>
<th ng-repeat="field in fieldsList">{{ ctxfields[field] }}</th>
<th ng-if="fieldLink"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="item in items">
<td ng-repeat="field in fieldsList"
style="max-width: calc(100vh / {{ fieldsList.length }});"
title="{{ item.fields[field] }}">
{{ item.fields[field] }}
</td>
<td ng-if="fieldLink">
<a href="{{ item.fields[fieldLink] }}"
tilte="{{ fieldLinkLabel }}"
target="_blank"
class="table-dropdown-button">
<i class="fa fa-external-link"></i>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- CARDS -->
<section ng-if="view == 'cards'">
<div class="row row-equal-height"
ods-results="items"
ods-results-context="ctx"
ods-results-max="{{ 8 * itemsPerRow }}">
<div ng-repeat="item in items"
class="{{ 'col-md-' + (12/itemsPerRow) }} margin-bottom-20">
<div class="content-card"
ng-class="{'content-card-horizontal': imagePosition === 'left' }">
<div class="content-card-img"
ng-if="item.fields[fieldPhoto]"
style="{{ 'background-image: url(/explore/dataset/' + ctx.dataset.datasetid + '/files/' + item.fields[fieldPhoto].id + '/300/);' }}">
</div>
<div class="content-card-body">
<h2 class="content-card-title text-center">
{{ item.fields[cardTitle] }}
</h2>
<div class="content-card-fields">
<dl>
<dt ng-repeat-start="field in fieldsList">{{ ctxfields[field] }}</dt>
<dd ng-repeat-end>{{ item.fields[field] }}</dd>
</dl>
</div>
<div ng-if="fieldLink" class="text-center">
<!-- if fieldLink is a json and contains the 'id' key, it means that the link points to a file hosted on ODS platform and can be downloaded. If it's an external link, the other link tag is used -->
<a ng-if="item.fields[fieldLink].id"
href="/explore/dataset/{{ ctx.dataset.datasetid }}/files/{{ item.fields[fieldLink].id }}/download/"
target="_blank"
class="content-card-button">
{{ fieldLinkLabel }}
</a>
<!-- fieldLink is used here
For more advanced scenario, you can send the user to :
- the dataset table filtered with the fieldLink value, through a text query
href="/explore/dataset/{{ datasetid }}/table?q={{ item.fields[fieldLink] }}"
- the dataset table filtered with the fieldLink value, through a refine on the field
href="/explore/dataset/{{ datasetid }}/table?refine.{{ fieldLink }}={{ item.fields[fieldLink] }}"
- a page using url-sync=true setting :
href="/pages/yourpage/?refine.{{ fieldLink }}={{ item.fields[fieldLink] }}"
-->
<a ng-if="!item.fields[fieldLink].id" href="{{ item.fields[fieldLink] }}"
target="_blank"
class="content-card-button">
{{ fieldLinkLabel }}
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</ods-dataset-context>
</div>
/* General Layout
========================================================================== */
:root {
--secondary-color: black;
}
@media screen and (min-width: 992px) {
.row-equal-height {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
margin-bottom: 20px;
}
/* Fix for early content wrapping in Safari*/
.row-equal-height:before,
.row-equal-height:after {
content: normal;
}
}
.page-title {
font-size: 3rem;
font-weight: bold;
margin-top: 0;
margin-bottom: 1rem;
}
.page-subtitle {
font-size: 1.2rem;
line-height: 2;
margin-top: 0;
margin-bottom: 3rem;
}
.margin-bottom-20 {
margin-bottom: 20px;
}
/* Search Module
========================================================================== */
.search-module-container {
padding: 26px;
margin-bottom: 20px;
}
.search-module {
display: flex;
align-items: stretch;
border-bottom: 1px solid #dee5ef;
margin-bottom: 13px;
transition: all .2s;
}
.search-module:hover,
.search-module:focus-within {
border-bottom-color: var(--links);
}
.search-module-icon {
color: #898d92;
margin-right: 8px;
align-self: center;
}
.search-module-input {
background-color: transparent;
width: 100%;
outline: none;
border: none;
padding: 12px 0;
transition: all .2s;
color: var(--text);
}
.search-module-input::placeholder {
transition: all .2s;
}
.search-module-clear {
color: #898d92;
font-size: 1rem;
background: transparent;
border: none;
margin: 0;
outline: none;
padding: 0 0 0 12px;
transition: all .2s;
}
.search-module-clear:hover {
opacity: .65;
}
.search-module:hover .search-module-icon,
.search-module:focus-within .search-module-icon,
.search-module:hover .search-module-input::placeholder,
.search-module:focus-within .search-module-input::placeholder {
color: var(--links)
}
/* Filters
========================================================================== */
.filter-list {
display: flex;
flex-wrap: wrap;
position: relative;
}
.filter-list > * {
margin: 0 0 10px;
width: 100%;
}
.odswidget-select .odswidget-select-dropdown.open .odswidget-select-dropdown-menu {
width: 100%
}
.clear-filters {
display: flex;
align-items: center;
justify-content: center;
}
.clear-filters-button:hover {
opacity: 0.65;
}
.odswidget-select,
.odswidget-select .odswidget-select-dropdown {
width: 100%;
}
@media screen and (min-width: 500px) {
.filter-list > * {
margin: 0 10px 10px 0;
width: inherit;
}
.odswidget-select .odswidget-select-dropdown.open .odswidget-select-dropdown-menu {
width: max-content;
min-width: 240px;
}
}
/*********** Filter date ************/
.filter-date {
display: flex;
flex-direction: column;
align-items: center;
margin: 13px 26px 32px 26px;
}
.odswidget-date-range-slider {
width: 100%;
}
.filter-date-button {
margin-left: 0;
margin-top: 13px;
white-space: nowrap;
text-decoration: underline;
}
.filter-date-button:not(.filter-date-button-disabled):hover {
opacity: 0.65;
}
.filter-date-button-disabled {
opacity: 0.5;
pointer-event: none;
text-decoration: none;
}
@media screen and (min-width: 500px) {
.filter-date {
flex-direction: row;
}
.filter-date-button {
margin-left: 50px;
margin-top: 0;
}
}
/* date range slider style override */
.odswidget-date-range-slider .irs--flat .irs-from, .odswidget-date-range-slider .irs--flat .irs-single, .odswidget-date-range-slider .irs--flat .irs-to {
color: var(--text);
border: 1px solid #cbd2db;
border-radius: 2rem;
background: #FFFFFF;
}
.odswidget-date-range-slider .irs--flat .irs-from:before, .odswidget-date-range-slider .irs--flat .irs-single:before, .odswidget-date-range-slider .irs--flat .irs-to:before {
border-top-color: var(--text);
}
.odswidget-date-range-slider .irs--flat .irs-bar {
background-color: var(--highlight);
}
.odswidget-date-range-slider .irs--flat .irs-handle>i:first-child {
background-color: var(--highlight);
}
.odswidget-date-range-slider .irs--flat .irs-handle.state_hover>i:first-child, .odswidget-date-range-slider .irs--flat .irs-handle:hover>i:first-child {
background-color: var(--text);
}
/* Content Card
========================================================================== */
.content-card {
background-color: var(--boxes-background);
border-radius: 4px;
height: 100%;
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.13);
}
.content-card-horizontal {
display: flex;
}
.content-card-horizontal .content-card-img {
height: auto;
flex: 0 0 25%;
width: 25%;
border-radius: 4px 0 0 4px;
}
.content-card-img {
display: block;
height: 110px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.content-card-body {
padding: 26px;
flex: 1 1 auto;
}
.content-card-title {
color: var(--titles);
font-size: 1.2rem;
line-height: 1.5;
font-weight: normal;
margin-top: 0;
margin-bottom: 13px;
max-width: 100%;
}
.content-card-description {
color: var(--text);
font-size: 1rem;
line-height: 1.5;
font-weight: normal;
margin-top: 0;
margin-bottom: 26px;
max-width: 100%;
}
.content-card-fields dt {
font-size: 0.8rem;
opacity: 0.8;
}
.content-card-fields dd {
margin-left: 0;
}
.content-card-icon {
color: var(--highlight);
font-size: 2rem;
margin-bottom: 13px;
max-width: 100%;
}
.content-card-link {
color: var(--links);
font-weight: bold;
transition: all .2s;
opacity: 1;
max-width: 100%;
}
.content-card-link:hover {
opacity: .7;
text-decoration: none;
}
.content-card-button {
color: var(--highlight);
border: 1px solid var(--highlight);
background: transparent;
display: inline-block;
text-align: center;
font-size: .867rem;
border-radius: 4px;
padding: .5rem 1.15rem;
text-decoration: none;
transition: all .2s;
}
.content-card-button:hover {
background-color: var(--highlight);
color: #FFFFFF;
text-decoration: none;
}
/* KPI Card
========================================================================== */
@media screen and (min-width: 992px) {
.kpis-container {
display: flex;
justify-content: center;
}
}
.kpi-card {
background-color: var(--boxes-background);
height: 100%;
padding: 39px;
border-radius: 4px;
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.13);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
text-align: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
}
.kpi-icon {
color: var(--highlight);
color: var(--secondary-color);
font-size: 4rem;
margin-top: 0;
margin-bottom: 13px;
max-width: 100%;
}
.kpi-title {
font-weight: normal;
color: var(--highlight);
font-size: 3.2rem;
margin-top: 0;
margin-bottom: 13px;
max-width: 100%;
}
.kpi-unit {
font-size: 0.8em;
color: var(--secondary-color);
}
.kpi-description {
color: var(--text);
font-size: 1rem;
line-height: 1.5;
font-weight: normal;
margin-top: 0;
margin-bottom: 0;
max-width: 100%;
}
/* Table Module Basic
========================================================================== */
.table-module {
height: 1000px;
overflow-y: auto;
}
.table-basic {
display: table;
border-collapse: collapse;
width: 100%;
white-space: nowrap;
background-color: #FFFFFF;
margin-bottom: 20px;
}
.table-basic thead th {
color: var(--titles);
background-color: #f6f8fb;
font-weight: 500;
padding: 13px 3px;
position: sticky;
top: 0;
z-index: 1;
}
.table-basic thead th:first-child,
.table-basic tbody td:first-child {
padding-left: 13px;
}
.table-basic tr td,
.table-basic tbody th {
font-weight: normal;
border-top: 1px solid #dee5ef;
}
.table-basic tr td {
padding: 13px 3px;
}
.table-basic td {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.table-dropdown-button {
width: 28px;
height: 28px;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 1px solid transparent;
border-radius: 4px;
margin-left: auto;
}
.table-basic tr:hover .table-dropdown-button {
border-color: var(--highlight);
text-decoration: none;
}
/** Specific override for custom views **/
@media screen and (min-width: 1408px) {
.ods-dataset-visualization .ods-tabs__pane .container:not(.is-max-desktop):not(.is-max-widescreen) {
max-width: 100%;
}
}
@media screen and (min-width: 1216px) {
.ods-dataset-visualization .ods-tabs__pane .container:not(.is-max-desktop) {
max-width: 100%;
}
}
@media screen and (min-width: 1024px) {
.ods-dataset-visualization .ods-tabs__pane .container {
max-width: 100%;
}
}