Browse Source

added external legend example, visible option for groups, getLegend() function

v3_develop
Alex de Mulder 9 years ago
parent
commit
d384d0d281
10 changed files with 2128 additions and 1789 deletions
  1. +8
    -1
      HISTORY.md
  2. +1744
    -1687
      dist/vis.js
  3. +16
    -2
      docs/graph2d.html
  4. +213
    -9
      examples/graph2d/09_external_legend.html
  5. +9
    -7
      lib/DOMutil.js
  6. +26
    -0
      lib/timeline/Graph2d.js
  7. +23
    -13
      lib/timeline/component/DataAxis.js
  8. +14
    -0
      lib/timeline/component/GraphGroup.js
  9. +17
    -4
      lib/timeline/component/Legend.js
  10. +58
    -66
      lib/timeline/component/LineGraph.js

+ 8
- 1
HISTORY.md View File

@ -3,7 +3,14 @@ http://visjs.org
## not yet released, version 3.1.1
(no changes yet)
### General
- Refactored Timeline and Graph2d to use the same core.
### Graph2D
- Added visible property to the groups.
- Added getLegend() method.
## 2014-07-22, version 3.1.0

+ 1744
- 1687
dist/vis.js
File diff suppressed because it is too large
View File


+ 16
- 2
docs/graph2d.html View File

@ -174,7 +174,7 @@ var items = [
</tr>
</table>
<h3 id="Groups">Groups</h3>
<h3 id="groups">Groups</h3>
<p>
Like the items, groups are regular JavaScript Arrays and Objects.
@ -191,7 +191,8 @@ var items = [
var groups = new vis.DataSet();
groups.add({
id: 1,
content: 'Group 1'
content: 'Group 1',
// Optional: a field 'visible'
// Optional: a field 'className'
// Optional: options
})
@ -241,6 +242,12 @@ groups.add({
<td>This field is optional. The options can be used to give a group a specific draw style.
Any options that are colored green in the Configuration Options can be used as options here.
</tr>
<tr>
<td>visible</td>
<td>Boolean</td>
<td>true</td>
<td>This field is optional. If false, the graph will not be drawn.
</tr>
</table>
<h2 id="Configuration_Options">Configuration Options</h2>
@ -674,6 +681,13 @@ Graph2d.clear({options: true}); // clear options only
</td>
</tr>
<tr>
<td>getLegend(groupId, iconWidth, iconHeight)</td>
<td>SVGelement, String, String</td>
<td>Returns an object containing an SVG element with the icon of the group (size determined by iconWidth and iconHeight), the label of the group (content) and the yAxisOrientation of the group (left or right).
</td>
</tr>
<tr>
<td>getWindow()</td>
<td>Object</td>

+ 213
- 9
examples/graph2d/09_external_legend.html View File

@ -1,7 +1,7 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Graph2d | Both Axis Example</title>
<title>Graph2d | External legend Example</title>
<link href="../../dist/vis.css" rel="stylesheet" type="text/css" />
<style type="text/css">
body, html {
@ -28,28 +28,185 @@
stroke-width:2px;
stroke: #029200;
}
path.customStyle3.fill {
fill-opacity:0.5 !important;
stroke: none;
}
.graphGroup0 {
fill:#4f81bd;
fill-opacity:0;
stroke-width:2px;
stroke: #4f81bd;
}
.graphGroup1 {
fill:#f79646;
fill-opacity:0;
stroke-width:2px;
stroke: #f79646;
}
.graphGroup2 {
fill: #8c51cf;
fill-opacity:0;
stroke-width:2px;
stroke: #8c51cf;
}
.graphGroup3 {
fill: #75c841;
fill-opacity:0;
stroke-width:2px;
stroke: #75c841;
}
.graphGroup4 {
fill: #ff0100;
fill-opacity:0;
stroke-width:2px;
stroke: #ff0100;
}
.graphGroup5 {
fill: #37d8e6;
fill-opacity:0;
stroke-width:2px;
stroke: #37d8e6;
}
.graphGroup6 {
fill: #042662;
fill-opacity:0;
stroke-width:2px;
stroke: #042662;
}
.graphGroup7 {
fill:#00ff26;
fill-opacity:0;
stroke-width:2px;
stroke: #00ff26;
}
.graphGroup8 {
fill:#ff00ff;
fill-opacity:0;
stroke-width:2px;
stroke: #ff00ff;
}
.graphGroup9 {
fill: #8f3938;
fill-opacity:0;
stroke-width:2px;
stroke: #8f3938;
}
.fill {
fill-opacity:0.1;
stroke: none;
}
.bar {
fill-opacity:0.5;
stroke-width:1px;
}
.point {
stroke-width:2px;
fill-opacity:1.0;
}
.legendBackground {
stroke-width:1px;
fill-opacity:0.9;
fill: #ffffff;
stroke: #c2c2c2;
}
.outline {
stroke-width:1px;
fill-opacity:1;
fill: #ffffff;
stroke: #e5e5e5;
}
.iconFill {
fill-opacity:0.3;
stroke: none;
}
div.descriptionContainer {
float:left;
height:30px;
width:160px;
padding-left:5px;
padding-right:5px;
line-height: 30px;
}
div.iconContainer {
float:left;
}
div.legendElementContainer {
display:inline-block;
width:200px;
height:30px;
border-style:solid;
border-width:1px;
border-color: #e0e0e0;
background-color: #ffffff;
margin:4px;
padding:4px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor:pointer;
}
div.legendElementContainer.hidden {
background-color: #d3e6ff;
}
svg.legendIcon {
width:30px;
height:30px;
}
div.externalLegend {
position:relative;
margin-left: -5px;
width: 900px;
}
</style>
<script src="../../dist/vis.js"></script>
</head>
<body>
<h2>Graph2d | Both Axis Example</h2>
<div style="width:700px; font-size:14px; text-align: justify;">
This example shows the some of the graphs outlined on the right side using the <code>yAxisOrientation</code> option within the groups.
We also show a few more custom styles for the graphs. Finally, the legend is manually positioned. Both the left and right axis
have their own legend. If one of the axis is unused, the legend is not shown. The options for the legend have been split
in a <code>left</code> and a <code>right</code> segment. The default position of the left axis has been changed.
<h2>Graph2d | External custom legend</h2>
<div style="width:800px; font-size:14px; text-align: justify;">
This example shows how to create an external custom legend using the getLegend function. We use normal JavaScript to show and hide the
groups by updating the dataset.
</div>
<br />
<div id="Legend" class="externalLegend"></div>
<div id="visualization"></div>
<script type="text/javascript">
// create a dataSet with groups
var names = ['SquareShaded', 'Bar', 'Blank', 'CircleShaded'];
@ -127,13 +284,60 @@
var dataset = new vis.DataSet(items);
var options = {
dataAxis: {showMinorLabels: false},
legend: {left:{position:"bottom-left"}},
start: '2014-06-09',
end: '2014-07-03'
};
var graph2d = new vis.Graph2d(container, items, options, groups);
function populateExternalLegend() {
var groupsData = groups.get();
var legendDiv = document.getElementById("Legend");
legendDiv.innerHTML = "";
for (var i = 0; i < groupsData.length; i++) {
var containerDiv = document.createElement("div");
var iconDiv = document.createElement("div");
var descriptionDiv = document.createElement("div");
containerDiv.className = 'legendElementContainer';
containerDiv.id = groupsData[i].id + "_legendContainer"
iconDiv.className = "iconContainer";
descriptionDiv.className = "descriptionContainer";
var legend = graph2d.getLegend(groupsData[i].id,30,30);
legend.icon.setAttributeNS(null, "class", "legendIcon");
iconDiv.appendChild(legend.icon);
descriptionDiv.innerHTML = legend.label;
console.log(legend)
if (legend.orientation == 'left') {
descriptionDiv.style.textAlign = "left";
containerDiv.appendChild(iconDiv);
containerDiv.appendChild(descriptionDiv);
}
else {
descriptionDiv.style.textAlign = "right";
containerDiv.appendChild(descriptionDiv);
containerDiv.appendChild(iconDiv);
}
legendDiv.appendChild(containerDiv);
containerDiv.onclick = toggleGraph.bind(this,groupsData[i].id);
}
}
function toggleGraph(groupId) {
var container = document.getElementById(groupId + "_legendContainer")
if (graph2d.isGroupVisible(groupId) == true) {
groups.update({id:groupId, visible:false});
container.className = container.className + " hidden";
}
else {
groups.update({id:groupId, visible:true});
container.className = container.className.replace("hidden","");
}
}
populateExternalLegend()
</script>
</body>

+ 9
- 7
lib/DOMutil.js View File

@ -84,7 +84,7 @@ exports.getSVGElement = function (elementType, JSONcontainer, svgContainer) {
*/
exports.getDOMElement = function (elementType, JSONcontainer, DOMContainer) {
var element;
// allocate SVG element, if it doesnt yet exist, create one.
// allocate DOM element, if it doesnt yet exist, create one.
if (JSONcontainer.hasOwnProperty(elementType)) { // this element has been created before
// check if there is an redundant element
if (JSONcontainer[elementType].redundant.length > 0) {
@ -150,10 +150,12 @@ exports.drawPoint = function(x, y, group, JSONcontainer, svgContainer) {
* @param className
*/
exports.drawBar = function (x, y, width, height, className, JSONcontainer, svgContainer) {
var rect = exports.getSVGElement('rect',JSONcontainer, svgContainer);
rect.setAttributeNS(null, "x", x - 0.5 * width);
rect.setAttributeNS(null, "y", y);
rect.setAttributeNS(null, "width", width);
rect.setAttributeNS(null, "height", height);
rect.setAttributeNS(null, "class", className);
// if (height != 0) {
var rect = exports.getSVGElement('rect',JSONcontainer, svgContainer);
rect.setAttributeNS(null, "x", x - 0.5 * width);
rect.setAttributeNS(null, "y", y);
rect.setAttributeNS(null, "width", width);
rect.setAttributeNS(null, "height", height);
rect.setAttributeNS(null, "class", className);
// }
};

+ 26
- 0
lib/timeline/Graph2d.js View File

@ -218,5 +218,31 @@ Graph2d.prototype.setGroups = function(groups) {
this.linegraph.setGroups(newDataSet);
};
/**
*
* @param groupId
* @param width
* @param height
*/
Graph2d.prototype.getLegend = function(groupId, width, height) {
if (width === undefined) {width = 15;}
if (height === undefined) {height = 15;}
if (this.linegraph.groups[groupId] !== undefined) {
return this.linegraph.groups[groupId].getLegend(width,height);
}
else {
return "cannot find group:" + groupId;
}
}
Graph2d.prototype.isGroupVisible = function(groupId) {
if (this.linegraph.groups[groupId] !== undefined) {
return this.linegraph.groups[groupId].visible;
}
else {
return false;
}
}
module.exports = Graph2d;

+ 23
- 13
lib/timeline/component/DataAxis.js View File

@ -155,8 +155,10 @@ DataAxis.prototype._redrawGroupIcons = function () {
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight);
y += iconHeight + iconOffset;
if (this.groups[groupId].visible == true) {
this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight);
y += iconHeight + iconOffset;
}
}
}
@ -211,7 +213,15 @@ DataAxis.prototype.setRange = function (start, end) {
*/
DataAxis.prototype.redraw = function () {
var changeCalled = false;
if (this.amountOfGroups == 0) {
var activeGroups = 0;
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
if (this.groups[groupId].visible == true) {
activeGroups++;
}
}
}
if (this.amountOfGroups == 0 || activeGroups == 0) {
this.hide();
}
else {
@ -272,7 +282,8 @@ DataAxis.prototype.redraw = function () {
* @private
*/
DataAxis.prototype._redrawLabels = function () {
DOMutil.prepareElements(this.DOMelements);
DOMutil.prepareElements(this.DOMelements.lines);
DOMutil.prepareElements(this.DOMelements.labels);
var orientation = this.options['orientation'];
@ -281,7 +292,6 @@ DataAxis.prototype._redrawLabels = function () {
var step = new DataStep(this.range.start, this.range.end, minimumStep, this.dom.frame.offsetHeight);
this.step = step;
step.first();
// get the distance in pixels for a step
var stepPixels = this.dom.frame.offsetHeight / ((step.marginRange / step.step) + 1);
this.stepPixels = stepPixels;
@ -320,7 +330,6 @@ DataAxis.prototype._redrawLabels = function () {
if (isMajor && this.options['showMajorLabels'] && this.master == true ||
this.options['showMinorLabels'] == false && this.master == false && isMajor == true) {
if (y >= 0) {
this._redrawLabel(y - 2, step.getCurrent(), orientation, 'yAxis major', this.props.majorCharHeight);
}
@ -341,7 +350,8 @@ DataAxis.prototype._redrawLabels = function () {
if (this.maxLabelSize > (this.width - offset) && this.options.visible == true) {
this.width = this.maxLabelSize + offset;
this.options.width = this.width + "px";
DOMutil.cleanupElements(this.DOMelements);
DOMutil.cleanupElements(this.DOMelements.lines);
DOMutil.cleanupElements(this.DOMelements.labels);
this.redraw();
return true;
}
@ -349,12 +359,14 @@ DataAxis.prototype._redrawLabels = function () {
else if (this.maxLabelSize < (this.width - offset) && this.options.visible == true && this.width > this.minWidth) {
this.width = Math.max(this.minWidth,this.maxLabelSize + offset);
this.options.width = this.width + "px";
DOMutil.cleanupElements(this.DOMelements);
DOMutil.cleanupElements(this.DOMelements.lines);
DOMutil.cleanupElements(this.DOMelements.labels);
this.redraw();
return true;
}
else {
DOMutil.cleanupElements(this.DOMelements);
DOMutil.cleanupElements(this.DOMelements.lines);
DOMutil.cleanupElements(this.DOMelements.labels);
return false;
}
};
@ -370,10 +382,9 @@ DataAxis.prototype._redrawLabels = function () {
*/
DataAxis.prototype._redrawLabel = function (y, text, orientation, className, characterHeight) {
// reuse redundant label
var label = DOMutil.getDOMElement('div',this.DOMelements, this.dom.frame); //this.dom.redundant.labels.shift();
var label = DOMutil.getDOMElement('div',this.DOMelements.labels, this.dom.frame); //this.dom.redundant.labels.shift();
label.className = className;
label.innerHTML = text;
if (orientation == 'left') {
label.style.left = '-' + this.options.labelOffsetX + 'px';
label.style.textAlign = "right";
@ -403,7 +414,7 @@ DataAxis.prototype._redrawLabel = function (y, text, orientation, className, cha
*/
DataAxis.prototype._redrawLine = function (y, orientation, className, offset, width) {
if (this.master == true) {
var line = DOMutil.getDOMElement('div',this.DOMelements, this.dom.lineContainer);//this.dom.redundant.lines.shift();
var line = DOMutil.getDOMElement('div',this.DOMelements.lines, this.dom.lineContainer);//this.dom.redundant.lines.shift();
line.className = className;
line.innerHTML = '';
@ -435,7 +446,6 @@ DataAxis.prototype.convertValue = function (value) {
DataAxis.prototype._calculateCharSize = function () {
// determine the char width and height on the minor axis
if (!('minorCharHeight' in this.props)) {
var textMinor = document.createTextNode('0');
var measureCharMinor = document.createElement('DIV');
measureCharMinor.className = 'yAxis minor measure';

+ 14
- 0
lib/timeline/component/GraphGroup.js View File

@ -19,6 +19,7 @@ function GraphGroup (group, groupId, options, groupsUsingDefaultStyles) {
this.groupsUsingDefaultStyles[0] += 1;
}
this.itemsData = [];
this.visible = group.visible === undefined ? true : group.visible;
}
GraphGroup.prototype.setItems = function(items) {
@ -69,6 +70,7 @@ GraphGroup.prototype.update = function(group) {
this.group = group;
this.content = group.content || 'graph';
this.className = group.className || this.className || "graphGroup" + this.groupsUsingDefaultStyles[0] % 10;
this.visible = group.visible === undefined ? true : group.visible;
this.setOptions(group.options);
};
@ -118,4 +120,16 @@ GraphGroup.prototype.drawIcon = function(x, y, JSONcontainer, SVGcontainer, icon
}
};
/**
*
* @param iconWidth
* @param iconHeight
* @returns {{icon: HTMLElement, label: (group.content|*|string), orientation: (.options.yAxisOrientation|*)}}
*/
GraphGroup.prototype.getLegend = function(iconWidth, iconHeight) {
var svg = document.createElementNS('http://www.w3.org/2000/svg',"svg");
this.drawIcon(0,0.5*iconHeight,[],svg,iconWidth,iconHeight);
return {icon: svg, label: this.content, orientation:this.options.yAxisOrientation};
}
module.exports = GraphGroup;

+ 17
- 4
lib/timeline/component/Legend.js View File

@ -102,7 +102,16 @@ Legend.prototype.setOptions = function(options) {
};
Legend.prototype.redraw = function() {
if (this.options[this.side].visible == false || this.amountOfGroups == 0 || this.options.enabled == false) {
var activeGroups = 0;
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
if (this.groups[groupId].visible == true) {
activeGroups++;
}
}
}
if (this.options[this.side].visible == false || this.amountOfGroups == 0 || this.options.enabled == false || activeGroups == 0) {
this.hide();
}
else {
@ -149,7 +158,9 @@ Legend.prototype.redraw = function() {
var content = '';
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
content += this.groups[groupId].content + '<br />';
if (this.groups[groupId].visible == true) {
content += this.groups[groupId].content + '<br />';
}
}
}
this.dom.textArea.innerHTML = content;
@ -171,8 +182,10 @@ Legend.prototype.drawLegendIcons = function() {
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight);
y += iconHeight + this.options.iconSpacing;
if (this.groups[groupId].visible == true) {
this.groups[groupId].drawIcon(x, y, this.svgElements, this.svg, iconWidth, iconHeight);
y += iconHeight + this.options.iconSpacing;
}
}
}

+ 58
- 66
lib/timeline/component/LineGraph.js View File

@ -411,8 +411,6 @@ LineGraph.prototype._updateGroup = function (group, groupId) {
LineGraph.prototype._updateAllGroupData = function () {
if (this.itemsData != null) {
// ~450 ms @ 500k
var groupsContent = {};
for (var groupId in this.groups) {
if (this.groups.hasOwnProperty(groupId)) {
@ -431,16 +429,6 @@ LineGraph.prototype._updateAllGroupData = function () {
this.groups[groupId].setItems(groupsContent[groupId]);
}
}
// // ~4500ms @ 500k
// for (var groupId in this.groups) {
// if (this.groups.hasOwnProperty(groupId)) {
// this.groups[groupId].setItems(this.itemsData.get({filter:
// function (item) {
// return (item.group == groupId);
// }, type:{x:"Date"}}
// ));
// }
// }
}
};
@ -556,13 +544,6 @@ LineGraph.prototype.redraw = function() {
LineGraph.prototype._updateGraph = function () {
// reset the svg elements
DOMutil.prepareElements(this.svgElements);
// // very slow...
// groupData = group.itemsData.get({filter:
// function (item) {
// return (item.x > minDate && item.x < maxDate);
// }}
// );
if (this.width != 0 && this.itemsData != null) {
var group, groupData, preprocessedGroup, i;
@ -591,38 +572,44 @@ LineGraph.prototype._updateGraph = function () {
if (groupIds.length > 0) {
for (i = 0; i < groupIds.length; i++) {
group = this.groups[groupIds[i]];
groupData = [];
// optimization for sorted data
if (group.options.sort == true) {
var guess = Math.max(0,util.binarySearchGeneric(group.itemsData, minDate, 'x', 'before'));
for (var j = guess; j < group.itemsData.length; j++) {
var item = group.itemsData[j];
if (item !== undefined) {
if (item.x > maxDate) {
groupData.push(item);
break;
}
else {
groupData.push(item);
if (group.visible == true) {
groupData = [];
// optimization for sorted data
if (group.options.sort == true) {
var guess = Math.max(0,util.binarySearchGeneric(group.itemsData, minDate, 'x', 'before'));
for (var j = guess; j < group.itemsData.length; j++) {
var item = group.itemsData[j];
if (item !== undefined) {
if (item.x > maxDate) {
groupData.push(item);
break;
}
else {
groupData.push(item);
}
}
}
}
}
else {
for (var j = 0; j < group.itemsData.length; j++) {
var item = group.itemsData[j];
if (item !== undefined) {
if (item.x > minDate && item.x < maxDate) {
groupData.push(item);
else {
for (var j = 0; j < group.itemsData.length; j++) {
var item = group.itemsData[j];
if (item !== undefined) {
if (item.x > minDate && item.x < maxDate) {
groupData.push(item);
}
}
}
}
// preprocess, split into ranges and data
preprocessedGroup = this._preprocessData(groupData, group);
groupRanges.push({min: preprocessedGroup.min, max: preprocessedGroup.max});
preprocessedGroupData.push(preprocessedGroup.data);
}
else {
groupRanges.push({});
preprocessedGroupData.push([]);
}
// preprocess, split into ranges and data
preprocessedGroup = this._preprocessData(groupData, group);
groupRanges.push({min: preprocessedGroup.min, max: preprocessedGroup.max});
preprocessedGroupData.push(preprocessedGroup.data);
}
// update the Y axis first, we use this data to draw at the correct Y points
@ -643,11 +630,13 @@ LineGraph.prototype._updateGraph = function () {
// draw the groups
for (i = 0; i < groupIds.length; i++) {
group = this.groups[groupIds[i]];
if (group.options.style == 'line') {
this._drawLineGraph(processedGroupData[i], group);
}
else {
this._drawBarGraph (processedGroupData[i], group);
if (group.visible == true) {
if (group.options.style == 'line') {
this._drawLineGraph(processedGroupData[i], group);
}
else {
this._drawBarGraph (processedGroupData[i], group);
}
}
}
}
@ -674,24 +663,27 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) {
for (var i = 0; i < groupIds.length; i++) {
orientation = 'left';
var group = this.groups[groupIds[i]];
if (group.options.yAxisOrientation == 'right') {
orientation = 'right';
}
if (group.visible == true) {
if (group.options.yAxisOrientation == 'right') {
orientation = 'right';
}
minVal = groupRanges[i].min;
maxVal = groupRanges[i].max;
minVal = groupRanges[i].min;
maxVal = groupRanges[i].max;
if (orientation == 'left') {
yAxisLeftUsed = true;
minLeft = minLeft > minVal ? minVal : minLeft;
maxLeft = maxLeft < maxVal ? maxVal : maxLeft;
}
else {
yAxisRightUsed = true;
minRight = minRight > minVal ? minVal : minRight;
maxRight = maxRight < maxVal ? maxVal : maxRight;
if (orientation == 'left') {
yAxisLeftUsed = true;
minLeft = minLeft > minVal ? minVal : minLeft;
maxLeft = maxLeft < maxVal ? maxVal : maxLeft;
}
else {
yAxisRightUsed = true;
minRight = minRight > minVal ? minVal : minRight;
maxRight = maxRight < maxVal ? maxVal : maxRight;
}
}
}
console.log(yAxisRightUsed)
if (yAxisLeftUsed == true) {
this.yAxisLeft.setRange(minLeft, maxLeft);
}
@ -715,9 +707,9 @@ LineGraph.prototype._updateYAxis = function (groupIds, groupRanges) {
this.yAxisRight.master = !yAxisLeftUsed;
if (this.yAxisRight.master == false) {
if (yAxisRightUsed == true) {
this.yAxisLeft.lineOffset = this.yAxisRight.width;
}
if (yAxisRightUsed == true) {this.yAxisLeft.lineOffset = this.yAxisRight.width;}
else {this.yAxisLeft.lineOffset = 0;}
changeCalled = this.yAxisLeft.redraw() || changeCalled;
this.yAxisRight.stepPixelsForced = this.yAxisLeft.stepPixels;
changeCalled = this.yAxisRight.redraw() || changeCalled;

Loading…
Cancel
Save