Skip to content

Instantly share code, notes, and snippets.

@jsanz
Last active August 25, 2021 06:18
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jsanz/8025628d669f1d9d0647f658f5511774 to your computer and use it in GitHub Desktop.
Save jsanz/8025628d669f1d9d0647f658f5511774 to your computer and use it in GitHub Desktop.
CARTO VL + Airship + Vue component

Minimal example on how to wrap Airship components as a Vue ones and then use them on a CARTO VL visualization. This just outlines the procedure explained in the Airship + Vue guide.

On this minimal example a layer switcher component is used to handle the UI so it only knows about its state, and then emits input methods to the parent Vue instance to allow mapping the boolean property of the sweitcher and then react to changes on it on the layer.

A range slider is used twice (to show component reusability) to allow filtering the visualization by a couple of fields.

Check also how the isLayerVisible computed variable is used for conditional rendering of several classes and widgets disable.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="shortcut icon" href=//carto.com/favicon.ico>
<title>Airship + CARTO VL + Vue.JS</title>
<!-- Airship CSS-->
<link rel="stylesheet" href="https://libs.cartocdn.com/airship-style/v1.0.3/airship.css">
<link rel="stylesheet" href="https://libs.cartocdn.com/airship-icons/v1.0.3/icons.css">
<!-- Mapbox CSS -->
<link href="https://api.tiles.mapbox.com/mapbox-gl-js/v0.50.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://carto.com/developers/carto-vl/examples/maps/style.css" rel="stylesheet">
<!-- Application CSS-->
<link rel="stylesheet" href="styles.css">
</head>
<body class="as-app-body">
<div id="app" class="as-app">
<as-responsive-content>
<aside class="as-sidebar as-sidebar--left" data-name="Widgets">
<div class="as-container as-container--border">
<section class="as-box" :class="{ 'as-color--type-03': !isLayerVisible }">
<range-slider
title="Filter by population"
v-model="rangePop"
:min-value="0" :max-value="50000000" :step="500000"
:draggable="true"
:format-value="formatter"
:disabled="! isLayerVisible || population.selection.length == 2">
</range-slider>
<range-slider
title="Filter by latitude"
v-model="latitude"
:min-value="-90" :max-value="90" :step="5"
:draggable="true"
:disabled="! isLayerVisible">
</range-slider>
<histogram
heading="Population"
description="In millions of inhabitants"
:disable-interactivity="! isLayerVisible"
:show-clear="true"
:data="population.viewport"
@selection-changed="population.selection = $event"
>
</histogram>
<categories
heading="Place types"
:disable-interactivity="! isLayerVisible"
:show-clear-button="true"
:categories="placeTypes.viewport"
@categories-selected="placeTypes.selected = $event"
>
</categories>
</section>
</div>
</aside>
<main class="as-main">
<div class="as-map-area">
<div id="map"></div>
<div class="as-map-panels" data-name="Layer">
<div class="as-panel as-panel--top as-panel--right as-panel--vertical ">
<div class="as-panel__element as-p--16">
<toggle
v-model=isLayerVisible
label="Toggle layer" />
</div>
<!-- Example of simple formula widget -->
<div class="as-panel__element as-p--16" :class="{ 'as-color--type-03': !isLayerVisible }">
<h4 class="as-subheader">{{formatter(this.totalPop)}}</h4>
<p class="as-caption">total pop on <strong>{{totalCount}}</strong> places</p>
</div>
</div>
</div>
</div>
</main>
</as-responsive-content>
</div>
<!-- Vue components, for some reason they need to be put
before the other external components to prevent the layer
switch not being properly rendered-->
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.21/dist/vue.js"></script>
<script src="./vue-component-toggle.js"></script>
<script src="./vue-component-range-slider.js"></script>
<script src="./vue-component-categories.js"></script>
<script src="./vue-component-histogram.js"></script>
<!-- External JS: Airship, CARTO VL, Mapbox JS, Vue-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.min.js"></script>
<script src="https://libs.cartocdn.com/airship-components/v1.0.3/airship.js"></script>
<script src="https://libs.cartocdn.com/carto-vl/v1.1.1/carto-vl.js"></script>
<script src="https://api.tiles.mapbox.com/mapbox-gl-js/v0.50.0/mapbox-gl.js"></script>
<!-- Main Vue object and mapping logic-->
<script src="./vue_config.js"></script>
<script src="./main.js"></script>
</body>
</html>
document
.querySelector('as-responsive-content')
.addEventListener('ready', () => {
const map = new mapboxgl.Map({
container: 'map',
style: carto.basemaps.voyager,
center: [0, 0],
zoom: 1,
scrollZoom: false,
hash: true
});
const nav = new mapboxgl.NavigationControl({ showCompass: false });
map.addControl(nav, 'top-left');
carto.setDefaultAuth({
username: 'cartovl',
apiKey: 'default_public'
});
const source = new carto.source.SQL('select *, 1 as counter from populated_places');
const viz = new carto.Viz(
`
color: ramp(globalQuantiles($pop_max, 5), sunsetdark)
width: 4
strokeWidth: 0
filter: between($pop_max, 0, 5000000) and between($latitude, -90, 90)
@totalCount: viewportSum($counter)
@totalPop: viewportSum($pop_max)
@featureclaHist: viewportHistogram($featurecla)
@populationHist: viewportHistogram($pop_max,10)
`
);
const layer = new carto.Layer('layer', source, viz);
layer.addTo(map, 'watername_ocean');
// Attach the layer to the Vue instance
vm.layer = layer;
vm.viz = viz;
layer.on('updated',function(){
// Total population
vm.totalPop = viz.variables.totalPop.value;
// Count of cities
vm.totalCount = viz.variables.totalCount.value;
// Place types (category widget)
vm.placeTypes.viewport = viz.variables.featureclaHist.value.map( c => {
return {
name: c.x,
value: c.y
}
});
// Population (histogram widget)
vm.population.viewport = viz.variables.populationHist.value.map(entry => {
return {
start: (entry.x[0] / 1e6).toFixed(3),
end: (entry.x[1] / 1e6).toFixed(3),
value: entry.y
}
});
});
});
.as-panel__element {
min-width: 150px;
}
.as-box{
padding: 4px 16px;
}
.range-slider-widget {
padding: 8px 10px;
}
as-category-widget{
padding: 8px;
}
Vue.component('Categories', {
'template':
`
<as-category-widget
ref="widget"
:bind="$attrs"
:is-loading="isLoading"
>
</as-category-widget>
`,
props: {
categories:{ type: Array },
valueFormatter: Symbol,
debounceCategoriesDelay: {
type: Number,
default: 1500
}
},
data: function() {
return {
widget: null,
isLoading: true
}
},
watch: {
categories: function(newValue){
this.isLoading = false;
this.widget.categories = newValue;
}
},
mounted: function() {
this.widget = this.$refs.widget
const widget = this.widget;
const delay = this.debounceCategoriesDelay;
//Airship event for categories updated
widget.addEventListener('categoriesSelected', _.debounce(event =>{
this.$emit('categories-selected',event.detail)
}, delay));
if (this.valueFormatter){
widget.valueFormatter = this.valueFormatter
} else {
widget.valueFormatter = value => `${value}`;
}
}
});
Vue.component('Histogram', {
'template':
`
<as-histogram-widget
ref="widget"
:bind="$attrs"
>
</as-histogram-widget>
`,
props: {
data: Array,
tooltipFormatter: Symbol
},
data: function() {
return {
widget: null,
isLoading: true
}
},
watch: {
data: function(newValue){
this.isLoading = false;
this.widget.data = newValue;
}
},
mounted: function() {
this.widget = this.$refs.widget
const widget = this.widget;
//Airship event for categories updated
widget.addEventListener('selectionChanged', event => {
this.$emit(
'selection-changed',
event.detail !== null
? event.detail
: []
)
});
if (this.tooltipFormatter){
widget.tooltipFormatter = this.tooltipFormatter
} else {
widget.tooltipFormatter = widget.defaultFormatter;
}
}
});
// Register a component that wraps the Airship range slider
Vue.component('RangeSlider', {
'template':
`<div class="range-slider-widget">
<as-widget-header>
<h2 class="as-widget-header__header">{{title}}</h2>
</as-widget-header>
<as-range-slider
ref="rangeSliderWidget"
:min-value="minValue"
:max-value="maxValue"
:step="step"
:draggable="draggable"
:disabled="disabled"
>
</as-range-slider>
</div>
`,
props: {
title: {
type: String,
required: true
},
minValue: Number,
maxValue: Number,
step: Number,
disabled: { type: Boolean, default: false },
draggable: { type: Boolean, default: false },
formatValue: { type: Function, default: (value) => value }
},
data: function () {
return {
rangeSliderWidget: null,
range: null
};
},
watch: {
range: function(value){
this.$emit('input', value);
}
},
computed: {
value: function(){
return this.range
? range[range.length - 1]
: null
}
},
mounted: function () {
this.rangeSliderWidget = this.$refs.rangeSliderWidget;
const widget = this.rangeSliderWidget;
// Widget set up
widget.addEventListener('changeEnd', event => {
this.range = event.detail;
});
widget.formatValue = this.formatValue;
if (this.draggable){
widget.range = [this.minValue, this.maxValue];
}
}
});
/*
Simple switch component that attaches to a boolean
variable.
*/
Vue.component('Toggle', {
'template':
`
<as-switch
ref="switchWidget"
:bind="$attrs"
:checked=checked
:label=label>
</as-switch>
`,
inheritAttrs: false,
props: {
name: String,
label: {
type: String,
required: true
}
},
data: function () {
return {
switchWidget: null,
checked: true
};
},
watch: {
checked: function(value){
this.$emit('input', value);
}
},
mounted: function () {
this.switchWidget = this.$refs.switchWidget;
// Anytime the airship element changes update checked
this.switchWidget.addEventListener('change', event => {
this.checked = event.detail;
});
}
});
// Ignore airship tags
Vue.config.ignoredElements = [/as-\w+/];
const numbFormatter = new Intl.NumberFormat('es-ES', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
});
const vm = new Vue({
el: '#app',
data: {
isLayerVisible: true,
rangePop: null,
formatter: numbFormatter.format,
latitude: [-90, 90],
totalPop: 0,
totalCount: 0,
placeTypes: {
viewport: [],
selected: []
},
population: {
viewport: [],
selection: []
}
},
watch: {
// Link the data variable to the layer show/hide
isLayerVisible: function (isShowOrHide) {
isShowOrHide
?
this.layer.show() :
this.layer.hide();
},
rangePop: function () {
this.viz.filter.blendTo(this.layerFilter);
},
latitude: function () {
this.viz.filter.blendTo(this.layerFilter);
},
'placeTypes.selected': function () {
this.viz.filter.blendTo(this.layerFilter);
},
'population.selection': function () {
this.viz.filter.blendTo(this.layerFilter);
}
},
computed: {
layerFilter: function () {
let filterConditions = [];
if (this.rangePop) {
const [minRange, maxRange] = this.rangePop;
filterConditions.push(`between($pop_max,${minRange},${maxRange})`);
}
if (this.latitude) {
const [minLat, maxLat] = this.latitude;
filterConditions.push(`between($latitude, ${minLat}, ${maxLat})`);
}
if (this.placeTypes.selected && this.placeTypes.selected.length > 0) {
const placeTypesFilter = this.placeTypes.selected.map((type) => {
return `'${type}'`;
}).join(',');
filterConditions.push(`$featurecla in [${placeTypesFilter}]`);
}
if (this.population.selection && this.population.selection.length == 2){
const [minSel, maxSel] = this.population.selection;
filterConditions.push(`between($pop_max,${minSel*1e6},${maxSel*1e6})`);
}
return filterConditions.join(' and ');
}
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment