Browse Source

Merge branch 'develop' into v4

Conflicts:
	HISTORY.md
	bower.json
	dist/vis.js
	dist/vis.map
	dist/vis.min.css
	dist/vis.min.js
	lib/network/Network.js
	lib/network/mixins/ManipulationMixin.js
	lib/timeline/component/ItemSet.js
	lib/timeline/component/graph2d_types/bar.js
	lib/timeline/component/item/Item.js
	package.json
	test/timeline.html
flowchartTest
jos 9 years ago
parent
commit
33a76d824e
37 changed files with 929 additions and 256 deletions
  1. +16
    -0
      CONTRIBUTING.md
  2. +61
    -13
      HISTORY.md
  3. +3
    -1
      dist/vis.css
  4. +3
    -2
      docs/dataset.html
  5. +3
    -2
      docs/dataview.html
  6. +64
    -3
      docs/graph2d.html
  7. +24
    -10
      docs/network.html
  8. +106
    -23
      docs/timeline.html
  9. +1
    -1
      examples/graph2d/01_basic.html
  10. +1
    -1
      examples/graph2d/13_localization.html
  11. +1
    -0
      examples/graph2d/index.html
  12. +1
    -0
      examples/timeline/08_edit_items.html
  13. +80
    -0
      examples/timeline/35_item_ordering.html
  14. +1
    -0
      examples/timeline/index.html
  15. +11
    -3
      lib/DataSet.js
  16. +9
    -7
      lib/DataView.js
  17. +38
    -11
      lib/timeline/Core.js
  18. +59
    -2
      lib/timeline/Graph2d.js
  19. +55
    -1
      lib/timeline/Timeline.js
  20. +7
    -0
      lib/timeline/component/CurrentTime.js
  21. +7
    -0
      lib/timeline/component/CustomTime.js
  22. +4
    -0
      lib/timeline/component/DataAxis.js
  23. +36
    -7
      lib/timeline/component/Group.js
  24. +153
    -120
      lib/timeline/component/ItemSet.js
  25. +12
    -6
      lib/timeline/component/TimeAxis.js
  26. +2
    -0
      lib/timeline/component/css/item.css
  27. +10
    -3
      lib/timeline/component/item/BackgroundItem.js
  28. +3
    -9
      lib/timeline/component/item/BoxItem.js
  29. +19
    -1
      lib/timeline/component/item/Item.js
  30. +0
    -3
      lib/timeline/component/item/PointItem.js
  31. +14
    -10
      lib/timeline/component/item/RangeItem.js
  32. +1
    -1
      lib/timeline/locales.js
  33. +18
    -0
      lib/util.js
  34. +2
    -2
      misc/how_to_publish.md
  35. +45
    -1
      test/DataView.test.js
  36. +20
    -10
      test/timeline.html
  37. +39
    -3
      test/timeline_groups.html

+ 16
- 0
CONTRIBUTING.md View File

@ -0,0 +1,16 @@
## Contributing
Contributions to the vis.js library are very welcome! We can't do this alone.
You can contribute in different ways: spread the word, report bugs, come up with
ideas and suggestions, and contribute to the code.
There are a few preferences regarding code contributions:
- vis.js follows the node.js code style as described
[here](http://nodeguide.com/style.html).
- When implementing new features, please update the documentation accordingly.
- Send pull requests to the `develop` branch, not the `master` branch.
- Only commit changes done in the source files under `lib`, not to the builds
which are located in the folder `dist`.
Thanks!

+ 61
- 13
HISTORY.md View File

@ -27,29 +27,82 @@ http://visjs.org
- Rebuilt the cluster system
## not yet released, version 3.12.1-SNAPSHOT
## not yet released, version 3.10.1-SNAPSHOT
### Timeline
- Fixed #761: Timeline and Graph2d throwing an error when locale is not found.
Gives a warning message instead.
- Fixed #782: Contents of items created from a template being unnecessary
recreated on click, causing mouse events to get lost.
### Network
- Fixed titles not working when any of the nodes has id `0`.
## 2015-04-07, version 3.12.0
### Network
- Fixed support for DataSet with custom id fields (option `fieldId`).
### Timeline
- Orientation can now be configured separately for axis and items.
- The event handlers `onMove` and `onMoving` are now invoked with all item
properties as argument, and can be used to update all properties (like
content, className, etc) and add new properties as well.
- Fixed #654: removed unnecessary minimum height for groups, takes the
height of the group label as minimum height now.
- Fixed #708: detecting wrong group when page is scrolled.
- Fixed #733: background items being selected on shift+click.
## 2015-03-05, version 3.11.0
### Network
- (added gradient coloring for lines, but set for release in 4.0 due to required refactoring of options)
- Fixed bug where a network that has frozen physics would resume redrawing after setData, setOptions etc.
- (add docs) Added option to bypass default groups. If more groups are specified in the nodes than there are in the groups, loop over supplied groups instead of default.
- (add docs) Added two new static smooth curves modes: curveCW and curve CCW.
- Added request redraw for certain internal processes to reduce number of draw calls.
- Added option to bypass default groups. If more groups are specified in the nodes than there are in the groups, loop over supplied groups instead of default.
- Added two new static smooth curves modes: curveCW and curve CCW.
- Added request redraw for certain internal processes to reduce number of draw calls (performance improvements!).
- Added pull request for usage of Icons. Thanks @Dude9177!
- Allow hierarchical view to be set in setOptions.
- Fixed manipulation bar for mobile.
### Graph2d
- Fixed #670: Bug when updating data in a DataSet, when Network is connected to the DataSet via a DataView.
- Fixed #688: Added a css class to be able to distinguish buttons "Edit node"
and "Edit edge".
### Timeline
- Implemented orientation option `'both'`, displaying a time axis both on top
and bottom (#665).
- Implemented creating new range items by dragging in an empty space with the
ctrl key down.
- Implemented configuration option `order: function` to define a custom ordering
for the items (see #538, #234).
- Implemented events `click`, `doubleClick`, and `contextMenu`.
- Implemented method `getEventProperties(event)`.
- Fixed not property initializing with a DataView for groups.
- Merged add custom timebar functionality, thanks @aytech!
- Fixed #664: end of item not restored when canceling a move event.
- Fixed #609: reduce the left/right dragarea when an item range is very small,
so you can still move it as a whole.
- Fixed #676: misalignment of background items when using subgroups and the
group label's height is larger than the contents.
### Graph2d
- Implemented events `click`, `doubleClick`, and `contextMenu`.
- Implemented method `getEventProperties(event)`.
### DataSet/DataView
- Implemented support for mapping field names. Thanks @spatialillusions.
- Fixed #670: DataView not passing a data property on update events (see #670)
## 2015-02-11, version 3.10.0
@ -91,11 +144,6 @@ http://visjs.org
- Fixed a bug in the `DataSet` returning an empty object instead of `null` when
no item was found when using both a filter and specifying fields.
### Timeline
- Implemented option `timeAxis: {scale: string, step: number}` to set a
fixed scale.
## 2015-01-16, version 3.9.1

+ 3
- 1
dist/vis.css View File

@ -262,6 +262,7 @@
.vis-item.vis-range .vis-drag-left {
position: absolute;
width: 24px;
max-width: 20%;
height: 100%;
top: 0;
left: -4px;
@ -272,6 +273,7 @@
.vis-item.vis-range .vis-drag-right {
position: absolute;
width: 24px;
max-width: 20%;
height: 100%;
top: 0;
right: -4px;
@ -1221,4 +1223,4 @@ div.vis-color-picker input.vis-range-brightness {
div.vis-color-picker input.vis-saturation-range {
width: 289px !important;
}*/
}*/

+ 3
- 2
docs/dataset.html View File

@ -740,9 +740,10 @@ DataSet.map(callback [, options]);
<tr>
<td>fields</td>
<td>String[&nbsp;]</td>
<td>String[&nbsp;] | Object.&lt;String,&nbsp;String&gt;</td>
<td>
An array with field names.
An array with field names, or an object with current field name and
new field name that the field is returned as.
By default, all properties of the items are emitted.
When <code>fields</code> is defined, only the properties
whose name is specified in <code>fields</code> will be included

+ 3
- 2
docs/dataview.html View File

@ -129,9 +129,10 @@ var data = new vis.DataView(dataset, options)
<tr>
<td>fields</td>
<td>String[&nbsp;]</td>
<td>String[&nbsp;] | Object.&lt;String,&nbsp;String&gt;</td>
<td>
An array with field names.
An array with field names, or an object with current field name and
new field name that the field is returned as.
By default, all properties of the items are emitted.
When <code>fields</code> is defined, only the properties
whose name is specified in <code>fields</code> will be included

+ 64
- 3
docs/graph2d.html View File

@ -678,7 +678,7 @@ The options colored in green can also be used as options for the groups. All opt
<td>orientation</td>
<td>String</td>
<td>'bottom'</td>
<td>Orientation of the timeline: 'top' or 'bottom' (default). If orientation is 'bottom', the time axis is drawn at the bottom, and if 'top', the axis is drawn on top.</td>
<td>Orientation of the timeline: 'top', 'bottom' (default), or 'both'. If orientation is 'bottom', the time axis is drawn at the bottom. When 'top', the axis is drawn on top. When 'both', two axes are drawn, both on top and at the bottom.</td>
</tr>
<tr>
@ -813,6 +813,29 @@ Graph2d.clear({options: true}); // clear options only
</td>
</tr>
<tr>
<td>click</td>
<td>Fired when clicked inside the Graph2d.
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Graph2d.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>contextmenu</td>
<td>Fired when right-clicked inside the Graph2d. Note that in order to prevent the context menu from showing up, default behavior of the event must be stopped:
<pre class="prettyprint lang-js">graph2d.on('contextmenu', function (props) {
alert('Right click!');
props.event.preventDefault();
});
</pre>
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Graph2d.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>destroy()</td>
<td>none</td>
@ -820,6 +843,22 @@ Graph2d.clear({options: true}); // clear options only
</td>
</tr>
<tr>
<td>doubleClick</td>
<td>Fired when double clicked inside the Graph2d.
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Graph2d.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>fit()</td>
<td>none</td>
<td>Adjust the visible window such that it fits all items.
</td>
</tr>
<tr>
<td>getCurrentTime()</td>
<td>Date</td>
@ -834,6 +873,24 @@ Graph2d.clear({options: true}); // clear options only
</td>
</tr>
<tr id="getEventProperties">
<td>getEventProperties(event)</td>
<td>Object</td>
<td>
Returns an Object with relevant properties from an event:
<ul>
<li><code>pageX</code> (Number): absolute horizontal position of the click event.</li>
<li><code>pageY</code> (Number): absolute vertical position of the click event.</li>
<li><code>x</code> (Number): relative horizontal position of the click event.</li>
<li><code>y</code> (Number): relative vertical position of the click event.</li>
<li><code>time</code> (Date): Date of the clicked event.</li>
<li><code>value</code> (Number[]): The data value of the click event. The array contains one value when there is one data axis visible, and two values when there are two visible data axes.</li>
<li><code>what</code> (String | null): name of the clicked thing: <code>background</code>, <code>axis</code>, <code>dat-axis</code>, <code>custom-time</code>, or <code>current-time</code>, <code>legend</code>.</li>
<li><code>event</code> (Object): the original click event.</li>
</ul>
</td>
</tr>
<tr>
<td>getLegend(groupId, iconWidth, iconHeight)</td>
<td>SVGelement, String, String</td>
@ -854,9 +911,13 @@ Graph2d.clear({options: true}); // clear options only
</tr>
<tr>
<td>fit()</td>
<td>hiddenDates</td>
<td>Object</td>
<td>none</td>
<td>Adjust the visible window such that it fits all items.
<td>This option allows you to hide specific timespans from the time axis. The dates can be supplied as an object:
<code>{start: '2014-03-21 00:00:00', end: '2014-03-28 00:00:00', [repeat:'daily']}</code> or as an Array of these objects. The repeat argument is optional.
The possible values are (case-sensitive): <code>daily, weekly, monthly, yearly</code>. To hide a weekend, pick any Saturday as start and the following Monday as end
and set repeat to weekly.
</td>
</tr>

+ 24
- 10
docs/network.html View File

@ -165,14 +165,14 @@ The constructor accepts three parameters:
<code>edges</code>, which both contain an array with objects.
Optionally, data may contain an <code>options</code> object.
The parameter <code>data</code> is optional, data can also be set using
the method <code>setData</code>. Section <a href="#Data_Format">Data Format</a>
the method <code>setData</code>. Section <a href="#Data_format">Data Format</a>
describes the data object.
</li>
<li>
<code>options</code> is an optional Object containing a name-value map
with options. Options can also be set using the method
<code>setOptions</code>.
Section <a href="#Configuration_Options">Configuration Options</a>
Section <a href="#Configuration_options">Configuration Options</a>
describes the available options.
</li>
</ul>
@ -214,7 +214,7 @@ var data = {
<span style="font-weight: bold;">A property <code>options</code></span>,
containing an object with global options.
Options can be provided as third parameter in the network constructor
as well. Section <a href="#Configuration_Options">Configuration Options</a>
as well. Section <a href="#Configuration_options">Configuration Options</a>
describes the available options.
</li>
@ -280,13 +280,13 @@ When using a DataSet, the network is automatically updating to changes in the Da
<td>allowedToMoveX</td>
<td>Boolean</td>
<td>no</td>
<td>If allowedToMoveX is false, then the node will not move in the X direction from its position.</td>
<td>If allowedToMoveX is false, then the node will not move in the X direction from its position. This does not do anything in hierarchical views.</td>
</tr>
<tr>
<td>allowedToMoveY</td>
<td>Boolean</td>
<td>no</td>
<td>If allowedToMoveY is false, then the node will not move in the Y direction from its position.</td>
<td>If allowedToMoveY is false, then the node will not move in the Y direction from its position. This does not do anything in hierarchical views.</td>
</tr>
<tr>
@ -599,7 +599,14 @@ var options = {
<td>When a Network is configured to be <code>clickToUse</code>, it will react to mouse, touch, and keyboard events only when active.
When active, a blue shadow border is displayed around the Network. The Network is set active by clicking on it, and is changed to inactive again by clicking outside the Network or by pressing the ESC key.</td>
</tr>
<tr>
<td>useDefaultGroups</td>
<td>boolean</td>
<td>true</td>
<td>If true, the default groups are used when groups are used. If you have defined your own groups those will be used. If you have an item with a group that is NOT in your own group list,
setting useDefaultGroups true will iterate over the default groups for unknown groups. If it is set to false, it will iterate over your own groups for unknown groups.
</td>
</tr>
<tr>
<td><a href="#Physics">physics</a></td>
<td>Object</td>
@ -647,7 +654,7 @@ var options = {
</tr>
<tr>
<td>freezeForStabilization</a></td>
<td>freezeForStabilization</td>
<td>Boolean</td>
<td>false</td>
<td>
@ -758,7 +765,7 @@ var options = {
<td>smoothCurves.type</td>
<td>String</td>
<td>"continuous"</td>
<td>This option only affects NONdynamic smooth curves. The supported types are: <code>continuous, discrete, diagonalCross, straightCross, horizontal, vertical</code>. The effects of these types
<td>This option only affects NONdynamic smooth curves. The supported types are: <code>continuous, discrete, diagonalCross, straightCross, horizontal, vertical, curvedCW, curvedCCW</code>. The effects of these types
are shown in examples <a href="../examples/network/26_staticSmoothCurves.html">26</a> and <a href="../examples/network/27_world_cup_network.html">27</a></td>
</tr>
<tr>
@ -972,7 +979,7 @@ mySize = minSize + diff * scale;
<td>When using values, you can let the font scale with the size of the nodes if you enable the scaleFontWithValue option. This is the minimum value of the fontSize.</td>
</tr>
<tr>
<td></td>fontSizeMax</td>
<td>fontSizeMax</td>
<td>Number</td>
<td>30</td>
<td>When using values, you can let the font scale with the size of the nodes if you enable the scaleFontWithValue option. This is the maximum value of the fontSize.</td>
@ -1483,7 +1490,7 @@ To unify the physics system, the damping, repulsion distance and edge length hav
If no options for the physics system are supplied, the Barnes-Hut method will be used with the default parameters. If you want to customize the physics system easily, you can use the configurePhysics option. <br/>
When using the hierarchical display option, hierarchicalRepulsion is automatically used as the physics solver. Similarly, if you use the hierarchicalRepulsion physics option, hierarchical display is automatically turned on with default settings.
<p class="important_note">Note: if the behaviour of your network is not the way you want it, use configurePhysics as described <u><a href="#PhysicsConfiguration">below</a></u> or by <u><a href="../examples/network/25_physics_configuration.html">example 25</a></u>.</p>
<p class="important_note">Note: if the behaviour of your network is not the way you want it, use configurePhysics as described <u><a href="#PhysicsConfiguration">below</a></u> or by <u><a href="../examples/network/25_physics_configuration.html">example 25</a></u>.
</p>
<pre class="prettyprint">
// These variables must be defined in an options object named physics.
@ -2648,6 +2655,13 @@ network.off('select', onSelect);
none
</td>
</tr>
<tr>
<td>stabilizationIterationsDone</td>
<td>Fired once when the network finished the initial stabilization run. This is fired REGARDLESS if the network has stabilized. It only means that the amount of configured stabilizationIterations have been completed.
<td>
none
</td>
</tr>
<tr>
<td>stabilized</td>
<td>Fired every time the network has been stabilized. This event can be used to trigger the .storePositions() function after stabilization. Fired with an object having the following properties:</td>

+ 106
- 23
docs/timeline.html View File

@ -205,7 +205,7 @@ var items = [
</tr>
<tr>
<td>end</td>
<td>Date</td>
<td>Date | number | string | Moment</td>
<td>no</td>
<td>The end date of the item. The end date is optional, and can be left <code>null</code>.
If end date is provided, the item is displayed as a range.
@ -232,7 +232,7 @@ var items = [
</tr>
<tr>
<td>start</td>
<td>Date</td>
<td>Date | number | string | Moment</td>
<td>yes</td>
<td>The start date of the item, for example <code>new Date(2010,9,23)</code>.</td>
</tr>
@ -468,7 +468,7 @@ var options = {
<tr>
<td>end</td>
<td>Date | Number | String</td>
<td>Date | Number | String | Moment</td>
<td>none</td>
<td>The initial end date for the axis of the timeline.
If not provided, the latest date present in the items set is taken as
@ -554,6 +554,13 @@ var options = {
<td>A map with i18n locales. See section <a href="#Localization">Localization</a> for more information.</td>
</tr>
<tr>
<td>margin</td>
<td>Number | Object</td>
<td>Object</td>
<td>When a number, applies the margin to <code>margin.axis</code>, <code>margin.item.horizontal</code>, and <code>margin.item.vertical</code>.</td>
</tr>
<tr>
<td>margin.axis</td>
<td>Number</td>
@ -584,7 +591,7 @@ var options = {
<tr>
<td>max</td>
<td>Date | Number | String</td>
<td>Date | Number | String | Moment</td>
<td>none</td>
<td>Set a maximum Date for the visible range.
It will not be possible to move beyond this maximum.
@ -600,7 +607,7 @@ var options = {
<tr>
<td>min</td>
<td>Date | Number | String</td>
<td>Date | Number | String | Moment</td>
<td>none</td>
<td>Set a minimum Date for the visible range.
It will not be possible to move beyond this minimum.
@ -664,24 +671,39 @@ var options = {
</td>
</tr>
<!-- TODO: cleanup option order
<tr>
<td>order</td>
<td>Function</td>
<td>none</td>
<td>Provide a custom sort function to order the items. The order of the
<td>
<p>Provide a custom sort function to order the items. The order of the
items is determining the way they are stacked. The function
order is called with two parameters, both of type
`vis.components.items.Item`.
order is called with two arguments containing the data of two items to be
compared.
</p>
<p style="font-style: italic">WARNING: Use with caution. Custom ordering is not suitable for large amounts of items. On load, the Timeline will render all items once to determine their width and height. Keep the number of items in this configuration limited to a maximum of a few hundred items.</p>
</td>
</tr>
-->
<tr>
<td>orientation</td>
<td>String | Object</td>
<td>'bottom'</td>
<td>Orientation of the timelines axis and items. When orientation is a string, the value is applied to both items and axis. Can be 'top', 'bottom' (default), or 'both'.</td>
</tr>
<tr>
<td>orientation.axis</td>
<td>String</td>
<td>'bottom'</td>
<td>Orientation of the timeline axis: 'top', 'bottom' (default), or 'both'. If orientation is 'bottom', the time axis is drawn at the bottom. When 'top', the axis is drawn on top. When 'both', two axes are drawn, both on top and at the bottom.</td>
</tr>
<tr>
<td>orientation.item</td>
<td>String</td>
<td>'bottom'</td>
<td>Orientation of the timeline: 'top' or 'bottom' (default). If orientation is 'bottom', the time axis is drawn at the bottom, and if 'top', the axis is drawn on top.</td>
<td>Orientation of the timeline items: 'top' or 'bottom' (default). Determines whether items are aligned to the top or bottom of the Timeline.</td>
</tr>
<tr>
@ -762,7 +784,7 @@ var options = {
<tr>
<td>start</td>
<td>Date | Number | String</td>
<td>Date | Number | String | Moment</td>
<td>none</td>
<td>The initial start date for the axis of the timeline.
If not provided, the earliest date present in the events is taken as start date.</td>
@ -918,6 +940,26 @@ timeline.clear({options: true}); // clear options only
</td>
</tr>
<tr id="getEventProperties">
<td>getEventProperties(event)</td>
<td>Object</td>
<td>
Returns an Object with relevant properties from an event:
<ul>
<li><code>group</code> (Number | null): the id of the clicked group.</li>
<li><code>item</code> (Number | null): the id of the clicked item.</li>
<li><code>pageX</code> (Number): absolute horizontal position of the click event.</li>
<li><code>pageY</code> (Number): absolute vertical position of the click event.</li>
<li><code>x</code> (Number): relative horizontal position of the click event.</li>
<li><code>y</code> (Number): relative vertical position of the click event.</li>
<li><code>time</code> (Date): Date of the clicked event.</li>
<li><code>snappedTime</code> (Date): Date of the clicked event, snapped to a nice value.</li>
<li><code>what</code> (String | null): name of the clicked thing: <code>item</code>, <code>background</code>, <code>axis</code>, <code>group-label</code>, <code>custom-time</code>, or <code>current-time</code>.</li>
<li><code>event</code> (Object): the original click event.</li>
</ul>
</td>
</tr>
<tr>
<td>getSelection()</td>
<td>Number[]</td>
@ -1092,16 +1134,49 @@ timeline.off('select', onSelect);
<th>Description</th>
<th>Properties</th>
</tr>
<tr>
<td>finishedRedraw</td>
<td>Fired after a redraw is complete. When moving the timeline around, this could be fired frequently.
</td>
<td>
none.
</td>
</tr>
<tr>
<tr>
<td>click</td>
<td>Fired when clicked inside the Timeline.
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Timeline.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>contextmenu</td>
<td>Fired when right-clicked inside the Timeline. Note that in order to prevent the context menu from showing up, default behavior of the event must be stopped:
<pre class="prettyprint lang-js">timeline.on('contextmenu', function (props) {
alert('Right click!');
props.event.preventDefault();
});
</pre>
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Timeline.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>doubleClick</td>
<td>Fired when double clicked inside the Timeline.
</td>
<td>
Passes a properties object as returned by the method <a href="#getEventProperties"><code>Timeline.getEventProperties(event)</code></a>.
</td>
</tr>
<tr>
<td>finishedRedraw</td>
<td>Fired after a redraw is complete. When moving the timeline around, this could be fired frequently.
</td>
<td>
none.
</td>
</tr>
<tr>
<td>rangechange</td>
<td>Fired repeatedly when the timeline window is being changed.
</td>
@ -1170,8 +1245,16 @@ timeline.off('select', onSelect);
<h2 id="Editing_Items">Editing Items</h2>
<p>
When the Timeline is configured to be editable (both options <code>selectable</code> and <code>editable</code> are <code>true</code>), the user can move items by dragging them, can create a new item by double tapping on an empty space, can update an item by double tapping it, and can delete a selected item by clicking the delete button on the top right.
When the Timeline is configured to be editable (both options <code>selectable</code> and <code>editable</code> are <code>true</code>), the user can:
</p>
<ul>
<li>Select an item by clicking it, and use ctrl+click to or shift+click to select multiple items</li>
<li>Move selected items by dragging them.</li>
<li>Create a new item by double tapping on an empty space.</li>
<li>Create a new range item by dragging on an empty space with the ctrl key down.</li>
<li>Update an item by double tapping it.</li>
<li>Delete a selected item by clicking the delete button on the top right.</li>
</ul>
<p>Option <code>editable</code> accepts a boolean or an object. When <code>editable</code> is a boolean, all manipulation actions will be either enabled or disabled. When <code>editable</code> is an object, one can enable individual manipulation actions:</p>

+ 1
- 1
examples/graph2d/01_basic.html View File

@ -44,7 +44,7 @@
var dataset = new vis.DataSet(items);
var options = {
start: '2014-06-10',
end: '2014-06-18',
end: '2014-06-18'
};
var graph2d = new vis.Graph2d(container, dataset, options);
</script>

+ 1
- 1
examples/graph2d/13_localization.html View File

@ -13,7 +13,7 @@
}
</style>
<script src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.8.4/moment-with-langs.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.8.1/moment-with-locales.min.js"></script>
<script src="../../dist/vis.js"></script>
<link href="../../dist/vis.css" rel="stylesheet" type="text/css" />
<script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,'script','//www.google-analytics.com/analytics.js','ga');ga('create', 'UA-61231638-1', 'auto');ga('send', 'pageview');</script></head>

+ 1
- 0
examples/graph2d/index.html View File

@ -25,6 +25,7 @@
<p><a href="16_bothAxis_titles.html">16_bothAxis_titles.html</a></p>
<p><a href="17_dynamicStyling.html">17_dynamicStyling.html</a></p>
<p><a href="18_scatterplot.html">18_scatterplot.html</a></p>
<p><a href="19_labels.html">19_labels.html</a></p>
</div>
</body>

+ 1
- 0
examples/timeline/08_edit_items.html View File

@ -64,6 +64,7 @@
onMoving: function (item, callback) {
if (item.start < min) item.start = min;
if (item.start > max) item.start = max;
if (item.end > max) item.end = max;
callback(item); // send back the (possibly) changed item
},

+ 80
- 0
examples/timeline/35_item_ordering.html View File

@ -0,0 +1,80 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Timeline | Item ordering</title>
<style type="text/css">
body, html {
font-family: sans-serif;
}
p {
max-width: 800px;
}
</style>
<script src="../../dist/vis.js"></script>
<link href="../../dist/vis.css" rel="stylesheet" type="text/css" />
</head>
<body>
<h1>Item ordering</h1>
<p>
By default, the items displayed on the Timeline are unordered. They are
stacked in the order that they where loaded. This means that way items are
stacked can change while moving and zooming the Timeline.
</p>
<p>
To display and stack the items in a controlled order, you can provide a
custom sorting function via the configuration option <code>order</code>.
</p>
<p>
WARNING: Custom ordering is only suitable for small amounts of items (up to a few
hundred), as the Timeline has to render <i>all</i> items once on load to
determine their width and height.
</p>
<p>
<label for="ordering"><input type="checkbox" id="ordering" checked/> Apply custom ordering. Order items by their id.</label>
</p>
<div id="visualization"></div>
<script type="text/javascript">
// DOM element where the Timeline will be attached
var container = document.getElementById('visualization');
// Create a DataSet (allows two way data-binding)
var items = new vis.DataSet();
var date = vis.moment('2015-03-02');
for (var i = 0; i < 100; i++) {
date.add(Math.round(Math.random() * 2), 'hour');
items.add({
id: i,
content: 'Item ' + i,
start: date.clone(),
end: date.clone().add(4, 'hour')
});
}
function customOrder (a, b) {
// order by id
return a.id - b.id;
}
// Configuration for the Timeline
var options = {
order: customOrder,
editable: true,
margin: {item: 0}
};
// Create a Timeline
var timeline = new vis.Timeline(container, items, options);
var ordering = document.getElementById('ordering');
ordering.onchange = function () {
timeline.setOptions({
order: ordering.checked ? customOrder: null
});
};
</script>
</body>
</html>

+ 1
- 0
examples/timeline/index.html View File

@ -45,6 +45,7 @@
<p><a href="32_grid_styling.html">32_grid_styling.html</a></p>
<p><a href="33_custom_snapping.html">33_custom_snapping.html</a></p>
<p><a href="34_add_custom_timebar.html">34_add_custom_timebar.html</a></p>
<p><a href="35_item_ordering.html">35_item_ordering.html</a></p>
<p><a href="requirejs/requirejs_example.html">requirejs_example.html</a></p>
</div>

+ 11
- 3
lib/DataSet.js View File

@ -661,9 +661,17 @@ DataSet.prototype._filterFields = function (item, fields) {
var filteredItem = {};
for (var field in item) {
if (item.hasOwnProperty(field) && (fields.indexOf(field) != -1)) {
filteredItem[field] = item[field];
if(Array.isArray(fields)){
for (var field in item) {
if (item.hasOwnProperty(field) && (fields.indexOf(field) != -1)) {
filteredItem[field] = item[field];
}
}
}else{
for (var field in item) {
if (item.hasOwnProperty(field) && fields.hasOwnProperty(field)) {
filteredItem[fields[field]] = item[field];
}
}
}

+ 9
- 7
lib/DataView.js View File

@ -258,12 +258,13 @@ DataView.prototype.getDataSet = function () {
* @private
*/
DataView.prototype._onEvent = function (event, params, senderId) {
var i, len, id, item,
ids = params && params.items,
data = this._data,
added = [],
updated = [],
removed = [];
var i, len, id, item;
var ids = params && params.items;
var data = this._data;
var updatedData = [];
var added = [];
var updated = [];
var removed = [];
if (ids && data) {
switch (event) {
@ -290,6 +291,7 @@ DataView.prototype._onEvent = function (event, params, senderId) {
if (item) {
if (this._ids[id]) {
updated.push(id);
updatedData.push(params.data[i]);
}
else {
this._ids[id] = true;
@ -328,7 +330,7 @@ DataView.prototype._onEvent = function (event, params, senderId) {
this._trigger('add', {items: added}, senderId);
}
if (updated.length) {
this._trigger('update', {items: updated}, senderId);
this._trigger('update', {items: updated, data: updatedData}, senderId);
}
if (removed.length) {
this._trigger('remove', {items: removed}, senderId);

+ 38
- 11
lib/timeline/Core.js View File

@ -6,6 +6,7 @@ var DataSet = require('../DataSet');
var DataView = require('../DataView');
var Range = require('./Range');
var ItemSet = require('./component/ItemSet');
var TimeAxis = require('./component/TimeAxis');
var Activator = require('../shared/Activator');
var DateUtil = require('./DateUtil');
var CustomTime = require('./component/CustomTime');
@ -27,7 +28,7 @@ Emitter(Core.prototype);
* top, bottom, content, and background panel.
* @param {Element} container The container element where the Core will
* be attached.
* @private
* @protected
*/
Core.prototype._create = function (container) {
this.dom = {};
@ -205,9 +206,40 @@ Core.prototype._create = function (container) {
Core.prototype.setOptions = function (options) {
if (options) {
// copy the known options
var fields = ['width', 'height', 'minHeight', 'maxHeight', 'autoResize', 'start', 'end', 'orientation', 'clickToUse', 'dataAttributes', 'hiddenDates'];
var fields = ['width', 'height', 'minHeight', 'maxHeight', 'autoResize', 'start', 'end', 'clickToUse', 'dataAttributes', 'hiddenDates'];
util.selectiveExtend(fields, this.options, options);
if ('orientation' in options) {
if (typeof options.orientation === 'string') {
this.options.orientation = options.orientation;
}
else if (typeof options.orientation === 'object' && 'axis' in options.orientation) {
this.options.orientation = options.orientation.axis;
}
}
if (this.options.orientation === 'both') {
if (!this.timeAxis2) {
var timeAxis2 = this.timeAxis2 = new TimeAxis(this.body);
timeAxis2.setOptions = function (options) {
var _options = options ? util.extend({}, options) : {};
_options.orientation = 'top'; // override the orientation option, always top
TimeAxis.prototype.setOptions.call(timeAxis2, _options);
};
this.components.push(timeAxis2);
}
}
else {
if (this.timeAxis2) {
var index = this.components.indexOf(this.timeAxis2);
if (index !== -1) {
this.components.splice(index, 1);
}
this.timeAxis2.destroy();
this.timeAxis2 = null;
}
}
if ('hiddenDates' in this.options) {
DateUtil.convertHiddenOptions(this.body, this.options.hiddenDates);
}
@ -233,11 +265,6 @@ Core.prototype.setOptions = function (options) {
// propagate options to all components
this.components.forEach(component => component.setOptions(options));
// TODO: remove deprecation error one day (deprecated since version 0.8.0)
if (options && options.order) {
throw new Error('Option order is deprecated. There is no replacement for this feature.');
}
// redraw everything
this._redraw();
};
@ -779,7 +806,7 @@ Core.prototype.getCurrentTime = function() {
* Convert a position on screen (pixels) to a datetime
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
* @private
* @protected
*/
// TODO: move this function to Range
Core.prototype._toTime = function(x) {
@ -790,7 +817,7 @@ Core.prototype._toTime = function(x) {
* Convert a position on the global screen (pixels) to a datetime
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
* @private
* @protected
*/
// TODO: move this function to Range
Core.prototype._toGlobalTime = function(x) {
@ -804,7 +831,7 @@ Core.prototype._toGlobalTime = function(x) {
* @param {Date} time A date
* @return {int} x The position on the screen in pixels which corresponds
* with the given date.
* @private
* @protected
*/
// TODO: move this function to Range
Core.prototype._toScreen = function(time) {
@ -819,7 +846,7 @@ Core.prototype._toScreen = function(time) {
* @param {Date} time A date
* @return {int} x The position on root in pixels which corresponds
* with the given date.
* @private
* @protected
*/
// TODO: move this function to Range
Core.prototype._toGlobalScreen = function(time) {

+ 59
- 2
lib/timeline/Graph2d.js View File

@ -90,6 +90,16 @@ function Graph2d (container, items, groups, options) {
this.itemsData = null; // DataSet
this.groupsData = null; // DataSet
this.on('tap', function (event) {
me.emit('click', me.getEventProperties(event))
});
this.on('doubletap', function (event) {
me.emit('doubleClick', me.getEventProperties(event))
});
this.dom.root.oncontextmenu = function (event) {
me.emit('contextmenu', me.getEventProperties(event))
};
// apply options
if (options) {
this.setOptions(options);
@ -191,7 +201,7 @@ Graph2d.prototype.getLegend = function(groupId, width, height) {
else {
return "cannot find group:" + groupId;
}
}
};
/**
* This checks if the visible option of the supplied group (by ID) is true or false.
@ -205,7 +215,7 @@ Graph2d.prototype.isGroupVisible = function(groupId) {
else {
return false;
}
}
};
/**
@ -239,5 +249,52 @@ Graph2d.prototype.getItemRange = function() {
};
/**
* Generate Timeline related information from an event
* @param {Event} event
* @return {Object} An object with related information, like on which area
* The event happened, whether clicked on an item, etc.
*/
Graph2d.prototype.getEventProperties = function (event) {
var pageX = event.gesture ? event.gesture.center.pageX : event.pageX;
var pageY = event.gesture ? event.gesture.center.pageY : event.pageY;
var x = pageX - util.getAbsoluteLeft(this.dom.centerContainer);
var y = pageY - util.getAbsoluteTop(this.dom.centerContainer);
var time = this._toTime(x);
var element = util.getTarget(event);
var what = null;
if (util.hasParent(element, this.timeAxis.dom.foreground)) {what = 'axis';}
else if (this.timeAxis2 && util.hasParent(element, this.timeAxis2.dom.foreground)) {what = 'axis';}
else if (util.hasParent(element, this.linegraph.yAxisLeft.dom.frame)) {what = 'data-axis';}
else if (util.hasParent(element, this.linegraph.yAxisRight.dom.frame)) {what = 'data-axis';}
else if (util.hasParent(element, this.linegraph.legendLeft.dom.frame)) {what = 'legend';}
else if (util.hasParent(element, this.linegraph.legendRight.dom.frame)) {what = 'legend';}
else if (util.hasParent(element, this.customTime.bar)) {what = 'custom-time';} // TODO: fix for multiple custom time bars
else if (util.hasParent(element, this.currentTime.bar)) {what = 'current-time';}
else if (util.hasParent(element, this.dom.center)) {what = 'background';}
var value = [];
var yAxisLeft = this.linegraph.yAxisLeft;
var yAxisRight = this.linegraph.yAxisRight;
if (!yAxisLeft.hidden) {
value.push(yAxisLeft.screenToValue(y));
}
if (!yAxisRight.hidden) {
value.push(yAxisRight.screenToValue(y));
}
return {
event: event,
what: what,
pageX: pageX,
pageY: pageY,
x: x,
y: y,
time: time,
value: value
}
};
module.exports = Graph2d;

+ 55
- 1
lib/timeline/Timeline.js View File

@ -38,7 +38,7 @@ function Timeline (container, items, groups, options) {
autoResize: true,
orientation: 'bottom',
orientation: 'bottom', // axis orientation: 'bottom', 'top', or 'both'
width: null,
height: null,
maxHeight: null,
@ -83,6 +83,7 @@ function Timeline (container, items, groups, options) {
// time axis
this.timeAxis = new TimeAxis(this.body);
this.timeAxis2 = null; // used in case of orientation option 'both'
this.components.push(this.timeAxis);
// current time bar
@ -101,6 +102,16 @@ function Timeline (container, items, groups, options) {
this.itemsData = null; // DataSet
this.groupsData = null; // DataSet
this.on('tap', function (event) {
me.emit('click', me.getEventProperties(event))
});
this.on('doubletap', function (event) {
me.emit('doubleClick', me.getEventProperties(event))
});
this.dom.root.oncontextmenu = function (event) {
me.emit('contextmenu', me.getEventProperties(event))
};
// apply options
if (options) {
this.setOptions(options);
@ -325,5 +336,48 @@ Timeline.prototype.getItemRange = function() {
};
};
/**
* Generate Timeline related information from an event
* @param {Event} event
* @return {Object} An object with related information, like on which area
* The event happened, whether clicked on an item, etc.
*/
Timeline.prototype.getEventProperties = function (event) {
var item = this.itemSet.itemFromTarget(event);
var group = this.itemSet.groupFromTarget(event);
var pageX = event.gesture ? event.gesture.center.pageX : event.pageX;
var pageY = event.gesture ? event.gesture.center.pageY : event.pageY;
var x = pageX - util.getAbsoluteLeft(this.dom.centerContainer);
var y = pageY - util.getAbsoluteTop(this.dom.centerContainer);
var snap = this.itemSet.options.snap || null;
var scale = this.body.util.getScale();
var step = this.body.util.getStep();
var time = this._toTime(x);
var snappedTime = snap ? snap(time, scale, step) : time;
var element = util.getTarget(event);
var what = null;
if (item != null) {what = 'item';}
else if (util.hasParent(element, this.timeAxis.dom.foreground)) {what = 'axis';}
else if (this.timeAxis2 && util.hasParent(element, this.timeAxis2.dom.foreground)) {what = 'axis';}
else if (util.hasParent(element, this.itemSet.dom.labelSet)) {what = 'group-label';}
else if (util.hasParent(element, this.customTime.bar)) {what = 'custom-time';} // TODO: fix for multiple custom time bars
else if (util.hasParent(element, this.currentTime.bar)) {what = 'current-time';}
else if (util.hasParent(element, this.dom.center)) {what = 'background';}
return {
event: event,
item: item ? item.id : null,
group: group ? group.groupId : null,
what: what,
pageX: pageX,
pageY: pageY,
x: x,
y: y,
time: time,
snappedTime: snappedTime
}
};
module.exports = Timeline;

+ 7
- 0
lib/timeline/component/CurrentTime.js View File

@ -88,6 +88,13 @@ CurrentTime.prototype.redraw = function() {
var x = this.body.util.toScreen(now);
var locale = this.options.locales[this.options.locale];
if (!locale) {
if (!this.warned) {
console.log('WARNING: options.locales[\'' + this.options.locale + '\'] not found. See http://visjs.org/docs/timeline.html#Localization');
this.warned = true;
}
locale = this.options.locales['en']; // fall back on english when not available
}
var title = locale.current + ' ' + locale.time + ': ' + moment(now).format('dddd, MMMM Do YYYY, H:mm:ss');
title = title.charAt(0).toUpperCase() + title.substring(1);

+ 7
- 0
lib/timeline/component/CustomTime.js View File

@ -120,6 +120,13 @@ CustomTime.prototype.redraw = function () {
var x = this.body.util.toScreen(this.customTime);
var locale = this.options.locales[this.options.locale];
if (!locale) {
if (!this.warned) {
console.log('WARNING: options.locales[\'' + this.options.locale + '\'] not found. See http://visjs.org/docs/timeline.html#Localization');
this.warned = true;
}
locale = this.options.locales['en']; // fall back on english when not available
}
var title = locale.time + ': ' + moment(this.customTime).format('dddd, MMMM Do YYYY, H:mm:ss');
title = title.charAt(0).toUpperCase() + title.substring(1);

+ 4
- 0
lib/timeline/component/DataAxis.js View File

@ -484,6 +484,10 @@ DataAxis.prototype.convertValue = function (value) {
return convertedValue;
};
DataAxis.prototype.screenToValue = function (x) {
return this.valueAtZero - (x / this.conversionFactor);
};
/**
* Create a label for the axis at position x
* @private

+ 36
- 7
lib/timeline/component/Group.js View File

@ -148,8 +148,6 @@ Group.prototype.getLabelWidth = function() {
Group.prototype.redraw = function(range, margin, restack) {
var resized = false;
this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range);
// force recalculation of the height of the items when the marker height changed
// (due to the Timeline being attached to the DOM or changed from display:none to visible)
var markerHeight = this.dom.marker.clientHeight;
@ -165,11 +163,42 @@ Group.prototype.redraw = function(range, margin, restack) {
}
// reposition visible items vertically
if (this.itemSet.options.stack) { // TODO: ugly way to access options...
stack.stack(this.visibleItems, margin, restack);
if (typeof this.itemSet.options.order === 'function') {
// a custom order function
if (restack) {
// brute force restack of all items
// show all items
var me = this;
var limitSize = false;
util.forEach(this.items, function (item) {
if (!item.displayed) {
item.redraw();
me.visibleItems.push(item);
}
item.repositionX(limitSize);
});
// order all items and force a restacking
var customOrderedItems = this.orderedItems.byStart.slice().sort(function (a, b) {
return me.itemSet.options.order(a.data, b.data);
});
stack.stack(customOrderedItems, margin, true /* restack=true */);
}
this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range);
}
else { // no stacking
stack.nostack(this.visibleItems, margin, this.subgroups);
else {
// no custom order function, lazy stacking
this.visibleItems = this._updateVisibleItems(this.orderedItems, this.visibleItems, range);
if (this.itemSet.options.stack) { // TODO: ugly way to access options...
stack.stack(this.visibleItems, margin, restack);
}
else { // no stacking
stack.nostack(this.visibleItems, margin, this.subgroups);
}
}
// recalculate the height of the group
@ -236,7 +265,7 @@ Group.prototype._calculateHeight = function (margin) {
height = max + margin.item.vertical / 2;
}
else {
height = margin.axis + margin.item.vertical;
height = 0;
}
height = Math.max(height, this.props.label.height);

+ 153
- 120
lib/timeline/component/ItemSet.js View File

@ -29,7 +29,7 @@ function ItemSet(body, options) {
this.defaultOptions = {
type: null, // 'box', 'point', 'range', 'background'
orientation: 'bottom', // 'top' or 'bottom'
orientation: 'bottom', // item orientation: 'top' or 'bottom'
align: 'auto', // alignment of box items
stack: true,
groupOrder: null,
@ -276,9 +276,18 @@ ItemSet.prototype._create = function(){
ItemSet.prototype.setOptions = function(options) {
if (options) {
// copy all options that we know
var fields = ['type', 'align', 'orientation', 'padding', 'stack', 'selectable', 'groupOrder', 'dataAttributes', 'template','hide', 'snap'];
var fields = ['type', 'align', 'order', 'padding', 'stack', 'selectable', 'groupOrder', 'dataAttributes', 'template','hide', 'snap'];
util.selectiveExtend(fields, this.options, options);
if ('orientation' in options) {
if (typeof options.orientation === 'string') {
this.options.orientation = options.orientation;
}
else if (typeof options.orientation === 'object' && 'item' in options.orientation) {
this.options.orientation = options.orientation.item;
}
}
if ('margin' in options) {
if (typeof options.margin === 'number') {
this.options.margin.axis = options.margin;
@ -1020,12 +1029,13 @@ ItemSet.prototype._addItem = function(item) {
*/
ItemSet.prototype._updateItem = function(item, itemData) {
var oldGroupId = item.data.group;
var oldSubGroupId = item.data.subgroup;
// update the items data (will redraw the item when displayed)
item.setData(itemData);
// update group
if (oldGroupId != item.data.group) {
if (oldGroupId != item.data.group || oldSubGroupId != item.data.subgroup) {
var oldGroup = this.groups[oldGroupId];
if (oldGroup) oldGroup.remove(item);
@ -1112,31 +1122,21 @@ ItemSet.prototype._onDragStart = function (event) {
if (dragLeftItem) {
props = {
item: dragLeftItem,
initialX: event.center.x
initialX: event.center.x,
dragLeft: true,
data: util.extend({}, item.data) // clone the items data
};
if (me.options.editable.updateTime) {
props.start = item.data.start.valueOf();
}
if (me.options.editable.updateGroup) {
if ('group' in item.data) props.group = item.data.group;
}
this.touchParams.itemProps = [props];
}
else if (dragRightItem) {
props = {
item: dragRightItem,
initialX: event.center.x
initialX: event.center.x,
dragRight: true,
data: util.extend({}, item.data) // clone the items data
};
if (me.options.editable.updateTime) {
props.end = item.data.end.valueOf();
}
if (me.options.editable.updateGroup) {
if ('group' in item.data) props.group = item.data.group;
}
this.touchParams.itemProps = [props];
}
else {
@ -1144,30 +1144,66 @@ ItemSet.prototype._onDragStart = function (event) {
var item = me.items[id];
var props = {
item: item,
initialX: event.center.x
initialX: event.center.x,
data: util.extend({}, item.data) // clone the items data
};
if (me.options.editable.updateTime) {
if ('start' in item.data) {
props.start = item.data.start.valueOf();
if ('end' in item.data) {
// we store a duration here in order not to change the width
// of the item when moving it.
props.duration = item.data.end.valueOf() - props.start;
}
}
}
if (me.options.editable.updateGroup) {
if ('group' in item.data) props.group = item.data.group;
}
return props;
});
}
event.stopPropagation();
}
else if (this.options.editable.add && event.gesture.srcEvent.ctrlKey) {
// create a new range item when dragging with ctrl key down
this._onDragStartAddItem(event);
}
};
/**
* Start creating a new range item by dragging.
* @param {Event} event
* @private
*/
ItemSet.prototype._onDragStartAddItem = function (event) {
var snap = this.options.snap || null;
var xAbs = util.getAbsoluteLeft(this.dom.frame);
var x = event.gesture.center.pageX - xAbs - 10; // minus 10 to compensate for the drag starting as soon as you've moved 10px
var time = this.body.util.toTime(x);
var scale = this.body.util.getScale();
var step = this.body.util.getStep();
var start = snap ? snap(time, scale, step) : start;
var end = start;
var itemData = {
type: 'range',
start: start,
end: end,
content: 'new item'
};
var id = util.randomUUID();
itemData[this.itemsData._fieldId] = id;
var group = this.groupFromTarget(event);
if (group) {
itemData.group = group.groupId;
}
var newItem = new RangeItem(itemData, this.conversion, this.options);
newItem.id = id; // TODO: not so nice setting id afterwards
newItem.data = itemData;
this._addItem(newItem);
var props = {
item: newItem,
dragRight: true,
initialX: event.gesture.center.pageX,
data: util.extend({}, itemData)
};
this.touchParams.itemProps = [props];
event.stopPropagation();
};
/**
@ -1177,6 +1213,8 @@ ItemSet.prototype._onDragStart = function (event) {
*/
ItemSet.prototype._onDrag = function (event) {
if (this.touchParams.itemProps) {
event.stopPropagation();
var me = this;
var snap = this.options.snap || null;
var xOffset = this.body.dom.root.offsetLeft + this.body.domProps.left.width;
@ -1190,60 +1228,65 @@ ItemSet.prototype._onDrag = function (event) {
var initial = me.body.util.toTime(props.initialX - xOffset);
var offset = current - initial;
if ('start' in props) {
var start = new Date(props.start + offset);
newProps.start = snap ? snap(start, scale, step) : start;
}
var itemData = util.extend({}, props.item.data); // clone the data
if ('end' in props) {
var end = new Date(props.end + offset);
newProps.end = snap ? snap(end, scale, step) : end;
}
else if ('duration' in props) {
newProps.end = new Date(newProps.start.valueOf() + props.duration);
if (me.options.editable.updateTime) {
if (props.dragLeft) {
// drag left side of a range item
if (itemData.start != undefined) {
var initialStart = util.convert(props.data.start, 'Date');
var start = new Date(initialStart.valueOf() + offset);
itemData.start = snap ? snap(start, scale, step) : start;
}
}
else if (props.dragRight) {
// drag right side of a range item
if (itemData.end != undefined) {
var initialEnd = util.convert(props.data.end, 'Date');
var end = new Date(initialEnd.valueOf() + offset);
itemData.end = snap ? snap(end, scale, step) : end;
}
}
else {
// drag both start and end
if (itemData.start != undefined) {
var initialStart = util.convert(props.data.start, 'Date').valueOf();
var start = new Date(initialStart + offset);
if (itemData.end != undefined) {
var initialEnd = util.convert(props.data.end, 'Date');
var duration = initialEnd.valueOf() - initialStart.valueOf();
itemData.start = snap ? snap(start, scale, step) : start;
itemData.end = new Date(itemData.start.valueOf() + duration);
}
else {
itemData.start = snap ? snap(start, scale, step) : start;
}
}
}
}
if ('group' in props) {
// drag from one group to another
var group = me.groupFromTarget(event);
newProps.group = group && group.groupId;
if (me.options.editable.updateGroup && (!props.dragLeft && !props.dragRight)) {
if (itemData.group != undefined) {
// drag from one group to another
var group = me.groupFromTarget(event);
if (group) {
itemData.group = group.groupId;
}
}
}
// confirm moving the item
var itemData = util.extend({}, props.item.data, newProps);
me.options.onMoving(itemData, function (itemData) {
if (itemData) {
me._updateItemProps(props.item, itemData);
props.item.setData(itemData);
}
});
});
this.stackDirty = true; // force re-stacking of all items next redraw
this.body.emitter.emit('change');
event.stopPropagation();
}
};
/**
* Update an items properties
* @param {Item} item
* @param {Object} props Can contain properties start, end, and group.
* @private
*/
ItemSet.prototype._updateItemProps = function(item, props) {
// TODO: copy all properties from props to item? (also new ones)
if ('start' in props) {
item.data.start = props.start;
}
if ('end' in props) {
item.data.end = props.end;
}
else if ('duration' in props) {
item.data.end = new Date(props.start.valueOf() + props.duration);
}
if ('group' in props && item.data.group != props.group) {
this._moveToGroup(item, props.group)
}
};
@ -1273,35 +1316,35 @@ ItemSet.prototype._moveToGroup = function(item, groupId) {
*/
ItemSet.prototype._onDragEnd = function (event) {
if (this.touchParams.itemProps) {
event.stopPropagation();
// prepare a change set for the changed items
var changes = [],
me = this,
dataset = this.itemsData.getDataSet();
var changes = [];
var me = this;
var dataset = this.itemsData.getDataSet();
var itemProps = this.touchParams.itemProps ;
this.touchParams.itemProps = null;
itemProps.forEach(function (props) {
var id = props.item.id,
itemData = me.itemsData.get(id, me.itemOptions);
var changed = false;
if ('start' in props.item.data) {
changed = (props.start != props.item.data.start.valueOf());
itemData.start = util.convert(props.item.data.start,
dataset._options.type && dataset._options.type.start || 'Date');
}
if ('end' in props.item.data) {
changed = changed || (props.end != props.item.data.end.valueOf());
itemData.end = util.convert(props.item.data.end,
dataset._options.type && dataset._options.type.end || 'Date');
}
if ('group' in props.item.data) {
changed = changed || (props.group != props.item.data.group);
itemData.group = props.item.data.group;
}
var id = props.item.id;
var exists = me.itemsData.get(id, me.itemOptions) != null;
// only apply changes when start or end is actually changed
if (changed) {
if (!exists) {
// add a new item
me.options.onAdd(props.item.data, function (itemData) {
me._removeItem(props.item); // remove temporary item
if (itemData) {
me.itemsData.getDataSet().add(itemData);
}
// force re-stacking of all items next redraw
me.stackDirty = true;
me.body.emitter.emit('change');
});
}
else {
// update existing item
var itemData = util.extend({}, props.item.data); // clone the data
me.options.onMove(itemData, function (itemData) {
if (itemData) {
// apply changes
@ -1310,7 +1353,7 @@ ItemSet.prototype._onDragEnd = function (event) {
}
else {
// restore original values
me._updateItemProps(props.item, props);
props.item.setData(props.data);
me.stackDirty = true; // force re-stacking of all items next redraw
me.body.emitter.emit('change');
@ -1323,8 +1366,6 @@ ItemSet.prototype._onDragEnd = function (event) {
if (changes.length) {
dataset.update(changes);
}
event.stopPropagation();
}
};
@ -1345,7 +1386,7 @@ ItemSet.prototype._onSelectItem = function (event) {
var oldSelection = this.getSelection();
var item = ItemSet.itemFromTarget(event);
var item = this.itemFromTarget(event);
var selection = item ? [item.id] : [];
this.setSelection(selection);
@ -1371,7 +1412,7 @@ ItemSet.prototype._onAddItem = function (event) {
var me = this,
snap = this.options.snap || null,
item = ItemSet.itemFromTarget(event);
item = this.itemFromTarget(event);
if (item) {
// update item
@ -1429,7 +1470,7 @@ ItemSet.prototype._onMultiSelectItem = function (event) {
if (!this.options.selectable) return;
var selection,
item = ItemSet.itemFromTarget(event);
item = this.itemFromTarget(event);
if (item) {
// multi select items
@ -1451,7 +1492,9 @@ ItemSet.prototype._onMultiSelectItem = function (event) {
var start = _item.data.start;
var end = (_item.data.end !== undefined) ? _item.data.end : start;
if (start >= range.min && end <= range.max) {
if (start >= range.min &&
end <= range.max &&
!(_item instanceof BackgroundItem)) {
selection.push(_item.id); // do not use id but item.id, id itself is stringified
}
}
@ -1517,7 +1560,7 @@ ItemSet._getItemRange = function(itemsData) {
* @param {Event} event
* @return {Item | null} item
*/
ItemSet.itemFromTarget = function(event) {
ItemSet.prototype.itemFromTarget = function(event) {
var target = event.target;
while (target) {
if (target.hasOwnProperty('timeline-item')) {
@ -1536,33 +1579,23 @@ ItemSet.itemFromTarget = function(event) {
* @return {Group | null} group
*/
ItemSet.prototype.groupFromTarget = function(event) {
// TODO: cleanup when the new solution is stable (also on mobile)
//var target = event.target;
//while (target) {
// if (target.hasOwnProperty('timeline-group')) {
// return target['timeline-group'];
// }
// target = target.parentNode;
//}
//
var clientY = event.center.clientY;
var pageY = event.gesture ? event.gesture.center.pageY : event.pageY;
for (var i = 0; i < this.groupIds.length; i++) {
var groupId = this.groupIds[i];
var group = this.groups[groupId];
var foreground = group.dom.foreground;
var top = util.getAbsoluteTop(foreground);
if (clientY > top && clientY < top + foreground.offsetHeight) {
if (pageY > top && pageY < top + foreground.offsetHeight) {
return group;
}
if (this.options.orientation === 'top') {
if (i === this.groupIds.length - 1 && clientY > top) {
if (i === this.groupIds.length - 1 && pageY > top) {
return group;
}
}
else {
if (i === 0 && clientY < top + foreground.offset) {
if (i === 0 && pageY < top + foreground.offset) {
return group;
}
}

+ 12
- 6
lib/timeline/component/TimeAxis.js View File

@ -34,8 +34,7 @@ function TimeAxis (body, options) {
};
this.defaultOptions = {
orientation: 'bottom', // supported: 'top', 'bottom'
// TODO: implement timeaxis orientations 'left' and 'right'
orientation: 'bottom', // axis orientation: 'top' or 'bottom'
showMinorLabels: true,
showMajorLabels: true,
format: null,
@ -65,7 +64,6 @@ TimeAxis.prototype.setOptions = function(options) {
if (options) {
// copy all options that we know
util.selectiveExtend([
'orientation',
'showMinorLabels',
'showMajorLabels',
'hiddenDates',
@ -73,6 +71,15 @@ TimeAxis.prototype.setOptions = function(options) {
'timeAxis'
], this.options, options);
if ('orientation' in options) {
if (typeof options.orientation === 'string') {
this.options.orientation = options.orientation;
}
else if (typeof options.orientation === 'object' && 'axis' in options.orientation) {
this.options.orientation = options.orientation.axis;
}
}
// apply locale to moment.js
// TODO: not so nice, this is applied globally to moment.js
if ('locale' in options) {
@ -131,9 +138,8 @@ TimeAxis.prototype.redraw = function () {
this._calculateCharSize();
// TODO: recalculate sizes only needed when parent is resized or options is changed
var orientation = this.options.orientation,
showMinorLabels = this.options.showMinorLabels,
showMajorLabels = this.options.showMajorLabels;
var showMinorLabels = this.options.showMinorLabels;
var showMajorLabels = this.options.showMajorLabels;
// determine the width and height of the elemens for the axis
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;

+ 2
- 0
lib/timeline/component/css/item.css View File

@ -97,6 +97,7 @@
.vis-item.vis-range .vis-drag-left {
position: absolute;
width: 24px;
max-width: 20%;
height: 100%;
top: 0;
left: -4px;
@ -107,6 +108,7 @@
.vis-item.vis-range .vis-drag-right {
position: absolute;
width: 24px;
max-width: 20%;
height: 100%;
top: 0;
right: -4px;

+ 10
- 3
lib/timeline/component/item/BackgroundItem.js View File

@ -147,6 +147,8 @@ BackgroundItem.prototype.repositionY = function(margin) {
// special positioning for subgroups
if (this.data.subgroup !== undefined) {
// TODO: instead of calculating the top position of the subgroups here for every BackgroundItem, calculate the top of the subgroup once in Itemset
var itemSubgroup = this.data.subgroup;
var subgroups = this.parent.subgroups;
var subgroupIndex = subgroups[itemSubgroup].index;
@ -172,15 +174,20 @@ BackgroundItem.prototype.repositionY = function(margin) {
// and when the orientation is bottom:
else {
var newTop = this.parent.top;
var totalHeight = 0;
for (var subgroup in subgroups) {
if (subgroups.hasOwnProperty(subgroup)) {
if (subgroups[subgroup].visible == true && subgroups[subgroup].index > subgroupIndex) {
newTop += subgroups[subgroup].height + margin.item.vertical;
if (subgroups[subgroup].visible == true) {
var newHeight = subgroups[subgroup].height + margin.item.vertical;
totalHeight += newHeight;
if (subgroups[subgroup].index > subgroupIndex) {
newTop += newHeight;
}
}
}
}
height = this.parent.subgroups[itemSubgroup].height + margin.item.vertical;
this.dom.box.style.top = newTop + 'px';
this.dom.box.style.top = (this.parent.height - totalHeight + newTop) + 'px';
this.dom.box.style.bottom = '';
}
}

+ 3
- 9
lib/timeline/component/item/BoxItem.js View File

@ -151,9 +151,6 @@ BoxItem.prototype.hide = function() {
if (dom.line.parentNode) dom.line.parentNode.removeChild(dom.line);
if (dom.dot.parentNode) dom.dot.parentNode.removeChild(dom.dot);
this.top = null;
this.left = null;
this.displayed = false;
}
};
@ -166,9 +163,6 @@ BoxItem.prototype.repositionX = function() {
var start = this.conversion.toScreen(this.data.start);
var align = this.options.align;
var left;
var box = this.dom.box;
var line = this.dom.line;
var dot = this.dom.dot;
// calculate left position of the box
if (align == 'right') {
@ -183,13 +177,13 @@ BoxItem.prototype.repositionX = function() {
}
// reposition box
box.style.left = this.left + 'px';
this.dom.box.style.left = this.left + 'px';
// reposition line
line.style.left = (start - this.props.line.width / 2) + 'px';
this.dom.line.style.left = (start - this.props.line.width / 2) + 'px';
// reposition dot
dot.style.left = (start - this.props.dot.width / 2) + 'px';
this.dom.dot.style.left = (start - this.props.dot.width / 2) + 'px';
};
/**

+ 19
- 1
lib/timeline/component/item/Item.js View File

@ -54,6 +54,11 @@ Item.prototype.unselect = function() {
* @param {Object} data
*/
Item.prototype.setData = function(data) {
var groupChanged = data.group != undefined && this.data.group != data.group;
if (groupChanged) {
this.parent.itemSet._moveToGroup(this, data.group);
}
this.data = data;
this.dirty = true;
if (this.displayed) this.redraw();
@ -170,7 +175,8 @@ Item.prototype._updateContents = function (element) {
content = this.data.content;
}
if(content !== this.content) {
var changed = this._contentToString(this.content) !== this._contentToString(content);
if (changed) {
// only replace the content when changed
if (content instanceof Element) {
element.innerHTML = '';
@ -255,4 +261,16 @@ Item.prototype._updateStyle = function(element) {
}
};
/**
* Stringify the items contents
* @param {string | Element | undefined} content
* @returns {string | undefined}
* @private
*/
Item.prototype._contentToString = function (content) {
if (typeof content === 'string') return content;
if (content && 'outerHTML' in content) return content.outerHTML;
return content;
};
module.exports = Item;

+ 0
- 3
lib/timeline/component/item/PointItem.js View File

@ -144,9 +144,6 @@ PointItem.prototype.hide = function() {
this.dom.point.parentNode.removeChild(this.dom.point);
}
this.top = null;
this.left = null;
this.displayed = false;
}
};

+ 14
- 10
lib/timeline/component/item/RangeItem.js View File

@ -140,30 +140,34 @@ RangeItem.prototype.hide = function() {
box.parentNode.removeChild(box);
}
this.top = null;
this.left = null;
this.displayed = false;
}
};
/**
* Reposition the item horizontally
* @param {boolean} [limitSize=true] If true (default), the width of the range
* item will be limited, as the browser cannot
* display very wide divs. This means though
* that the applied left and width may
* not correspond to the ranges start and end
* @Override
*/
RangeItem.prototype.repositionX = function() {
RangeItem.prototype.repositionX = function(limitSize) {
var parentWidth = this.parent.width;
var start = this.conversion.toScreen(this.data.start);
var end = this.conversion.toScreen(this.data.end);
var contentLeft;
var contentWidth;
// limit the width of the this, as browsers cannot draw very wide divs
if (start < -parentWidth) {
start = -parentWidth;
}
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
// limit the width of the range, as browsers cannot draw very wide divs
if (limitSize === undefined || limitSize === true) {
if (start < -parentWidth) {
start = -parentWidth;
}
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
}
}
var boxWidth = Math.max(end - start, 1);

+ 1
- 1
lib/timeline/locales.js View File

@ -8,7 +8,7 @@ exports['en_US'] = exports['en'];
// Dutch
exports['nl'] = {
custom: 'aangepaste',
current: 'aangepaste',
time: 'tijd'
};
exports['nl_NL'] = exports['nl'];

+ 18
- 0
lib/util.js View File

@ -722,6 +722,24 @@ exports.getTarget = function(event) {
return target;
};
/**
* Check if given element contains given parent somewhere in the DOM tree
* @param {Element} element
* @param {Element} parent
*/
exports.hasParent = function (element, parent) {
var e = element;
while (e) {
if (e === parent) {
return true;
}
e = e.parentNode;
}
return false;
};
exports.option = {};
/**

+ 2
- 2
misc/how_to_publish.md View File

@ -56,7 +56,7 @@ This generates the vis.js library in the folder `./dist`.
Verify if it installs the just released version, and verify if it works.
- Verify within an hour whether vis.js is updated on http://cdnjs.com/
- Verify within a day or so whether vis.js is updated on http://cdnjs.com/
## Update website
@ -66,7 +66,7 @@ This generates the vis.js library in the folder `./dist`.
- Copy the `examples` folder from the `master` branch to the `github-pages` branch.
- Create a packaged version of vis.js. Go to the `master` branch and run:
zip vis.zip dist docs examples README.md HISTORY.md LICENSE* NOTICE -r
zip vis.zip dist docs examples README.md HISTORY.md CONTRIBUTING.md LICENSE* NOTICE -r
- Move the created zip file `vis.zip` to the `download` folder in the
`github-pages` branch. TODO: this should be automated.

+ 45
- 1
test/DataView.test.js View File

@ -148,5 +148,49 @@ describe('DataView', function () {
assert.deepEqual(added, [2, 3]);
assert.deepEqual(updated, []);
assert.deepEqual(removed, []);
})
});
it('should pass data of changed items when updating a DataSet', function () {
var data = new DataSet([
{id: 1, title: 'Item 1', group: 1},
{id: 2, title: 'Item 2', group: 2},
{id: 3, title: 'Item 3', group: 2}
]);
var view = new DataView(data, {
filter: function (item) {
return item.group === 2;
}
});
var dataUpdates = [];
var viewUpdates = [];
data.on('update', function (event, properties, senderId) {
dataUpdates.push([event, properties]);
});
view.on('update', function (event, properties, senderId) {
viewUpdates.push([event, properties]);
});
// make a change not affecting the DataView
data.update({id: 1, title: 'Item 1 (changed)'});
assert.deepEqual(dataUpdates, [
['update', {items: [1], data: [{id: 1, title: 'Item 1 (changed)'}]}]
]);
assert.deepEqual(viewUpdates, []);
// make a change affecting the DataView
data.update({id: 2, title: 'Item 2 (changed)'});
assert.deepEqual(dataUpdates, [
['update', {items: [1], data: [{id: 1, title: 'Item 1 (changed)'}]}],
['update', {items: [2], data: [{id: 2, title: 'Item 2 (changed)'}]}]
]);
assert.deepEqual(viewUpdates, [
['update', {items: [2], data: [{id: 2, title: 'Item 2 (changed)'}]}]
]);
});
});

+ 20
- 10
test/timeline.html View File

@ -41,14 +41,15 @@
}
</style>
<script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,'script','//www.google-analytics.com/analytics.js','ga');ga('create', 'UA-61231638-1', 'auto');ga('send', 'pageview');</script></head>
<script>(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)})(window,document,'script','//www.google-analytics.com/analytics.js','ga');ga('create', 'UA-61231638-1', 'auto');ga('send', 'pageview');</script></head>
<body>
<div>
<label for="orientation">Orientation</label>
<select id="orientation">
<option value="both" selected>both</option>
<option value="bottom">bottom</option>
<option value="top">top</option>
<option value="bottom" selected>bottom</option>
</select>
</div>
<script>
@ -98,7 +99,7 @@
{_id: 0, content: someHtml, start: now.clone().add(3, 'days').toDate(), title: 'hello title!'},
{_id: '1', content: 'item 1<br>start', start: now.clone().add(4, 'days').toDate()},
{_id: 2, content: '<a href="javascript: alert(\'you clicked an anchor\');">Click here! (anchor)</a><br><br>' +
'<div onclick="alert(\'you clicked a div\'); ">Click here! (div)</div>', start: now.clone().add(-2, 'days').toDate() },
'<div onclick="alert(\'you clicked a div\'); ">Click here! (div)</div>', start: now.clone().add(-2, 'days').toDate() },
{_id: 3, content: 'item 3', start: now.clone().add(2, 'days').toDate(), style: 'color: red;'},
{
_id: 4, content: 'item 4 foo bar foo bar foo bar foo bar foo bar',
@ -126,6 +127,7 @@
var options = {
editable: true,
//orientation: 'top',
orientation: 'both',
start: now.clone().add(-7, 'days'),
end: now.clone().add(7, 'days'),
//maxHeight: 200,
@ -163,13 +165,21 @@
console.log('select', selection);
});
// timeline.on('touch', function (event) {
// console.log('touch', event, Object.keys(event));
// });
//
// timeline.on('release', function (event) {
// console.log('release', event, Object.keys(event));
// });
timeline.on('click', function (props) {
console.log('click', props);
});
timeline.on('contextmenu', function (props) {
console.log('contextmenu', props);
});
// timeline.on('touch', function (event) {
// console.log('touch', event, Object.keys(event));
// });
//
// timeline.on('release', function (event) {
// console.log('release', event, Object.keys(event));
// });
/*
timeline.on('rangechange', function (range) {

+ 39
- 3
test/timeline_groups.html View File

@ -13,6 +13,19 @@
box-sizing: border-box;
width: 100%;
}
.vis.timeline .item.red,
.vis.timeline .item.red.selected {
background-color: red;
color: white;
}
.vis.timeline .item.green,
.vis.timeline .item.green.selected {
background-color: green;
color: white;
}
</style>
<script src="../dist/vis.js"></script>
@ -23,8 +36,9 @@
<div>
<label for="orientation">Orientation</label>
<select id="orientation">
<option value="top">top</option>
<option value="both">both</option>
<option value="bottom" selected>bottom</option>
<option value="top">top</option>
</select>
</div>
<script>
@ -57,6 +71,7 @@
var items = new vis.DataSet();
for (var i = 0; i < itemCount; i++) {
var start = now.clone().add(Math.random() * 200, 'hours');
var end = Math.random() > 0.5 ? start.clone().add(24, 'hours') : undefined;
var group = Math.floor(Math.random() * groupCount);
items.add({
id: i,
@ -64,8 +79,9 @@
content: 'item ' + i +
' <span style="color:#97B0F8;">(' + names[group] + ')</span>',
start: start,
end: end,
title: 'Title for item ' + i,
type: 'box',
//type: 'box',
className: 'myItem'
});
}
@ -81,6 +97,10 @@
updateGroup: true
},
// orientation: {
// item: 'top',
// axis: 'both'
// },
onAdd: function (item, callback) {
item.content = prompt('Enter text content for new item:', item.content);
@ -104,10 +124,14 @@
},
onMoving: function (item, callback) {
var min = moment().minutes(0).seconds(0).milliseconds(0).add(2, 'day').toDate();
var min = moment().minutes(0).seconds(0).milliseconds(0).add(-2, 'day').toDate();
if (item.start < min) {
item.start = min;
}
//item.className = item.id > 3 ? 'red' : 'green';
//item.group = Math.random() > 0.5 ? 2 : 1;
//item.hasMoved = true;
//item.content = Math.round(Math.random() * 4);
callback(item); // send back item as confirmation (can be changed)
},
@ -165,6 +189,18 @@
});
*/
timeline.on('click', function (props) {
console.log('click', props);
});
timeline.on('doubleClick', function (props) {
console.log('doubleClick', props);
});
timeline.on('contextmenu', function (props) {
console.log('contextmenu', props);
});
items.on('add', console.log.bind(console));
items.on('update', console.log.bind(console));
items.on('remove', console.log.bind(console));

Loading…
Cancel
Save