UNPKG

create-gojs-kit

Version:

A CLI for downloading GoJS samples, extensions, and docs

649 lines (609 loc) 30.1 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"/> <link rel="preconnect" href="https://rsms.me/"> <link rel="stylesheet" href="../assets/css/style.css"> <!-- Copyright 1998-2025 by Northwoods Software Corporation. --> <meta itemprop="name" content="Changed Events" /> <meta property="og:title" content="Changed Events" /> <meta name="twitter:title" content="Changed Events" /> <meta property="og:image" content="https://gojs.net/latest/assets/images/fp/defaultCard.png" /> <meta itemprop="image" content="https://gojs.net/latest/assets/images/fp/defaultCard.png" /> <meta name="twitter:image" content="https://gojs.net/latest/assets/images/fp/defaultCard.png" /> <meta property="og:url" content="https://gojs.net/latest/intro/changedEvents.html" /> <meta property="twitter:url" content="https://gojs.net/latest/intro/changedEvents.html" /> <meta name="twitter:card" content="summary_large_image" /> <meta property="og:type" content="website" /> <meta property="twitter:domain" content="gojs.net" /> <title> Changed Events | GoJS </title> <link rel="stylesheet" href="../assets/css/prism.css"/> </head> <script> window.diagrams = []; window.goCode = function (pre, w, h, parentid, animation) { window .diagrams .push([pre, w, h, parentid, animation]); } </script> <body> <nav id="navTop" class=" w-full h-[var(--topnav-h)] z-30 bg-white border-b border-b-gray-200"> <div class="max-w-screen-xl mx-auto flex flex-wrap items-start justify-between px-4"> <a class="text-white bg-nwoods-primary font-bold !leading-[calc(var(--topnav-h)_-_1px)] my-0 px-2 text-4xl lg:text-5xl logo" href="../"> GoJS </a> <div class="relative"> <button id="topnavButton" class="h-[calc(var(--topnav-h)_-_1px)] px-2 m-0 text-gray-900 bg-inherit shadow-none md:hidden hover:!bg-inherit hover:!text-nwoods-accent hover:!shadow-none" aria-label="Navigation"> <svg class="h-7 w-7 block" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> <div id="topnavList" class="hidden md:block"> <div class="absolute right-0 z-30 flex flex-col items-end rounded border border-gray-200 p-4 pl-12 shadow bg-white text-gray-900 font-semibold md:flex-row md:space-x-4 md:items-start md:border-0 md:p-0 md:shadow-none md:bg-inherit"> <a href="../learn/">Learn</a> <a href="../samples/">Samples</a> <a href="../intro/">Intro</a> <a href="../api/">API</a> <a href="../download.html">Download</a> <a href="https://forum.nwoods.com/c/gojs/11" target="_blank" rel="noopener">Forum</a> <a id="tc" href="https://nwoods.com/contact.html" target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/contact.html', 'contact');">Contact</a> <a id="tb" href="https://nwoods.com/sales/index.html" target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/sales/index.html', 'buy');">Buy</a> </div> </div> </div> </div> </nav> <script> window.addEventListener("DOMContentLoaded", function () { // topnav var topButton = document.getElementById("topnavButton"); var topnavList = document.getElementById("topnavList"); if (topButton && topnavList) { topButton.addEventListener("click", function (e) { topnavList .classList .toggle("hidden"); e.stopPropagation(); }); document.addEventListener("click", function (e) { // if the clicked element isn't the list, close the list if (!topnavList.classList.contains("hidden") && !e.target.closest("#topnavList")) { topButton.click(); } }); // set active <a> element var url = window .location .href .toLowerCase(); var aTags = topnavList.getElementsByTagName('a'); for (var i = 0; i < aTags.length; i++) { var lowerhref = aTags[i] .href .toLowerCase(); if (lowerhref.endsWith('.html')) lowerhref = lowerhref.slice(0, -5); if (url.startsWith(lowerhref)) { aTags[i] .classList .add('active'); break; } } } }); </script> <div class="sticky top-0 left-0 z-10 px-2 w-full bg-white border-b border-b-gray-200 md:hidden"> <button id="sidenavButton" class="flex p-2 text-gray-900 bg-inherit shadow-none items-center text-sm font-semibold hover:!bg-inherit hover:!text-nwoods-accent hover:!shadow-none" aria-label="Navigation"> <svg class="h-7 w-7 block mr-2" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> <span>Menu</span> </button> </div> <script> window.addEventListener("DOMContentLoaded", function () { // sidenav var sideButton = document.getElementById("sidenavButton"); var sidenav = document.getElementById("sidenav"); if (sideButton && sidenav) { sideButton.addEventListener("click", function (e) { sidenav .classList .toggle("hidden"); e.stopPropagation(); }); document.addEventListener("click", function (e) { // if the clicked element isn't the list, close the list if (!sidenav.classList.contains("hidden") && !e.target.closest("#sidenavList")) { sideButton.click(); } }); } }); </script> <div class="flex flex-row md:min-h-screen w-full max-w-screen-xl mx-auto"> <aside id="sidenav" class="hidden fixed top-0 left-0 z-10 w-full bg-black/10 min-h-screen max-h-screen overflow-x-hidden overflow-y-auto shrink-0 md:block md:sticky md:w-52 md:min-h-0 md:bg-inherit md:border-r md:border-r-gray-200 md:overscroll-auto"> <nav id="sidenavList" class="flex flex-col bg-white w-52 min-h-screen pl-2 pt-4 pb-24 md:w-full md:min-h-0"> <a href="index.html">Basics</a> <a href="buildingObjects.html">Building Parts</a> <a href="usingModels.html">Using Models</a> <a href="dataBinding.html">Data Binding</a> <a href="react.html">GoJS with React</a> <a href="svelte.html">GoJS with Svelte</a> <a href="angular.html">GoJS with Angular</a> <a href="textBlocks.html">TextBlocks</a> <a href="shapes.html">Shapes</a> <a href="pictures.html">Pictures</a> <a href="panels.html">Panels</a> <a href="tablePanels.html">Table Panels</a> <a href="brush.html">Brushes</a> <a href="sizing.html">Sizing Objects</a> <a href="itemArrays.html">Item Arrays</a> <a href="changedEvents.html">Changed Events</a> <a href="transactions.html">Transactions</a> <a href="viewport.html">Coordinates</a> <a href="initialView.html">Initial View</a> <a href="collections.html">Collections</a> <a href="links.html">Links</a> <a href="linkLabels.html">Link Labels</a> <a href="connectionPoints.html">Link Points</a> <a href="ports.html">Ports</a> <a href="nodes.html">Nodes</a> <a href="typings.html">Typings</a> <a href="debugging.html">Debugging</a> <a href="layouts.html">Layouts</a> <a href="routers.html">Routers</a> <a href="trees.html">Trees</a> <a href="subtrees.html">SubTrees</a> <a href="groups.html">Groups</a> <a href="subgraphs.html">SubGraphs</a> <a href="sizedGroups.html">Sized Groups</a> <a href="selection.html">Selection</a> <a href="highlighting.html">Highlighting</a> <a href="theming.html">Theming</a> <a href="tooltips.html">ToolTips</a> <a href="contextMenus.html">Context Menus</a> <a href="events.html">Diagram Events</a> <a href="tools.html">Tools</a> <a href="commands.html">Commands</a> <a href="accessibility.html">Accessibility</a> <a href="buttons.html">Buttons</a> <a href="permissions.html">Permissions</a> <a href="validation.html">Validation</a> <a href="animation.html">Animation</a> <a href="HTMLInteraction.html">HTML Interaction</a> <a href="layers.html">Layers &amp; Z-ordering</a> <a href="palette.html">Palette</a> <a href="overview.html">Overview</a> <a href="replacingDeleting.html">Replacing and Deleting</a> <a href="templateMaps.html">Template Maps</a> <a href="legends.html">Legends and Titles</a> <a href="extensions.html">Extensions</a> <a href="geometry.html">Geometry Strings</a> <a href="grids.html">Grid Patterns</a> <a href="graduatedPanels.html">Graduated Panels</a> <a href="SVGContext.html">Rendering to SVG</a> <a href="makingSVG.html">Snapshot to SVG</a> <a href="makingImages.html">Diagram Images</a> <a href="printing.html">Printing</a> <a href="serverSideImages.html">Server-side Images</a> <a href="nodeScript.html">GoJS in Node.js</a> <a href="testing.html">Testing</a> <a href="performance.html">Performance</a> <a href="platforms.html">Platforms</a> <a href="deployment.html">Deployment</a> </nav> </aside> <script> var navList = document.getElementById('sidenavList'); if (navList !== null) { var url = window.location.href; var lindex = url.lastIndexOf('/'); url = url .slice(lindex + 1) .toLowerCase(); var aTags = navList.getElementsByTagName('a'); var currentindex = -1; for (var i = 0; i < aTags.length; i++) { var lowerhref = aTags[i] .href .toLowerCase(); if (lowerhref.indexOf('/' + url) !== -1) { currentindex = i; aTags[i] .classList .add('active'); break; } } } </script> <div class="px-4 pb-16 w-full overflow-hidden prose"> <h1>Changed Events</h1> <p> There are three basic kinds of events that <b>GoJS</b> generates: <a>DiagramEvent</a>s, <a>InputEvent</a>s, and <a>ChangedEvent</a>s. This page talks about the latter, which are generated as <a>Diagram</a>s, <a>GraphObject</a>s, <a>Model</a>s, or Model data objects are modified. See the page <a href="events.html">Events</a> for the former two kinds of events. </p> <p> <a>ChangedEvent</a>s in <b>GoJS</b> are notifications of state changes, mostly object property changes. The ChangedEvent records the kind of change that occurred and enough information to be able to undo and redo them. </p> <p> Changed events are produced by both <a>Model</a> and <a>Diagram</a>. They are multicast events, so you can call <a>Model.addChangedListener</a> and <a>Diagram.addChangedListener</a>, as well as the corresponding removeChangedListener methods. For convenience you can also specify a Model change listener on a Diagram: <a>Diagram.addModelChangedListener</a>. <a>ChangedEvent</a>s received by a Model change listener will have a non-null value for <a>ChangedEvent.model</a>. Similarly, ChangedEvents received by a Diagram change listener will have non-null value for <a>ChangedEvent.diagram</a>. </p> <p> A <a>Diagram</a> always registers itself as a listener on its <a>Model</a>, so that it can automatically notice changes to the model and update its Parts accordingly. Furthermore the <a>UndoManager</a>, if enabled, automatically listens to changes to both the model and the diagram, so that it can record the change history and perform undo and redo. </p> <h2 id="ModelAndDataChanges">Model and Data changes</h2> <h3 id="ModelPropertyChanges">Model property changes</h3> <p> Model ChangedEvents record state changes either to data in a model or to the <a>Model</a> itself. ChangedEvents for models are generated by calls to <a>Model.setDataProperty</a> and by <a>Model</a> property setters. Property changes are identified by the <a>ChangedEvent.change</a> property value being <a>ChangeType.Property</a>. </p> <p> For property changes, the information includes the <a>ChangedEvent.object</a> that was modified, the <a>ChangedEvent.propertyName</a>, and the <a>ChangedEvent.oldValue</a> and <a>ChangedEvent.newValue</a> values for that property. </p> <p> Some changes represent structural changes to the model, not just simple model data changes. "Structural" changes are the insertion, modification, or removal of relationships that the model is responsible for maintaining. In such cases the <a>ChangedEvent.modelChange</a> property will be a non-empty string naming the kind of change. The following names for Property <a>ChangedEvent</a>s correspond to structural model data changes: </p> <ul> <li>"<b>nodeDataArray</b>", when the <a>Model.nodeDataArray</a> Array has been replaced</li> <li>"<b>nodeCategory</b>", due to a call to <a>Model.setCategoryForNodeData</a></li> <li>"<b>nodeGroupKey</b>", due to a call to <a>GraphLinksModel.setGroupKeyForNodeData</a></li> <li>"<b>linkDataArray</b>", when the <a>GraphLinksModel.linkDataArray</a> Array has been replaced</li> <li>"<b>linkFromKey</b>", due to a call to <a>GraphLinksModel.setFromKeyForLinkData</a></li> <li>"<b>linkToKey</b>", due to a call to <a>GraphLinksModel.setToKeyForLinkData</a></li> <li>"<b>linkFromPortId</b>", due to a call to <a>GraphLinksModel.setFromPortIdForLinkData</a></li> <li>"<b>linkToPortId</b>", due to a call to <a>GraphLinksModel.setToPortIdForLinkData</a></li> <li>"<b>linkLabelKeys</b>", due to a call to <a>GraphLinksModel.setLabelKeysForLinkData</a></li> <li>"<b>linkCategory</b>", due to a call to <a>GraphLinksModel.setCategoryForLinkData</a></li> <li>"<b>nodeParentKey</b>", due to a call to <a>TreeModel.setParentKeyForNodeData</a></li> <li>"<b>parentLinkCategory</b>", due to a call to <a>TreeModel.setParentLinkCategoryForNodeData</a></li> </ul> <p> The value of <a>ChangedEvent.modelChange</a> will be one of these strings. The value of <a>ChangedEvent.propertyName</a> depends on the name of the actual data property that was modified. For example, for the model property change "linkFromKey", the actual property name defaults to "from". But you might be using a different data property name by having set <a>GraphLinksModel.linkFromKeyProperty</a> to some other name. </p> <p> Any property can be changed on a node data or link data object, by calling <a>Model.setDataProperty</a>. Such a call will result in the property name to be recorded as the <a>ChangedEvent.propertyName</a>. These cases are treated as normal property changes, not structural model changes, so <a>ChangedEvent.modelChange</a> will be the empty string. The value of <a>ChangedEvent.object</a> will of course be the JavaScript object that was modified. </p> <p> Some changes may happen temporarily because some code, such as in a Tool, might want to use temporary objects for their own purposes. However your change listener might not be interested in such <a>ChangedEvent</a>s. If that is the case, you may want to ignore the ChangedEvent if <a>Model.skipsUndoManager</a> (or <a>Diagram.skipsUndoManager</a>) is true. </p> <p> Finally, there are property changes on the model itself. For a listing of such properties, see the documentation for <a>Model</a>, <a>GraphLinksModel</a>, and <a>TreeModel</a>. These cases are also treated as normal property changes, so <a>ChangedEvent.modelChange</a> will be the empty string. Both <a>ChangedEvent.model</a> and <a>ChangedEvent.object</a> will be the model itself. </p> <h3 id="ModelCollectionChanges">Model collection changes</h3> <p> Other kinds of changed events include <a>ChangeType.Insert</a> and <a>ChangeType.Remove</a>. In addition to all of the previously mentioned ChangedEvent properties used to record a property change, the <a>ChangedEvent.oldParam</a> and <a>ChangedEvent.newParam</a> provide the "index" information needed to be able to properly undo and redo the change. </p> <p> The following names for Insert and Remove <a>ChangedEvent</a>s correspond to model changes to collections, as the value of <a>ChangedEvent.modelChange</a>: </p> <ul> <li>"<b>nodeDataArray</b>", due to a call to <a>Model.addNodeData</a> or <a>Model.removeNodeData</a></li> <li>"<b>linkDataArray</b>", due to a call to <a>GraphLinksModel.addLinkData</a> or <a>GraphLinksModel.removeLinkData</a></li> <li>"<b>linkLabelKeys</b>", due to a call to <a>GraphLinksModel.addLabelKeyForLinkData</a> or <a>GraphLinksModel.removeLabelKeyForLinkData</a></li> </ul> <h3 id="Transactions">Transactions</h3> <p> The final kind of model changed event is <a>ChangeType.Transaction</a>. These are not strictly object changes in the normal sense, but they do notify about state changes in the model or the UndoManager, when a transaction starts or finishes or when an undo or redo starts or finishes. </p> <p> The following values of <a>ChangedEvent.propertyName</a> describe the kind of transaction-related event that just occurred: </p> <ul> <li>"<b>StartingFirstTransaction</b>"</li> <li>"<b>StartedTransaction</b>"</li> <li>"<b>CommittingTransaction</b>"</li> <li>"<b>CommittedTransaction</b>"</li> <li>"<b>RolledBackTransaction</b>"</li> <li>"<b>StartingUndo</b>"</li> <li>"<b>FinishedUndo</b>"</li> <li>"<b>StartingRedo</b>"</li> <li>"<b>FinishedRedo</b>"</li> </ul> <p> In each case the <a>ChangedEvent.object</a> is the <a>Transaction</a> holding a sequence of <a>ChangedEvent</a>s. The <a>ChangedEvent.oldValue</a> is the name of the transaction -- the string passed to <a>UndoManager.startTransaction</a> or <a>UndoManager.commitTransaction</a>. The various standard commands and tools that perform transactions document the transaction name(s) that they employ. But your code can employ as many transaction names as you like. </p> <p class="box bg-danger"> As a general rule, you should not make any changes to the model or any of its data in a listener for any Transaction ChangedEvent. </p> <h3 id="SavingModelWhenTransactionsComplete">Saving the Model when Transactions Complete</h3> <p> It is commonplace to want to update a server database when a transaction has finished. Use the <a>ChangedEvent.isTransactionFinished</a> read-only property to detect that case. You'll want to implement a Changed listener as follows: </p> <pre class="lang-js"><code> // notice whenever a transaction or undo/redo has occurred diagram.addModelChangedListener(evt => { if (evt.isTransactionFinished) saveModel(evt.model); }); </code></pre> <p> The value of <a>Transaction.changes</a> will be a List of <a>ChangedEvent</a>s, in the order that they were recorded. Those ChangedEvents represent changes both to the <a>Model</a> or its data and to the <a>Diagram</a> or its <a>GraphObject</a>s. Model changes will have <code>e.model !== null</code>; diagram changes will have <code>e.diagram !== null</code>. </p> <h3 id="IncrementallySavingChangesToModel">Incrementally Saving Changes to the Model</h3> <p> If you do not want to save the whole model at the end of each transaction, but only certain changes to the model, you can iterate over the list of changes to pick out the ones that you care about. For example, here is a listener that logs a message only when node data is added to or removed from the <a>Model.nodeDataArray</a>. </p> <pre class="lang-js"><code> diagram.addModelChangedListener(evt => { // ignore unimportant Transaction events if (!evt.isTransactionFinished) return; const txn = evt.object; // a Transaction if (txn === null) return; // iterate over all of the actual ChangedEvents of the Transaction txn.changes.each(e => { // ignore any kind of change other than adding/removing a node if (e.modelChange !== "nodeDataArray") return; // record node insertions and removals if (e.change === go.ChangeType.Insert) { console.log(evt.propertyName + " added node with key: " + e.newValue.key); } else if (e.change === go.ChangeType.Remove) { console.log(evt.propertyName + " removed node with key: " + e.oldValue.key); } }); }); </code></pre> <p> The above listener will put out messages as the user adds nodes (including by copying) and deletes nodes. The <a>ChangedEvent.propertyName</a> of the Transaction event (i.e. <i>evt</i> in the code above) will be either "CommittedTransaction", "FinishedUndo", or "FinishedRedo". Note that a "FinishedUndo" of the removal of a node is really adding the node, just as the undo of the insertion of a node actually removes it. </p> <p> Similarly, here is an example of noticing when links are connected, reconnected, or disconnected. This not only checks for insertions to and removals from <a>GraphLinksModel.linkDataArray</a>, but also changes to the "from" and the "to" properties of the link data. </p> <pre class="lang-js"><code> diagram.addModelChangedListener(evt => { // ignore unimportant Transaction events if (!evt.isTransactionFinished) return; const txn = evt.object; // a Transaction if (txn === null) return; // iterate over all of the actual ChangedEvents of the Transaction txn.changes.each(e => { // record node insertions and removals if (e.change === go.ChangeType.Property) { if (e.modelChange === "linkFromKey") { console.log(evt.propertyName + " changed From key of link: " + e.object + " from: " + e.oldValue + " to: " + e.newValue); } else if (e.modelChange === "linkToKey") { console.log(evt.propertyName + " changed To key of link: " + e.object + " from: " + e.oldValue + " to: " + e.newValue); } } else if (e.change === go.ChangeType.Insert && e.modelChange === "linkDataArray") { console.log(evt.propertyName + " added link: " + e.newValue); } else if (e.change === go.ChangeType.Remove && e.modelChange === "linkDataArray") { console.log(evt.propertyName + " removed link: " + e.oldValue); } }); }); </code></pre> <p> Note: the above code only works for a <a>GraphLinksModel</a>, where the link data are separate JavaScript objects. </p> <p> Look at the <a href="../samples/UpdateDemo.html">Update Demo</a> for a demonstration of how you can keep track of changes to a model when a transaction is committed or when an undo or redo is finished. The common pattern is to iterate over the ChangedEvents of the current Transaction in order to decide what to record in a database. </p> <p> It is also possible to send incremental updates to a database using <a>Model.toIncrementalJson</a> or <a>Model.toIncrementalData</a>, which iterate over the changes in a transaction and group them into a JSON-formatted string or an object representing any updates. </p> <pre class="lang-js"><code> diagram.addModelChangedListener(e => { // ignore unimportant Transaction events if (!evt.isTransactionFinished) return; const json = e.model.toIncrementalJson(e); const data = e.model.toIncrementalData(e); ... send to server/database ... }); </code></pre> <p> Caution: don't call <code>JSON.stringify</code> on the result of <a>Model.toIncrementalData</a>, because that will not properly handle any instances of JavaScript classes that are referenced by the object's properties. Instead call <a>Model.toIncrementalJson</a>, which will produce a more compact textual serialization. If you have to do your own serialization, please ignore the internal hash id property that GoJS may place on objects. </p> <h2 id="DiagramAndGraphObjectChanges">Diagram and GraphObject changes</h2> <p> Diagram ChangedEvents record state changes to <a>GraphObject</a>s or <a>RowColumnDefinition</a>s in a diagram, or to a <a>Layer</a> in a diagram, or to the <a>Diagram</a> itself. For such events, <a>ChangedEvent.diagram</a> will be non-null. </p> <p> Most ChangedEvents for diagrams record property changes, such as when some code sets the <a>TextBlock.text</a> property or the <a>Part.location</a> property. There are a few places which generate ChangedEvents recording insertions into or removals from collections, such as <a>Panel.insertAt</a>. There are never any ChangedEvents for diagrams that are <a>ChangeType.Transaction</a>. </p> <p> Although ChangedEvents for diagrams are important for undo/redo in order to retain visual fidelity, one normally ignores them when saving models. Only ChangedEvents for models record state changes to model data. So for saving to a database, you will want to consider only those ChangedEvents for which <a>ChangedEvent.model</a> is non-null. </p> </div> </div> <footer class="bg-white text-gray-900 border-t border-t-gray-200"> <div class="w-full max-w-screen-lg mx-auto px-4 py-6"> <p id="version" class="text-xs text-gray-900 m-0"></p> <div class="text-sm px-0 mb-4 grid grid-cols-2 sm:grid-cols-3 gap-y-10"> <div> <h2 class="text-base font-semibold text-nwoods-primary">GoJS</h2> <ul class="list-none space-y-4 md:space-y-1 px-0"> <li> <a href="../samples/index.html">Samples</a> </li> <li> <a href="../learn/index.html">Learn</a> </li> <li> <a href="../intro/index.html">Intro</a> </li> <li> <a href="../api/index.html">API</a> </li> <li> <a href="../changelog.html">Changelog</a> </li> <li> <a href="https://github.com/NorthwoodsSoftware/GoJS" target="_blank" rel="noopener">GitHub</a> </li> </ul> </div> <div> <h2 class="text-base font-semibold text-nwoods-primary">Support</h2> <ul class="list-none space-y-4 md:space-y-1 px-0"> <li> <a href="https://nwoods.com/contact.html" target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/contact.html', 'contact');">Contact</a> </li> <li> <a href="https://forum.nwoods.com/c/gojs" target="_blank" rel="noopener">Forum</a> </li> <li> <a href="https://nwoods.com/app/activate.aspx?sku=gojs" target="_blank" rel="noopener">Activate</a> </li> <li> <a href="https://nwoods.com/sales/index.html" target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/sales/index.html', 'buy');">Buy</a> </li> <li> <a href="https://nwoods.com/register.html" target="_blank" rel="noopener">Register</a> </li> </ul> </div> <div> <h2 class="text-base font-semibold text-nwoods-primary">Company</h2> <ul class="list-none space-y-4 md:space-y-1 px-0"> <li> <a target="_blank" href="https://nwoods.com" target="_blank" rel="noopener">Northwoods</a> </li> <li> <a target="_blank" href="https://nwoods.com/about.html" target="_blank" rel="noopener">About Us</a> </li> <li> <a target="_blank" href="https://nwoods.com/contact.html" target="_blank" rel="noopener">Contact Us</a> </li> <li> <a target="_blank" href="https://nwoods.com/consulting.html" target="_blank" rel="noopener">Consulting</a> </li> <li> <a target="_blank" href="https://twitter.com/northwoodsgo" target="_blank" rel="noopener">Twitter</a> </li> </ul> </div> </div> <p class="text-sm text-gray-900 md:mb-6"> Copyright 1998-2025 <a href="https://nwoods.com">Northwoods Software</a> </p> </div> </footer> </body> <script async src="https://www.googletagmanager.com/gtag/js?id=G-S5QK8VSK84"></script> <script> window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-S5QK8VSK84'); var getOutboundLink = function (url, label) { gtag('event', 'click', { 'event_category': 'outbound', 'event_label': label, 'transport_type': 'beacon' }); } const params = new URL(document.location).searchParams let a = params.get('a'); if (a) localStorage.setItem('a', a); a = localStorage.getItem('a'); if (a) { const links = [...document.body.getElementsByTagName("a")].filter((l) => l.href.includes('nwoods.com')); for (const l of links) { const url = new URL(l.href); url.searchParams.set('a', a); l.href = url; } } </script> <script src="../assets/js/prism.js"></script> <script src="https://cdn.jsdelivr.net/npm/gojs@3.1.0"></script> <script src="../assets/js/goDoc.js"></script> <script> document.addEventListener("DOMContentLoaded", function () { if (window.go) document .getElementById('version') .textContent = "GoJS version " + go.version; if (window.goDoc) window.goDoc(); var d = window.diagrams; for (var i = 0; i < d.length; i++) { var dargs = d[i]; goCodeExecute(dargs[0], dargs[1], dargs[2], dargs[3], dargs[4]); } if (window.extra) window.extra(); } ); </script> </html>