Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add venn diagram #2794

Merged
merged 20 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions js/components/charts/chartVenn.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="venn-chart">
<div class="container">
<button title="PNG" class="export-button" data-bind="click: $component.export">PNG</button>
<button title="SVG" class="exportSvg-button" data-bind="click: $component.exportSvg">SVG</button>
<div id="venn"></div>
</div>
</div>
224 changes: 224 additions & 0 deletions js/components/charts/venn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
define([
'knockout',
'text!./chartVenn.html',
'components/Component',
'utils/CommonUtils',
'utils/ChartUtils',
'd3',
'venn',
'less!./venn.less'
], function(
ko,
view,
Component,
commonUtils,
ChartUtils,
d3,
venn
){

class Venn extends Component {

constructor(params,container){
super(params);
this.firstConceptSet = params.firstConceptSet();
this.secondConceptSet = params.secondConceptSet();
this.data = params.data();
this.container = container;
this.chartName = ko.computed(() => {

return `${this.firstConceptSet}_${this.secondConceptSet}_venn`.replaceAll(' ', '_')
});
this.conceptInBothConceptSets = [];
this.conceptInFirstConceptSetOnly = [];
this.conceptInSecondConceptSetOnly = [];
this.selectOutsideConceptSet = params.lastSelectedMatchFilter.extend({notify: 'always'});
this.sets = ko.computed(() => {
this.data.forEach(concept => {
if (concept.conceptIn1Only === 1) {
this.conceptInFirstConceptSetOnly.push(concept.conceptName);
}
if (concept.conceptIn2Only === 1) {
this.conceptInSecondConceptSetOnly.push(concept.conceptName);
}
if (concept.conceptIn1And2 === 1) {
this.conceptInBothConceptSets.push(concept.conceptName);
}
});

let lengthFirstConceptSets = this.conceptInFirstConceptSetOnly.length + this.conceptInBothConceptSets.length;
let lengthSecondConceptSets = this.conceptInSecondConceptSetOnly.length + this.conceptInBothConceptSets.length;
let lengthBothConceptSets = this.conceptInBothConceptSets.length;
const maxDifference = 999;

if (lengthFirstConceptSets/lengthSecondConceptSets > maxDifference) {
const difference = Math.round(lengthFirstConceptSets/lengthSecondConceptSets);
const sameLength = lengthSecondConceptSets === lengthBothConceptSets;
lengthBothConceptSets = sameLength ? lengthBothConceptSets*Math.round(difference/100) : lengthBothConceptSets + lengthBothConceptSets * Math.round(difference/100);
lengthSecondConceptSets *= Math.round(difference/100);
}
if (lengthSecondConceptSets/lengthFirstConceptSets > maxDifference) {
const difference = Math.round(lengthSecondConceptSets/lengthFirstConceptSets);
const sameLength = lengthFirstConceptSets === lengthBothConceptSets;
lengthBothConceptSets = sameLength ? lengthBothConceptSets * Math.round(difference/100) : lengthBothConceptSets + lengthBothConceptSets * Math.round(difference/100);
lengthFirstConceptSets *= Math.round(difference/100);
}

const conceptSets = [
{sets: ['CS1'], size: lengthFirstConceptSets, tooltipText: this.conceptInFirstConceptSetOnly, amountOnly:this.conceptInFirstConceptSetOnly.length, count: this.conceptInFirstConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.firstConceptSet, key: '1 Only' },
{sets: ['CS2'], size: lengthSecondConceptSets, tooltipText: this.conceptInSecondConceptSetOnly,amountOnly:this.conceptInSecondConceptSetOnly.length,count: this.conceptInSecondConceptSetOnly.length + this.conceptInBothConceptSets.length, name: this.secondConceptSet, key: '2 Only'},
];
if (this.conceptInBothConceptSets.length > 0) {
conceptSets.push({
sets: ['CS1','CS2'],
size: lengthBothConceptSets,
count: this.conceptInBothConceptSets.length,
amountOnly: this.conceptInBothConceptSets.length,
label: `${this.conceptInBothConceptSets.length} common concept${this.conceptInBothConceptSets.length === 1 ? '' : 's'}`,
tooltipText: this.conceptInBothConceptSets,
key: 'Both'
});
}
return conceptSets.sort((a,b) => b.size - a.size);
});

let chart = venn.VennDiagram();
chart.wrap(false)
.height(450);

const textY = [0,0,30];
const colors = ['#1f77b4','#17becf', '#d62728'];
const defaultColors = ['#d9edf7','#bdf9ff','#f2dede'];
let div = d3.select("#venn").datum(this.sets()).call(chart);
div.selectAll("text").attr("y", function(d,i) { return textY[i] + (+d3.select(this).attr("y")); }).style("font-size", '12px').style("fill", 'black').style('visibility', function(d) { return d.amountOnly ? 'visible' : 'hidden'});

div.selectAll("path")
.style("stroke", function(d,i) { return colors[i]; })
.style("stroke-width", 2)
.style("cursor",'pointer')
.style("fill-opacity", 1)
.style("fill", function(d,i) {return defaultColors[i]; })
.attr("class", function(d,i) { return d.key; })
.attr("color", function(d,i) {return colors[i]; })
.attr("defaultColor", function(d,i) {return defaultColors[i]; });

// calculate new circles coordinates
const circlesPath = [];
div.selectAll("path").each(function (d) { circlesPath.push(d3.select(this).attr('d'))});
const rightCircleEqual = !this.sets()[1].amountOnly;
const newCirclePathes = this.calculateCoordinate(circlesPath,rightCircleEqual);
div.selectAll("path").attr('d',(d,i) => newCirclePathes[i]);

// add a tooltip
let tooltip = d3.select("body").append("div")
.attr("class", "venntooltip");

// add listeners to all the groups to display tooltip on mouseover
div.selectAll("g")
.on("click", function(d) {
params.updateOutsideFilters(d.key);
})

.on("mouseover", function(d) {
// sort all the areas relative to the current item
venn.sortAreas(div, d);
tooltip.transition().duration(400).style("opacity", 0.9).style("visibility", 'visible');
const title = `<div class="title">${d.label ? 'Common concepts' : `${d.name} concept set`}</div>`;
const amount = d.label ? `<div class="title">The amount: ${d.count}</div>` : `<div class="title">The total amount of concepts: ${d.count}</div>`;
const concepts = d.label ? `<div></div>` : `<div class="title">The concepts are only in this concept set: ${d.amountOnly}</div>`;
const textHtml = `${title}${amount}${concepts}`;
tooltip.html(textHtml);
let selection = d3.select(this).transition("tooltip").duration(400);
selection.select("text").style("font-size", '16px');
selection.select("path")
.style("stroke-width", 3);
})

.on("mousemove", function() {
tooltip.style("left", (d3.event.pageX + 40) + "px")
.style("top", (d3.event.pageY - 40) + "px");
})

.on("mouseout", function() {
tooltip.transition().duration(400).style('opacity', 0).style("visibility", 'hidden');
let selection = d3.select(this).transition("tooltip").duration(400);
selection.select("text").style("font-size", '12px');
selection.select("path")
.style("stroke-width", 2);
});

const subscriptions = [];
subscriptions.push(
this.selectOutsideConceptSet.subscribe(function (newValue) {
if (this.selectOutsideConceptSet !== "") {
div.selectAll("path")
.filter(function(d) { return d.key === newValue;})
.classed("selected", function() { return !d3.select(this).classed("selected"); })
.style('fill', function() {

if (d3.select(this).classed("selected")) {
return d3.select(this).attr('color');
} else {
return d3.select(this).attr('defaultColor');
}
});
}
})
);
}

calculateCoordinate(circles,rightCircleEqual) {
if (!rightCircleEqual) {
return circles;
}

const findNumbers = /-?\d+(\.\d+)?/g;
let csLeftCircle = circles[0];
let csRightCircle = circles[1];
let csCommonCircle = circles[2];
const [startX, startY] = csCommonCircle.match(/M\s(.*)/)[0].match(findNumbers);

if (!!csCommonCircle.match(/A\s(.*)/)) {
const endY = csCommonCircle.match(/A\s(.*)/)[0].match(findNumbers)[6];
const commonCurvies = csCommonCircle.match(/A\s(.*)/g);
const commonCurveCS1 = commonCurvies[0].match(findNumbers);
const commonCurveCS2 = commonCurvies[1].match(findNumbers);

const cs1curve = csLeftCircle.match(/A\s(.*)/i)[0].match(findNumbers);
csLeftCircle = `M ${Math.round(startX)} ${Math.round(startY)}
A ${Math.round(commonCurveCS1[0])} ${Math.round(commonCurveCS1[1])} 0 ${commonCurveCS1[3]} ${commonCurveCS1[4]} ${Math.round(startX)} ${Math.round(endY) - 1}
A ${Math.round(cs1curve[0])} ${Math.round(cs1curve[1])} 0 1 0 ${Math.round(startX)} ${Math.round(startY)} z`;

const cs2curve = csRightCircle.match(/A\s(.*)/i)[0].match(findNumbers);
csCommonCircle = `M ${Math.round(startX)} ${Math.round(startY)}
A ${Math.round(commonCurveCS2[0])} ${Math.round(commonCurveCS2[1])} 0 0 0 ${Math.round(startX)} ${Math.round(endY)}
A ${Math.round(cs2curve[0])} ${Math.round(cs2curve[1])} 0 ${cs2curve[3]} 1 ${Math.round(startX)} ${Math.round(startY) - 1} z`;

csRightCircle = '';
} else {
const commonCurve = csCommonCircle.match(/a\s(.*)/)[0].match(findNumbers);
const cs1curve = csLeftCircle.match(/a\s(.*)/i)[0].match(findNumbers);
const sX = Number(startX) + Number(commonCurve[0]);
const sY = startY;

csLeftCircle =`M ${sX} ${sY}
A ${commonCurve[0]} ${commonCurve[1]} 0 1 1 ${sX} ${sY - 0.1}
A ${cs1curve[0]} ${cs1curve[1]} 0 1 0 ${sX} ${sY} z`;
}

return [csLeftCircle,csRightCircle,csCommonCircle];
}

export() {
const svg = this.container.element.querySelector('svg');
ChartUtils.downloadSvgAsPng(svg, this.chartName() || "untitled.png");
}
exportSvg() {
const svg = this.container.element.querySelector('svg');
ChartUtils.downloadSvg(svg, this.chartName() + ".svg" || "untitled.svg");
}

}

return commonUtils.build('venn-diagram', Venn, view);
});
53 changes: 53 additions & 0 deletions js/components/charts/venn.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.venn-chart {
display: flex;
justify-content: center;
position: relative;
.venn-legend {
position: absolute;
width: 100%;
left: 70%;
}

.container {
position:relative;
font-size: 10px;
}

.export-button {
display:none;
position:absolute;
top:0px;
right:32px;
}
.exportSvg-button {
display:none;
position:absolute;
top:0px;
right:0px;
}
.container:hover > .export-button, .container:hover > .exportSvg-button {
display:block;
}
}

.venntooltip {
position: absolute;
font-size: 14px;
min-width: 120px;
min-height: 16px;
max-width: 600px;
max-height: 500px;
background: #333;
color: #ddd;
border: 0px;
border-radius: 8px;
padding: 20px;
overflow-y: hidden;
.title {
font-weight: bold;
font-size: 16px;
}
}
#venn {
text-align: center;
}
23 changes: 23 additions & 0 deletions js/components/faceted-datatable.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ define(['knockout', 'text!./faceted-datatable.html', 'crossfilter', 'utils/Commo
self.scrollY = params.scrollY || null;
self.scrollCollapse = params.scrollCollapse || false;

self.outsideFilters = (params.outsideFilters || ko.observable()).extend({notify: 'always'});

self.updateFilters = function (data, event) {
var facet = data.facet;
data.selected(!data.selected());
Expand All @@ -88,8 +90,22 @@ define(['knockout', 'text!./faceted-datatable.html', 'crossfilter', 'utils/Commo
});
}
self.data.valueHasMutated();

if (params?.updateLastSelectedMatchFilter) {
params.updateLastSelectedMatchFilter(data.key);
}
}


self.updateOutsideFilters = function (key) {
const facets = self.facets();
const facetItems = facets.map(facet => facet.facetItems);
const selectedItemIndex = facetItems.findIndex(facet => facet.find(el => el.key === key));
const selectedFacet = facets[selectedItemIndex].facetItems.find(facet => facet.key === key);

self.updateFilters({...selectedFacet});
};

// additional helper function to help with crossfilter-ing dimensions that contain nulls
self.facetDimensionHelper = function facetDimensionHelper(val) {
var ret = val === null ? self.nullFacetLabel : val;
Expand Down Expand Up @@ -144,6 +160,13 @@ define(['knockout', 'text!./faceted-datatable.html', 'crossfilter', 'utils/Commo
})
);

subscriptions.push(
self.outsideFilters.subscribe(function (newValue) {
if (self.outsideFilters() != undefined) {
self.updateOutsideFilters(newValue);
}
})
);
// init component
if (ko.isComputed(self.reference)) {
// valueHasMutated doesn't work for computed
Expand Down
23 changes: 23 additions & 0 deletions js/pages/concept-sets/components/tabs/conceptset-compare.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,31 @@
"></select>
</div>
</div>
<div class="panel panel-default" data-bind="collapsable: showDiagram, collapseTargetClass: 'panel-body', collapseOptions: { selectorClass: 'panel-heading', collapsabledClass: 'active' }">
<div class="panel-heading panel-subheading panel-heading-collapsible div-enabled" data-bind="click: toggleShowDiagram">
<span class="glyphicon-chevron-up"><i class="fas fa-chart-pie"></i> <span data-bind="text: ko.i18n('ADDSOURCE', 'Venn Diagram')"></span></span>
</div>
<div class="panel-body collapse">
<!-- ko if: !compareResultsSame() -->
<div class="compare-results-same">Compared Concept Sets contain Concepts which are identical</div>
<!-- /ko -->
<!-- ko if: compareResultsSame() -->
<venn-diagram params="{
data: compareResults,
firstConceptSet: compareCS1Caption,
secondConceptSet:compareCS2Caption,
updateOutsideFilters: $component.updateOutsideFilters,
lastSelectedMatchFilter: $component.lastSelectedMatchFilter
}"></venn-diagram>
<!-- /ko -->
</div>

</div>
<div id="compareResults">
<faceted-datatable params="{
reference:$component.compareResults,
outsideFilters: $component.outsideFilters,
updateLastSelectedMatchFilter: $component.updateLastSelectedMatchFilter,
columns: compareResultsColumns,
options:compareResultsOptions,
order: $component.compareResultsOptions.order,
Expand All @@ -111,6 +133,7 @@
language: ko.i18n('datatable.language')
}">
</faceted-datatable>

</div>
</div>
</div>
Expand Down
Loading