Skip to content

Instantly share code, notes, and snippets.

@chrispahm
Created April 9, 2023 18:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chrispahm/ca5bbdd8366edcfc59763c7833b41164 to your computer and use it in GitHub Desktop.
Save chrispahm/ca5bbdd8366edcfc59763c7833b41164 to your computer and use it in GitHub Desktop.
<template>
<div class="">
<div class="container">
<div v-if="loading" style="z-index: 100;">
<div class="loading" />
<span style="position: absolute;margin-left: -10px">Lädt...</span>
</div>
<div id="geocoder" class="geocoder" />
<div class="break" />
<div class="header">
<div style="max-width: 70%; margin-left: 10px;margin-bottom: 5px;">
<canvas id="crop-summary" height="50px" width="700px" />
</div>
<span style="margin-right: 10px; margin-bottom: 5px; font-weight: 600; font-size: 22px;">{{ roundedLand }} ha</span>
</div>
<div id="map" />
<div class="footer">
<ul>
<draggable
v-model="crops"
v-bind="dragOptions"
draggable=".crop"
:delay="400"
class="bubble-wrapper"
:remove-on-spill="true"
:disabled="false"
:drag="checkMove"
@start="startDrag"
@end="endDrag"
@spill="deleteCrop"
>
<li v-for="crop in crops" :key="crop.name" class="crop" @click="selectedCrop = crop.name">
<div
:class="['bubble',selectedCrop === crop.name ? 'selectedCrop' : '']"
:style="{ backgroundColor: crop.color }"
/>
<span>{{ truncateName(crop.name) }}</span>
</li>
<div slot="footer" style="display: inherit;">
<div style="border-left: 1px solid #ececec; height: 100%; padding-left: 25px;" />
<li class="addCrop" @click="showModal = true">
<div
style="width: 30px; height: 30px; line-height: 28px;"
class="flex text-gray-600 font-bold justify-center rounded-full bg-gray-200"
>
<p>+</p>
</div>
<span>Hinzufügen</span>
</li>
</div>
</draggable>
</ul>
</div>
</div>
<modal v-if="showModal" :default-color="randomColor()" @addCrop="addCrop" @close="showModal = false" />
</div>
</template>
<script>
import mapboxgl from 'mapbox-gl'
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'
import PouchDB from 'pouchdb-browser'
import 'mapbox-gl/dist/mapbox-gl.css'
import Chart from 'chart.js/auto'
import ChartDataLabels from 'chartjs-plugin-datalabels'
import { union } from 'polygon-clipping'
import bbox from '@turf/bbox'
import draggable from '@/components/vuedraggable.umd.js'
import modal from '@/components/modal.vue'
const db = new PouchDB('https://fruchtfolge.agp.uni-bonn.de/db/fields', { skip_setup: true })
/*
Chart.Legend.prototype.afterFit = function () {
this.height = this.height - 30
}
*/
Chart.register(ChartDataLabels)
export default {
components: {
modal,
draggable
},
data () {
return {
loading: true,
showModal: false,
draggedElement: null,
crops: [{
color: '#F88353',
name: 'Belana',
area: 0
}, {
color: '#FAA54B',
name: 'Agria',
area: 0
}, {
color: '#3996CD',
name: 'Ackerbohnen',
area: 0
}, {
color: '#1975B2',
name: 'Winterweizen',
area: 0
}, {
color: '#F8214F',
name: 'Zwiebeln',
area: 0
}, {
color: '#4D4777',
name: 'Mais',
area: 0
}, {
color: '#B9262C',
name: 'Möhren',
area: 0
}, {
color: '#f5f5f5',
name: '(Unbekannt)',
area: 0
}],
selectedCrop: 'Belana',
totalArea: 0,
featureCollection: {
type: 'FeatureCollection',
features: []
}
}
},
computed: {
roundedLand () {
return Math.round(this.totalArea * Math.pow(10, 1)) / Math.pow(10, 1)
},
dragOptions () {
return {
animation: 200,
forceFallback: true,
group: 'description',
disabled: false,
ghostClass: 'dontDisplayGhost'
}
}
},
watch: {
crops: {
deep: true,
handler () {
return this.updateData()
}
}
},
beforeCreate () {
// reload the page early if no query parameters are attached
// eslint-disable-next-line nuxt/no-globals-in-created
const urlParams = new URLSearchParams(window.location.search)
if (!urlParams.has('id')) {
const sessionId = new Date().toISOString()
urlParams.set('id', sessionId)
// eslint-disable-next-line nuxt/no-globals-in-created
document.location.search = urlParams.toString()
}
},
async mounted () {
// get or create session id
const urlParams = new URLSearchParams(window.location.search)
this.sessionId = urlParams.get('id')
try {
const data = await db.get(this.sessionId)
this._rev = data._rev
this.featureCollection = data.featureCollection
// update feauture collection in case it's containing old data
data.featureCollection.features = data.featureCollection.features.map((f) => {
if (!f.id) { f.id = f.properties.ID }
if (!f.properties.area) { f.properties.area = f.properties.AREA_HA }
return f
})
this.totalArea = data.featureCollection.features.reduce((sum, plot) => {
sum += plot.properties.area || 0
return sum
}, 0)
if (data.crops) {
this.crops = data.crops
} else {
// extract crops from fields
}
this.selectedCrop = data.selectedCrop || this.crops[0].name
// this.totalArea = data.totalArea || 0
} catch (e) {
console.log(e)
// no data yet
}
if (urlParams.has('preset') && !this.featureCollection.features.length) {
const preset = urlParams.get('preset')
try {
const data = await db.get(preset)
this.featureCollection = data.featureCollection
this.crops = data.crops
this.selectedCrop = data.selectedCrop || this.crops[0].name
this.totalArea = data.totalArea || 0
} catch (e) {
// preset doesn't exist
}
}
this.createMap()
this.createChart()
this.loading = false
},
methods: {
startDrag (e) {
this.draggedElement = e.item
this.draggedElement.style = 'cursor: grabbing;'
},
endDrag (e) {
this.draggedElement.style = 'cursor: pointer;'
},
checkMove (e, ghostEl) {
if (e.y > 737 && ghostEl.classList.contains('drag-test')) {
// inside footer
ghostEl.classList.remove('drag-test')
} else if (e.y < 737 && !ghostEl.classList.contains('drag-test')) {
ghostEl.classList.add('drag-test')
}
},
addCrop (cropData) {
// update crops array
this.crops.push(cropData)
// add crop to chart
this.chart.data.datasets.push({
data: [cropData.area],
label: cropData.name,
backgroundColor: cropData.color,
barPercentage: 1,
categoryPercentage: 1,
barThickness: 30,
maxBarThickness: 30
})
this.chart.update()
// update map colors
this.map.setPaintProperty('plots', 'fill-color', {
type: 'categorical',
property: 'crop',
stops: this.crops.map(c => [c.name, c.color])
})
},
deleteCrop (item) {
const index = item.oldIndex
const crop = this.crops[index]
// remove selected plots and total
this.featureCollection.features = this.featureCollection.features.filter(p => p.properties.crop !== crop.name)
this.totalArea = this.totalArea - crop.area
this.map.getSource('plots').setData(this.featureCollection)
// remove from chart
this.chart.data.datasets.splice(index, 1)
this.chart.update()
// remove from crops array
this.crops.splice(index, 1)
// make adjacent crop the selected one
if (this.selectedCrop === crop.name) {
if (this.crops.length && this.crops[index]) {
this.selectedCrop = this.crops[index].name
} else if (this.crops.length) {
this.selectedCrop = this.crops[0].name
}
}
this.updateData()
},
async updateData () {
let data = {
_id: this.sessionId,
_rev: this._rev
}
try {
data = await db.get(this.sessionId)
} catch (e) {
// no data yet
}
data.crops = this.crops
data.featureCollection = this.featureCollection
data.totalArea = this.totalArea
data.selectedCrop = this.selectedCrop
try {
await db.put(data)
} catch (e) {
// eslint-disable-next-line no-console
console.log(e)
}
console.log('updated')
},
truncateName (name) {
if (name.length > 10) {
return name.slice(0, 8) + '..'
} else {
return name
}
},
merge (inputs) {
const output = {
id: inputs[0].id,
type: inputs[0].type,
geometry: {
coordinates: union(...inputs.map(i => i.geometry.coordinates)),
type: 'MultiPolygon'
},
properties: inputs[0].properties
}
return output
},
createMap () {
mapboxgl.accessToken = 'pk.eyJ1IjoidG9mZmkiLCJhIjoiY2lvMDBxMzR3MDB1eHZza2x4NGI2YjI5OSJ9.IEaNA05pWbT92nOu-lEOYw'
this.map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/satellite-streets-v11?optimize=true',
center: [8.3502733, 52.0887843],
zoom: 13
})
this.map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
}), 'bottom-right'
)
const geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl
})
if (screen.width < 475) {
document.getElementById('geocoder').appendChild(geocoder.onAdd(this.map))
} else {
this.map.addControl(geocoder)
}
this.map.on('load', () => {
this.map.addSource('plots', {
type: 'geojson',
data: this.featureCollection
})
this.map.addSource('plot-shapes', {
url: 'mapbox://toffi.plots-germany',
type: 'vector'
})
this.map.addLayer(
{
id: 'plots-germany-outline',
type: 'line',
source: 'plot-shapes',
'source-layer': 'plots_germany',
minzoom: 10,
paint: {
'line-color': 'hsl(0, 0%, 100%)',
'line-width': [
'interpolate',
['linear'],
['zoom'],
0,
0,
12,
0,
14,
2
]
}
},
'country-label'
)
this.map.addLayer(
{
id: 'plots-germany',
type: 'fill',
source: 'plot-shapes',
'source-layer': 'plots_germany',
minzoom: 10,
paint: {
'fill-color': 'hsl(0, 0%, 100%)',
'fill-outline-color': 'hsl(0, 0%, 100%)',
'fill-antialias': false,
'fill-opacity': [
'interpolate',
['linear'],
['zoom'],
0,
0,
12,
0,
14,
0.26
]
}
},
'country-label'
)
this.map.addLayer({
id: 'plots',
type: 'fill',
source: 'plots',
layout: {},
paint: {
'fill-color': {
type: 'categorical',
property: 'crop',
stops: this.crops.map(c => [c.name, c.color])
},
'fill-opacity': 1
}
})
this.map.addLayer({
id: 'plots-label',
type: 'symbol',
source: 'plots',
minzoom: 13,
maxzoom: 14,
layout: {
'text-field': ['format',
['number-format', ['get', 'area'], { 'max-fraction-digits': 2 }], ' ha', {} // ['get', 'AREA_HA'], {}, // Use default formatting
],
'text-font': [
'DIN Offc Pro Medium',
'Arial Unicode MS Bold'
],
'text-size': 12
}
})
this.map.addLayer({
id: 'plots-label-prev-crop',
type: 'symbol',
source: 'plots',
minzoom: 14,
layout: {
'text-field': ['format',
['number-format', ['get', 'area'], { 'max-fraction-digits': 2 }], ' ha', {}, // ['get', 'AREA_HA'], {}, // Use default formatting
'\n', {},
'Ausgewählt: ', {
'text-font': ['literal', ['DIN Offc Pro Italic']],
'font-scale': 0.8
}, ['get', 'crop'],
{
'text-font': ['literal', ['DIN Offc Pro Italic']],
'font-scale': 0.8
}
],
'text-font': [
'DIN Offc Pro Medium',
'Arial Unicode MS Bold'
],
'text-size': 12
}
})
if (this.featureCollection?.features?.length) {
this.map.fitBounds(bbox(this.featureCollection), { padding: 30, duration: 0 })
}
})
this.map.on('click', (e) => {
const features = this.map.queryRenderedFeatures(e.point)
let feature
if (features[0]) {
console.log(features[0])
try {
const featureData = this.map.querySourceFeatures(features[0].source, {
sourceLayer: features[0].sourceLayer,
filter: ['==', '$id', features[0].id]
})
if (Array.isArray(featureData)) {
feature = this.merge(featureData)
} else {
feature = featureData
}
} catch (e) {
// clicked on a non-feature, fail silently
return
}
// assign the id if it's not given
if (!feature.properties.ID) {
feature.properties.ID = feature.id
}
// check if the feature is on the map already
const match = this.featureCollection.features.find(f => f.properties.ID === feature.properties.ID)
if (match) {
// if the plot was clicked upon while a different crop was chosen,
// we just switch the crop shares accordingly
if (match.properties.crop !== this.selectedCrop) {
const oldCrop = this.crops.find(c => c.name === match.properties.crop)
const newCrop = this.crops.find(c => c.name === this.selectedCrop)
oldCrop.area = oldCrop.area - match.properties.area
newCrop.area = newCrop.area + match.properties.area
match.properties.crop = this.selectedCrop
} else {
const crop = this.crops.find(c => c.name === match.properties.crop)
crop.area = crop.area - match.properties.area
this.totalArea = this.totalArea - match.properties.area
const index = this.featureCollection.features.indexOf(match)
this.featureCollection.features.splice(index, 1)
}
// check if the selected crop is different than the current crop
} else {
const crop = this.crops.find(c => c.name === this.selectedCrop)
crop.area = crop.area + feature.properties.area
this.totalArea = this.totalArea + feature.properties.area
feature.properties.crop = this.selectedCrop
this.featureCollection.features.push(feature)
}
this.map.getSource('plots').setData(this.featureCollection)
this.updateChart()
}
// this.featureCollection.features.find()
})
},
updateChart () {
this.chart.data.datasets.forEach((dataset, i) => {
dataset.data[0] = this.crops[i].area < 0.1 ? 0 : this.crops[i].area
})
this.chart.update()
},
randomColor () {
return '#' + Math.floor(Math.random() * 16777215).toString(16)
},
createChart () {
const ctx = document.getElementById('crop-summary').getContext('2d')
const getOrCreateTooltip = (chart) => {
let tooltipEl = chart.canvas.parentNode.querySelector('div')
if (!tooltipEl) {
tooltipEl = document.createElement('div')
tooltipEl.style.background = 'rgba(0, 0, 0, 0.7)'
tooltipEl.style.borderRadius = '3px'
tooltipEl.style.color = 'white'
tooltipEl.style.opacity = 1
tooltipEl.style.pointerEvents = 'none'
tooltipEl.style.position = 'absolute'
tooltipEl.style.transform = 'translate(-50%, 0)'
tooltipEl.style.transition = 'all .1s ease'
const table = document.createElement('table')
table.style.margin = '0px'
tooltipEl.appendChild(table)
chart.canvas.parentNode.appendChild(tooltipEl)
}
return tooltipEl
}
const externalTooltipHandler = (context) => {
// Tooltip Element
const { chart, tooltip } = context
const tooltipEl = getOrCreateTooltip(chart)
// Hide if no tooltip
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = 0
return
}
// Set Text
if (tooltip.body) {
const titleLines = tooltip.title || []
const bodyLines = tooltip.body.map(b => b.lines)
const tableHead = document.createElement('thead')
titleLines.forEach((title) => {
const tr = document.createElement('tr')
tr.style.borderWidth = 0
const th = document.createElement('th')
th.style.borderWidth = 0
const text = document.createTextNode(title)
th.appendChild(text)
tr.appendChild(th)
tableHead.appendChild(tr)
})
const tableBody = document.createElement('tbody')
bodyLines.forEach((body, i) => {
const colors = tooltip.labelColors[i]
const span = document.createElement('span')
span.style.background = colors.backgroundColor
span.style.borderColor = colors.borderColor
span.style.borderWidth = '2px'
span.style.marginRight = '10px'
span.style.height = '10px'
span.style.width = '10px'
span.style.display = 'inline-block'
const tr = document.createElement('tr')
tr.style.backgroundColor = 'inherit'
tr.style.borderWidth = 0
const td = document.createElement('td')
td.style.borderWidth = 0
const text = document.createTextNode(body)
td.appendChild(span)
td.appendChild(text)
tr.appendChild(td)
tableBody.appendChild(tr)
})
const tableRoot = tooltipEl.querySelector('table')
// Remove old children
while (tableRoot.firstChild) {
tableRoot.firstChild.remove()
}
// Add new children
tableRoot.appendChild(tableHead)
tableRoot.appendChild(tableBody)
}
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas
// Display, position, and set styles for font
tooltipEl.style.opacity = 1
tooltipEl.style.left = positionX + tooltip.caretX + 'px'
tooltipEl.style.top = positionY + tooltip.caretY + 'px'
tooltipEl.style.font = tooltip.options.bodyFont.string
tooltipEl.style.padding = tooltip.options.padding + 'px ' + tooltip.options.padding + 'px'
}
this.chart = new Chart(ctx, {
type: 'bar',
data: {
labels: [''],
datasets: this.crops.map((crop, i) => {
return {
data: [crop.area || 0],
label: crop.name,
backgroundColor: crop.color,
barPercentage: 1,
categoryPercentage: 1,
barThickness: 30,
maxBarThickness: 30
}
})
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0
}
},
plugins: {
legend: {
display: false,
align: 'start'
},
tooltip: {
enabled: false,
position: 'nearest',
external: externalTooltipHandler
},
datalabels: {
color (context) {
const crop = context.dataset.label
return crop === '(Unbekannt)' ? 'black' : 'white'
},
anchor: 'end',
align: 'start',
display (context) {
return context.dataset.data[context.dataIndex] > 5
},
font: {
weight: 'normal'
},
formatter (value, context) {
return Math.round(value) + ' ha'
}
}
},
scales: {
y: {
display: false,
stacked: true,
gridLines: {
display: false
},
ticks: {
display: false
}
},
x: {
display: false,
stacked: true,
ticks: {
// min: 0,
// max: 100,
mirror: true,
display: false
},
gridLines: {
display: false
}
}
}
}
})
}
}
}
</script>
<style>
body {
margin: 0;
padding: 0;
}
#map {
position: absolute;
top: 65px;
bottom: 84px;
width: 100%;
}
#crop-summary {
width: 100% !important;
}
.geocoder {
/*position: absolute;*/
/*top: 0;*/
z-index: 100;
width: 100%;
min-width: 100%;
background-color: white;
}
.mapboxgl-ctrl-geocoder {
border-bottom: 1px solid #ececec;
border-radius: 0px;
box-shadow: unset;
}
.break {
flex-basis: 100%;
height: 0;
}
.header {
/*position: absolute;*/
/*top: 0;*/
width: 100%;
background-color: white;
/* height: 150px; */
min-height: 50px;
z-index: 10;
display: inline-flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
}
@media (min-width: 576px) {
.header {
padding: 10px 10px 0px 10px;
align-items: center;
}
}
.loading {
position: absolute;
background-color: white;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.footer {
display: flex;
position: absolute;
bottom: 0;
width: 100%;
height: 84px;
overflow: scroll;
}
.footer::before, .footer::after {
content: ''; /* Insert pseudo-element */
margin: auto; /* Make it push flex items to the center */
}
.crop {
display: flex;
width: 50px;
margin-right: 25px;
font-size: 14px;
cursor: pointer;
flex-direction: column;
justify-content: center;
align-items: center;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.crop:hover .bubble {
border: 3px solid #878484;
}
.crop:active .bubble {
animation: pulse 1s 1, done 2s infinite 0.5s;
}
@keyframes done {
0% {
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.3);
}
70% {
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.3);
}
100% {
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.3);
}
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
}
.selectedCrop {
border: 3px solid black !important;
}
.addCrop {
display: flex;
width: 50px;
margin-right: 25px;
font-size: 14px;
cursor: pointer;
flex-direction: column;
justify-content: center;
align-items: center;
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.addCrop:hover div {
border: 3px solid #878484 !important;
}
.bubble {
width: 30px;
height: 30px;
background: grey;
border-radius: 50%;
}
.bubble-wrapper {
padding: 15px 0px 15px 15px;
width: fit-content;
display: flex;
flex-wrap: nowrap;
overflow-y: clip;
}
/* Sample `apply` at-rules with Tailwind CSS
.container {
@apply min-h-screen flex justify-center items-center text-center mx-auto;
}
*/
.container {
margin: 0 auto;
/* min-height: 100vh; */
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
text-align: center;
}
.title {
font-family:
'Quicksand',
'Source Sans Pro',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
display: block;
font-weight: 300;
font-size: 100px;
color: #35495e;
letter-spacing: 1px;
}
.subtitle {
font-weight: 300;
font-size: 42px;
color: #526488;
word-spacing: 5px;
padding-bottom: 15px;
}
.links {
padding-top: 15px;
}
.flip-list-move {
transition: transform 0.8s ease;
}
.dontDisplayGhost {
opacity: 0;
}
.sortable-drag .bubble {
transform: scale(0.8);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.3);
}
.drag-test {
width: 24px;
height: 24px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' x='0px' y='0px'%0Awidth='24' height='24'%0AviewBox='0 0 24 24'%0Astyle=' fill:%23000;'%3E%3Cpath fill='white' d='M 10 2 L 9 3 L 4 3 L 4 5 L 5 5 L 5 20 C 5 20.522222 5.1913289 21.05461 5.5683594 21.431641 C 5.9453899 21.808671 6.4777778 22 7 22 L 17 22 C 17.522222 22 18.05461 21.808671 18.431641 21.431641 C 18.808671 21.05461 19 20.522222 19 20 L 19 5 L 20 5 L 20 3 L 15 3 L 14 2 L 10 2 z'%3E%3C/path%3E%3Cpath d='M 10 2 L 9 3 L 4 3 L 4 5 L 5 5 L 5 20 C 5 20.522222 5.1913289 21.05461 5.5683594 21.431641 C 5.9453899 21.808671 6.4777778 22 7 22 L 17 22 C 17.522222 22 18.05461 21.808671 18.431641 21.431641 C 18.808671 21.05461 19 20.522222 19 20 L 19 5 L 20 5 L 20 3 L 15 3 L 14 2 L 10 2 z M 7 5 L 17 5 L 17 20 L 7 20 L 7 5 z M 9 7 L 9 18 L 11 18 L 11 7 L 9 7 z M 13 7 L 13 18 L 15 18 L 15 7 L 13 7 z'%3E%3C/path%3E%3C/svg%3E");
background-size: 100%;
}
.drag-test .bubble {
box-shadow: none;
}
.drag-test > * {
color: transparent;
border: none !important;
border-radius: none !important;
color: transparent !important;
background-color: unset !important;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment