create-gojs-kit
Version:
A CLI for downloading GoJS samples, extensions, and docs
829 lines (730 loc) • 37.1 kB
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"/>
<meta name="description" content="Example of a security dashboard made with GoJS. Diagram for monitoring live video feeds and swipe access doors." />
<meta itemprop="description" content="Example of a security dashboard made with GoJS. Diagram for monitoring live video feeds and swipe access doors." />
<meta property="og:description" content="Example of a security dashboard made with GoJS. Diagram for monitoring live video feeds and swipe access doors." />
<meta name="twitter:description" content="Example of a security dashboard made with GoJS. Diagram for monitoring live video feeds and swipe access doors." />
<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="Security Monitor Dashboard" />
<meta property="og:title" content="Security Monitor Dashboard" />
<meta name="twitter:title" content="Security Monitor Dashboard" />
<meta property="og:image" content="https://gojs.net/latest/assets/images/screenshots/security.png" />
<meta itemprop="image" content="https://gojs.net/latest/assets/images/screenshots/security.png" />
<meta name="twitter:image" content="https://gojs.net/latest/assets/images/screenshots/security.png" />
<meta property="og:url" content="https://gojs.net/latest/samples/security.html" />
<meta property="twitter:url" content="https://gojs.net/latest/samples/security.html" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="og:type" content="website" />
<meta property="twitter:domain" content="gojs.net" />
<title>
Security Monitor Dashboard | GoJS Diagramming Library
</title>
</head>
<body>
<!-- This top nav is not part of the sample code -->
<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="flex flex-col prose">
<div class="w-full max-w-screen-xl mx-auto">
<!-- * * * * * * * * * * * * * -->
<!-- Start of GoJS sample code -->
<script src="https://cdn.jsdelivr.net/npm/gojs@3.1.0"></script>
<div id="allSampleContent" class="p-4 w-full">
<script src="../extensions/SectorReshapingTool.js"></script>
<script id="code">
const scaleMult = 2.5;
let currentScale = 1 * scaleMult;
function init() {
// clear the output log, some browsers will save text in textareas
document.getElementById('outputLog').innerText = '';
myDiagram = new go.Diagram('myDiagramDiv', {
contentAlignment: go.Spot.Center,
initialAutoScale: go.AutoScale.Uniform,
allowClipboard: false,
allowCopy: false,
'grid.gridCellSize': new go.Size(1, 1),
'draggingTool.isGridSnapEnabled': true,
'toolManager.hoverDelay': 350, // changes delay for tooltips
'animationManager.isInitial': false
});
// install the SectorReshapingTool as a mouse-down tool
myDiagram.toolManager.mouseDownTools.add(new SectorReshapingTool());
// the background image, a floor plan
myDiagram.add(
new go.Part({ // this Part is not bound to any model data
layerName: 'Background',
position: new go.Point(0, 0),
selectable: false,
pickable: false
})
.add(
new go.Picture("images/officeFloorPlan.png", { width: 2833, height: 1333 })
));
// Doors represent an access-controlled door (eg, by keycard). They open and close automatically when simulated persons use them.
myDiagram.nodeTemplateMap.add('door',
new go.Node({
resizeObjectName: 'DOOR',
locationSpot: go.Spot.Left,
pickable: false,
selectable: false
})
.bindTwoWay('location', 'loc', go.Point.parse, go.Point.stringify)
.bindTwoWay('angle', 'ang')
.add(
new go.Shape({
name: 'DOOR',
strokeWidth: 1,
fill: 'rgb(0,255,0)',
height: 9,
width: 50
})
.bind('width')
.bind('height')
));
// Cameras represent a security camera's visible area
myDiagram.nodeTemplateMap.add('camera',
new go.Node('Spot', {
isInDocumentBounds: false,
isShadowed: true,
shadowOffset: new go.Point(2,2),
shadowColor: 'black',
locationSpot: go.Spot.Center,
locationObjectName: 'LAMP',
selectionObjectName: 'LAMP',
selectionAdorned: false,
toolTip:
new go.Adornment('Auto')
.add(
new go.Shape('RoundedRectangle', {
fill: 'white'
}),
new go.TextBlock('Live', { margin: 6 })
.bind('text', 'status')
)
})
.bindTwoWay('location', 'loc', go.Point.parse, go.Point.stringify)
.add(
new go.Panel('Spot', { name: 'LAMP' })
.add(
new go.Shape({ // arc
name: 'ARC',
fill: 'skyblue',
strokeWidth: 5,
opacity: 0.5,
shadowVisible: false
})
.bindObject('stroke', 'isSelected', s => s ? 'blue' : null)
.bind('fill', 'status', s => s !== 'Live' ? 'red' : 'skyblue')
.bind('geometry', '', makeSector),
new go.Shape('Circle', { name: 'SHAPE', width: 6, height: 6 }),
new go.TextBlock('!', {
alignmentFocus: new go.Spot(1, 0, -30, -30),
font: '64pt Arial',
stroke: 'red',
shadowVisible: true
})
.bind('visible', 'status', s => s!== 'Live')
),
new go.TextBlock({
alignment: new go.Spot(0.5, 0.5, 0, 3),
alignmentFocus: go.Spot.Top,
stroke: 'blue',
background: 'rgba(255,255,255,0.3)'
})
.bindTwoWay('alignment', 'spot', go.Spot.parse, go.Spot.stringify)
.bind('text', 'name')
));
// This code keeps certain parts at a constant size in the viewport, by
// adjusting for any scaling done by zooming in or out.
let origscale = NaN;
myDiagram.addDiagramListener('InitialLayoutCompleted', e => {
origscale = myDiagram.scale;
});
myDiagram.addDiagramListener('ViewportBoundsChanged', e => {
if (isNaN(origscale)) return;
var newscale = myDiagram.scale;
if (e.subject.scale === newscale) return; // optimization: don't scale Nodes when just scrolling/panning
myDiagram.skipsUndoManager = true;
myDiagram.startTransaction('scale Nodes');
currentScale = scaleMult * origscale / newscale;
myDiagram.parts.each(p => {
if (p.layerName !== 'Adornment') return; // don't scale background image
p.scale = currentScale;
});
myDiagram.commitTransaction('scale Nodes');
myDiagram.skipsUndoManager = false;
});
// Create the Diagram's Model
loadModel();
// Toggle random events on
toggleEvents();
// palette
myPalette = new go.Palette('myPaletteDiv', {
layout: new go.GridLayout({
cellSize: new go.Size(1, 1),
wrappingColumn: 1
}),
'dragSelectingTool.isEnabled': false,
hasHorizontalScrollbar: false,
hasVerticalScrollbar: false,
allowHorizontalScroll: false,
allowVerticalScroll: false,
allowZoom: false,
contentAlignment: go.Spot.Center,
'toolManager.hoverDelay': myDiagram.toolManager.hoverDelay,
scale: 0.6,
scrollMode: go.Diagram.InfiniteScroll,
nodeTemplate: myDiagram.nodeTemplate, // shared with the main Diagram
nodeTemplateMap: myDiagram.nodeTemplateMap, // shared with the main Diagram
model: new go.GraphLinksModel({
nodeDataArray: [
{ category: 'camera', sweep: 90, radius: 90, angle: 315, status: 'Live' },
{ category: 'camera', sweep: 90, radius: 90, angle: 315, status: 'Offline' }
]
})
});
// center the elements in the palette, this is needed because the "center"
// of the camera is technically the black circle
myPalette.addDiagramListener('InitialLayoutCompleted', () => {
myPalette.nodes.each(n => {
const p = n.position.copy();
p.x -= n.getDocumentBounds().width / 2 * myPalette.scale;
n.position = p;
});
});
// link each camera to a "Live" video feed
myDiagram.addDiagramListener("InitialLayoutCompleted", e => {
myDiagram.nodes.each(linkCam);
});
// unlink deleted cameras
myDiagram.addDiagramListener('SelectionDeleting', e => {
// the DiagramEvent.subject is the collection of Parts about to be deleted
// reverse to put back in the same order for load
[... e.subject].reverse().forEach(part => {
if (part.category !== 'camera') return;
if (part.data?._cameraDiv?.firstChild?.src) {
camIMGs.unshift(part.data._cameraDiv.firstChild.src);
}
part.data._cameraDiv.remove();
});
});
// highlight current camera
let prevSelection = null;
myDiagram.addDiagramListener('ChangedSelection', e => {
if (prevSelection) {
prevSelection.each(part => { // undo old highlights
if (!part) return;
if (part.category !== 'camera') return;
if (!part.data._cameraDiv) return;
part.data._cameraDiv.style.borderColor = 'rgba(0,0,0,0)';
});
}
prevSelection = e.subject.copy();
e.subject.each(part => { // highlight currently selected
if (!part) return;
if (part.category !== 'camera') return;
if (!part.data._cameraDiv) linkCam(part);
part.data._cameraDiv.style.borderColor = 'white';
});
});
/** "malfunctioning" camera(s) **/
// set camera 1 to offline
const cam = myDiagram.findNodeForKey(1);
const date = new Date();
date.setHours(date.getHours() * (Math.random() * 0.5 + 0.25));
myDiagram.model.setDataProperty(cam.data, 'status', `Camera offline since ${date.toLocaleString()}`);
// alternate opacity of all "malfunctioning" cameras
let cur_op = 0.4;
setInterval(() => {
cur_op = cur_op !== 0.4 ? 0.4 : 0.63;
myDiagram.nodes.each(n => {
if (n.data.category !== 'camera') return;
if (n.data.status === 'Live') return;
const arc = n.findObject('ARC');
myDiagram.model.setDataProperty(arc, 'opacity', cur_op);
});
}, 650);
saveModel(); // update model to match random camera status
}
window.addEventListener('DOMContentLoaded', init);
function makeSector(data) {
// Geometry converter for the node's "LAMP" Shape
var radius = SectorReshapingTool.getRadius(data);
var angle = SectorReshapingTool.getAngle(data);
var sweep = SectorReshapingTool.getSweep(data);
var start = new go.Point(radius, 0).rotate(angle);
var geo = new go.Geometry()
.add(
new go.PathFigure(radius + start.x, radius + start.y) // start point
.add(
new go.PathSegment(
go.SegmentType.Arc,
angle, sweep, // angles
radius, radius, radius, radius
)
)
.add(new go.PathSegment(go.SegmentType.Line, radius, radius).close())
)
.add(new go.PathFigure(0, 0)) // make sure the Geometry always includes the whole circle
.add(new go.PathFigure(2 * radius, 2 * radius)); // even if only a small sector is "lit"
return geo;
}
const camIMGs = [
'https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/01_01_RIMG0002.jpg/1200px-01_01_RIMG0002.jpg?20201006124235',
'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b7/2000_core-repository02_hg.jpg/180px-2000_core-repository02_hg.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/2000_core-repository03_hg.jpg/180px-2000_core-repository03_hg.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d2/2019-06_arolsen_archives_01.jpg/180px-2019-06_arolsen_archives_01.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c4/Albert_Schr%C3%B6der_Raum_mit_Aktenschrank.jpg/180px-Albert_Schr%C3%B6der_Raum_mit_Aktenschrank.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/AMVB.JPG/135px-AMVB.JPG',
'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8e/Archiv_hlavn%C3%ADho_m%C4%9Bsta_Prahy%2C_archiv%C3%A1lie.jpg/135px-Archiv_hlavn%C3%ADho_m%C4%9Bsta_Prahy%2C_archiv%C3%A1lie.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Archiv_hlavn%C3%ADho_m%C4%9Bsta_Prahy%2C_spisy_okresn%C3%ADho_soudu_Kr%C3%A1lovsk%C3%A9_Vinohrady.jpg/180px-Archiv_hlavn%C3%ADho_m%C4%9Bsta_Prahy%2C_spisy_okresn%C3%ADho_soudu_Kr%C3%A1lovsk%C3%A9_Vinohrady.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/AMVB.JPG/135px-AMVB.JPG',
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Benutzersaal_des_Evangelischen_Zentralarchivs_in_Berlin.jpg/180px-Benutzersaal_des_Evangelischen_Zentralarchivs_in_Berlin.jpg',
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Stanford%2C_California%2C_United_States_Post_Office%2C_interior%2C_March_2019.jpg/1200px-Stanford%2C_California%2C_United_States_Post_Office%2C_interior%2C_March_2019.jpg?20190618001927',
'https://upload.wikimedia.org/wikipedia/commons/5/5f/Office_interior_in_Tamil_Nadu.jpg?20131024091005',
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Centralia%2C_WA_-_main_post_office_interior_04.jpg/1200px-Centralia%2C_WA_-_main_post_office_interior_04.jpg?20120914040255',
'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/Chehalis_Post_Office_interior_02.jpg/1200px-Chehalis_Post_Office_interior_02.jpg?20150127045910',
'https://upload.wikimedia.org/wikipedia/commons/1/14/Peles_Castle_Interior_-_Queens_Office_%281%29.jpg?20221112191528',
'https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Ticket_Office_Interior_-_geograph.org.uk_-_3967333.jpg/640px-Ticket_Office_Interior_-_geograph.org.uk_-_3967333.jpg'
]
// link the given node to a new camera feed
function linkCam(node) {
if (!node) return;
if (node.category !== 'camera') return;
const feedDiv = document.getElementById('cameraFeeds');
const div = document.createElement('div');
div.className = 'cameraDiv';
feedDiv.appendChild(div);
node.data._cameraDiv = div; // starts with "_" to prevent the property from being saved in JSON
if (node.data.status === 'Live' && camIMGs[0]) {
const img = document.createElement('img');
div.appendChild(img)
img.src = camIMGs[0];
img.draggable = false;
camIMGs.splice(0,1);
img.style = 'width: 100%; height: 100%; object-fit: cover;';
img.style.userSelect = 'none';
} else {
if (node.data.status === 'Live') { // no more feeds
myDiagram.model.setDataProperty(node.data, 'status', 'No video feed');
}
const div2 = document.createElement('div');
div2.className = 'innerCamera';
div.appendChild(div2);
const text = document.createElement('p');
text.className = 'cameraLabel';
text.innerHTML = 'NO SIGNAL';
div2.appendChild(text);
}
div.onclick = () => {
myDiagram.select(node);
myDiagram.focus();
};
}
function loadModel() {
// this way the SelectionDeleting event is triggered and the camera feed is correctly cleared
myDiagram.selectCollection(myDiagram.nodes);
myDiagram.commandHandler.deleteSelection();
myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
}
function saveModel() {
let text = myDiagram.model.toJson();
document.getElementById("mySavedModel").value = text;
}
// create an event popup, with log message
function createEvent(node, diaTxt, logTxt) {
// output log
const elem = document.getElementById('outputLog');
elem.value = `${new Date().toLocaleString()} ${logTxt}\r\n` + (elem.value || elem.innerText || '');
// add the text animation
const p =
new go.Part('Auto', { pickable: false, layerName: 'Adornment', scale: currentScale })
.add(
new go.Shape('RoundedRectangle', { fill: 'white' }),
new go.TextBlock(diaTxt, { margin: 8 })
);
myDiagram.add(p);
// position for doors, location for cameras
const isDoor = node.data.category === 'door';
const pos2 = (isDoor ? node.position.copy() : node.location.copy());
if (isDoor) {
pos2.x += node.getDocumentBounds().width;
} else {
pos2.y -= 25;
}
pos2.x += 10;
pos2.y -= 20;
const pos1 = pos2.copy();
pos1.y += 40;
const anim3 = new go.Animation();
anim3.easing = go.Animation.EaseOutExpo;
anim3.duration = 2000;
anim3.add(p, (isDoor ? 'position' : 'location'), pos1, pos2);
anim3.finished = () => myDiagram.remove(p);
anim3.start();
}
// open the specified door
const doorTriggers = {}; // map of door nodes to animation count
function openDoor(node) {
if (node.data.category !== 'door') return;
if (doorTriggers[node.key]) return;
const data = node.data;
const door = node.findObject('DOOR');
const randomUserId = 1000 + parseInt(Math.random() * 2500);
// multiple nodes can be modified by a single animation,
// however since each one here has a different duration
// they use separate Animations
// animate door color
const animColor = new go.Animation();
animColor.easing = go.Animation.EaseOutExpo;
animColor.duration = 1350;
animColor.add(door, 'fill', door.fill, 'rgb(255,0,0)');
// animate door opening
const anim = new go.Animation();
const originalWidth = door.width;
anim.add(door, 'width', door.width, Math.max(0, door.width * 0.2 - door.strokeWidth));
anim.easing = go.Animation.EaseOutExpo;
anim.duration = 1000;
anim.finished = () => {
// reset color
const animColor2 = new go.Animation();
animColor2.easing = go.Animation.EaseOutExpo;
animColor2.duration = 1350;
animColor2.add(door, 'fill', 'rgb(255,0,0)', 'rgb(0,255,0)');
// close door
const anim2 = new go.Animation();
anim2.add(door, 'width', door.width, originalWidth);
anim2.easing = go.Animation.EaseOutExpo;
anim2.duration = 500;
anim2.finished = () => { doorTriggers[node.key] = false; }
anim2.start();
animColor2.start();
};
doorTriggers[node.key] = true;
// diagram text popup and event log
createEvent(node, `Opened by USER ${randomUserId}`, `USER ${randomUserId} opened ${data.key}`);
anim.start();
animColor.start();
}
// open the specified camera
const cameraTriggers = {} // map of camera nodes to how many times they have been triggered
triggerCamera = function (node) {
if (node?.data?.category !== 'camera') return;
if (node.data.status !== 'Live') return; // don't animate "broken" cameras
const data = node.data;
const light = node.findObject('ARC');
// animations
// animate door color
const animColor = new go.Animation();
animColor.easing = go.Animation.EaseOutExpo;
animColor.duration = 750;
animColor.add(light, 'fill', light.fill, 'rgb(255,255,0)');
animColor.finished = () => {
cameraTriggers[node]--;
if (cameraTriggers[node]) return;
setTimeout(() => {
const animColor2 = new go.Animation();
// reset color
animColor2.easing = go.Animation.EaseOutExpo;
animColor2.duration = animColor.duration * 1.2;
animColor2.add(light, 'fill', light.fill, myDiagram.nodeTemplateMap.get('camera').findObject('ARC').fill);
animColor2.start();
}, 250); // let the camera stay yellow before changing back
};
if (isNaN(cameraTriggers[node])) cameraTriggers[node] = 1;
else cameraTriggers[node]++;
// diagram text popup and event log
createEvent(node, `Motion detected`, `MOTION on cam ${[... document.getElementById('cameraFeeds').children].indexOf(node.data._cameraDiv) + 1}`);
animColor.start();
}
// emit random events
let _timeout = null;
let _prevNode = null;
function toggleEvents() {
if (!_timeout) { // start
const doEvent = () => {
// pick a random door/camera
let pickRand = () => [... myDiagram.nodes][parseInt(Math.random() * myDiagram.nodes.count)];
let node = pickRand();
// reduce chance of picking the same node twice in a row
if (node === _prevNode) node = pickRand();
_prevNode = node;
if (node.category === 'door') {
openDoor(node);
} else {
triggerCamera(node);
}
// call this function again after a random amount of time
_timeout = setTimeout(() => {
doEvent();
}, 600 + Math.random()*1150);
}
doEvent();
} else { // stop
clearInterval(_timeout);
_timeout = null;
}
}
</script>
<div id="sample">
<div style="width: 100%; display: flex;border: solid 1px black;">
<div
id="myPaletteDiv"
style="width: 100px; height: 600px; background: whitesmoke; border-right: solid 1px black;">
</div>
<div id="myDiagramDiv" style="height: 600px; flex-grow: 1;"></div>
</div>
<p>
This sample demonstrates a security dashboard built with GoJS. <a>Node</a>s are used to represent the doors and
cameras. When hovering over the camera a <a>Tooltip</a> will show its status. You can make new cameras
by dragging one into the <a>Diagram</a> from the <a>Palette</a>. Then it can be rotated, resized, and have the field
of view changed using the <a href="../samples/SectorReshaping.html">Sector Reshaping Tool</a>.
</p>
<p>
This sample simulates doors opening and cameras detecting motion. This information is conveyed via <a>Animation</a>s in the
Diagram and an "Event Log" in the HTML below.
</p>
<p>
Because of this sample's complexity, it is meant for a demonstration of several GoJS features, rather than a starting point for your own project.
We are happy to help you begin a proof-of-concept for your own project if you have similar needs.
</p>
<div class="w-full flex flex-wrap">
<div class="flex-1 min-w-[500px] mb-4 mr-2">
Clicking a (simulated) video feed below will highlight the corresponding camera in the Diagram. A single camera is offline to demonstrate a signal connection error.
<div id="cameraFeeds" class="border border-black w-full min-h-[100px] bg-[#1f4963] grid grid-cols-4"></div>
</div>
<div class="flex-none w-[500px] min-h-[200px] flex flex-col mb-4">
Event Log
<textarea id="outputLog" style="width: 100%; height: 100%;resize: none;" autocomplete="off"></textarea>
</div>
</div>
<br>
<button onclick="saveModel()">Save</button>
<button onclick="loadModel()">Load</button>
<br>
<textarea id="mySavedModel" style="width: 90%;height: 400px" autocomplete="off">
{ "class": "GraphLinksModel",
"nodeDataArray": [
{"category":"door","loc":"535 641.85","ang":270,"width":62.7,"height":15,"key":"sub office 1"},
{"category":"door","loc":"588.45 665","width":56.1,"height":15,"key":"office 1"},
{"category":"door","loc":"721.8 738","key":"bath 1","width":48.4,"height":15},
{"category":"door","loc":"942.4 664","key":"office 2","width":57.2,"height":15},
{"category":"door","loc":"401.45 942","key":"main entrance","width":100.1,"height":15},
{"loc":"715 1125.35","category":"door","key":"lounge 1","ang":270,"width":62.7,"height":15},
{"loc":"721.2 1007","category":"door","key":"storage 1","width":61.6,"height":15},
{"loc":"1119.25 738","category":"door","key":"meeting 1","width":60.5,"height":15},
{"loc":"1607.2 407","category":"door","key":"meeting 2","width":61.6,"height":15},
{"loc":"2133 671.2","category":"door","key":"office 3","ang":90,"width":61.6,"height":15},
{"loc":"2310 669.65","category":"door","key":"second entrance","ang":90,"width":62.7,"height":15},
{"loc":"1996.25 407","category":"door","key":"lounge 2","width":60.5,"height":15},
{"loc":"1808.75 561","category":"door","key":"bath 2","width":49.5,"height":15},
{"loc":"1898.7 561","category":"door","key":"bath 3","width":50.6,"height":15},
{"loc":"2310 947.15","category":"door","key":"sub office 4","ang":90,"width":62.7,"height":15},
{"loc":"2133 935.35","category":"door","key":"sub office 2","ang":270,"width":62.7,"height":15},
{"loc":"2133 949.2","category":"door","key":"sub office 3","ang":90,"width":61.6,"height":15},
{"loc":"2285.8 1018","category":"door","key":"sub office 5","width":61.6,"height":15,"ang":180},
{"loc":"2310 568.75","category":"door","key":"office 4","width":60.5,"height":15,"ang":90},
{"loc":"2310 451.1","category":"door","key":"storage 2","ang":90,"width":63.8,"height":15},
{"loc":"2310 371.25","category":"door","key":"meeting 3","ang":270,"width":60.5,"height":15},
{"status":"Camera offline since 6/30/2025, 11:06:17 AM","loc":"546 675","category":"camera","sweep":90,"radius":160,"key":1},
{"status":"Live","loc":"704 1194","category":"camera","sweep":90,"radius":280,"key":2,"angle":180},
{"status":"Live","loc":"1937 683","category":"camera","sweep":92,"radius":111,"key":3,"angle":179},
{"status":"Live","loc":"2460 663","category":"camera","sweep":90,"radius":128,"key":4,"angle":90},
{"status":"Live","loc":"524 674","category":"camera","sweep":90,"radius":162,"key":5,"angle":90},
{"category":"camera","sweep":90,"radius":430,"angle":180,"status":"Live","key":-27,"loc":"2117 1167"},
{"category":"camera","sweep":90,"radius":475,"angle":90,"status":"Live","key":-28,"loc":"1577 142"},
{"category":"camera","sweep":90,"radius":430,"angle":90,"status":"Live","key":-29,"loc":"867 150"},
{"category":"camera","sweep":90,"radius":225,"angle":180,"status":"Live","key":-30,"loc":"1236 998"},
{"category":"camera","sweep":90,"radius":270,"angle":90,"status":"Live","key":-31,"loc":"1937 118"},
{"category":"camera","sweep":90,"radius":210,"angle":270,"status":"Live","key":-32,"loc":"1968 370"},
{"category":"camera","sweep":90,"radius":282,"angle":360,"status":"Live","key":-33,"loc":"2328 120"},
{"category":"camera","sweep":90,"radius":145,"angle":270,"status":"Live","key":-34,"loc":"1970 643"}
],
"linkDataArray": []}
</textarea>
</div>
<style>
.cameraDiv {
float: left;
border: 4px solid;
border-color: rgba(0,0,0,0);
aspect-ratio: 16 / 9;
}
.innerCamera {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: rgb(64,64,64);
}
.cameraLabel {
user-select: none;
margin: 0;
color: white;
}
</style>
</div>
<!-- * * * * * * * * * * * * * -->
<!-- End of GoJS sample code -->
</div>
<div id="allTagDescriptions" class="p-4 w-full max-w-screen-xl mx-auto">
<hr/>
<h3 class="text-xl">GoJS Features in this sample</h3>
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<h4>Animation</h4>
<p>
<b>GoJS</b> offers several built-in animations, enabled by default, as well as the ability to create arbitrary animations.
</p>
<p>
The <a href="../api/symbols/Diagram.html#animationManager" target="api">Diagram.animationManager</a> handles animations within a <a href="../api/symbols/Diagram.html" target="api">Diagram</a>.
The <a href="../api/symbols/AnimationManager.html" target="api">AnimationManager</a> automatically sets up and dispatches default animations, and has properties to customize and disable them.
Custom animations are possible by creating instances of <a href="../api/symbols/Animation.html" target="api">Animation</a> or <a href="../api/symbols/AnimationTrigger.html" target="api">AnimationTrigger</a>.
More information can be found in the <a href="../intro/animation.html">GoJS Intro</a>.
</p>
<p>
<a href="../samples/index.html#animation">Related samples</a>
</p>
<hr>
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<h4>Geometry Path Strings</h4>
<p>
The <b>GoJS</b> <a href="../api/symbols/Geometry.html" target="api">Geometry</a> class controls the "shape" of a <a href="../api/symbols/Shape.html" target="api">Shape</a>,
whereas the <a href="../api/symbols/Shape.html#fill" target="api">Shape.fill</a> and <a href="../api/symbols/Shape.html#stroke" target="api">Shape.stroke</a> and other shape properties control the colors and appearance of the shape.
For common shape figures, there are predefined geometries that can be used by setting <a href="../api/symbols/Shape.html#figure" target="api">Shape.figure</a>.
However one can also define custom geometries.
</p>
<p>
One can construct any Geometry by allocating and initializing a <a href="../api/symbols/Geometry.html" target="api">Geometry</a> of at least one <a href="../api/symbols/PathFigure.html" target="api">PathFigure</a> holding some <a href="../api/symbols/PathSegment.html" target="api">PathSegment</a>s.
But you may find that using the string representation of a Geometry is easier to write and save in a database.
Use the static method <a href="../api/symbols/Geometry.html#parse" target="api">Geometry.parse</a> or the <a href="../api/symbols/Shape.html#geometryString" target="api">Shape.geometryString</a> property to transform a geometry path string into a <a href="../api/symbols/Geometry.html" target="api">Geometry</a> object.
</p>
<p>
More information can be found in the <a href="../intro/geometry.html">GoJS Intro</a>.
</p>
<p>
<a href="../samples/index.html#geometries">Related samples</a>
</p>
<hr>
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<h4>Palette</h4>
<p>
A <a href="../api/symbols/Palette.html" target="api">Palette</a> is a subclass of <a href="../api/symbols/Diagram.html" target="api">Diagram</a> that is used to display a number of <a href="../api/symbols/Part.html" target="api">Part</a>s that
can be dragged into the diagram that is being modified by the user.
The initialization of a <a href="../api/symbols/Palette.html" target="api">Palette</a> is just like the initialization of any <a href="../api/symbols/Diagram.html" target="api">Diagram</a>.
Like Diagrams, you can have more than one Palette on the page at the same time.
</p>
<p>
More information can be found in the <a href="../intro/palette.html">GoJS Intro</a>.
</p>
<p>
<a href="../samples/index.html#palette">Related samples</a>
</p>
<hr>
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<h4>ToolTips</h4>
<p>
A tooltip is an <a href="../api/symbols/Adornment.html" target="api">Adornment</a> that is shown when the mouse hovers over an object that has its <a href="../api/symbols/GraphObject.html#toolTip" target="api">GraphObject.toolTip</a> set.
The tooltip part is bound to the same data as the part itself.
</p>
<p>
It is typical to implement a tooltip as a "ToolTip" Panel holding a <a href="../api/symbols/TextBlock.html" target="api">TextBlock</a> or a Panel of TextBlocks and other objects.
Each "ToolTip" is just an "Auto" Panel <a href="../api/symbols/Adornment.html" target="api">Adornment</a> that is shadowed, and where the border is a rectangular <a href="../api/symbols/Shape.html" target="api">Shape</a> with a light gray fill.
However you can implement the tooltip as any arbitrarily complicated Adornment.
</p>
<p>
More information can be found in the <a href="../intro/tooltips.html">GoJS Intro</a>.
</p>
<p>
<a href="../samples/index.html#tooltips">Related samples</a>
</p>
<hr>
<!-- blacklist tags that do not correspond to a specific GoJS feature -->
<h4>SCADA Diagrams</h4>
<p>
SCADA (supervisory control and data acquisition) diagrams are used to display, control, and supervise machines and processes. GoJS is used worldwide to build human-machine interface diagrams in monitoring and control software. GoJS SCADA applications include power plant and refinery monitoring, heavy industry management, building security monitoring, and more.
</p>
<p>
<a href="../samples/index.html#scada">Related samples</a>
</p>
<hr>
</div>
</div>
</body>
<!-- This script is part of the gojs.net website, and is not needed to run the sample -->
<script src="../assets/js/goSamples.js"></script>
</html>