Relation tooltip, filter, page title
This commit is contained in:
parent
a43c320af6
commit
1650efae49
3 changed files with 185 additions and 56 deletions
|
@ -22,11 +22,13 @@
|
|||
--selected-color: var(--color1);
|
||||
--link-color: rgba(255,255,255,0.5);
|
||||
--link-hover-color: var(--hover-color);
|
||||
--link-hover-related-color: var(--hover-related-color);
|
||||
--link-hover-related-color: var(--hover-related-color);
|
||||
--link-focus-color: var(--hover-color);
|
||||
|
||||
--body-back: #96a7b7; /*#9cb3c9; /*#8195a7; /*#9cb3c9; #b9cada*/
|
||||
--title-color: #1c1c1c;
|
||||
|
||||
--zoom: 1; /* to be overriden by js */
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -63,33 +65,33 @@ svg .links line, svg .links path {
|
|||
/* stroke: #f3722c; */
|
||||
/* stroke: #9df32c; */
|
||||
stroke: var(--link-color);
|
||||
stroke-width: 6;
|
||||
stroke-width: calc(10px / var(--zoom));
|
||||
fill: none;
|
||||
transition: stroke-width 1s;
|
||||
/* transition: stroke-width 1s; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg .links line.hover, svg .links path.hover {
|
||||
stroke: var(--link-hover-color);
|
||||
stroke: var(--link-hover-color) !important;
|
||||
/* stroke-width: 12; */
|
||||
marker-end: url(#arrowHeadSelected);
|
||||
marker-end: url(#arrowHeadSelected) !important;
|
||||
}
|
||||
|
||||
svg .links .linkedHover path{
|
||||
stroke: var(--link-hover-related-color);
|
||||
stroke-width: 12;
|
||||
stroke-width: calc(12px / var(--zoom));
|
||||
marker-end: url(#arrowHeadSelectedRelated);
|
||||
}
|
||||
|
||||
svg .links .linkedSelected path{
|
||||
stroke: var(--link-focus-color);
|
||||
stroke-width: 12;
|
||||
marker-end: url(#arrowHeadSelected);
|
||||
stroke: var(--link-hover-related-color);
|
||||
stroke-width: calc(12px / var(--zoom));
|
||||
marker-end: url(#arrowHeadSelectedRelated);
|
||||
}
|
||||
|
||||
svg.zoomed .links line, svg.zoomed .links path {
|
||||
/* svg.zoomed .links line, svg.zoomed .links path {
|
||||
stroke-width: 2;
|
||||
}
|
||||
} */
|
||||
|
||||
/* svg.zoomed .links line, svg.zoomed .links path.hover {
|
||||
stroke-width: 4;
|
||||
|
@ -154,16 +156,18 @@ svg #header text#subtitle {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.node text.nodeTitle {
|
||||
text-anchor: start;
|
||||
dominant-baseline: hanging;
|
||||
font-size: calc(20pt / var(--zoom)) !important;
|
||||
/*achieves a 'text-anchor: top'*/
|
||||
/* font-size: 16pt; */
|
||||
/*Set this in JS*/
|
||||
transition: font-size .4s, opacity 1s;
|
||||
transition: opacity 1s;
|
||||
fill: #5d5d5f; /*also when hovering node*/
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
/* pointer-events: none; */
|
||||
/*prevent mouse glitches*/
|
||||
}
|
||||
|
||||
|
@ -171,6 +175,7 @@ svg #header text#subtitle {
|
|||
.node text.nodeTitle.overlapping {
|
||||
/* used to be shown on hover, but disabled now that we have a tooltip */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
svg.zoomed .node text.nodeTitle {
|
||||
|
@ -190,10 +195,10 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
.node.linkHover circle, .node.linkHover path, .node.linkedHover path, label:hover .node path {
|
||||
fill: var(--hover-related-color) !important;
|
||||
stroke: var(--hover-related-color);
|
||||
stroke-width: 5px;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.node.linkedSelected path {
|
||||
fill: var(--hover-related-color) !important;
|
||||
.selectedNode .node:not(.linkedSelected) path {
|
||||
fill: lightgray !important;
|
||||
/* same as linkHover/linkedHover but without border */
|
||||
}
|
||||
|
||||
|
@ -204,7 +209,7 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
.node:hover circle, .node:hover path, .node.selected path {
|
||||
fill: var(--hover-color) !important;
|
||||
stroke: var(--hover-color);
|
||||
stroke-width: 5px;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
/*
|
||||
.node:hover text {
|
||||
|
@ -212,7 +217,11 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
fill: var(--hover-color);
|
||||
} */
|
||||
|
||||
.node.selected circle, .node.selected path {
|
||||
|
||||
/* .selectedNode .node circle, .selectedNode .node path {
|
||||
fill: lightgray !important;
|
||||
} */
|
||||
.selectedNode .node.selected circle, .selectedNode .node.selected path {
|
||||
fill: var(--selected-color) !important;
|
||||
stroke: var(--selected-color);
|
||||
stroke-width: 5px;
|
||||
|
@ -245,7 +254,7 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
|
||||
.node.Institution circle, .node.Institution path {
|
||||
/* fill: lightcoral; */
|
||||
fill: red;
|
||||
fill: #11F999;
|
||||
}
|
||||
|
||||
.node.Dataset circle, .node.Dataset path {
|
||||
|
@ -258,37 +267,37 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
}
|
||||
|
||||
.node.Institution.Institution-company path{
|
||||
fill: blue;
|
||||
fill: #45F68A;
|
||||
}
|
||||
.node.Institution.Institution-government path{
|
||||
fill: orange;
|
||||
fill: #60F37B;
|
||||
}
|
||||
.node.Institution.Institution-local-government path{
|
||||
fill: purple;
|
||||
fill: #75F06D;
|
||||
}
|
||||
.node.Institution.Institution-law-enforcement path{
|
||||
fill: seagreen;
|
||||
fill: #87EC60;
|
||||
}
|
||||
.node.Institution.Institution-ngo path{
|
||||
fill: yellow;
|
||||
fill: #97E853;
|
||||
}
|
||||
.node.Institution.Institution-university path{
|
||||
fill: pink;
|
||||
fill: #A6E447;
|
||||
}
|
||||
.node.Institution.Institution-research path{
|
||||
fill: fuchsia;
|
||||
fill: #B4E03C;
|
||||
}
|
||||
.node.Institution.Institution-project path{
|
||||
fill: rgb(0, 255, 255);
|
||||
fill: #C1DB31;
|
||||
}
|
||||
.node.Institution.Institution-watchdog path{
|
||||
fill: rgb(145, 255, 0);
|
||||
fill: #CED628;
|
||||
}
|
||||
.node.Institution.Institution-expert-group path{
|
||||
fill: rgb(149, 87, 161);
|
||||
fill: #DAD121;
|
||||
}
|
||||
.node.Institution.Institution-foundation path{
|
||||
fill: brown;
|
||||
fill: #E5CB1E;
|
||||
}
|
||||
.node.Institution.Institution-international-organization path{
|
||||
fill: gray;
|
||||
|
@ -348,6 +357,13 @@ svg.zoomed.zoomed2 .node text.nodeTitle {
|
|||
border-radius: 5px;
|
||||
box-shadow: 2px 2px 5px rgba(0, 0, 0, .5);
|
||||
}
|
||||
#tooltip.link{
|
||||
|
||||
}
|
||||
|
||||
#tooltip .entity{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tooltip:not(.visible){
|
||||
position:absolute;
|
||||
|
|
161
www/graph.js
161
www/graph.js
|
@ -22,6 +22,13 @@ const CONFIG = {
|
|||
"label": "Institution",
|
||||
"type": "categories",
|
||||
},
|
||||
// TODO: nested filters
|
||||
// TODO:restructure, allow for groups of types:
|
||||
// [12:08, 07-05-2021] Francesco Ragazzi: Government / Regional / local can be the same
|
||||
// [12:08, 07-05-2021] Francesco Ragazzi: Also IO
|
||||
// [12:08, 07-05-2021] Francesco Ragazzi: NGO, Foundation can be grouped
|
||||
// [12:08, 07-05-2021] Francesco Ragazzi: University, Research, Expert group can be grouped
|
||||
// [12:08, 07-05-2021] Francesco Ragazzi: Watchdog and can also be grouped with government
|
||||
"Law Enforcement": {
|
||||
// "label": "Institution",
|
||||
"type": "institution_types",
|
||||
|
@ -95,8 +102,53 @@ const CONFIG = {
|
|||
"Software Deployed",
|
||||
"Software Developer",
|
||||
"Dataset Developer",
|
||||
"Related Institutions",
|
||||
"Involved Entities",
|
||||
],
|
||||
|
||||
"link_labels": {
|
||||
"Clients": {
|
||||
"label": "is client of",
|
||||
"swap": true,
|
||||
},
|
||||
"Managed by": {
|
||||
"label": "manages",
|
||||
"swap": true,
|
||||
},
|
||||
"Used by": {
|
||||
"label": "uses",
|
||||
"swap": true,
|
||||
},
|
||||
"Funded by": {
|
||||
"label": "is funded by",
|
||||
"swap": false,
|
||||
},
|
||||
"Provided by": {
|
||||
"label": "provides",
|
||||
"swap": true,
|
||||
},
|
||||
"Software Deployed": {
|
||||
"label": "is used in",
|
||||
"swap": true,
|
||||
},
|
||||
"Software Developer": {
|
||||
"label": "is developed by",
|
||||
"swap": false,
|
||||
},
|
||||
"Dataset Developer": {
|
||||
"label": "develops dataset for",
|
||||
"swap": true,
|
||||
},
|
||||
"Related Institutions": {
|
||||
"label": "is related to",
|
||||
"swap": true,
|
||||
},
|
||||
"Involved Entities": {
|
||||
"label": "is involved in",
|
||||
"swap": true,
|
||||
},
|
||||
},
|
||||
|
||||
"geo_properties": [
|
||||
"Geolocation",
|
||||
"City Coordinates",
|
||||
|
@ -113,7 +165,7 @@ const CONFIG = {
|
|||
"Country": ["Country"],
|
||||
"City": ["City"],
|
||||
// ["Deployment type"], // TODO: select this
|
||||
"Institution" : ["Institution Type"], // TODO: select this (local gov, etc.)
|
||||
"Institution": ["Institution Type"], // TODO: select this (local gov, etc.)
|
||||
"Dataset": ["Datasets used"],
|
||||
"Company": ["Managed by", "Provided by", "Developped by (institutions)"],
|
||||
"Tech": ["Technologies Used", "Software Deployed"],
|
||||
|
@ -130,6 +182,17 @@ const CONFIG = {
|
|||
]
|
||||
};
|
||||
|
||||
|
||||
function getLinkLabelConfig(linkName){
|
||||
if(CONFIG.link_labels.hasOwnProperty(linkName)){
|
||||
return CONFIG.link_labels[linkName];
|
||||
}
|
||||
return {
|
||||
'label': linkName,
|
||||
'swap': false,
|
||||
}
|
||||
}
|
||||
|
||||
// let width = window.innerWidth;
|
||||
// let height = window.innerHeight;
|
||||
|
||||
|
@ -264,7 +327,7 @@ function getTitle(obj) {
|
|||
function getCategories(obj) {
|
||||
// console.log(obj);
|
||||
let cats = obj.printouts['Category'].map(n => n.fulltext.split(':')[1]);
|
||||
if(obj.printouts.hasOwnProperty("Institution Type") && obj.printouts['Institution Type'].length) {
|
||||
if (obj.printouts.hasOwnProperty("Institution Type") && obj.printouts['Institution Type'].length) {
|
||||
obj.printouts['Institution Type'].forEach(type => {
|
||||
cats.push(getInstitutionClass(type.fulltext));
|
||||
});
|
||||
|
@ -272,7 +335,7 @@ function getCategories(obj) {
|
|||
return cats;
|
||||
}
|
||||
function getInstitutionClass(name) {
|
||||
return "Institution-"+ slugify(name);
|
||||
return "Institution-" + slugify(name);
|
||||
}
|
||||
function getClasses(obj) {
|
||||
const classes = getCategories(obj);
|
||||
|
@ -339,7 +402,7 @@ class NodeMap {
|
|||
|
||||
render() {
|
||||
this.svg = this.root.append('svg')
|
||||
this.svg.append('defs').html(`<marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHead" ><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelected"><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="4" markerWidth="4" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelectedRelated"><path d="M0,-3L8,0L0,3"></path></marker>
|
||||
this.svg.append('defs').html(`<marker markerHeight="3" markerWidth="3" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHead" ><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="3" markerWidth="3" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelected"><path d="M0,-3L8,0L0,3"></path></marker><marker markerHeight="3" markerWidth="3" refY="0" refX="6" viewBox="0 -3 8 6" preserveAspectRatio="none" orient="auto" id="arrowHeadSelectedRelated"><path d="M0,-3L8,0L0,3"></path></marker>
|
||||
<!--Sketching:-->
|
||||
<defs>
|
||||
<filter id="tint">
|
||||
|
@ -358,7 +421,7 @@ class NodeMap {
|
|||
</filter>
|
||||
</defs>`);
|
||||
|
||||
this.svg.on('click', (e) => { console.log(e); this.deselectNode()})
|
||||
this.svg.on('click', (e) => { console.log(e); this.deselectNode() })
|
||||
|
||||
// const noise = 0.001;
|
||||
// this.svg.append('defs').append('filter').attr('id', 'splotch').html( `${
|
||||
|
@ -421,7 +484,7 @@ class NodeMap {
|
|||
return "country";
|
||||
})
|
||||
.attr("d", this.proj)
|
||||
// .attr("filter", 'url(#splotch)')
|
||||
// .attr("filter", 'url(#splotch)')
|
||||
// .attr("fill", );
|
||||
|
||||
this.g_borders
|
||||
|
@ -443,6 +506,7 @@ class NodeMap {
|
|||
if (zoomTimeout) {
|
||||
clearTimeout(zoomTimeout)
|
||||
}
|
||||
document.querySelector(':root').style.setProperty('--zoom', evt.transform.k);
|
||||
zoomTimeout = setTimeout(() => {
|
||||
this.g_nodes.attr('style', `font-size:${22000 / this.height / evt.transform.k}pt`)
|
||||
setTimeout(() => {
|
||||
|
@ -517,7 +581,6 @@ class NodeMap {
|
|||
|
||||
this.graph.nodes.forEach((d) => {
|
||||
for (const prop of CONFIG.geo_properties) {
|
||||
|
||||
// console.log(this,d.printouts, prop)
|
||||
if (d.printouts[prop].length) {
|
||||
// console.log("fix node", d);
|
||||
|
@ -667,8 +730,15 @@ class NodeMap {
|
|||
}
|
||||
|
||||
showTooltip(el, node, links) {
|
||||
if(el.tagName != 'path'){
|
||||
let parentEl = el.parentNode;
|
||||
if(parentEl.tagName != 'g'){
|
||||
parentEl = parentEl.parentNode;
|
||||
}
|
||||
el = parentEl.querySelector('path');
|
||||
}
|
||||
|
||||
// TODO: make links optional (otherwise collect links here)
|
||||
|
||||
this.tooltipEl.innerHTML = `
|
||||
<span class='category'>${getCategories(node)[0]}</span>
|
||||
<h3>${node.fulltext}</h3>
|
||||
|
@ -685,11 +755,46 @@ class NodeMap {
|
|||
this.tooltipEl.style.left = (rect.left + rect.width / 2 - rectTT.width / 2) + 'px';
|
||||
// console.log(el, node, rect.top);
|
||||
|
||||
this.tooltipEl.classList.add('visible');
|
||||
this.tooltipEl.classList.add('visible', 'node');
|
||||
}
|
||||
|
||||
showRelationTooltip(link, evt) {
|
||||
const {label, swap} = getLinkLabelConfig(link.name);
|
||||
if(swap){
|
||||
this.tooltipEl.innerHTML = `
|
||||
<span class='relation'>
|
||||
<span class='entity'>${link.target.fulltext}</span>
|
||||
${label}
|
||||
<span class='entity'>${link.source.fulltext}</span>
|
||||
</span>
|
||||
`;
|
||||
} else {
|
||||
this.tooltipEl.innerHTML = `
|
||||
<span class='relation'>
|
||||
<span class='entity'>${link.source.fulltext}</span>
|
||||
${label}
|
||||
<span class='entity'>${link.target.fulltext}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const rectTT = this.tooltipEl.getBoundingClientRect();
|
||||
|
||||
this.trackerEv = (evt) => {
|
||||
this.tooltipEl.style.top = (evt.clientY - rectTT.height - 10) + 'px';
|
||||
this.tooltipEl.style.left = (evt.clientX - rectTT.width / 2) + 'px';
|
||||
};
|
||||
window.addEventListener('mousemove', this.trackerEv);
|
||||
|
||||
this.tooltipEl.classList.add('visible', 'link');
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
this.tooltipEl.classList.remove('visible');
|
||||
this.tooltipEl.classList.remove('visible', 'node', 'link');
|
||||
if(this.trackerEv){
|
||||
window.removeEventListener('mousemove', this.trackerEv);
|
||||
this.trackerEv = null;
|
||||
}
|
||||
}
|
||||
|
||||
selectNode(node) {
|
||||
|
@ -713,8 +818,8 @@ class NodeMap {
|
|||
document.getElementById(node.id).classList.add('selected');
|
||||
connectedNodes.forEach(n => document.getElementById(n.id).classList.add('linkedSelected'));
|
||||
links.forEach(l => document.getElementById(getLinkId(l)).classList.add('linkedSelected'));
|
||||
|
||||
|
||||
this.container.classed('selectedNode', true);
|
||||
// TODO: show details;
|
||||
|
||||
// alert('not yet implemented');
|
||||
|
@ -730,6 +835,7 @@ class NodeMap {
|
|||
while (els.length) {
|
||||
els[0].classList.remove('linkedSelected');
|
||||
}
|
||||
this.container.classed('selectedNode', false);
|
||||
}
|
||||
|
||||
update() {
|
||||
|
@ -779,7 +885,10 @@ class NodeMap {
|
|||
.attr('d', (n) => {
|
||||
return getSymbolForNode(n)(n);
|
||||
})
|
||||
var nodeTitle = group.append('text').attr("class", "nodeTitle").attr("y", "3").attr('x', 5);
|
||||
var nodeTitle = group.append('text').attr("class", "nodeTitle").attr("y", "4").attr('x', 5);
|
||||
// nodeTitle.on('mouseover', (evt, n) =>{
|
||||
// console.log(evt,n)
|
||||
// });
|
||||
nodeTitle
|
||||
.each(function (node, i, nodes) {
|
||||
var textLength = void 0;
|
||||
|
@ -790,7 +899,7 @@ class NodeMap {
|
|||
titleTexts = splitText(titleText);
|
||||
}
|
||||
if (titleTexts !== false) {
|
||||
const tspan1 = self.append("tspan").text(titleTexts[0]).attr("y", "3").attr("x", "5");
|
||||
const tspan1 = self.append("tspan").text(titleTexts[0]).attr("y", "4").attr("x", "5");
|
||||
const tspan = self.append("tspan").text(titleTexts[1]).attr("dy", "1em").attr("x", "5");
|
||||
// const textLength1 = tspan.node().getComputedTextLength();
|
||||
// const textLength2 = tspan.node().getComputedTextLength();
|
||||
|
@ -873,8 +982,8 @@ class NodeMap {
|
|||
group.append("path")
|
||||
.attr("marker-end", "url(#arrowHead)")
|
||||
.attr('id', (d, i) => 'linkpath_' + i)
|
||||
.on("mouseover", function (ev, link) {
|
||||
d3.select(this).classed('hover', true);
|
||||
.on("mouseover", (ev, link) => {
|
||||
d3.select(ev.target).classed('hover', true);
|
||||
const nodes = document.getElementsByClassName('node');
|
||||
for (let n of nodes) {
|
||||
const d = d3.select(n).datum();
|
||||
|
@ -882,9 +991,13 @@ class NodeMap {
|
|||
n.classList.add('linkHover');
|
||||
}
|
||||
}
|
||||
|
||||
this.showRelationTooltip(link, ev);
|
||||
|
||||
// console.log(l);
|
||||
}).on("mouseout", function (ev, link) {
|
||||
d3.select(this).classed('hover', false);
|
||||
}).on("mouseout", (ev, link) => {
|
||||
this.hideTooltip();
|
||||
d3.select(ev.target).classed('hover', false);
|
||||
const nodes = document.getElementsByClassName('linkHover');
|
||||
while (nodes.length) {
|
||||
nodes[0].classList.remove('linkHover');
|
||||
|
@ -1120,7 +1233,7 @@ class AlluvialMap {
|
|||
|
||||
render() {
|
||||
this.svg = this.root.append('svg')
|
||||
|
||||
|
||||
this.resize();
|
||||
|
||||
this.sankey = d3.sankey()
|
||||
|
@ -1581,9 +1694,9 @@ class Store {
|
|||
}
|
||||
|
||||
isFiltered(node) {
|
||||
if(this.filters.categories.includes(node.printouts['Category'][0].fulltext.split(':')[1]))
|
||||
if (this.filters.categories.includes(node.printouts['Category'][0].fulltext.split(':')[1]))
|
||||
return true;
|
||||
if(node.printouts['Institution Type'].length && this.filters.institution_types.includes(node.printouts['Institution Type'][0].fulltext))
|
||||
if (node.printouts['Institution Type'].length && this.filters.institution_types.includes(node.printouts['Institution Type'][0].fulltext))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
@ -1634,14 +1747,14 @@ class Store {
|
|||
Object.keys(CONFIG.filters).forEach(f => {
|
||||
const settings = CONFIG.filters[f];
|
||||
|
||||
if(settings.type == 'institution_types')
|
||||
// TODO; For now, skip
|
||||
if (settings.type == 'institution_types')
|
||||
// TODO; For now, skip
|
||||
return;
|
||||
|
||||
let categories = [f];
|
||||
if( settings.type == 'institution_types')
|
||||
if (settings.type == 'institution_types')
|
||||
categories = ['Institution', getInstitutionClass(f)];
|
||||
|
||||
|
||||
let labelEl = document.createElement('label')
|
||||
let inputEl = document.createElement('input')
|
||||
let textEl = document.createElement('span');
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="graph.css">
|
||||
|
||||
<title>Remote Biometric Identification | A survey of the European Union</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -18,7 +18,7 @@
|
|||
|
||||
|
||||
<aside id="filters">
|
||||
<h3>Legend</h3>
|
||||
<h3>Filter</h3>
|
||||
</aside>
|
||||
|
||||
</header>
|
||||
|
|
Loading…
Reference in a new issue