/** *******************************************************************************************
PRSM Participatory System Mapper
Copyright (c) [2022] Nigel Gilbert email: prsm@prsm.uk
This software is licenced under the PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
See the file LICENSE.md for details.
This is the main entry point for PRSM.
********************************************************************************************/
/* global localStorage, Image, HTMLElement, confirm, Worker, requestAnimationFrame, getComputedStyle, prompt */
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { Network } from 'vis-network/peer'
import { DataSet } from 'vis-data/peer'
import diff from 'microdiff'
import localForage from 'localforage'
import {
listen,
elem,
getScaleFreeNetwork,
uuidv4,
isEmpty,
deepMerge,
deepCopy,
splitText,
dragElement,
standardizeColor,
setNodeHidden,
setEdgeHidden,
factorSizeToPercent,
setFactorSizeFromPercent,
convertDashes,
getDashes,
objectEquals,
generateName,
statusMsg,
alertMsg,
clearStatusBar,
shorten,
initials,
CP,
timeAndDate,
setEndOfContenteditable,
exactTime,
humanSize,
isQuillEmpty,
displayHelp,
} from './utils.js'
import {
openFile,
savePRSMfile,
exportPNGfile,
setFileName,
exportExcel,
exportDOT,
exportGML,
exportGraphML,
exportGEXF,
exportNotes,
readSingleFile,
} from './files.js'
import Tutorial from './tutorial.js'
import { styles } from './samples.js'
import { trophic } from './trophic.js'
import { cluster, openCluster } from './cluster.js'
import { mergeRoom, diffRoom } from './merge.js'
import Quill from 'quill'
import {
setUpSamples,
reApplySampleToNodes,
refreshSampleNode,
reApplySampleToLinks,
refreshSampleLink,
legend,
clearLegend,
} from './styles.js'
import {
canvas,
nChanges,
setUpBackground,
updateFromRemote,
redraw,
resizeCanvas,
zoomCanvas,
panCanvas,
deselectTool,
copyBackgroundToClipboard,
pasteBackgroundFromClipboard,
upgradeFromV1,
updateFromDrawingMap,
} from './background.js'
import { version } from '../package.json'
import { compressToUTF16, decompressFromUTF16 } from 'lz-string'
const appName = 'Participatory System Mapper'
const shortAppName = 'PRSM'
const GRIDSPACING = 50 // for snap to grid
const NODEWIDTH = 10 // chars for label splitting
const TIMETOSLEEP = 15 * 60 * 1000 // if no mouse movement for this time, user is assumed to have left or is sleeping
const TIMETOEDIT = 5 * 60 * 1000 // if node/edge edit dialog is not saved after this time, the edit is cancelled
const magnification = 3 // magnification of the loupe (magnifier 'glass')
export const NLEVELS = 20 // max. number of levels for trophic layout
const ROLLBACKS = 10 // max. number of versions stored for rollback
export let network
export let room
/* debug options (add to the URL thus: &debug=yjs,gui)
* yjs - display yjs observe events on console
* changes - show details of changes to yjs types
* trans - all transactions
* gui - show all mouse events
* plain - save PRSM file as plain text, not compressed
* cluster - show creation of clusters
* aware - show awareness traffic
* round - round trip timing
* back - drawing on background
*/
export let debug = ''
let viewOnly // when true, user can only view, not modify, the network
let showCopyMapButton = false // show the Copy Map button on the navbar in viewOnly mode
let nodes // a dataset of nodes
let edges // a dataset of edges
export let data // an object with the nodes and edges datasets as properties
export const doc = new Y.Doc()
export let websocket = 'wss://www.prsm.uk/wss' // web socket server URL
let wsProvider // web socket provider
export let clientID // unique ID for this browser
let yNodesMap // shared map of nodes
let yEdgesMap // shared map of edges
export let ySamplesMap // shared map of styles
export let yNetMap // shared map of global network settings
export let yPointsArray // shared array of the background drawing commands
export let yDrawingMap // shared map of background objects
export let yUndoManager // shared list of commands for undo
let dontUndo // when non-null, don't add an item to the undo stack
let yAwareness // awareness channel
export let yHistory // log of actions
export let container // the DOM body element
export let netPane // the DOM pane showing the network
let panel // the DOM right side panel element
let myNameRec // the user's name record {actual name, type, etc.}
export let lastNodeSample = 'group0' // the last used node style
export let lastLinkSample = 'edge0' // the last used edge style
/** @type {(string|boolean)} */
let inAddMode = false // true when adding a new Factor to the network; used to choose cursor pointer
let inEditMode = false // true when node or edge is being edited (dialog is open)
let snapToGridToggle = false // true when snapping nodes to the (unseen) grid
export let drawingSwitch = false // true when the drawing layer is uppermost
let showNotesToggle = true // show notes when factors and links are selected
let showVotingToggle = false // whether to show the voting thumb up/down buttons under factors
// if set, there are nodes that need to be hidden when the map is drawn for the first time
const hiddenNodes = { radiusSetting: null, streamSetting: null, pathsSetting: null, selected: [] }
const tutorial = new Tutorial() // object driving the tutorial
export const cp = new CP()
// color picker
let checkMapSaved = false // if the map is new (no 'room' in URL), or has been imported from a file, and changes have been made, warn user before quitting
let dirty = false // map has been changed by user and may need saving
let followme // clientId of user's cursor to follow
let editor = null // Quill editor
let popupWindow = null // window for editing Notes
let popupEditor = null // Quill editor in popup window
let sideDrawEditor = null // Quill editor in side drawer
let loadingDelayTimer // timer to delay the start of the loading animation for few moments
let netLoaded = false // becomes true when map is fully displayed
let savedState = '' // the current state of the map (nodes, edges, network settings) before current user action
let unknownRoomTimeout = null // timer to check if the room exists
const setupStartTime = Date.now() // time when setup started
/**
* top level function to initialise everything
*/
window.addEventListener('load', () => {
loadingDelayTimer = setTimeout(() => {
elem('loading').style.display = 'block'
}, 200)
addEventListeners()
setUpPage()
setUpBackground()
startY()
setUpUserName()
setUpAwareness()
setUpShareDialog()
draw()
})
/**
* Clean up before user departs
*/
window.onbeforeunload = function (event) {
unlockAll()
yAwareness.setLocalStateField('addingFactor', { state: 'done' })
yAwareness.setLocalState(null)
// get confirmation from user before exiting if there are unsaved changes
if (checkMapSaved && dirty) {
event.preventDefault()
event.returnValue = 'You have unsaved unchanges. Are you sure you want to leave?'
}
}
/**
* Set up all the permanent event listeners
*/
function addEventListeners() {
listen('maptitle', 'keydown', (e) => {
// disallow Enter key
if (e.key === 'Enter') {
e.preventDefault()
}
})
listen('net-pane', 'keydown', (e) => {
if (e.which === 8 || e.which === 46) deleteNode()
})
listen('recent-rooms-caret', 'click', createTitleDropDown)
listen('maptitle', 'keydown', (e) => {
if (e.target.innerText === 'Untitled map') {
window.getSelection().selectAllChildren(e.target)
}
})
listen('maptitle', 'keyup', mapTitle)
listen('maptitle', 'paste', pasteMapTitle)
listen('maptitle', 'click', (e) => {
if (e.target.innerText === 'Untitled map') {
window.getSelection().selectAllChildren(e.target)
}
})
listen('body', 'keydown', (e) => {
if ((e.ctrlKey && e.key === 's') || (e.metaKey && e.key === 's')) {
savePRSMfile()
e.preventDefault()
}
})
listen('body', 'keydown', (e) => {
if ((e.ctrlKey && e.key === 'o') || (e.metaKey && e.key === 'o')) {
openFile()
e.preventDefault()
}
})
listen('body', 'keydown', (e) => {
if ((e.ctrlKey && e.key === 'z') || (e.metaKey && e.key === 'z')) {
undo()
e.preventDefault()
}
})
listen('body', 'keydown', (e) => {
if ((e.ctrlKey && e.key === 'y') || (e.metaKey && e.key === 'y')) {
redo()
e.preventDefault()
}
})
listen('addNode', 'click', plusNode)
listen('net-pane', 'contextmenu', contextMenu)
listen('net-pane', 'click', unFollow)
listen('net-pane', 'click', removeTitleDropDown)
listen('drawer-handle', 'click', () => {
elem('drawer-wrapper').classList.toggle('hide-drawer')
})
listen('addLink', 'click', plusLink)
listen('deleteNode', 'click', deleteNode)
listen('undo', 'click', undo)
listen('redo', 'click', redo)
listen('fileInput', 'change', readSingleFile)
listen('openFile', 'click', openFile)
listen('replaceMap', 'click', openFile)
listen('mergeMap', 'click', mergeMap)
listen('merge', 'click', doMerge)
listen('mergeClose', 'click', () => elem('mergeDialog').close())
listen('saveFile', 'click', savePRSMfile)
listen('exportPRSM', 'click', savePRSMfile)
listen('exportImage', 'click', exportPNGfile)
listen('exportExcel', 'click', exportExcel)
listen('exportGML', 'click', exportGML)
listen('exportDOT', 'click', exportDOT)
listen('exportGraphML', 'click', exportGraphML)
listen('exportGEXF', 'click', exportGEXF)
listen('exportNotes', 'click', exportNotes)
listen('copy-map', 'click', () => doClone(false))
listen('search', 'click', search)
listen('help', 'click', displayHelp)
listen('panelToggle', 'click', togglePanel)
listen('zoom', 'change', zoomnet)
listen('navbar', 'dblclick', fit)
listen('zoomminus', 'click', () => {
zoomincr(-0.1)
})
listen('zoomplus', 'click', () => {
zoomincr(0.1)
})
listen('nodesButton', 'click', (e) => {
openTab('nodesTab', e)
})
listen('linksButton', 'click', (e) => {
openTab('linksTab', e)
})
listen('networkButton', 'click', (e) => {
openTab('networkTab', e)
})
listen('analysisButton', 'click', (e) => {
openTab('analysisTab', e)
})
listen('layoutSelect', 'change', autoLayout)
listen('snaptogridswitch', 'click', snapToGridSwitch)
listen('curveSelect', 'change', selectCurve)
listen('drawing', 'click', toggleDrawingLayer)
listen('allFactors', 'click', selectAllFactors)
listen('allLinks', 'click', selectAllLinks)
listen('showLegendSwitch', 'click', legendSwitch)
listen('showVotingSwitch', 'click', votingSwitch)
listen('showUsersSwitch', 'click', showUsersSwitch)
listen('showHistorySwitch', 'click', showHistorySwitch)
listen('showNotesSwitch', 'click', showNotesSwitch)
listen('clustering', 'change', selectClustering)
listen('lock', 'click', setFixed)
listen('newNodeWindow', 'click', openNotesWindow)
listen('newEdgeWindow', 'click', openNotesWindow)
Array.from(document.getElementsByName('radius')).forEach((elem) => {
elem.addEventListener('change', analyse)
})
Array.from(document.getElementsByName('stream')).forEach((elem) => {
elem.addEventListener('change', analyse)
})
Array.from(document.getElementsByName('paths')).forEach((elem) => {
elem.addEventListener('change', analyse)
})
listen('sizing', 'change', sizingSwitch)
Array.from(document.getElementsByClassName('sampleNode')).forEach((elem) =>
elem.addEventListener('click', (event) => {
applySampleToNode(event)
})
)
Array.from(document.getElementsByClassName('sampleLink')).forEach((elem) =>
elem.addEventListener('click', (event) => {
applySampleToLink(event)
})
)
listen('nodeStyleEditFactorSize', 'input', (event) => progressBar(event.target))
listen('history-copy', 'click', copyHistoryToClipboard)
listen('body', 'copy', copyToClipboard)
listen('body', 'paste', pasteFromClipboard)
// change pointer when entering drag handles
Array.from(document.getElementsByClassName('drag-handle')).forEach((el) => {
el.addEventListener('pointerenter', () => (el.style.cursor = 'move'))
el.addEventListener('pointerout', () => (el.style.cursor = 'auto'))
})
// if user has changed to this tab, ensure that the network has been drawn
document.addEventListener('visibilitychange', () => {
network.redraw()
})
}
/**
* create all the DOM elements on the web page
*/
function setUpPage() {
elem('version').innerHTML = version
container = elem('container')
netPane = elem('net-pane')
panel = elem('panel')
// check debug options set on URL: ?debug=yjs|gui|cluster|viewing|start|copyButton
// each of these generates trace output on the console
const searchParams = new URL(document.location).searchParams
if (searchParams.has('debug')) debug = searchParams.get('debug')
// don't allow user to change anything if URL includes ?viewing
// this is now obsolete, but retained for backwards compatibility
viewOnly = searchParams.has('viewing')
if (viewOnly) hideNavButtons()
if (searchParams.has('copyButton')) showCopyMapButton = true
// treat user as first time user if URL includes ?start=true
if (searchParams.has('start')) localStorage.setItem('doneIntro', 'false')
panel.classList.add('hide')
container.panelHidden = true
cp.createColorPicker('netBackColorWell', updateNetBack)
setUpPinchZoom()
setUpSamples()
updateLastSamples(lastNodeSample, lastLinkSample)
makeNotesPanelResizeable(elem('nodeNotePanel'))
makeNotesPanelResizeable(elem('edgeNotePanel'))
dragElement(elem('nodeNotePanel'), elem('nodeNoteHeader'))
dragElement(elem('edgeNotePanel'), elem('edgeNoteHeader'))
hideNotes()
setUpSideDrawer()
displayWhatsNew()
}
const sliderColor = getComputedStyle(document.documentElement).getPropertyValue('--slider')
/**
* draw the solid bar to the left of the thumb on a slider
* @param {HTMLElement} sliderEl input[type=range] element
*/
export function progressBar(sliderEl) {
const sliderValue = sliderEl.value
sliderEl.style.background = `linear-gradient(to right, ${sliderColor} ${sliderValue}%, #ccc ${sliderValue}%)`
}
/**
* show the What's New modal dialog unless this is a new user or user has already seen this dialog
* for this (Major.Minor) version
*/
function displayWhatsNew() {
// new user - don't tell them what is new
if (!localStorage.getItem('doneIntro')) return
const versionDecoded = version.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/)
const seen = localStorage.getItem('seenWN')
if (seen) {
const seenDecoded = seen.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/)
// if this is a new minor version, show the What's New dialog
if (
seenDecoded &&
versionDecoded[1] === seenDecoded[1] &&
versionDecoded[2] === seenDecoded[2]
) {
return
}
}
elem('whatsnewversion').innerHTML = `Version ${version}`
elem('whatsnew').style.display = 'flex'
elem('net-pane').addEventListener('click', hideWhatsNew, { once: true })
}
/**
* hide the What's New dialog when the user has clicked Continue, and note tha the user has seen it
*/
function hideWhatsNew() {
localStorage.setItem('seenWN', version)
elem('whatsnew').style.display = 'none'
}
/**
* create a new shared document and start the WebSocket provider
*/
function startY(newRoom) {
const url = new URL(document.location)
if (newRoom) room = newRoom
else {
// get the room number from the URL, or if none, generate a new one
room = url.searchParams.get('room')
}
if (room == null || room === '') {
room = generateRoom()
checkMapSaved = true
} else room = room.toUpperCase()
// if debug flag includes 'local' or using a non-standard port (i.e neither 80 nor 443)
// assume that the websocket port is 1234 in the same domain as the url
if (/local/.test(debug) || (url.port && url.port !== 80 && url.port !== 443)) {
websocket = `ws://${url.hostname}:1234`
}
wsProvider = new WebsocketProvider(websocket, `prsm${room}`, doc)
wsProvider.on('synced', () => {
// if this is a clone, load the cloned data
initiateClone()
// (if the room already exists, wait until the map data is loaded before displaying it)
if (url.searchParams.get('room') !== null) {
observed('synced')
if (/load/.test(debug)) {
console.log(
`Nodes: ${yNodesMap.size} Edges: ${yEdgesMap.size} Samples: ${ySamplesMap.size} Network settings: ${yNetMap.size} Points: ${yPointsArray.length} Drawing objects: ${yDrawingMap.size} History entries: ${yHistory.length} `
)
}
unknownRoomTimeout = setTimeout(() => {
if (!netLoaded) {
displayNetPane(
`${exactTime()} Timed out waiting for ${room} to load. Found only ${Array.from(foundMaps).join(', ')} maps.`
)
}
}, 6000)
} else {
// if this is a new map, display it
displayNetPane(`${exactTime()} no remote content loaded from ${websocket}`)
}
})
wsProvider.disconnectBc()
wsProvider.on('status', (event) => {
console.log(
`${exactTime()}${event.status}${event.status === 'connected' ? ' to' : ' from'} room ${room} using ${websocket}`
)
})
/*
create a yMap for the nodes and one for the edges (we need two because there is no
guarantee that the the ids of nodes will differ from the ids of edges)
*/
yNodesMap = doc.getMap('nodes')
yEdgesMap = doc.getMap('edges')
ySamplesMap = doc.getMap('samples')
yNetMap = doc.getMap('network')
yPointsArray = doc.getArray('points')
yDrawingMap = doc.getMap('drawing')
yHistory = doc.getArray('history')
yAwareness = wsProvider.awareness
/* create a dummy item in yNodesMap and yEdgesMap to stop having to wait for the these maps
if there are no nodes or edges (thus allowing to distinguish between zero nodes/edges and
no node/edge map yet loaded) */
yNodesMap.set('_dummy_', { dummy: true })
yEdgesMap.set('_dummy_', { dummy: true })
/* set up observers to listen for changes in the yMaps */
doc.on('afterTransaction', () => {
if (!netLoaded) {
fit()
}
})
if (/trans/.test(debug)) {
doc.on('afterTransaction', (tr) => {
console.log(
`${exactTime()} transaction (${JSON.stringify(tr)}) (${tr.local ? 'local' : 'remote'})`
)
console.log('netLoaded', netLoaded)
const nodesEvent = tr.changed.get(yNodesMap)
if (nodesEvent) console.log(nodesEvent)
const edgesEvent = tr.changed.get(yEdgesMap)
if (edgesEvent) console.log(edgesEvent)
const sampleEvent = tr.changed.get(ySamplesMap)
if (sampleEvent) console.log(sampleEvent)
const netEvent = tr.changed.get(yNetMap)
if (netEvent) console.log(netEvent)
})
}
clientID = doc.clientID
console.log(`My client ID: ${clientID}`)
/* set up the undo managers */
yUndoManager = new Y.UndoManager([yNodesMap, yEdgesMap, yNetMap], {
trackedOrigins: new Set([null]), // add undo items to the stack by default
})
dontUndo = null
nodes = new DataSet()
edges = new DataSet()
data = { nodes, edges }
/*
for convenience when debugging
*/
window.debug = debug
window.data = data
window.clientID = clientID
window.yNodesMap = yNodesMap
window.yEdgesMap = yEdgesMap
window.ySamplesMap = ySamplesMap
window.yNetMap = yNetMap
window.yUndoManager = yUndoManager
window.yHistory = yHistory
window.yPointsArray = yPointsArray
window.yDrawingMap = yDrawingMap
window.styles = styles
window.yAwareness = yAwareness
window.mergeRoom = mergeRoom
window.diffRoom = diffRoom
window.wsProvider = wsProvider
const foundMaps = new Set()
/**
* note that one of the required yMaps has been loaded; if all have been found, display the map
* @param {string} what name of the yMap that has just been loaded
*/
function observed(what) {
// do nothing if the map is already displayed
if (netLoaded) return
if (/load/.test(debug)) {
console.log(`${exactTime()} Observed: ${what}`)
}
foundMaps.add(what)
if (
foundMaps.has('nodes') &&
foundMaps.has('edges') &&
foundMaps.has('network') &&
foundMaps.has('synced')
) {
displayNetPane(`${exactTime()} all content loaded from ${websocket}`)
if (/load/.test(debug)) {
console.log(
`Nodes: ${yNodesMap.size} Edges: ${yEdgesMap.size} Samples: ${ySamplesMap.size} Network settings: ${yNetMap.size} Points: ${yPointsArray.length} Drawing objects: ${yDrawingMap.size} History entries: ${yHistory.length} `
)
}
}
}
/*
nodes.on listens for when local nodes or edges are changed (added, updated or removed).
If a local node is removed, the yMap is updated to broadcast to other clients that the node
has been deleted. If a local node is added or updated, that is also broadcast.
*/
nodes.on('*', (evt, properties, origin) => {
yjsTrace(
'nodes.on',
`${evt} ${JSON.stringify(properties.items)} origin: ${origin} dontUndo: ${dontUndo}`
)
clearTimeout(unknownRoomTimeout)
if (!viewOnly) {
doc.transact(() => {
properties.items.forEach((id) => {
if (origin === null) {
// this is a local change
if (evt === 'remove') {
yNodesMap.delete(id.toString())
} else {
yNodesMap.set(id.toString(), deepCopy(nodes.get(id)))
}
}
})
}, dontUndo)
}
dontUndo = null
})
/*
yNodesMap.observe listens for changes in the yMap, receiving a set of the keys that have
had changed values. If the change was to delete an entry, the corresponding node and all links to/from it are
removed from the local nodes dataSet. Otherwise, if the received node differs from the local one,
the local node dataSet is updated (which includes adding a new node if it does not already exist locally).
*/
yNodesMap.observe((evt) => {
yjsTrace('yNodesMap.observe', evt)
const nodesToUpdate = []
const nodesToRemove = []
for (const key of evt.keysChanged) {
if (yNodesMap.has(key)) {
const obj = yNodesMap.get(key)
if (objectEquals(obj, { dummy: true })) continue // skip dummy entry
if (!objectEquals(obj, data.nodes.get(key))) {
// fix nodes if this is a view only copy
if (viewOnly) obj.fixed = true
nodesToUpdate.push(deepCopy(obj))
// if a note on a node is being remotely edited and is on display here, update the local note and the padlock
if (editor && editor.id === key && evt.transaction.local === false) {
editor.setContents(obj.note)
elem('fixed').style.display = obj.fixed ? 'inline' : 'none'
elem('unfixed').style.display = obj.fixed ? 'none' : 'inline'
}
}
} else {
hideNotes()
if (data.nodes.get(key)) {
network.getConnectedEdges(key).forEach((edge) => nodesToRemove.push(edge))
}
nodesToRemove.push(key)
}
}
if (nodesToUpdate.length > 0) nodes.update(nodesToUpdate, 'remote')
if (nodesToRemove.length > 0) nodes.remove(nodesToRemove, 'remote')
if (/changes/.test(debug) && (nodesToUpdate.length > 0 || nodesToRemove.length > 0)) {
showChange(evt, yNodesMap)
}
observed('nodes')
})
/*
See comments above about nodes
*/
edges.on('*', (evt, properties, origin) => {
yjsTrace(
'edges.on',
`${evt} ${JSON.stringify(properties.items)} origin: ${origin} dontUndo: ${dontUndo}`
)
if (!viewOnly) {
doc.transact(() => {
properties.items.forEach((id) => {
if (origin === null) {
if (evt === 'remove') yEdgesMap.delete(id.toString())
else {
yEdgesMap.set(id.toString(), deepCopy(edges.get(id)))
}
}
})
}, dontUndo)
}
dontUndo = null
})
yEdgesMap.observe((evt) => {
yjsTrace('yEdgesMap.observe', evt)
const edgesToUpdate = []
const edgesToRemove = []
for (const key of evt.keysChanged) {
if (yEdgesMap.has(key)) {
const obj = yEdgesMap.get(key)
if (objectEquals(obj, { dummy: true })) continue // skip dummy entry
if (!objectEquals(obj, data.edges.get(key))) {
edgesToUpdate.push(deepCopy(obj))
if (editor && editor.id === key && evt.transaction.local === false) {
editor.setContents(obj.note)
}
}
} else {
hideNotes()
edgesToRemove.push(key)
}
}
if (edgesToUpdate.length > 0) edges.update(edgesToUpdate, 'remote')
if (edgesToRemove.length > 0) edges.remove(edgesToRemove, 'remote')
if (edgesToUpdate.length > 0 || edgesToRemove.length > 0) {
// if user is in mid-flight adding a Link, and someone else has just added a link,
// vis-network will cancel the edit mode for this user. Re-instate it.
if (inAddMode === 'addLink') network.addEdgeMode()
}
if (/changes/.test(debug) && (edgesToUpdate.length > 0 || edgesToRemove.length > 0)) {
showChange(evt, yEdgesMap)
}
observed('edges')
})
/**
* utility trace function that prints the change in the value of a YMap property to the console
* @param {YEvent} evt
* @param {MapType} ymap
*/
function showChange(evt, ymap) {
evt.changes.keys.forEach((change, key) => {
if (change.action === 'add') {
console.log(
`Property "${key}" was added.
Initial value: `,
ymap.get(key)
)
} else if (change.action === 'update') {
console.log(
`Property "${key}" was updated.
New value: "`,
ymap.get(key),
`"
Previous value: "`,
change.oldValue,
`"
Difference: "`,
typeof change.oldValue === 'object' && typeof ymap.get(key) === 'object'
? diff(change.oldValue, ymap.get(key))
: `${change.oldValue} ${ymap.get(key)}`,
'"'
)
} else if (change.action === 'delete') {
console.log(
`Property "${key}" was deleted.
Previous value: `,
change.oldValue
)
}
})
}
ySamplesMap.observe((evt) => {
yjsTrace('ySamplesMap.observe', evt)
const nodesToUpdate = []
const edgesToUpdate = []
for (const key of evt.keysChanged) {
const sample = ySamplesMap.get(key)
if (sample.node !== undefined) {
if (!objectEquals(styles.nodes[key], sample.node)) {
styles.nodes[key] = sample.node
refreshSampleNode(key)
nodesToUpdate.push(key)
}
} else {
if (!objectEquals(styles.edges[key], sample.edge)) {
styles.edges[key] = sample.edge
refreshSampleLink(key)
edgesToUpdate.push(key)
}
}
}
if (nodesToUpdate) {
reApplySampleToNodes(nodesToUpdate)
}
if (edgesToUpdate) {
reApplySampleToLinks(edgesToUpdate)
}
observed('samples')
})
/*
Map controls (those on the Network tab) are of three kinds:
1. Those that affect only the local map and are not promulgated to other users
e.g zoom, show drawing layer, show history
2. Those where the control status (e.g. whether a switch is on or off) is promulgated,
but the effect of the switch is handled by yNodesMap and yEdgesMap (e.g. Show Factors
x links away; Size Factors to)
3. Those whose effects are promulgated and switches controlled here by yNetMap (e.g
Background)
For cases 2 and 3, the functions called here must not invoke yNetMap.set() to avoid loops
*/
yNetMap.observe((evt) => {
yjsTrace('YNetMap.observe', evt)
if (evt.transaction.origin) {
// evt is not local
for (const key of evt.keysChanged) {
const obj = yNetMap.get(key)
switch (key) {
case 'viewOnly': {
viewOnly = viewOnly || obj
if (viewOnly) {
hideNavButtons()
disableSideDrawerEditing()
}
break
}
case 'mapTitle':
case 'maptitle': {
setMapTitle(obj)
break
}
case 'snapToGrid': {
doSnapToGrid(obj)
break
}
case 'curve': {
setCurve(obj)
break
}
case 'background': {
setBackground(obj)
break
}
case 'legend': {
setLegend(obj, false)
break
}
case 'voting': {
setVoting(obj)
break
}
case 'showNotes': {
doShowNotes(obj)
break
}
case 'radius': {
hiddenNodes.radiusSetting = obj.radiusSetting
hiddenNodes.selected = obj.selected
setRadioVal('radius', hiddenNodes.radiusSetting)
break
}
case 'stream': {
hiddenNodes.streamSetting = obj.streamSetting
hiddenNodes.selected = obj.selected
setRadioVal('stream', hiddenNodes.streamSetting)
break
}
case 'paths': {
hiddenNodes.pathsSetting = obj.pathsSetting
hiddenNodes.selected = obj.selected
setRadioVal('paths', hiddenNodes.pathsSetting)
break
}
case 'sizing': {
sizing(obj)
break
}
case 'hideAndStream':
case 'linkRadius':
// old settings (before v1.6) - ignore
break
case 'factorsHiddenByStyle': {
updateFactorsOrLinksHiddenByStyle(obj)
break
}
case 'linksHiddenByStyle': {
updateFactorsOrLinksHiddenByStyle(obj)
break
}
case 'attributeTitles': {
recreateClusteringMenu(obj)
break
}
case 'cluster': {
setCluster(obj)
break
}
case 'mapDescription': {
setSideDrawer(obj)
break
}
case 'lastLoaded':
case 'version': {
// ignore these - for info only
break
}
default:
console.log('Bad key in yMapNet.observe: ', key)
}
}
}
observed('network')
})
yPointsArray.observe((evt) => {
yjsTrace('yPointsArray.observe', yPointsArray.get(yPointsArray.length - 1))
if (evt.transaction.local === false) upgradeFromV1(yPointsArray.toArray())
})
yDrawingMap.observe((evt) => {
yjsTrace('yDrawingMap.observe', evt)
updateFromRemote(evt)
observed('drawing')
})
yHistory.observe(() => {
yjsTrace('yHistory.observe', yHistory.get(yHistory.length - 1))
if (elem('showHistorySwitch').checked) showHistory()
observed('history')
})
yUndoManager.on('stack-item-added', (evt) => {
yjsTrace('yUndoManager.on stack-item-added', evt)
if (/changes/.test(debug)) {
evt.changedParentTypes.forEach((v) => {
showChange(v[0], v[0].target)
})
}
undoRedoButtonStatus()
})
yUndoManager.on('stack-item-popped', (evt) => {
yjsTrace('yUndoManager.on stack-item-popped', evt)
if (/changes/.test(debug)) {
evt.changedParentTypes.forEach((v) => {
showChange(v[0], v[0].target)
})
}
pruneDanglingEdges()
undoRedoButtonStatus()
})
/**
* In some slightly obscure circumstances, (specifically, client A undoes the creation of a factor that
* client B has subsequently linked to another factor), the undo operation can result in a link that
* has no source or destination factor. Tracking such a situation is rather complex, so this cleans
* up the mess without bothering about its cause.
*/
function pruneDanglingEdges() {
data.edges.forEach((edge) => {
if (data.nodes.get(edge.from) === null) {
dontUndo = 'danglingEdge'
data.edges.remove(edge.id)
}
if (data.nodes.get(edge.to) == null) {
dontUndo = 'danglingEdge'
data.edges.remove(edge.id)
}
})
}
} // end startY()
/**
* load cloned data from localStorage
* if there is no clone, returns without doing anything
*/
function initiateClone() {
localForage
.getItem('clone')
.then((clone) => {
localForage
.removeItem('clone')
.then(() => {
// if there is no clone, clone will be null
if (clone) {
const state = JSON.parse(decompressFromUTF16(clone))
data.nodes.update(state.nodes)
data.edges.update(state.edges)
doc.transact(() => {
for (const k in state.net) {
yNetMap.set(k, state.net[k])
}
viewOnly = state.options.viewOnly
yNetMap.set('viewOnly', viewOnly)
data.nodes.get().forEach((obj) => (obj.fixed = viewOnly))
if (viewOnly) hideNavButtons()
for (const k in state.samples) {
ySamplesMap.set(k, state.samples[k])
}
if (state.paint) {
yPointsArray.delete(0, yPointsArray.length)
yPointsArray.insert(0, state.paint)
}
if (state.drawing) {
for (const k in state.drawing) {
yDrawingMap.set(k, state.drawing[k])
}
updateFromDrawingMap()
}
logHistory(state.options.created.action, state.options.created.actor)
}, 'clone')
unSelect()
fit()
}
})
.catch((err) => {
console.log('Cant delete localForage clone key: ', err)
})
})
.catch((err) => {
console.log('Cant get localForage clone key: ', err)
})
}
/**
* Display observed yjs event
* @param {string} where
* @param {object} what
*/
function yjsTrace(where, what) {
if (/yjs/.test(debug)) {
console.log(exactTime(), where, what)
}
}
/**
* create a random string of the form AAA-BBB-CCC-DDD
*/
function generateRoom() {
let room = ''
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 3; j++) {
room += String.fromCharCode(65 + Math.floor(Math.random() * 26))
}
if (i < 3) room += '-'
}
return room
}
/**
* randomly create some nodes and edges as a binary tree, mainly used for testing
* @param {number} nNodes
*/
function getRandomData(nNodes) {
const SFNdata = getScaleFreeNetwork(nNodes)
nodes.add(SFNdata.nodes)
edges.add(SFNdata.edges)
reApplySampleToNodes(['group0'])
reApplySampleToLinks(['edge0'])
recalculateStats()
}
/**
* Once any existing map has been loaded, fit it to the pane and reveal it
* @param {string} msg message for console
*/
function displayNetPane(msg) {
console.log(msg)
if (!netLoaded) {
elem('loading').style.display = 'none'
fit()
setMapTitle(yNetMap.get('mapTitle'))
netPane.style.visibility = 'visible'
clearTimeout(loadingDelayTimer)
yUndoManager.clear()
undoRedoButtonStatus()
network.unselectAll()
setUpTutorial()
netLoaded = true
drawMinimap()
savedState = saveState()
setAnalysisButtonsFromRemote()
toggleDeleteButton()
setLegend(yNetMap.get('legend'), false)
console.log(
exactTime(),
`Doc size: ${humanSize(Y.encodeStateAsUpdate(doc).length)}, Load time: ${((Date.now() - setupStartTime) / 1000).toFixed(1)}s`
)
yNetMap.set('lastLoaded', Date.now())
yNetMap.set('version', version)
}
}
// to handle iPad viewport sizing problem when tab bar appears and to keep panels on screen
setvh()
window.onresize = function () {
setvh()
keepPaneInWindow(panel)
resizeCanvas()
}
/**
* Hack to get window size when orientation changes. Should use screen.orientation, but this is not
* implemented by Safari
*/
const portrait = window.matchMedia('(orientation: portrait)')
portrait.addEventListener('change', () => {
setvh()
})
/**
* in View Only mode, hide all the Nav Bar buttons except the search button
* and make the map title not editable
*/
function hideNavButtons() {
elem('buttons').style.visibility = 'hidden'
elem('search').parentElement.style.visibility = 'visible'
elem('search').parentElement.style.borderLeft = 'none'
if (showCopyMapButton) {
elem('copy-map-button').style.display = 'block'
elem('copy-map-button').style.visibility = 'visible'
}
elem('maptitle').contentEditable = 'false'
if (!container.panelHidden) {
panel.classList.add('hide')
container.panelHidden = true
}
}
/** restore all the Nav Bar buttons when leaving view only mode (e.g. when
* going back online)
*/
function showNavButtons() {
elem('buttons').style.visibility = 'visible'
elem('search').parentElement.style.visibility = 'visible'
elem('search').parentElement.style.borderLeft = '1px solid rgb(255, 255, 255)'
elem('copy-map-button').style.display = 'none'
elem('maptitle').contentEditable = 'true'
}
/**
* cancel View Only mode (only available via the console)
*/
function cancelViewOnly() {
viewOnly = false
yNetMap.set('viewOnly', false)
showNavButtons()
data.nodes.get().forEach((obj) => (obj.fixed = false))
network.setOptions({ interaction: { dragNodes: true, hover: true } })
}
window.cancelViewOnly = cancelViewOnly
/**
* to handle iOS weirdness in fixing the vh unit (see https://css-tricks.com/the-trick-to-viewport-units-on-mobile/)
*/
function setvh() {
document.body.height = window.innerHeight
// First we get the viewport height and we multiple it by 1% to get a value for a vh unit
const vh = window.innerHeight * 0.01
// Then we set the value in the --vh custom property to the root of the document
document.documentElement.style.setProperty('--vh', `${vh}px`)
}
/**
* retrieve or generate user's name
*/
function setUpUserName() {
try {
myNameRec = JSON.parse(localStorage.getItem('myName'))
} catch {
myNameRec = null
}
saveUserName(myNameRec?.name ? myNameRec.name : '')
console.log(`My name: ${myNameRec.name}`)
}
/**
* Save a new user name into local storage
* @param {String} name
*/
function saveUserName(name) {
if (name.length > 0) {
myNameRec.name = name
myNameRec.anon = false
} else {
myNameRec = generateName()
}
myNameRec.id = clientID
localStorage.setItem('myName', JSON.stringify(myNameRec))
yAwareness.setLocalState({ user: myNameRec })
showAvatars()
}
/**
* if this is the user's first time, show them how the user interface works
*/
function setUpTutorial() {
if (localStorage.getItem('doneIntro') !== 'done' && viewOnly === false) {
tutorial.onexit(function () {
localStorage.setItem('doneIntro', 'done')
})
tutorial.onstep(0, () => {
const splashNameBox = elem('splashNameBox')
const anonName = myNameRec.name || generateName().name
splashNameBox.placeholder = anonName
splashNameBox.focus()
splashNameBox.addEventListener('blur', () => {
saveUserName(splashNameBox.value || anonName)
})
splashNameBox.addEventListener('keyup', (e) => {
if (e.key === 'Enter') splashNameBox.blur()
})
})
tutorial.start()
}
}
/**
* draw the network, after setting the vis-network options
*/
function draw() {
// for testing, you can append ?t=XXX to the URL of the page, where XXX is the number
// of factors to include in a random network
const url = new URL(document.location.href.toLowerCase())
const nNodes = parseInt(url.searchParams.get('t'))
if (nNodes) getRandomData(nNodes)
// create a network
const options = {
nodes: {
chosen: {
node: function (values, id, selected) {
values.shadow = selected
},
},
},
edges: {
chosen: {
edge: function (values, id, selected) {
values.shadow = selected
},
},
smooth: { type: 'cubicBezier' },
},
physics: { enabled: false, stabilization: false },
interaction: {
multiselect: true,
selectConnectedEdges: false,
hover: false,
hoverConnectedEdges: false,
zoomView: false,
tooltipDelay: 0,
},
manipulation: {
enabled: false,
addNode: function (item, callback) {
item.label = ''
item = deepMerge(item, styles.nodes[lastNodeSample])
item.grp = lastNodeSample
item.created = timestamp()
addLabel(item, cancelAdd, callback)
showPressed('addNode', 'remove')
},
editNode: function (item, callback) {
// for some weird reason, vis-network copies the group properties into the
// node properties before calling this fn, which we don't want. So we
// revert to using the original node properties before continuing.
item = data.nodes.get(item.id)
item.modified = timestamp()
const point = network.canvasToDOM({ x: item.x, y: item.y })
editNode(item, point, cancelEdit, callback)
},
addEdge: function (item, callback) {
inAddMode = false
network.setOptions({ interaction: { dragView: true, selectable: true } })
showPressed('addLink', 'remove')
if (item.from === item.to) {
callback(null)
stopEdit()
return
}
if (duplEdge(item.from, item.to).length > 0) {
alertMsg('There is already a link from this Factor to the other.', 'error')
callback(null)
stopEdit()
return
}
if (data.nodes.get(item.from).isCluster || data.nodes.get(item.to).isCluster) {
alertMsg('Links cannot be made to or from a cluster', 'error')
callback(null)
stopEdit()
return
}
item = deepMerge(item, styles.edges[lastLinkSample])
item.grp = lastLinkSample
item.created = timestamp()
clearStatusBar()
callback(item)
logHistory(
`added link from '${data.nodes.get(item.from).label}' to '${data.nodes.get(item.to).label}'`
)
},
editEdge: {
editWithoutDrag: function (item, callback) {
item = data.edges.get(item.id)
item.modified = timestamp()
// find midpoint of edge
const point = network.canvasToDOM({
x: (network.getPosition(item.from).x + network.getPosition(item.to).x) / 2,
y: (network.getPosition(item.from).y + network.getPosition(item.to).y) / 2,
})
editEdge(item, point, cancelEdit, callback)
},
},
deleteNode: function (item, callback) {
let locked = false
item.nodes.forEach((nId) => {
const n = data.nodes.get(nId)
if (n.locked) {
locked = true
alertMsg(
`Factor '${shorten(n.oldLabel)}' can't be deleted because it is locked`,
'warn'
)
callback(null)
}
})
if (locked) return
clearStatusBar()
hideNotes()
// delete also all the edges that link to the nodes being deleted
item.nodes.forEach((nId) => {
network.getConnectedEdges(nId).forEach((eId) => {
if (item.edges.indexOf(eId) === -1) item.edges.push(eId)
})
})
item.edges.forEach((edgeId) => {
logHistory(
`deleted link from '${data.nodes.get(data.edges.get(edgeId).from).label}' to '${
data.nodes.get(data.edges.get(edgeId).to).label
}'`
)
})
network.unselectAll()
item.nodes.forEach((nodeId) => {
logHistory(`deleted factor: '${data.nodes.get(nodeId).label}'`)
})
callback(item)
},
deleteEdge: function (item, callback) {
item.edges.forEach((edgeId) => {
logHistory(
`deleted link from '${data.nodes.get(data.edges.get(edgeId).from).label}' to '${
data.nodes.get(data.edges.get(edgeId).to).label
}'`
)
})
callback(item)
},
controlNodeStyle: { shape: 'dot', color: 'red', size: 5, group: undefined },
},
}
if (viewOnly) {
options.interaction = { dragNodes: false, hover: false }
}
network = new Network(netPane, data, options)
window.network = network
elem('zoom').value = network.getScale()
// start with factor tab open, but hidden
elem('nodesButton').click()
// listen for click events on the network pane
let doubleClickTimer = null
network.on('click', (params) => {
if (/gui/.test(debug)) console.log('**click**', params)
// if user is doing an analysis, and has clicked on a node, show the node notes
if (
getRadioVal('radius') !== 'All' ||
getRadioVal('stream') !== 'All' ||
getRadioVal('paths') !== 'All'
) {
if (!showNotesToggle) return
hideNotes()
const clickedNodeId = network.getNodeAt(params.pointer.DOM)
if (clickedNodeId) showNodeData(clickedNodeId)
else {
const clickedEdgeId = network.getEdgeAt(params.pointer.DOM)
if (clickedEdgeId) showEdgeData(clickedEdgeId)
}
return
}
// if user has clicked on a portal node, open the map in another tab and go to it
if (params.nodes.length === 1) {
const node = data.nodes.get(params.nodes[0])
// tricky stuff to distinguish a single click (move to map) from a double click (edit node)
if (node.portal && doubleClickTimer === null) {
doubleClickTimer = setTimeout(() => {
window.open(`${window.location.pathname}?room=${node.portal}`, node.portal)
doubleClickTimer = null
}, 500)
}
}
const keys = params.event.pointers[0]
if (!keys) return
if (keys.metaKey) {
// if the Command key (on a Mac) is down, and the click is on a node/edge, log it to the console
if (params.nodes.length === 1) {
const node = data.nodes.get(params.nodes[0])
console.log('node = ', node)
window.node = node
}
if (params.edges.length === 1) {
const edge = data.edges.get(params.edges[0])
console.log('edge = ', edge)
window.edge = edge
}
return
}
if (keys.altKey) {
// if the Option/ALT key is down, add a node if on the background
if (params.nodes.length === 0 && params.edges.length === 0) {
const pos = params.pointer.canvas
let item = { id: uuidv4(), label: '', x: pos.x, y: pos.y }
item = deepMerge(item, styles.nodes[lastNodeSample])
item.grp = lastNodeSample
addLabel(item, clearPopUp, function (newItem) {
if (newItem !== null) data.nodes.add(newItem)
})
}
return
}
if (keys.shiftKey) {
if (!inEditMode) showMagnifier(keys)
return
}
// Might be a click on a thumb up/down
if (showVotingToggle) {
for (const node of data.nodes.get()) {
const bBox = network.getBoundingBox(node.id)
const clickPos = params.pointer.canvas
if (
clickPos.x > bBox.left &&
clickPos.x < bBox.right &&
clickPos.y > bBox.bottom &&
clickPos.y < bBox.bottom + 20
) {
if (clickPos.x < bBox.left + (bBox.right - bBox.left) / 2) {
// if user has not already voted for this, add their vote, i.e. add their clientID
// or if they have voted, remove it
if (node.thumbUp?.includes(clientID)) {
node.thumbUp = node.thumbUp.filter((c) => c !== clientID)
} else if (node.thumbUp) node.thumbUp.push(clientID)
else node.thumbUp = [clientID]
} else {
if (node.thumbDown?.includes(clientID)) {
node.thumbDown = node.thumbDown.filter((c) => c !== clientID)
} else if (node.thumbDown) node.thumbDown.push(clientID)
else node.thumbDown = [clientID]
}
data.nodes.update(node)
return
}
}
}
})
// despatch to edit a node or an edge or to fit the network on the pane
network.on('doubleClick', function (params) {
if (/gui/.test(debug)) console.log('**doubleClick**')
clearTimeout(doubleClickTimer)
doubleClickTimer = null
if (params.nodes.length === 1) {
if (!(viewOnly || inEditMode)) network.editNode()
} else if (params.edges.length === 1) {
if (!(viewOnly || inEditMode)) network.editEdgeMode()
} else {
fit()
}
})
network.on('selectNode', function (params) {
if (/gui/.test(debug)) console.log('selectNode', params)
// if user is doing an analysis, do nothing
if (
getRadioVal('radius') !== 'All' ||
getRadioVal('stream') !== 'All' ||
getRadioVal('paths') !== 'All'
) {
return
}
// if a 'hidden' node is clicked, it is selected, but we don't want this
// reset the selected nodes to all except the hidden one
network.setSelection({
nodes: params.nodes.filter((id) => !data.nodes.get(id).nodeHidden),
edges: params.edges.filter((id) => !data.edges.get(id).edgeHidden),
})
showSelected()
showNodeOrEdgeData()
toggleDeleteButton()
if (getRadioVal('radius') !== 'All') analyse()
if (getRadioVal('stream') !== 'All') analyse()
if (getRadioVal('paths') !== 'All') analyse()
})
network.on('deselectNode', function (params) {
if (/gui/.test(debug)) console.log('deselectNode', params)
// if user is doing an analysis, do nothing, but first reselect the unselected nodes
if (
getRadioVal('radius') !== 'All' ||
getRadioVal('stream') !== 'All' ||
getRadioVal('paths') !== 'All'
) {
network.setSelection({
nodes: params.previousSelection.nodes.map((node) => node.id),
edges: params.previousSelection.edges.map((edge) => edge.id),
})
return
}
// if some other node(s) are already selected, and the user has
// clicked on one of the selected nodes, do nothing,
// i.e reselect all the nodes previously selected
// similarly, if the user has clicked on a 'hidden' node,
// reselect the previous nodes and do nothing
if (params.nodes) {
// clicked on a node
const prevSelIds = params.previousSelection.nodes.map((node) => node.id)
let hiddenEdge
if (params.edges.length) {
hiddenEdge = data.edges.get(params.edges[0]).edgeHidden
}
if (
prevSelIds.includes(params.nodes[0]) ||
data.nodes.get(params.nodes[0]).nodeHidden ||
hiddenEdge
) {
// reselect the previously selected nodes
network.selectNodes(
params.previousSelection.nodes.map((node) => node.id),
false
)
return
}
}
showSelected()
showNodeOrEdgeData()
toggleDeleteButton()
})
network.on('hoverNode', function () {
changeCursor('grab')
})
network.on('blurNode', function () {
changeCursor('default')
})
network.on('selectEdge', function (params) {
if (/gui/.test(debug)) console.log('selectEdge')
// if user is doing an analysis, do nothing
if (
getRadioVal('radius') !== 'All' ||
getRadioVal('stream') !== 'All' ||
getRadioVal('paths') !== 'All'
) {
return
}
network.setSelection({
nodes: params.nodes.filter((id) => !data.nodes.get(id).nodeHidden),
edges: params.edges.filter((id) => !data.edges.get(id).edgeHidden),
})
showSelected()
showNodeOrEdgeData()
toggleDeleteButton()
})
network.on('deselectEdge', function (params) {
if (/gui/.test(debug)) console.log('deselectEdge')
// if user is doing an analysis, do nothing, but first reselect the unselected nodes
if (
getRadioVal('radius') !== 'All' ||
getRadioVal('stream') !== 'All' ||
getRadioVal('paths') !== 'All'
) {
network.setSelection({
nodes: params.previousSelection.nodes.map((node) => node.id),
edges: params.previousSelection.edges.map((edge) => edge.id),
})
return
}
if (params.edges) {
// clicked on an edge(see selectNode for comments)
const prevSelIds = params.previousSelection.edges.map((edge) => edge.id)
if (prevSelIds.includes(params.edges[0]) || data.edges.get(params.edges[0]).edgeHidden) {
// reselect the previously selected edges
network.selectEdges(
params.previousSelection.edges.map((edge) => edge.id),
false
)
return
}
}
hideNotes()
showSelected()
toggleDeleteButton()
})
network.on('oncontext', function (e) {
const nodeId = network.getNodeAt(e.pointer.DOM)
if (nodeId) openCluster(nodeId)
})
let viewPosition
let selectionCanvasStart = {}
let selectionStart = {}
const selectionArea = document.createElement('div')
selectionArea.className = 'selectionBox'
selectionArea.style.display = 'none'
elem('main').appendChild(selectionArea)
network.on('dragStart', function (params) {
if (/gui/.test(debug)) console.log('dragStart')
viewPosition = network.getViewPosition()
const e = params.event.pointers[0]
// start drawing a selection rectangle if the CTRL key is down and click is on the background
if (e.ctrlKey && params.nodes.length === 0 && params.edges.length === 0) {
network.setOptions({ interaction: { dragView: false } })
listen('net-pane', 'mousemove', showAreaSelection)
selectionStart = { x: e.offsetX, y: e.offsetY }
selectionCanvasStart = params.pointer.canvas
selectionArea.style.left = `${e.offsetX}px`
selectionArea.style.top = `${e.offsetY}px`
selectionArea.style.width = '0px'
selectionArea.style.height = '0px'
selectionArea.style.display = 'block'
return
}
if (e.altKey) {
if (!inAddMode) {
removeFactorCursor()
changeCursor('crosshair')
inAddMode = 'addLink'
showPressed('addLink', 'add')
statusMsg('Now drag to the middle of the Destination factor')
network.setOptions({ interaction: { dragView: false, selectable: false } })
network.addEdgeMode()
return
}
}
changeCursor('grabbing')
})
/**
* update the selection rectangle as the mouse moves
* @param {Event} event
*/
function showAreaSelection(event) {
selectionArea.style.left = `${Math.min(selectionStart.x, event.offsetX)}px`
selectionArea.style.top = `${Math.min(selectionStart.y, event.offsetY)}px`
selectionArea.style.width = `${Math.abs(event.offsetX - selectionStart.x)}px`
selectionArea.style.height = `${Math.abs(event.offsetY - selectionStart.y)}px`
}
network.on('dragging', function () {
if (/gui/.test(debug)) console.log('dragging')
const endViewPosition = network.getViewPosition()
panCanvas(viewPosition.x - endViewPosition.x, viewPosition.y - endViewPosition.y)
viewPosition = endViewPosition
})
network.on('dragEnd', function (params) {
if (/gui/.test(debug)) console.log('dragEnd')
const endViewPosition = network.getViewPosition()
panCanvas(viewPosition.x - endViewPosition.x, viewPosition.y - endViewPosition.y)
if (selectionArea.style.display === 'block') {
selectionArea.style.display = 'none'
network.setOptions({ interaction: { dragView: true } })
elem('net-pane').removeEventListener('mousemove', showAreaSelection)
}
const e = params.event.pointers[0]
if (e.ctrlKey && params.nodes.length === 0 && params.edges.length === 0) {
network.storePositions()
const selectionCanvasEnd = params.pointer.canvas
if (selectionCanvasStart.x > selectionCanvasEnd.x) {
;[selectionCanvasStart.x, selectionCanvasEnd.x] = [
selectionCanvasEnd.x,
selectionCanvasStart.x,
]
}
if (selectionCanvasStart.y > selectionCanvasEnd.y) {
;[selectionCanvasStart.y, selectionCanvasEnd.y] = [
selectionCanvasEnd.y,
selectionCanvasStart.y,
]
}
const selectedNodes = data.nodes.get({
filter: function (node) {
return (
!node.nodeHidden &&
node.x >= selectionCanvasStart.x &&
node.x <= selectionCanvasEnd.x &&
node.y >= selectionCanvasStart.y &&
node.y <= selectionCanvasEnd.y
)
},
})
network.setSelection({
nodes: selectedNodes.map((n) => n.id).concat(network.getSelectedNodes()),
})
showSelected()
showNodeOrEdgeData()
return
}
const newPositions = network.getPositions(params.nodes)
data.nodes.update(
data.nodes.get(params.nodes).map((n) => {
n.x = newPositions[n.id].x
n.y = newPositions[n.id].y
if (snapToGridToggle) snapToGrid(n)
return n
})
)
changeCursor('default')
})
network.on('controlNodeDragging', function () {
if (/gui/.test(debug)) console.log('controlNodeDragging')
changeCursor('crosshair')
})
network.on('controlNodeDragEnd', function (event) {
if (/gui/.test(debug)) console.log('controlNodeDragEnd')
if (event.controlEdge.from !== event.controlEdge.to) changeCursor('default')
})
network.on('beforeDrawing', (ctx) => redraw(ctx))
network.on('afterDrawing', (ctx) => drawBadges(ctx))
// listen for changes to the network structure
// and recalculate the network statistics when there is one
data.nodes.on('add', recalculateStats)
data.nodes.on('remove', recalculateStats)
data.edges.on('add', recalculateStats)
data.edges.on('remove', recalculateStats)
/* --------------------------------------------set up the magnifier -------------------------------------------- */
const magSize = 300 // diameter of loupe
const halfMagSize = magSize / 2.0
const netPaneCanvas = netPane.firstElementChild.firstElementChild
const magnifier = document.createElement('canvas')
magnifier.width = magSize
magnifier.height = magSize
magnifier.className = 'magnifier'
const magnifierCtx = magnifier.getContext('2d')
magnifierCtx.fillStyle = 'white'
netPane.appendChild(magnifier)
let bigNetPane = null
let bigNetwork = null
let bigNetCanvas = null
let netPaneRect = null
let magnifying = false
netPane.addEventListener('keydown', (e) => {
if (!inEditMode && e.shiftKey && !magnifying) createMagnifier(e)
})
netPane.addEventListener('mousemove', (e) => {
if (magnifying && !inEditMode && e.shiftKey) showMagnifier(e)
})
netPane.addEventListener('keyup', (e) => {
if (e.key === 'Shift') closeMagnifier()
})
// ensure magnifier shows even if mouse is over the panel (e.g. when doing analysis)
panel.addEventListener('keydown', (e) => {
if (!inEditMode && e.shiftKey && !magnifying) createMagnifier(e)
})
panel.addEventListener('mousemove', (e) => {
if (magnifying && !inEditMode && e.shiftKey) showMagnifier(e)
})
panel.addEventListener('keyup', (e) => {
if (e.key === 'Shift') closeMagnifier()
})
/**
* create a copy of the network, but magnified and off screen
*/
function createMagnifier(e) {
if (bigNetPane) {
bigNetwork.destroy()
bigNetPane.remove()
}
if (drawingSwitch) return
magnifying = true
netPaneRect = netPane.getBoundingClientRect()
network.storePositions()
bigNetPane = document.createElement('div')
bigNetPane.id = 'big-net-pane'
bigNetPane.style.position = 'absolute'
bigNetPane.style.top = '-9999px'
bigNetPane.style.left = '-9999px'
bigNetPane.style.width = `${netPane.offsetWidth * magnification}px`
bigNetPane.style.height = `${netPane.offsetHeight * magnification}px`
netPane.appendChild(bigNetPane)
const bigNetData = { nodes: new DataSet(), edges: new DataSet() }
bigNetData.nodes.add(data.nodes.get())
bigNetData.edges.add(data.edges.get())
bigNetwork = new Network(bigNetPane, bigNetData, { physics: { enabled: false } })
/* // unhide any hidden nodes and edges
let changedNodes = []
bigNetData.nodes.forEach((n) => {
if (n.nodeHidden) {
changedNodes.push(setNodeHidden(n, false))
}
})
let changedEdges = []
bigNetData.edges.forEach((e) => {
if (e.edgeHidden) {
changedEdges.push(setEdgeHidden(e, false))
}
})
bigNetData.nodes.update(changedNodes)
bigNetData.edges.update(changedEdges) */
bigNetCanvas = bigNetPane.firstElementChild.firstElementChild
bigNetwork.on('afterDrawing', () => {
setCanvasBackground(bigNetCanvas)
})
bigNetwork.moveTo({
position: network.getViewPosition(),
scale: magnification * network.getScale(),
})
netPane.style.cursor = 'none'
magnifier.style.display = 'none'
showMagnifier(e)
}
/**
* display the loupe, centred on the mouse pointer, and fill it with
* an image copied from the magnified network
*/
function showMagnifier(e) {
e.preventDefault()
if (drawingSwitch) return
if (bigNetCanvas == null) createMagnifier()
magnifierCtx.fillRect(0, 0, magSize, magSize)
magnifierCtx.drawImage(
bigNetCanvas,
((e.clientX - netPaneRect.x) * bigNetCanvas.width) / netPaneCanvas.clientWidth - halfMagSize,
((e.clientY - netPaneRect.y) * bigNetCanvas.height) / netPaneCanvas.clientHeight -
halfMagSize,
magSize,
magSize,
0,
0,
magSize,
magSize
)
magnifier.style.top = `${e.clientY - netPaneRect.y - halfMagSize}px`
magnifier.style.left = `${e.clientX - netPaneRect.x - halfMagSize}px`
magnifier.style.display = 'block'
}
/**
* destroy the magnified network copy
*/
function closeMagnifier() {
if (bigNetPane) {
bigNetwork.destroy()
bigNetPane.remove()
}
netPane.style.cursor = 'default'
magnifier.style.display = 'none'
magnifying = false
}
} // end draw()
/**
* draw the background on the given canvas (which will be a magnified version of the net pane)
* @param {HTMLElement} canvas
* @returns canvas
*/
export function setCanvasBackground(canvas) {
const context = canvas.getContext('2d')
context.setTransform()
context.globalCompositeOperation = 'destination-over'
// apply the background objects
const backgroundCanvas = document.getElementById('underlay').firstElementChild.firstElementChild
context.drawImage(backgroundCanvas, 0, 0, canvas.width, canvas.height)
// apply the background colour, if any, or white
context.fillStyle = elem('underlay').style.backgroundColor || 'rgb(255, 255, 255)'
context.fillRect(0, 0, canvas.width, canvas.height)
return canvas
}
/* --------------------------------------------draw and update the minimap -------------------------------------------- */
/**
* Draw the minimap, which is a scaled down version of the network
* with a 'radar' overlay showing the current view
*
* @param {number} [ratio=5] - the ratio of the size of the minimap to the network
*/
export function drawMinimap(ratio = 5) {
let fullNetPane, fullNetwork, initialScale, initialPosition, minimapWidth, minimapHeight
const minimapWrapper = document.getElementById('minimapWrapper') // a div to contain the minimap
const minimapImage = document.getElementById('minimapImage') // an img, child of minimapWrapper
const minimapRadar = document.getElementById('minimapRadar') // a div, child of minimapWrapper
// size the minimap
minimapSetup()
// set up dragging of the radar overlay
let dragging = false // if true, ignore clicks when user is dragging radar overlay
dragRadar()
/**
* Set the size of the minimap and its components
*/
function minimapSetup() {
const { clientWidth, clientHeight } = network.body.container
minimapWidth = clientWidth / ratio
minimapHeight = clientHeight / ratio
minimapWrapper.style.width = `${minimapWidth}px`
minimapWrapper.style.height = `${minimapHeight}px`
minimapRadar.style.width = `${minimapWidth}px`
minimapRadar.style.height = `${minimapHeight}px`
drawMinimapImage()
drawRadar()
}
/**
* Draw a copy of the full network offscreen, then create an image of it
* The visible network can't be used, because it may be scaled and panned, but the minimap image needs to
* show the full network
*/
function drawMinimapImage() {
if (!elem('fullnetPane')) {
// if the full network does not exist, create it
fullNetPane = document.createElement('div')
fullNetPane.style.position = 'absolute'
fullNetPane.style.top = '-9999px'
fullNetPane.style.left = '-9999px'
fullNetPane.style.width = `${netPane.offsetWidth}px`
fullNetPane.style.height = `${netPane.offsetHeight}px`
fullNetPane.id = 'fullNetPane'
netPane.appendChild(fullNetPane)
fullNetwork = new Network(fullNetPane, data, { physics: { enabled: false } })
}
fullNetwork.setOptions({ edges: { smooth: elem('curveSelect').value === 'cubicBezier' } })
fullNetwork.fit()
initialScale = fullNetwork.getScale()
initialPosition = fullNetwork.getViewPosition()
const fullNetworklCanvas = fullNetPane.firstElementChild.firstElementChild
fullNetwork.on('afterDrawing', () => {
// make the image as a reduced version of the fullNetwork
const tempCanvas = document.createElement('canvas')
const tempContext = tempCanvas.getContext('2d')
tempCanvas.width = minimapWidth
tempCanvas.height = minimapHeight
tempContext.drawImage(fullNetworklCanvas, 0, 0, minimapWidth, minimapHeight)
minimapImage.src = tempCanvas.toDataURL()
minimapImage.width = minimapWidth
minimapImage.height = minimapHeight
})
}
/**
* Move a radar overlay on the minimap to show the current view of the network
*/
function drawRadar() {
const scale = initialScale / network.getScale()
// fade out the whole minimap if the network is all visible in the viewport
// (there is no value in having a minimap in this case)
if (scale >= 1 && networkInPane()) {
minimapWrapper.style.display = 'none'
return
} else minimapWrapper.style.display = 'block'
const currentDOMPosition = network.canvasToDOM(network.getViewPosition())
const initialDOMPosition = network.canvasToDOM(initialPosition)
minimapRadar.style.left = `${Math.round(
((currentDOMPosition.x - initialDOMPosition.x) * scale) / ratio +
(minimapWidth * (1 - scale)) / 2
)}px`
minimapRadar.style.top = `${Math.round(
((currentDOMPosition.y - initialDOMPosition.y) * scale) / ratio +
(minimapHeight * (1 - scale)) / 2
)}px`
minimapRadar.style.width = `${minimapWidth * scale}px`
minimapRadar.style.height = `${minimapHeight * scale}px`
}
/**
*
* @returns {boolean} - true if the network is entirely within the viewport
*/
function networkInPane() {
const netPaneTopLeft = network.DOMtoCanvas({ x: 0, y: 0 })
const netPaneBottomRight = network.DOMtoCanvas({
x: netPane.clientWidth,
y: netPane.clientHeight,
})
for (const nodeId of data.nodes.getIds()) {
const boundingBox = network.getBoundingBox(nodeId)
if (boundingBox.left < netPaneTopLeft.x) return false
if (boundingBox.right > netPaneBottomRight.x) return false
if (boundingBox.top < netPaneTopLeft.y) return false
if (boundingBox.bottom > netPaneBottomRight.y) return false
}
return true
}
/**
* Whenever the network is resized, the minimap needs to be resized and the radar overlay moved
*/
network.on('resize', () => {
minimapSetup()
})
/**
* Whenever the network is changed, panned or zoomed, the radar overlay needs to be moved
*/
network.on('afterDrawing', () => {
drawRadar()
})
/**
* Set up dragging of the radar overlay
*/
function dragRadar() {
let x, y, radarStart
minimapRadar.addEventListener('pointerdown', dragMouseDown)
minimapWrapper.addEventListener(
'wheel',
(e) => {
e.preventDefault()
// reject all but vertical touch movements
if (Math.abs(e.deltaX) <= 1) zoomscroll(e)
},
{ passive: false }
)
/**
* note that the mouse is down on the radar overlay and start dragging
* @param {event} e
*/
function dragMouseDown(e) {
e.preventDefault()
x = e.clientX
y = e.clientY
radarStart = { x: minimapRadar.offsetLeft, y: minimapRadar.offsetTop }
minimapRadar.addEventListener('pointermove', drag)
minimapRadar.addEventListener('pointerup', dragMouseUp)
}
/**
* move the radar overlay as the mouse moves
* @param {event} e
*/
function drag(e) {
e.preventDefault()
dragging = true
const dx = e.clientX - x
const dy = e.clientY - y
let left = radarStart.x + dx
let top = radarStart.y + dy
if (left < 0) left = 0
if (left + minimapRadar.offsetWidth >= minimapWidth) {
left = minimapWidth - minimapRadar.offsetWidth
}
if (top < 0) top = 0
if (top + minimapRadar.offsetHeight >= minimapHeight) {
top = minimapHeight - minimapRadar.offsetHeight
}
minimapRadar.style.left = `${Math.round(left)}px`
minimapRadar.style.top = `${Math.round(top)}px`
const initialDOMPosition = network.canvasToDOM(initialPosition)
const scale = initialScale / network.getScale()
const radarRect = minimapRadar.getBoundingClientRect()
const wrapperRect = minimapWrapper.getBoundingClientRect()
network.moveTo({
position: network.DOMtoCanvas({
x:
((radarRect.left - wrapperRect.left + (radarRect.width - wrapperRect.width) / 2) *
ratio) /
scale +
initialDOMPosition.x,
y:
((radarRect.top - wrapperRect.top + (radarRect.height - wrapperRect.height) / 2) *
ratio) /
scale +
initialDOMPosition.y,
}),
})
}
/**
* note that the mouse is up and stop dragging
* @param {event} e
*/
function dragMouseUp(e) {
e.preventDefault()
if (dragging) {
minimapRadar.removeEventListener('pointermove', drag)
minimapRadar.removeEventListener('pointerup', dragMouseUp)
}
}
}
}
/* -------------------------------------------- network map utilities -------------------------------------------- */
/**
* clear the map by destroying all nodes and edges and background objects
*/
export function clearMap() {
doc.transact(() => {
unSelect()
ensureNotDrawing()
network.destroy()
checkMapSaved = true
data.nodes.clear()
data.edges.clear()
yDrawingMap.clear()
canvas.clear()
draw()
})
}
/**
* note that the map has been saved to file and so user does not need to be warned
* about quitting without saving
*/
export function markMapSaved() {
checkMapSaved = false
dirty = false
}
/**
* un fade the delete button to show that it can be used when something is selected
*/
export function toggleDeleteButton() {
if (network.getSelectedNodes().length > 0 || network.getSelectedEdges().length > 0) {
elem('deleteNode').classList.remove('disabled')
} else elem('deleteNode').classList.add('disabled')
}
function contextMenu(event) {
event.preventDefault()
}
/** **************************************************** update history for history log **************************/
/**
* return an object with the current time as an integer date and the current user's name
*/
export function timestamp() {
return { time: Date.now(), user: myNameRec.name }
}
window.timestamp = timestamp
/**
* Generate a key for a time slot in the history log
*
* @param {integer} time
* @returns {string} key
*/
function timekey(time) {
return room + time
}
/**
* push a record that action has been taken on to the end of the history log
* also record current state of the map for possible roll back
* and note changes have been made to the map
* @param {String} action
* @param {String} actor - the user who took the action
* @param {boolean} dontSaveState - if defined, don't save the current state of the map
*/
export async function logHistory(action, actor, dontSaveState = null) {
const now = Date.now()
yHistory.push([{ action, time: now, user: actor || myNameRec.name }])
// store the current state of the map for possible rollback
if (!dontSaveState) {
await localForage.setItem(timekey(now), savedState).then(() => {
savedState = saveState()
// delete all but the last ROLLBACKS saved states
for (let i = 0; i < yHistory.length - ROLLBACKS; i++) {
const obj = yHistory.get(i)
if (obj.time) localForage.removeItem(timekey(obj.time))
}
})
}
if (elem('history-window').style.display === 'block') showHistory()
dirty = true
}
/**
* Generate a compressed dump of the current state of the map, sufficient to reproduce it
* @returns binary string
*/
export function saveState(options) {
return compressToUTF16(
JSON.stringify({
nodes: data.nodes.get(),
edges: data.edges.get(),
net: yNetMap.toJSON(),
samples: ySamplesMap.toJSON(),
paint: yPointsArray.toArray(),
drawing: yDrawingMap.toJSON(),
options,
})
)
}
/** ****************************************************** map notes side drawer *********************************************************/
/**
* set up the side drawer for notes
*/
function setUpSideDrawer() {
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ align: [] }],
['link', 'image'],
[{ size: ['small', false, 'large', 'huge'] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ font: [] }],
['clean'],
]
sideDrawEditor = new Quill(elem('drawer-editor'), {
modules: { toolbar: viewOnly ? null : toolbarOptions },
placeholder: 'Notes about the map',
theme: 'snow',
readOnly: viewOnly,
})
sideDrawEditor.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
yNetMap.set('mapDescription', {
text: isQuillEmpty(sideDrawEditor) ? '' : sideDrawEditor.getContents(),
})
}
})
}
export function setSideDrawer(contents) {
sideDrawEditor.setContents(contents.text)
}
export function disableSideDrawerEditing() {
sideDrawEditor.disable()
elem('drawer').firstElementChild.style.display = 'none'
}
/** *********************************************************** badges around the factors ******************************************/
const noteImage = new Image()
noteImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktY2FyZC10ZXh0IiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik0xNC41IDNhLjUuNSAwIDAgMSAuNS41djlhLjUuNSAwIDAgMS0uNS41aC0xM2EuNS41IDAgMCAxLS41LS41di05YS41LjUgMCAwIDEgLjUtLjVoMTN6bS0xMy0xQTEuNSAxLjUgMCAwIDAgMCAzLjV2OUExLjUgMS41IDAgMCAwIDEuNSAxNGgxM2ExLjUgMS41IDAgMCAwIDEuNS0xLjV2LTlBMS41IDEuNSAwIDAgMCAxNC41IDJoLTEzeiIvPgogIDxwYXRoIGQ9Ik0zIDUuNWEuNS41IDAgMCAxIC41LS41aDlhLjUuNSAwIDAgMSAwIDFoLTlhLjUuNSAwIDAgMS0uNS0uNXpNMyA4YS41LjUgMCAwIDEgLjUtLjVoOWEuNS41IDAgMCAxIDAgMWgtOUEuNS41IDAgMCAxIDMgOHptMCAyLjVhLjUuNSAwIDAgMSAuNS0uNWg2YS41LjUgMCAwIDEgMCAxaC02YS41LjUgMCAwIDEtLjUtLjV6Ii8+Cjwvc3ZnPg=='
const lockImage = new Image()
lockImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktbG9jay1maWxsIiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik04IDFhMiAyIDAgMCAxIDIgMnY0SDZWM2EyIDIgMCAwIDEgMi0yem0zIDZWM2EzIDMgMCAwIDAtNiAwdjRhMiAyIDAgMCAwLTIgMnY1YTIgMiAwIDAgMCAyIDJoNmEyIDIgMCAwIDAgMi0yVjlhMiAyIDAgMCAwLTItMnoiLz4KPC9zdmc+'
const thumbUpImage = new Image()
thumbUpImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktaGFuZC10aHVtYnMtdXAiIHZpZXdCb3g9IjAgMCAxNiAxNiI+CiAgPHBhdGggZD0iTTguODY0LjA0NkM3LjkwOC0uMTkzIDcuMDIuNTMgNi45NTYgMS40NjZjLS4wNzIgMS4wNTEtLjIzIDIuMDE2LS40MjggMi41OS0uMTI1LjM2LS40NzkgMS4wMTMtMS4wNCAxLjYzOS0uNTU3LjYyMy0xLjI4MiAxLjE3OC0yLjEzMSAxLjQxQzIuNjg1IDcuMjg4IDIgNy44NyAyIDguNzJ2NC4wMDFjMCAuODQ1LjY4MiAxLjQ2NCAxLjQ0OCAxLjU0NSAxLjA3LjExNCAxLjU2NC40MTUgMi4wNjguNzIzbC4wNDguMDNjLjI3Mi4xNjUuNTc4LjM0OC45Ny40ODQuMzk3LjEzNi44NjEuMjE3IDEuNDY2LjIxN2gzLjVjLjkzNyAwIDEuNTk5LS40NzcgMS45MzQtMS4wNjRhMS44NiAxLjg2IDAgMCAwIC4yNTQtLjkxMmMwLS4xNTItLjAyMy0uMzEyLS4wNzctLjQ2NC4yMDEtLjI2My4zOC0uNTc4LjQ4OC0uOTAxLjExLS4zMy4xNzItLjc2Mi4wMDQtMS4xNDkuMDY5LS4xMy4xMi0uMjY5LjE1OS0uNDAzLjA3Ny0uMjcuMTEzLS41NjguMTEzLS44NTcgMC0uMjg4LS4wMzYtLjU4NS0uMTEzLS44NTZhMi4xNDQgMi4xNDQgMCAwIDAtLjEzOC0uMzYyIDEuOSAxLjkgMCAwIDAgLjIzNC0xLjczNGMtLjIwNi0uNTkyLS42ODItMS4xLTEuMi0xLjI3Mi0uODQ3LS4yODItMS44MDMtLjI3Ni0yLjUxNi0uMjExYTkuODQgOS44NCAwIDAgMC0uNDQzLjA1IDkuMzY1IDkuMzY1IDAgMCAwLS4wNjItNC41MDlBMS4zOCAxLjM4IDAgMCAwIDkuMTI1LjExMUw4Ljg2NC4wNDZ6TTExLjUgMTQuNzIxSDhjLS41MSAwLS44NjMtLjA2OS0xLjE0LS4xNjQtLjI4MS0uMDk3LS41MDYtLjIyOC0uNzc2LS4zOTNsLS4wNC0uMDI0Yy0uNTU1LS4zMzktMS4xOTgtLjczMS0yLjQ5LS44NjgtLjMzMy0uMDM2LS41NTQtLjI5LS41NTQtLjU1VjguNzJjMC0uMjU0LjIyNi0uNTQzLjYyLS42NSAxLjA5NS0uMyAxLjk3Ny0uOTk2IDIuNjE0LTEuNzA4LjYzNS0uNzEgMS4wNjQtMS40NzUgMS4yMzgtMS45NzguMjQzLS43LjQwNy0xLjc2OC40ODItMi44NS4wMjUtLjM2Mi4zNi0uNTk0LjY2Ny0uNTE4bC4yNjIuMDY2Yy4xNi4wNC4yNTguMTQzLjI4OC4yNTVhOC4zNCA4LjM0IDAgMCAxLS4xNDUgNC43MjUuNS41IDAgMCAwIC41OTUuNjQ0bC4wMDMtLjAwMS4wMTQtLjAwMy4wNTgtLjAxNGE4LjkwOCA4LjkwOCAwIDAgMSAxLjAzNi0uMTU3Yy42NjMtLjA2IDEuNDU3LS4wNTQgMi4xMS4xNjQuMTc1LjA1OC40NS4zLjU3LjY1LjEwNy4zMDguMDg3LjY3LS4yNjYgMS4wMjJsLS4zNTMuMzUzLjM1My4zNTRjLjA0My4wNDMuMTA1LjE0MS4xNTQuMzE1LjA0OC4xNjcuMDc1LjM3LjA3NS41ODEgMCAuMjEyLS4wMjcuNDE0LS4wNzUuNTgyLS4wNS4xNzQtLjExMS4yNzItLjE1NC4zMTVsLS4zNTMuMzUzLjM1My4zNTRjLjA0Ny4wNDcuMTA5LjE3Ny4wMDUuNDg4YTIuMjI0IDIuMjI0IDAgMCAxLS41MDUuODA1bC0uMzUzLjM1My4zNTMuMzU0Yy4wMDYuMDA1LjA0MS4wNS4wNDEuMTdhLjg2Ni44NjYgMCAwIDEtLjEyMS40MTZjLS4xNjUuMjg4LS41MDMuNTYtMS4wNjYuNTZ6Ii8+Cjwvc3ZnPg=='
const thumbUpFilledImage = new Image()
thumbUpFilledImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktaGFuZC10aHVtYnMtdXAtZmlsbCIgdmlld0JveD0iMCAwIDE2IDE2Ij4KICA8cGF0aCBkPSJNNi45NTYgMS43NDVDNy4wMjEuODEgNy45MDguMDg3IDguODY0LjMyNWwuMjYxLjA2NmMuNDYzLjExNi44NzQuNDU2IDEuMDEyLjk2NS4yMi44MTYuNTMzIDIuNTExLjA2MiA0LjUxYTkuODQgOS44NCAwIDAgMSAuNDQzLS4wNTFjLjcxMy0uMDY1IDEuNjY5LS4wNzIgMi41MTYuMjEuNTE4LjE3My45OTQuNjgxIDEuMiAxLjI3My4xODQuNTMyLjE2IDEuMTYyLS4yMzQgMS43MzMuMDU4LjExOS4xMDMuMjQyLjEzOC4zNjMuMDc3LjI3LjExMy41NjcuMTEzLjg1NiAwIC4yODktLjAzNi41ODYtLjExMy44NTYtLjAzOS4xMzUtLjA5LjI3My0uMTYuNDA0LjE2OS4zODcuMTA3LjgxOS0uMDAzIDEuMTQ4YTMuMTYzIDMuMTYzIDAgMCAxLS40ODguOTAxYy4wNTQuMTUyLjA3Ni4zMTIuMDc2LjQ2NSAwIC4zMDUtLjA4OS42MjUtLjI1My45MTJDMTMuMSAxNS41MjIgMTIuNDM3IDE2IDExLjUgMTZIOGMtLjYwNSAwLTEuMDctLjA4MS0xLjQ2Ni0uMjE4YTQuODIgNC44MiAwIDAgMS0uOTctLjQ4NGwtLjA0OC0uMDNjLS41MDQtLjMwNy0uOTk5LS42MDktMi4wNjgtLjcyMkMyLjY4MiAxNC40NjQgMiAxMy44NDYgMiAxM1Y5YzAtLjg1LjY4NS0xLjQzMiAxLjM1Ny0xLjYxNS44NDktLjIzMiAxLjU3NC0uNzg3IDIuMTMyLTEuNDEuNTYtLjYyNy45MTQtMS4yOCAxLjAzOS0xLjYzOS4xOTktLjU3NS4zNTYtMS41MzkuNDI4LTIuNTl6Ii8+Cjwvc3ZnPg=='
const thumbDownImage = new Image()
thumbDownImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktaGFuZC10aHVtYnMtZG93biIgdmlld0JveD0iMCAwIDE2IDE2Ij4KICA8cGF0aCBkPSJNOC44NjQgMTUuNjc0Yy0uOTU2LjI0LTEuODQzLS40ODQtMS45MDgtMS40Mi0uMDcyLTEuMDUtLjIzLTIuMDE1LS40MjgtMi41OS0uMTI1LS4zNi0uNDc5LTEuMDEyLTEuMDQtMS42MzgtLjU1Ny0uNjI0LTEuMjgyLTEuMTc5LTIuMTMxLTEuNDFDMi42ODUgOC40MzIgMiA3Ljg1IDIgN1YzYzAtLjg0NS42ODItMS40NjQgMS40NDgtMS41NDYgMS4wNy0uMTEzIDEuNTY0LS40MTUgMi4wNjgtLjcyM2wuMDQ4LS4wMjljLjI3Mi0uMTY2LjU3OC0uMzQ5Ljk3LS40ODRDNi45MzEuMDggNy4zOTUgMCA4IDBoMy41Yy45MzcgMCAxLjU5OS40NzggMS45MzQgMS4wNjQuMTY0LjI4Ny4yNTQuNjA3LjI1NC45MTMgMCAuMTUyLS4wMjMuMzEyLS4wNzcuNDY0LjIwMS4yNjIuMzguNTc3LjQ4OC45LjExLjMzLjE3Mi43NjIuMDA0IDEuMTUuMDY5LjEzLjEyLjI2OC4xNTkuNDAzLjA3Ny4yNy4xMTMuNTY3LjExMy44NTYgMCAuMjg5LS4wMzYuNTg2LS4xMTMuODU2LS4wMzUuMTItLjA4LjI0NC0uMTM4LjM2My4zOTQuNTcxLjQxOCAxLjIuMjM0IDEuNzMzLS4yMDYuNTkyLS42ODIgMS4xLTEuMiAxLjI3Mi0uODQ3LjI4My0xLjgwMy4yNzYtMi41MTYuMjExYTkuODc3IDkuODc3IDAgMCAxLS40NDMtLjA1IDkuMzY0IDkuMzY0IDAgMCAxLS4wNjIgNC41MWMtLjEzOC41MDgtLjU1Ljg0OC0xLjAxMi45NjRsLS4yNjEuMDY1ek0xMS41IDFIOGMtLjUxIDAtLjg2My4wNjgtMS4xNC4xNjMtLjI4MS4wOTctLjUwNi4yMjktLjc3Ni4zOTNsLS4wNC4wMjVjLS41NTUuMzM4LTEuMTk4LjczLTIuNDkuODY4LS4zMzMuMDM1LS41NTQuMjktLjU1NC41NVY3YzAgLjI1NS4yMjYuNTQzLjYyLjY1IDEuMDk1LjMgMS45NzcuOTk3IDIuNjE0IDEuNzA5LjYzNS43MSAxLjA2NCAxLjQ3NSAxLjIzOCAxLjk3Ny4yNDMuNy40MDcgMS43NjguNDgyIDIuODUuMDI1LjM2Mi4zNi41OTUuNjY3LjUxOGwuMjYyLS4wNjVjLjE2LS4wNC4yNTgtLjE0NC4yODgtLjI1NWE4LjM0IDguMzQgMCAwIDAtLjE0NS00LjcyNi41LjUgMCAwIDEgLjU5NS0uNjQzaC4wMDNsLjAxNC4wMDQuMDU4LjAxM2E4LjkxMiA4LjkxMiAwIDAgMCAxLjAzNi4xNTdjLjY2My4wNiAxLjQ1Ny4wNTQgMi4xMS0uMTYzLjE3NS0uMDU5LjQ1LS4zMDEuNTctLjY1MS4xMDctLjMwOC4wODctLjY3LS4yNjYtMS4wMjFMMTIuNzkzIDdsLjM1My0uMzU0Yy4wNDMtLjA0Mi4xMDUtLjE0LjE1NC0uMzE1LjA0OC0uMTY3LjA3NS0uMzcuMDc1LS41ODEgMC0uMjExLS4wMjctLjQxNC0uMDc1LS41ODEtLjA1LS4xNzQtLjExMS0uMjczLS4xNTQtLjMxNWwtLjM1My0uMzU0LjM1My0uMzU0Yy4wNDctLjA0Ny4xMDktLjE3Ni4wMDUtLjQ4OGEyLjIyNCAyLjIyNCAwIDAgMC0uNTA1LS44MDRsLS4zNTMtLjM1NC4zNTMtLjM1NGMuMDA2LS4wMDUuMDQxLS4wNS4wNDEtLjE3YS44NjYuODY2IDAgMCAwLS4xMjEtLjQxNUMxMi40IDEuMjcyIDEyLjA2MyAxIDExLjUgMXoiLz4KPC9zdmc+'
const thumbDownFilledImage = new Image()
thumbDownFilledImage.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktaGFuZC10aHVtYnMtZG93bi1maWxsIiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik02Ljk1NiAxNC41MzRjLjA2NS45MzYuOTUyIDEuNjU5IDEuOTA4IDEuNDJsLjI2MS0uMDY1YTEuMzc4IDEuMzc4IDAgMCAwIDEuMDEyLS45NjVjLjIyLS44MTYuNTMzLTIuNTEyLjA2Mi00LjUxLjEzNi4wMi4yODUuMDM3LjQ0My4wNTEuNzEzLjA2NSAxLjY2OS4wNzEgMi41MTYtLjIxMS41MTgtLjE3My45OTQtLjY4IDEuMi0xLjI3MmExLjg5NiAxLjg5NiAwIDAgMC0uMjM0LTEuNzM0Yy4wNTgtLjExOC4xMDMtLjI0Mi4xMzgtLjM2Mi4wNzctLjI3LjExMy0uNTY4LjExMy0uODU2IDAtLjI5LS4wMzYtLjU4Ni0uMTEzLS44NTdhMi4wOTQgMi4wOTQgMCAwIDAtLjE2LS40MDNjLjE2OS0uMzg3LjEwNy0uODItLjAwMy0xLjE0OWEzLjE2MiAzLjE2MiAwIDAgMC0uNDg4LS45Yy4wNTQtLjE1My4wNzYtLjMxMy4wNzYtLjQ2NWExLjg2IDEuODYgMCAwIDAtLjI1My0uOTEyQzEzLjEuNzU3IDEyLjQzNy4yOCAxMS41LjI4SDhjLS42MDUgMC0xLjA3LjA4LTEuNDY2LjIxN2E0LjgyMyA0LjgyMyAwIDAgMC0uOTcuNDg1bC0uMDQ4LjAyOWMtLjUwNC4zMDgtLjk5OS42MS0yLjA2OC43MjNDMi42ODIgMS44MTUgMiAyLjQzNCAyIDMuMjc5djRjMCAuODUxLjY4NSAxLjQzMyAxLjM1NyAxLjYxNi44NDkuMjMyIDEuNTc0Ljc4NyAyLjEzMiAxLjQxLjU2LjYyNi45MTQgMS4yOCAxLjAzOSAxLjYzOC4xOTkuNTc1LjM1NiAxLjU0LjQyOCAyLjU5MXoiLz4KPC9zdmc+'
/**
* draw badges (icons) around Factors and Links
* @param {CanvasRenderingContext2D} ctx NetPane canvas context
*/
function drawBadges(ctx) {
// padlock for locked factors
if (!viewOnly) {
// for a view only map, factors are always locked, so don't bother with padlock
data.nodes
.get()
.filter((node) => !node.nodeHidden && node.fixed && !node.clusteredIn)
.forEach((node) => {
const box = network.getBoundingBox(node.id)
drawTheBadge(lockImage, ctx, box.left - 10, box.top)
})
}
if (showNotesToggle) {
// note card for Factors and Links with Notes
data.nodes
.get()
.filter(
(node) =>
!node.hidden &&
!node.nodeHidden &&
node.note &&
node.note !== 'Notes' &&
!node.clusteredIn
)
.forEach((node) => {
const box = network.getBoundingBox(node.id)
drawTheBadge(noteImage, ctx, box.right, box.top)
})
// an edge note badge is placed where a middle arrow would be
const changedEdges = []
data.edges.get().forEach((edge) => {
if (
!edge.edgeHidden &&
edge.note &&
edge.note !== 'Notes' &&
edge.arrows &&
edge.arrows.middle &&
!edge.arrows.middle.enabled
) {
// there is a note, but the badge is not shown, so show it
changedEdges.push(edge)
edge.arrows.middle.enabled = true
edge.arrows.middle.type = 'image'
edge.arrows.middle.src = noteImage.src
} else if (
(!edge.note || (edge.note && edge.note === 'Notes') || edge.edgeHidden) &&
edge.arrows &&
edge.arrows.middle &&
edge.arrows.middle.enabled
) {
// there is not a note, but the badge is shown, so remove it
changedEdges.push(edge)
edge.arrows.middle.enabled = false
}
})
data.edges.update(changedEdges)
}
// draw the voting thumbs up/down (but not for nodes inside a cluster, or for cluster nodes)
if (showVotingToggle) {
data.nodes
.get()
.filter((node) => !node.hidden && !node.nodeHidden && !node.clusteredIn && !node.isCluster)
.forEach((node) => {
const box = network.getBoundingBox(node.id)
drawTheBadge(
node.thumbUp?.includes(clientID) ? thumbUpFilledImage : thumbUpImage,
ctx,
box.left + 20,
box.bottom
)
drawThumbCount(ctx, node.thumbUp, box.left + 36, box.bottom + 10)
drawTheBadge(
node.thumbDown?.includes(clientID) ? thumbDownFilledImage : thumbDownImage,
ctx,
box.right - 36,
box.bottom
)
drawThumbCount(ctx, node.thumbDown, box.right - 20, box.bottom + 10)
})
}
/**
*
* @param {image} badgeImage
* @param {context} ctx
* @param {number} x
* @param {number} y
*/
function drawTheBadge(badgeImage, ctx, x, y) {
ctx.beginPath()
ctx.drawImage(badgeImage, Math.floor(x), Math.floor(y))
}
/**
* draw the length of the voters array, i.e. the count of those who have voted
* @param {context} ctx
* @param {array} voters
* @param {number} x
* @param {number} y
*/
function drawThumbCount(ctx, voters, x, y) {
if (voters) {
ctx.beginPath()
ctx.fillStyle = 'black'
ctx.fillText(voters.length.toString(), x, y)
}
}
}
/**
* Move the node to the nearest spot that it on the grid
* @param {object} node
*/
function snapToGrid(node) {
node.x = GRIDSPACING * Math.round(node.x / GRIDSPACING)
node.y = GRIDSPACING * Math.round(node.y / GRIDSPACING)
}
/** ************************************************************* clipboard ************************************** */
/**
* Copy the selected nodes and links to the clipboard
* NB this doesn't yet work in Firefox, as they haven't implemented the Clipboard API and Permissions yet.
* @param {Event} event
*/
function copyToClipboard(event) {
if (document.getSelection().toString()) return // only copy factors if there is no text selected (e.g. in Notes)
event.preventDefault()
if (drawingSwitch) {
copyBackgroundToClipboard(event)
return
}
const nIds = network.getSelectedNodes()
const eIds = network.getSelectedEdges()
if (nIds.length + eIds.length === 0) {
alertMsg('Nothing selected to copy', 'warn')
return
}
const nodes = []
const edges = []
nIds.forEach((nId) => {
nodes.push(data.nodes.get(nId))
const edgesFromNode = network.getConnectedEdges(nId)
edgesFromNode.forEach((eId) => {
const edge = data.edges.get(eId)
if (nIds.includes(edge.to) && nIds.includes(edge.from) && !edges.find((e) => e.id === eId)) {
edges.push(edge)
}
})
})
eIds.forEach((eId) => {
const edge = data.edges.get(eId)
if (!nodes.find((n) => n.id === edge.from)) {
nodes.push(data.nodes.get(edge.from))
}
if (!nodes.find((n) => n.id === edge.to)) {
nodes.push(data.nodes.get(edge.to))
}
if (!edges.find((e) => e.id === eId)) edges.push(data.edges.get(eId))
})
copyText(JSON.stringify({ nodes, edges }))
}
/**
* copy the contents of the history log to the clipboard
* @param {object} event
*/
function copyHistoryToClipboard(event) {
event.preventDefault()
const history = yHistory
.toArray()
.map(
(rec) =>
`${timeAndDate(rec.time, true)}\t${rec.user}\t${rec.action.replace(/\s+/g, ' ').trim()}\n`
)
.join('')
copyText(history)
}
async function copyText(text) {
try {
if (typeof navigator.clipboard.writeText !== 'function') {
throw new Error('navigator.clipboard.writeText not a function')
}
} catch {
alertMsg('Copying not implemented in this browser', 'error')
return false
}
try {
await navigator.clipboard.writeText(text)
alertMsg('Copied to clipboard', 'info')
return true
} catch (err) {
console.error('Failed to copy: ', err)
alertMsg('Copy failed', 'error')
return false
}
}
async function pasteFromClipboard() {
if (drawingSwitch) {
pasteBackgroundFromClipboard()
return
}
const clip = await getClipboardContents()
let nodes
let edges
try {
;({ nodes, edges } = JSON.parse(clip))
} catch {
// silently return (i.e. use system paste) if there is nothing relevant on the clipboard
return
}
unSelect()
nodes.forEach((node) => {
const oldId = node.id
node.id = uuidv4()
node.x += 40
node.y += 40
edges.forEach((edge) => {
if (edge.from === oldId) edge.from = node.id
if (edge.to === oldId) edge.to = node.id
})
})
edges.forEach((edge) => {
edge.id = uuidv4()
})
data.nodes.add(nodes)
data.edges.add(edges)
network.setSelection({ nodes: nodes.map((n) => n.id), edges: edges.map((e) => e.id) })
showSelected()
alertMsg('Pasted', 'info')
logHistory('pasted factors and/or links from clipboard')
}
async function getClipboardContents() {
try {
if (typeof navigator.clipboard.readText !== 'function') {
throw new Error('navigator.clipboard.readText not a function')
}
} catch {
alertMsg('Pasting not implemented in this browser', 'error')
return null
}
try {
return await navigator.clipboard.readText()
} catch (err) {
console.error('Failed to read clipboard contents: ', err)
alertMsg('Failed to paste', 'error')
return null
}
}
/* ----------------- dialogs for creating and editing nodes and links ---------------- */
/**
* Initialise the dialog for creating nodes/edges
* @param {string} popUpTitle
* @param {number} height
* @param {object} item
* @param {function} cancelAction
* @param {function} saveAction
* @param {function} callback
*/
function initPopUp(popUpTitle, height, item, cancelAction, saveAction, callback) {
inAddMode = false
inEditMode = true
changeCursor('default')
elem('popup').style.height = `${height}px`
elem('popup').style.borderColor = item.color.background
elem('popup-operation').innerHTML = popUpTitle
elem('popup-saveButton').onclick = saveAction.bind(this, item, callback)
elem('popup-cancelButton').onclick = cancelAction.bind(this, item, callback)
const popupLabel = elem('popup-label')
popupLabel.style.fontSize = '14px'
popupLabel.innerText = item.label === undefined ? '' : item.label // .replace(/\n/g, ' ')
popupLabel.focus()
// Set the cursor to the end
setEndOfContenteditable(popupLabel)
listen('popup', 'keydown', captureReturn)
function captureReturn(e) {
if (e.key === 'Enter' && !e.shiftKey) {
elem('popup').removeEventListener('keydown', captureReturn)
saveAction(item, callback)
} else if (e.key === 'Escape') {
elem('popup').removeEventListener('keydown', captureReturn)
cancelAction(item, callback)
}
}
}
/**
* Position the editing dialog box so that it is to the left of the item being edited,
* but not outside the window
* @param {Object} point
*/
function positionPopUp(point) {
const popUp = elem('popup')
popUp.style.display = 'block'
// popup appears to the left of the given point
popUp.style.top = `${point.y - popUp.offsetHeight / 2}px`
const left = point.x - popUp.offsetWidth / 2 - 3
popUp.style.left = `${left < 0 ? 0 : left}px`
dragElement(popUp, elem('popup-top'))
}
/**
* Hide the editing dialog box
*/
function clearPopUp() {
elem('popup-saveButton').onclick = null
elem('popup-cancelButton').onclick = null
elem('popup-label').onkeyup = null
elem('popup').style.display = 'none'
if (elem('popup-node-editor')) elem('popup-node-editor').remove()
if (elem('popup-link-editor')) elem('popup-link-editor').remove()
if (elem('popup').timer) {
clearTimeout(elem('popup').timer)
elem('popup').timer = undefined
}
yAwareness.setLocalStateField('addingFactor', { state: 'done' })
inEditMode = false
}
/**
* User has pressed 'cancel' - abandon adding a node and hide the dialog
* @param {Function} callback
*/
function cancelAdd(item, callback) {
clearPopUp()
callback(null)
stopEdit()
}
/**
* User has pressed 'cancel' - abandon the edit and hide the dialog
* @param {object} item
* @param {function} [callback]
*/
function cancelEdit(item, callback) {
clearPopUp()
item.label = item.oldLabel
item.font.color = item.oldFontColor
if (item.shape === 'portal') item.shape = 'image'
if (item.from) {
unlockEdge(item)
} else {
unlockNode(item)
}
if (callback) callback(null)
stopEdit()
}
/**
* A factor is being created: get its label from the user
* @param {Object} item - the node
* @param {Function} cancelAction
* @param {Function} callback
*/
function addLabel(item, cancelAction, callback) {
if (elem('popup').style.display === 'block') return // can't add factor when factor is already being added
initPopUp('Add Factor', 60, item, cancelAction, saveLabel, callback)
const pos = network.canvasToDOM({ x: item.x, y: item.y })
positionPopUp(pos)
removeFactorCursor()
ghostFactor(pos)
elem('popup-label').focus()
}
/**
* broadcast to other users that a new factor is being added here
* @param {Object} pos offset coordinates of Add Factor dialog
*/
function ghostFactor(pos) {
yAwareness.setLocalStateField('addingFactor', {
state: 'adding',
pos: network.DOMtoCanvas(pos),
name: myNameRec.name,
})
elem('popup').timer = setTimeout(() => {
// close it after a time if the user has gone away
yAwareness.setLocalStateField('addingFactor', { state: 'done' })
}, TIMETOEDIT)
}
/**
* called when a node has been added. Save the label provided
* @param {Object} node the item that has been added
* @param {Function} callback
*/
function saveLabel(node, callback) {
node.label = splitText(elem('popup-label').innerText, NODEWIDTH)
clearPopUp()
if (node.label === '') {
alertMsg('No label: cancelled', 'error')
callback(null)
return
}
network.manipulation.inMode = 'addNode' // ensure still in Add mode, in case others have done something meanwhile
callback(node)
logHistory(`added factor '${node.label}'`)
}
/**
* Draw a dialog box for user to edit a node
* @param {Object} item the node
* @param {Object} point the centre of the node
* @param {Function} cancelAction what to do if the edit is cancelled
* @param {Function} callback what to do if the edit is saved
*/
function editNode(item, point, cancelAction, callback) {
if (item.locked) return
initPopUp('Edit Factor', 180, item, cancelAction, saveNode, callback)
elem('popup').insertAdjacentHTML(
'beforeend',
`
<div class="popup-node-editor" id="popup-node-editor">
<div>fill</div>
<div>border</div>
<div>font</div>
<div class="input-color-container">
<div class="color-well" id="node-backgroundColor"></div>
</div>
<div class="input-color-container">
<div class="color-well" id="node-borderColor"></div>
</div>
<div class="input-color-container">
<div class="color-well" id="node-fontColor"></div>
</div>
<div>
<select name="nodeEditShape" id="nodeEditShape">
<option value="box">Shape...</option>
<option value="ellipse">Ellipse</option>
<option value="circle">Circle</option>
<option value="dot">Dot</option>
<option value="box">Rect</option>
<option value="diamond">Diamond</option>
<option value="star">Star</option>
<option value="triangle">Triangle</option>
<option value="hexagon">Hexagon</option>
<option value="text">Text</option>
<option value="portal">Portal</option>
</select>
</div>
<div>
<select name="nodeEditBorder" id="node-borderType">
<option value="solid" selected>Solid</option>
<option value="dashed">Dashed</option>
<option value="dots">Dotted</option>
<option value="none">No border</option>
</select>
</div>
<div>
<select name="nodeEditFontSize" id="nodeEditFontSize">
<option value="14">Size...</option>
<option value="24">Large</option>
<option value="14">Normal</option>
<option value="10">Small</option>
</select>
</div>
<div id="popup-sizer">
<label
> Size:
<input type="range" class="xrange" id="nodeEditSizer" />
</label>
</div>
</div>
`
)
cp.createColorPicker('node-backgroundColor')
elem('node-backgroundColor').style.backgroundColor = standardizeColor(item.color.background)
if (item.shape === 'image' && !item.isCluster) {
item.shape = 'portal'
if (elem('popup-portal-room')) elem('popup-portal-room').value = item.portal
else {
makePortalInput(item.portal)
}
}
elem('nodeEditShape').value = item.shape
cp.createColorPicker('node-borderColor')
elem('node-borderColor').style.backgroundColor = standardizeColor(item.color.border)
cp.createColorPicker('node-fontColor')
elem('node-fontColor').style.backgroundColor = standardizeColor(item.font.color)
elem('node-borderType').value = getDashes(item.shapeProperties.borderDashes, item.borderWidth)
elem('nodeEditFontSize').value = item.font.size
elem('nodeEditSizer').value = factorSizeToPercent(item.size)
progressBar(elem('nodeEditSizer'))
listen('nodeEditSizer', 'input', (event) => progressBar(event.target))
listen('nodeEditShape', 'change', (event) => {
if (event.target.value === 'portal') {
makePortalInput(item.portal)
} else {
elem('popup-portal-link')?.remove()
item.portal = undefined
}
})
positionPopUp(point)
elem('popup-label').focus()
elem('popup').timer = setTimeout(() => {
// ensure that the node cannot be locked out for ever
cancelEdit(item, callback)
alertMsg('Edit timed out', 'warn')
}, TIMETOEDIT)
lockNode(item)
/**
* Generate HTML for the textarea to obtain the room name of the portal
* @param {string} portal room name to go to
*/
function makePortalInput(portal) {
portal = portal || ''
// expand the dialog to accommodate the textarea
elem('popup').style.height = `${230}px`
elem('popup-node-editor').insertAdjacentHTML(
'beforeend',
`<div id="popup-portal-link">
<label for="popup-portal-room">Map:</label>
<textarea id="popup-portal-room" rows="1" placeholder="ABC-DEF-GHI-JKL">${portal}</textarea>
</div>`
)
}
}
// fancy portal image icon
const portalSvg =
'<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path fill="#ff0000" d="M298.736 21.016c-99.298 0-195.928 104.647-215.83 233.736-7.074 45.887-3.493 88.68 8.512 124.787-4.082-6.407-7.92-13.09-11.467-20.034-16.516-32.335-24.627-65.378-25-96.272-11.74 36.254-8.083 82.47 14.482 126.643 27.7 54.227 81.563 91.94 139.87 97.502 5.658.725 11.447 1.108 17.364 1.108 99.298 0 195.93-104.647 215.83-233.736 9.28-60.196.23-115.072-22.133-156.506 21.625 21.867 36.56 45.786 44.617 69.496.623-30.408-14.064-65.766-44.21-95.806-33.718-33.598-77.227-50.91-114.995-50.723-2.328-.118-4.67-.197-7.04-.197zm-5.6 36.357c40.223 0 73.65 20.342 95.702 53.533 15.915 42.888 12.51 108.315.98 147.858-16.02 54.944-40.598 96.035-79.77 126.107-41.79 32.084-98.447 24.39-115.874-5.798-1.365-2.363-2.487-4.832-3.38-7.385 11.724 14.06 38.188 14.944 61.817 1.3 25.48-14.71 38.003-40.727 27.968-58.108-10.036-17.384-38.826-19.548-64.307-4.837-9.83 5.676-17.72 13.037-23.14 20.934.507-1.295 1.043-2.59 1.626-3.88-18.687 24.49-24.562 52.126-12.848 72.417 38.702 45.923 98.07 25.503 140.746-6.426 37.95-28.392 72.32-73.55 89.356-131.988 1.265-4.34 2.416-8.677 3.467-13.008-.286 2.218-.59 4.442-.934 6.678-16.807 109.02-98.412 197.396-182.272 197.396-35.644 0-65.954-15.975-87.74-42.71-26.492-48.396-15.988-142.083 4.675-185.15 26.745-55.742 66.133-122.77 134.324-116.804 46.03 4.027 63.098 58.637 39.128 116.22-8.61 20.685-21.192 39.314-36.21 54.313 24.91-16.6 46.72-42.13 59.572-73 23.97-57.583 6.94-113.422-39.13-116.805-85.737-6.296-137.638 58.55-177.542 128.485-9.21 19.9-16.182 40.35-20.977 60.707.494-7.435 1.312-14.99 2.493-22.652C127.67 145.75 209.275 57.373 293.135 57.373z"></path></g></svg>'
/**
* save the node format details that have been edited
* @param {Object} item the node that has been edited
* @param {Function} callback
*/
function saveNode(item, callback) {
unlockNode(item)
item.label = splitText(elem('popup-label').innerText, NODEWIDTH)
if (item.label === '') {
// if there is no label, cancel (nodes must have a label)
alertMsg('No label: cancelled', 'error')
callback(null)
}
let color = elem('node-backgroundColor').style.backgroundColor
item.color.background = color
item.color.highlight.background = color
item.color.hover.background = color
color = elem('node-borderColor').style.backgroundColor
item.color.border = color
item.color.highlight.border = color
item.color.hover.border = color
item.font.color = elem('node-fontColor').style.backgroundColor
const borderType = elem('node-borderType').value
item.borderWidth = borderType === 'none' ? 0 : borderType === 'solid' ? 1 : 4
item.shapeProperties.borderDashes = convertDashes(borderType)
item.shape = elem('nodeEditShape').value
if (item.shape === 'portal') {
item.portal = elem('popup-portal-room')?.value
if (!item.portal) {
alertMsg('No map room provided', 'error')
callback(null)
return
}
item.portal = item.portal.match(/[a-zA-Z]{3}-[a-zA-Z]{3}-[a-zA-Z]{3}-[a-zA-Z]{3}/)
if (!item.portal) {
alertMsg('Ill-formed map room provided', 'error')
callback(null)
return
}
item.portal = item.portal[0]
item.shape = 'image'
item.image = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(portalSvg)
}
if (item.isCluster) {
item.shape = 'image'
}
item.font.size = parseInt(elem('nodeEditFontSize').value)
setFactorSizeFromPercent(item, elem('nodeEditSizer').value)
network.manipulation.inMode = 'editNode' // ensure still in Add mode, in case others have done something meanwhile
if (item.label.replace(/\s+|\n/g, '') === item.oldLabel.replace(/\s+|\n/g, '')) {
logHistory(`edited factor: '${item.label}'`)
} else {
logHistory(`edited factor, changing label from '${item.oldLabel}' to '${item.label}'`)
}
clearPopUp()
callback(item)
}
/**
* User is about to edit the node. Make sure that no one else can edit it simultaneously
* @param {Node} item
*/
function lockNode(item) {
item.locked = true
item.opacity = 0.3
item.oldLabel = item.label
item.oldFontColor = item.font.color
item.label = `${item.label}\n\n[Being edited by ${myNameRec.name}]`
item.wasFixed = Boolean(item.fixed)
item.fixed = true
dontUndo = 'locked'
data.nodes.update(item)
}
/**
* User has finished editing the node. Unlock it.
* @param {Node} item
*/
function unlockNode(item) {
item.locked = false
item.opacity = 1
item.fixed = item.wasFixed
item.label = item.oldLabel
dontUndo = 'unlocked'
data.nodes.update(item)
showNodeOrEdgeData()
}
/**
* ensure that all factors and links are unlocked (called only when user leaves the page, to clear up for others)
*/
function unlockAll() {
data.nodes.forEach((node) => {
if (node.locked) cancelEdit(deepCopy(node))
})
data.edges.forEach((edge) => {
if (edge.locked) cancelEdit(deepCopy(edge))
})
}
/**
* Draw a dialog box for user to edit an edge
* @param {Object} item the edge
* @param {Object} point the centre of the edge
* @param {Function} cancelAction what to do if the edit is cancelled
* @param {Function} callback what to do if the edit is saved
*/
function editEdge(item, point, cancelAction, callback) {
if (item.locked) return
initPopUp('Edit Link', 170, item, cancelAction, saveEdge, callback)
elem('popup').insertAdjacentHTML(
'beforeend',
`<div class="popup-link-editor" id="popup-link-editor">
<div>colour</div>
<div></div>
<div></div>
<div class="input-color-container">
<div class="color-well" id="linkEditLineColor"></div>
</div>
<div>
<select name="linkEditWidth" id="linkEditWidth">
<option value="1">Width: 1</option>
<option value="4">Width: 4</option>
<option value="8">Width: 8</option>
</select>
</div>
<div>
<select name="linkEditArrows" id="linkEditArrows">
<option value="vee">Arrows...</option>
<option value="vee">Sharp</option>
<option value="arrow">Triangle</option>
<option value="bar">Bar</option>
<option value="circle">Circle</option>
<option value="box">Box</option>
<option value="diamond">Diamond</option>
<option value="none">None</option>
</select>
</div>
<div>
<select name="linkEditDashes" id="linkEditDashes">
<option value="solid" selected>Solid</option>
<option value="dashedLinks">Dashed</option>
<option value="dots">Dotted</option>
</select>
</div>
<div>
<i>Font size:</i>
</div>
<div>
<select id="linkEditFontSize">
<option value="24">Large</option>
<option value="14">Normal</option>
<option value="10">Small</option>
</select>
</div>
</div>
`
)
elem('popup').style.borderColor = item.color.color
elem('linkEditWidth').value = parseInt(item.width)
cp.createColorPicker('linkEditLineColor')
elem('linkEditLineColor').style.backgroundColor = standardizeColor(item.color.color)
elem('linkEditDashes').value = getDashes(item.dashes, null)
elem('linkEditArrows').value = item.arrows.to.enabled ? item.arrows.to.type : 'none'
elem('linkEditFontSize').value = parseInt(item.font.size)
positionPopUp(point)
elem('popup-label').focus()
elem('popup').timer = setTimeout(() => {
// ensure that the edge cannot be locked out for ever
cancelEdit(item, callback)
alertMsg('Edit timed out', 'warn')
}, TIMETOEDIT)
lockEdge(item)
}
/**
* save the edge format details that have been edited
* @param {Object} item the edge that has been edited
* @param {Function} callback
*/
function saveEdge(item, callback) {
unlockEdge(item)
item.label = splitText(elem('popup-label').innerText, NODEWIDTH)
if (item.label === '') item.label = ' '
const color = elem('linkEditLineColor').style.backgroundColor
item.color.color = color
item.color.hover = color
item.color.highlight = color
item.width = parseInt(elem('linkEditWidth').value)
if (!item.width) item.width = 1
item.dashes = convertDashes(elem('linkEditDashes').value)
item.arrows.to = {
enabled: elem('linkEditArrows').value !== 'none',
type: elem('linkEditArrows').value,
}
item.font.size = parseInt(elem('linkEditFontSize').value)
network.manipulation.inMode = 'editEdge' // ensure still in edit mode, in case others have done something meanwhile
// vis-network silently deselects all edges in the callback (why?). So we have to mark this edge as unselected in preparation
clearStatusBar()
logHistory(
`edited link from '${data.nodes.get(item.from).label}' to '${data.nodes.get(item.to).label}'`
)
clearPopUp()
callback(item)
}
function lockEdge(item) {
item.locked = true
item.font.color = 'rgba(0,0,0,0.5)'
item.opacity = 0.1
item.oldLabel = item.label || ' '
item.label = `Being edited by ${myNameRec.name}`
dontUndo = 'locked'
data.edges.update(item)
}
/**
* User has finished editing the edge. Unlock it.
* @param {object} item
*/
function unlockEdge(item) {
item.locked = false
item.font.color = 'rgba(0,0,0,1)'
item.opacity = 1
item.label = item.oldLabel
item.oldLabel = undefined
dontUndo = 'unlocked'
data.edges.update(item)
showNodeOrEdgeData()
}
/* ----------------- end of node and edge creation and editing dialog ----------------- */
/**
* if there is already a link from the 'from' node to the 'to' node, return it
* @param {Object} from A node
* @param {Object} to Another node
*/
function duplEdge(from, to) {
return data.edges.get({
filter: function (item) {
return item.from === from && item.to === to
},
})
}
/**
* Change the cursor style for the net pane and nav bar
* @param {object} newCursorStyle
*/
function changeCursor(newCursorStyle) {
if (inAddMode) return
netPane.style.cursor = newCursorStyle
elem('navbar').style.cursor = newCursorStyle
}
/**
* User has set or changed the map title: update the UI and broadcast the new title
* @param {event} e
*/
function mapTitle(e) {
let title = e.target.innerText.trim()
title = setMapTitle(title)
yNetMap.set('mapTitle', title)
}
function pasteMapTitle(e) {
e.preventDefault()
let paste = (e.clipboardData || window.clipboardData).getData('text/plain')
if (paste instanceof HTMLElement) paste = paste.textContent
const selection = window.getSelection()
if (!selection.rangeCount) return false
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(paste))
setMapTitle(elem('maptitle').innerText)
}
/**
* Format the map title
* @param {string} title
*/
export function setMapTitle(title) {
const div = elem('maptitle')
clearStatusBar()
if (!title) {
title = 'Untitled map'
}
if (title === 'Untitled map') {
div.classList.add('unsetmaptitle')
document.title = appName
} else {
if (title.length > 50) {
title = title.slice(0, 50)
alertMsg('Map title is too long: truncated', 'warn')
}
div.classList.remove('unsetmaptitle')
document.title = `${title}: ${shortAppName} map`
}
if (title !== div.innerText.trim()) div.innerText = title
if (title.length >= 50) setEndOfContenteditable(div)
setFileName()
titleDropDown(title)
return title
}
/**
* Add this title to the local record of maps used
* The list is stored as an object so that it is easy to add [room, title] pairs
* and easy to modify the title of an existing room
* @param {String} title
*/
const TITLELISTLEN = 500
function titleDropDown(title) {
let recentMaps = localStorage.getItem('recents')
if (recentMaps) recentMaps = JSON.parse(recentMaps)
else recentMaps = {}
// TODO this should be Map, not an object, to guarantee preservation of the insertion order
if (title !== 'Untitled map') {
recentMaps[room] = title
// save only the most recent entries
recentMaps = Object.fromEntries(Object.entries(recentMaps).slice(-TITLELISTLEN))
localStorage.setItem('recents', JSON.stringify(recentMaps))
}
// if there is more than 1, append a down arrow after the map title as a cue to there being a list
if (Object.keys(recentMaps).length > 1) {
elem('recent-rooms-caret').classList.remove('hidden')
}
}
/**
* Create a drop down list of previous maps used for user selection
*/
function createTitleDropDown() {
removeTitleDropDown()
const selectList = document.createElement('ul')
selectList.id = 'recent-rooms-select'
selectList.classList.add('room-titles')
elem('recent-rooms').appendChild(selectList)
const recentMaps = JSON.parse(localStorage.getItem('recents'))
// list is with New Map and then the most recent at the top
if (recentMaps) {
makeTitleDropDownEntry('*New map*', '*new*', false)
const props = Object.keys(recentMaps).reverse()
props.forEach((prop) => {
makeTitleDropDownEntry(recentMaps[prop], prop)
})
}
/**
* create a previous map menu item
* @param {string} name Title of map
* @param {string} room
*/
function makeTitleDropDownEntry(name, room) {
const li = document.createElement('li')
li.classList.add('room-title')
li.textContent = name
li.dataset.room = room
li.addEventListener('click', (event) => changeRoom(event))
selectList.appendChild(li)
}
}
/**
* User has clicked one of the previous map titles - confirm and change to the web page for that room
* @param {Event} event
*/
function changeRoom(event) {
if (data.nodes.length > 0) {
if (!confirm('Are you sure you want to move to a different map?')) return
}
const newRoom = event.target.dataset.room
removeTitleDropDown()
const url = new URL(document.location)
url.search = newRoom !== '*new*' ? `?room=${newRoom}` : ''
window.location.replace(url)
}
/**
* Remove the drop down list of previous maps if user clicks on the net-pane or on a map title.
*/
function removeTitleDropDown() {
const oldSelect = elem('recent-rooms-select')
if (oldSelect) oldSelect.remove()
}
/**
* unselect all nodes and edges
*/
export function unSelect() {
hideNotes()
network.unselectAll()
clearStatusBar()
}
/*
----------- Calculate statistics in the background -------------
*/
// set up a web worker to calculate network statistics in parallel with whatever
// the user is doing
const worker = new Worker(new URL('./betweenness.js', import.meta.url), { type: 'module' })
/**
* Ask the web worker to recalculate network statistics
*/
export function recalculateStats() {
// wait 200 mSecs for things to settle down before recalculating
setTimeout(() => {
worker.postMessage([nodes.get(), edges.get()])
}, 200)
}
worker.onmessage = function (e) {
if (typeof e.data === 'string') alertMsg(e.data, 'error')
else {
const nodesToUpdate = []
data.nodes.get().forEach((n) => {
if (n.bc !== e.data[n.id]) {
n.bc = e.data[n.id]
nodesToUpdate.push(n)
}
})
if (nodesToUpdate) {
data.nodes.update(nodesToUpdate)
}
}
}
/*
----------- Status messages ---------------------------------------
*/
/**
* return a string listing the labels of the given nodes, with nice connecting words
* @param {Array} factors List of node Ids
* @param {Boolean} suppressType If true, don't start string with 'Factors'
*/
function listFactors(factors, suppressType) {
if (factors.length > 5) return `${factors.length} factors`
let str = ''
if (!suppressType) {
str = 'Factor'
if (factors.length > 1) str = `${str}s`
str = `${str}: `
}
return str + lf(factors)
function lf(factors) {
// recursive fn to return a string of the node labels, separated by commas and 'and'
const n = factors.length
const label = `'${shorten(data.nodes.get(factors[0]).label)}'`
if (n === 1) return label
factors.shift()
if (n === 2) return label.concat(` and ${lf(factors)}`)
return label.concat(`, ${lf(factors)}`)
}
}
/**
* return a string listing the number of Links, or if just one, the starting and ending factors
* @param {Array} links
*/
function listLinks(links) {
if (links.length > 1) return `${links.length} links`
const link = data.edges.get(links[0])
return `Link from "${shorten(data.nodes.get(link.from).label)}" to "${shorten(data.nodes.get(link.to).label)}"`
}
/**
* returns string of currently selected labels of links and factors, nicely formatted
* @returns {String} string of labels of links and factors, nicely formatted
*/
function selectedLabels() {
const selectedNodes = network.getSelectedNodes()
const selectedEdges = network.getSelectedEdges()
let msg = ''
if (selectedNodes.length > 0) msg = listFactors(selectedNodes)
if (selectedNodes.length > 0 && selectedEdges.length > 0) msg += ' and '
if (selectedEdges.length > 0) msg += listLinks(selectedEdges)
return msg
}
/**
* show the nodes and links selected in the status bar
*/
function showSelected() {
const msg = selectedLabels()
if (msg.length > 0) statusMsg(`${msg} selected`)
else clearStatusBar()
toggleDeleteButton()
}
/* ----------------------------------------zoom slider -------------------------------------------- */
Network.prototype.zoom = function (scale) {
const newScale = scale === undefined ? 1 : scale < 0.001 ? 0.001 : scale
const animationOptions = { scale: newScale, animation: { duration: 0 } }
this.view.moveTo(animationOptions)
zoomCanvas(newScale)
}
/**
* rescale and redraw the network so that it fits the pane
*/
export function fit() {
const prevPos = network.getViewPosition()
network.fit({
position: { x: 0, y: 0 }, // fit to centre of canvas
})
const newPos = network.getViewPosition()
const newScale = network.getScale()
zoomCanvas(1.0)
panCanvas(prevPos.x - newPos.x, prevPos.y - newPos.y, 1.0)
zoomCanvas(newScale)
elem('zoom').value = newScale
network.storePositions()
}
/**
* expand/reduce the network view using the value in the zoom slider
*/
function zoomnet() {
network.zoom(Number(elem('zoom').value))
}
/**
* zoom by the given amount (+ve or -ve);
* used by the + and - buttons at the ends of the zoom slider
* and by trackpad zoom/pinch.
* If the new zoom level becomes below zero, do nothing
* @param {Number} incr
*/
function zoomincr(incr) {
let newScale = network.getScale() * (1 + incr)
if (newScale <= 0) newScale = 0.015
if (newScale <= 4 && newScale >= 0) {
elem('zoom').value = newScale
}
network.zoom(newScale)
}
/**
* Set up pinch-to-zoom using native touch events
*/
function setUpPinchZoom() {
let initialDistance = null
let initialScale = 1
function getTouchDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
netPane.addEventListener(
'touchstart',
(e) => {
if (e.touches.length === 2) {
e.preventDefault()
initialDistance = getTouchDistance(e.touches[0], e.touches[1])
initialScale = Number(elem('zoom').value)
}
},
{ passive: false }
)
netPane.addEventListener(
'touchmove',
(e) => {
if (e.touches.length === 2 && initialDistance) {
e.preventDefault()
const currentDistance = getTouchDistance(e.touches[0], e.touches[1])
const scale = currentDistance / initialDistance
let newZoom = initialScale * scale
if (newZoom > 4) newZoom = 4
if (newZoom <= 0.015) newZoom = 0.015
elem('zoom').value = newZoom
network.zoom(newZoom)
}
},
{ passive: false }
)
netPane.addEventListener('touchend', (e) => {
if (e.touches.length < 2) {
initialDistance = null
}
})
netPane.addEventListener('touchcancel', () => {
initialDistance = null
})
}
let clicks = 0 // accumulate 'mousewheel' clicks sent while display is updating
let ticking = false // if true, we are waiting for an AnimationFrame */
// see https://www.html5rocks.com/en/tutorials/speed/animations/
// listen for zoom/pinch (confusingly, referred to as mousewheel events)
listen(
'net-pane',
'wheel',
(e) => {
e.preventDefault()
// reject all but vertical touch movements
if (Math.abs(e.deltaX) <= 1) zoomscroll(e)
},
// must be passive, else pinch/zoom is intercepted by the browser itself
{ passive: false }
)
/**
* Zoom using a trackpad (with a mousewheel or two fingers)
* @param {Event} event
*/
function zoomscroll(event) {
clicks += event.deltaY
requestZoom()
}
function requestZoom() {
if (!ticking) requestAnimationFrame(zoomUpdate)
ticking = true
}
const MOUSEWHEELZOOMRATE = 0.01 // how many 'clicks' of the mouse wheel/finger track correspond to 1 zoom increment
function zoomUpdate() {
zoomincr(-clicks * MOUSEWHEELZOOMRATE)
ticking = false
clicks = 0
}
/* -----------Operations related to the top button bar (not the side panel)------------- */
/**
* react to the user pressing the Add node button
* handles cases when the button is disabled; has previously been pressed; and the Add link
* button is active, as well as the normal case
*
*/
function plusNode() {
switch (inAddMode) {
case 'disabled':
return
case 'addNode': {
removeFactorCursor()
showPressed('addNode', 'remove')
stopEdit()
break
}
case 'addLink': {
showPressed('addLink', 'remove')
stopEdit()
} // falls through
default:
// false
// don't allow user to add a factor while editing another one
if (elem('popup').style.display === 'block') break
network.unselectAll()
changeCursor('cell')
ghostCursor()
inAddMode = 'addNode'
showPressed('addNode', 'add')
unSelect()
statusMsg('Click on the map to add a factor')
network.addNodeMode()
}
}
/**
* show a box attached to the cursor to guide where the Factor will be placed when the user clicks.
*/
function ghostCursor() {
// no ghost cursor if the hardware only supports touch
if (!window.matchMedia('(any-hover: hover)').matches) return
const box = document.createElement('div')
box.classList.add('ghost-factor', 'factor-cursor')
box.innerText = 'Click on the map to add a factor'
box.id = 'factor-cursor'
document.body.appendChild(box)
const netPaneRect = netPane.getBoundingClientRect()
keepInWindow(box, netPaneRect)
document.addEventListener('pointermove', () => {
keepInWindow(box, netPaneRect)
})
function keepInWindow(box, netPaneRect) {
const boxHalfWidth = box.offsetWidth / 2
const boxHalfHeight = box.offsetHeight / 2
const left = window.event.pageX - boxHalfWidth
box.style.left = `${
left <= netPaneRect.left
? netPaneRect.left
: left >= netPaneRect.right - box.offsetWidth
? netPaneRect.right - box.offsetWidth
: left
}px`
const top = window.event.pageY - boxHalfHeight
box.style.top = `${
top <= netPaneRect.top
? netPaneRect.top
: top >= netPaneRect.bottom - box.offsetHeight
? netPaneRect.bottom - box.offsetHeight
: top
}px`
}
}
/**
* remove the factor cursor if it exists
*/
function removeFactorCursor() {
const factorCursor = elem('factor-cursor')
if (factorCursor) {
factorCursor.remove()
}
clearStatusBar()
}
/**
* react to the user pressing the Add Link button
* handles cases when the button is disabled; has previously been pressed; and the Add Node
* button is active, as well as the normal case
*/
function plusLink() {
switch (inAddMode) {
case 'disabled':
return
case 'addLink': {
showPressed('addLink', 'remove')
stopEdit()
break
}
case 'addNode': {
showPressed('addNode', 'remove')
stopEdit() // falls through
} // falls through
default:
// false
// don't allow user to add a factor while editing another one
if (elem('popup').style.display === 'block') break
removeFactorCursor()
if (data.nodes.length < 2) {
alertMsg('Two Factors needed to link', 'error')
break
}
changeCursor('crosshair')
inAddMode = 'addLink'
showPressed('addLink', 'add')
unSelect()
statusMsg(
'Now drag from the middle of the Source factor to the middle of the Destination factor'
)
network.setOptions({ interaction: { dragView: false, selectable: false } })
network.addEdgeMode()
}
}
/**
* cancel adding node and links
*/
function stopEdit() {
inAddMode = false
network.disableEditMode()
network.setOptions({ interaction: { dragView: true, selectable: true } })
clearStatusBar()
changeCursor('default')
}
/**
* Add or remove the CSS style showing that the button has been pressed
* @param {string} el the Id of the button
* @param {*} action whether to add or remove the style
*
*/
function showPressed(el, action) {
elem(el).children.item(0).classList[action]('pressed')
}
function undo() {
if (buttonIsDisabled('undo')) return
unSelect()
yUndoManager.undo()
logHistory('undid last action')
undoRedoButtonStatus()
}
function redo() {
if (buttonIsDisabled('redo')) return
unSelect()
yUndoManager.redo()
logHistory('redid last action')
undoRedoButtonStatus()
}
export function undoRedoButtonStatus() {
setButtonDisabledStatus('undo', yUndoManager.undoStack.length === 0)
setButtonDisabledStatus('redo', yUndoManager.redoStack.length === 0)
}
/**
* Returns true if the button is not disabled
* @param {String} id
* @returns Boolean
*/
function buttonIsDisabled(id) {
return elem(id).classList.contains('disabled')
}
/**
* Change the visible state of a button
* @param {String} id
* @param {Boolean} state - true to make the button disabled
*/
function setButtonDisabledStatus(id, state) {
if (state) elem(id).classList.add('disabled')
else elem(id).classList.remove('disabled')
}
/**
* Delete the selected node, plus all the edges that connect to it (so no edge is left dangling)
*/
function deleteNode() {
network.deleteSelected()
clearStatusBar()
toggleDeleteButton()
}
/**
* set up the modal dialog that opens when the user clicks the Share icon in the nav bar
*/
function setUpShareDialog() {
const modal = elem('shareModal')
const inputElem = elem('text-to-copy')
const copiedText = elem('copied-text')
// When the user clicks the button, open the modal
listen('share', 'click', () => {
const path = `${window.location.pathname}?room=${room}`
const linkToShare = window.location.origin + path
copiedText.style.display = 'none'
modal.style.display = 'block'
inputElem.cols = linkToShare.length.toString()
inputElem.value = linkToShare
inputElem.style.height = `${inputElem.scrollHeight - 3}px`
inputElem.select()
network.storePositions()
})
listen('clone-button', 'click', () => openWindow('clone'))
listen('view-button', 'click', () => openWindow('view'))
listen('table-button', 'click', () => openWindow('table'))
// When the user clicks on <span> (x), close the modal
listen('modal-close', 'click', (event) => closeShareDialog(event))
// When the user clicks anywhere on the background, close it
listen('shareModal', 'click', (event) => closeShareDialog(event))
listen('copy-text', 'click', (e) => {
e.preventDefault()
// Select the text
inputElem.select()
if (copyText(inputElem.value)) {
// Display the copied text message
copiedText.style.display = 'inline-block'
}
})
function openWindow(type) {
let path = ''
switch (type) {
case 'clone': {
doClone(false)
break
}
case 'view': {
doClone(true)
break
}
case 'table': {
path = `${window.location.pathname.replace('prsm.html', 'table.html')}?room=${room}`
window.open(path, '_blank')
break
}
default:
console.log('Bad case in openWindow()')
break
}
modal.style.display = 'none'
}
function closeShareDialog(event) {
if (event.target === modal || event.target === elem('modal-close')) {
modal.style.display = 'none'
}
}
}
function doClone(onlyView) {
// undo any ongoing analysis and unselect all nodes and edges
setRadioVal('radius', 'All')
setRadioVal('stream', 'All')
setRadioVal('paths', 'All')
analyse()
unSelect()
const options = {
created: {
action: `cloned this map from room: ${room + (onlyView ? ' (Read Only)' : '')}`,
actor: myNameRec.name,
},
viewOnly: onlyView,
}
// save state as a UTF16 string
const state = saveState(options)
// save it in local storage
localForage
.setItem('clone', state)
.then(() => {
// make a room id
const clonedRoom = generateRoom()
// open a new map
let path = `${window.location.pathname}?room=${clonedRoom}`
const debugType = new URL(window.location.href).searchParams.get('debug')
if (onlyView && elem('addCopyButton').checked) path += '©Button'
if (debugType) path += `&debug=${debugType}`
window.open(path, '_blank')
logHistory(
`made a ${onlyView ? 'read-only copy' : 'clone'} of the map into room: ${clonedRoom}`
)
})
.catch(function (err) {
console.log('Error saving clone to local storage:', err)
})
}
function mergeMap() {
elem('mergedRoom').value = ''
elem('mergeDialog').showModal()
}
function doMerge() {
const path = elem('mergedRoom').value
if (!path) {
alertMsg('No map given to merge', 'error')
return
}
try {
const url = new URL(path)
const roomToMerge = url.searchParams.get('room')
console.log('merging ', roomToMerge)
mergeRoom(roomToMerge)
logHistory(`merged map from room: ${roomToMerge}`)
} catch {
alertMsg('Invalid map URL', 'error')
return
}
elem('mergeDialog').close()
}
/* ----------------------------------------------------------- Search ------------------------------------------------------ */
/**
* Open an input for user to type label of node to search for and generate suggestions when user starts typing
*/
function search() {
const searchBar = elem('search-bar')
if (searchBar.style.display === 'block') hideSearchBar()
else {
searchBar.style.display = 'block'
elem('search-icon').style.display = 'block'
searchBar.focus()
listen('search-bar', 'keyup', searchTargets)
}
}
/**
* generate and display a set of suggestions - nodes with labels that include the substring that the user has typed
*/
function searchTargets() {
let str = elem('search-bar').value
if (!str || str === ' ') {
if (elem('targets')) elem('targets').remove()
return
}
let targets = elem('targets')
if (targets) targets.remove()
targets = document.createElement('ul')
targets.id = 'targets'
targets.classList.add('search-ul')
str = str.toLowerCase()
const suggestions = data.nodes.get().filter((n) => n.label.toLowerCase().includes(str))
suggestions.forEach((n) => {
const li = document.createElement('li')
li.classList.add('search-suggestion')
const div = document.createElement('div')
div.classList.add('search-suggestion-text')
div.innerText = n.label.replace(/\n/g, ' ')
div.dataset.id = n.id
div.addEventListener('click', (event) => doSearch(event))
li.appendChild(div)
targets.appendChild(li)
})
elem('suggestion-list').appendChild(targets)
}
/**
* do the search using the string in the search bar and, when found, focus on that node
*/
function doSearch(event) {
const nodeId = event.target.dataset.id
if (nodeId) {
const prevPos = network.getViewPosition()
network.focus(nodeId, { scale: 1.5 })
const newPos = network.getViewPosition()
const newScale = network.getScale()
zoomCanvas(1.0)
panCanvas(prevPos.x - newPos.x, prevPos.y - newPos.y, 1.0)
zoomCanvas(newScale)
elem('zoom').value = newScale
network.storePositions()
hideSearchBar()
}
}
function hideSearchBar() {
const searchBar = elem('search-bar')
if (elem('targets')) elem('targets').remove()
searchBar.value = ''
searchBar.style.display = 'none'
elem('search-icon').style.display = 'none'
}
/**
* show or hide the side panel
*/
function togglePanel() {
if (container.panelHidden) {
panel.classList.remove('hide')
positionNotes()
} else {
panel.classList.add('hide')
}
container.panelHidden = !container.panelHidden
}
dragElement(elem('panel'), elem('panelHeader'))
/* ------------------------------------------------operations related to the side panel ------------------------------------- */
/**
* when the window is resized, make sure that the pane is still visible
* @param {HTMLelement} pane
*/
function keepPaneInWindow(pane) {
if (pane.offsetLeft + pane.offsetWidth > container.offsetLeft + container.offsetWidth) {
pane.style.left = `${container.offsetLeft + container.offsetWidth - pane.offsetWidth}px`
}
if (pane.offsetTop + pane.offsetHeight > container.offsetTop + container.offsetHeight) {
pane.style.top = `${
container.offsetTop +
container.offsetHeight -
pane.offsetHeight -
document.querySelector('footer').offsetHeight
}px`
}
}
function openTab(tabId, evt) {
// Get all elements with class="tabcontent" and hide them by moving them off screen
const tabcontent = document.getElementsByClassName('tabcontent')
for (let i = 0; i < tabcontent.length; i++) {
tabcontent[i].classList.add('hide')
}
// Get all elements with class="tablinks" and remove the class "active"
const tablinks = document.getElementsByClassName('tablinks')
for (let i = 0; i < tablinks.length; i++) {
tablinks[i].className = tablinks[i].className.replace(' active', '')
}
// Show the current tab, and add an "active" class to the button that opened the tab
elem(tabId).classList.remove('hide')
evt.currentTarget.className += ' active'
clearStatusBar()
// if a Notes panel is in the way, move it
positionNotes()
}
// Factors and Links Tabs
function applySampleToNode(event) {
if (event.detail !== 1) return // only process single clicks here
const selectedNodeIds = network.getSelectedNodes()
if (selectedNodeIds.length === 0) return
const nodesToUpdate = []
const sample = event.currentTarget.groupNode
for (let node of data.nodes.get(selectedNodeIds)) {
if (node.isCluster) break
if (sample !== node.grp) {
node = deepMerge(node, styles.nodes[sample])
node.grp = sample
node.modified = timestamp()
nodesToUpdate.push(node)
}
}
data.nodes.update(nodesToUpdate)
const nNodes = nodesToUpdate.length
if (nNodes) {
logHistory(
`applied ${styles.nodes[sample].groupLabel} style to ${
nNodes === 1 ? nodesToUpdate[0].label : nNodes + ' factors'
}`
)
}
lastNodeSample = sample
}
/**
* Apply the sample's format to the selected links
* @param {event} event
*/
function applySampleToLink(event) {
if (event.detail !== 1) return // only process single clicks here
const sample = event.currentTarget.groupLink
const selectedEdges = network.getSelectedEdges()
if (selectedEdges.length === 0) return
const edgesToUpdate = []
for (let edge of data.edges.get(selectedEdges)) {
if (edge.isClusterEdge) break
if (sample !== edge.grp) {
edge = deepMerge(edge, styles.edges[sample])
edge.grp = sample
edge.modified = timestamp()
edgesToUpdate.push(edge)
}
}
data.edges.update(edgesToUpdate)
const nEdges = edgesToUpdate.length
if (nEdges) {
logHistory(
`applied ${styles.edges[sample].groupLabel} style to ${nEdges} link${nEdges === 1 ? '' : 's'} `
)
}
lastLinkSample = sample
}
/**
* Remember the last style sample that the user clicked and use this for future factors/links
* Mark the sample with a light blue border
* @param {number} nodeId
* @param {number} linkId
*/
export function updateLastSamples(nodeId, linkId) {
if (nodeId) {
lastNodeSample = nodeId
const sampleNodes = Array.from(document.getElementsByClassName('sampleNode'))
const node = sampleNodes.filter((e) => e.groupNode === nodeId)[0]
sampleNodes.forEach((n) => n.classList.remove('sampleSelected'))
node.classList.add('sampleSelected')
}
if (linkId) {
lastLinkSample = linkId
const sampleLinks = Array.from(document.getElementsByClassName('sampleLink'))
const link = sampleLinks.filter((e) => e.groupLink === linkId)[0]
sampleLinks.forEach((n) => n.classList.remove('sampleSelected'))
link.classList.add('sampleSelected')
}
}
/**
* Hide or reveal all the Factors or Links with the given style
* @param {Object} obj {sample: state}
*/
function updateFactorsOrLinksHiddenByStyle(obj) {
for (const sampleElementId in obj) {
const sampleElement = elem(sampleElementId)
const state = obj[sampleElementId]
sampleElement.dataset.hide = state ? 'hidden' : 'visible'
sampleElement.style.opacity = state ? 0.6 : 1.0
}
}
/** ******************************************************Notes********************************************** */
/**
* Globally either display or don't display notes when a factor or link is selected
* @param {Event} e
*/
function showNotesSwitch(e) {
showNotesToggle = e.target.checked
doShowNotes(showNotesToggle)
yNetMap.set('showNotes', showNotesToggle)
}
function doShowNotes(toggle) {
elem('showNotesSwitch').checked = toggle
showNotesToggle = toggle
network.redraw()
showNodeOrEdgeData()
}
/**
* User has clicked the padlock. Toggle padlock state and fix the location of the node
*/
function setFixed() {
if (viewOnly) return
const locked = elem('fixed').style.display === 'none'
const node = data.nodes.get(editor.id)
node.fixed = locked
elem('fixed').style.display = node.fixed ? 'inline' : 'none'
elem('unfixed').style.display = node.fixed ? 'none' : 'inline'
data.nodes.update(node)
}
/**
* Display a panel to show info about the selected edge or node
*/
function showNodeOrEdgeData() {
hideNotes()
if (!showNotesToggle) return
if (network.getSelectedNodes().length === 1) showNodeData()
else if (network.getSelectedEdges().length === 1) showEdgeData()
}
/**
* open another window (popupWindow) in which Notes can be edited
*/
function openNotesWindow() {
popupWindow = window.open(
'./dist/NoteWindow.html',
'popupWindowName',
'toolbar=no,width=600,height=600'
)
}
/**
* Hide the Node or Edge Data panel
*/
function hideNotes() {
if (editor == null) return
let notesPanel = document.getElementById('nodeNotePanel')
if (notesPanel.classList.contains('hide')) {
notesPanel = document.getElementById('edgeNotePanel')
}
if (notesPanel.classList.contains('hide')) return
notesPanel.classList.add('hide')
document.getSelection().removeAllRanges()
notesPanel.querySelector('.ql-toolbar').remove()
editor = null
if (popupWindow) popupWindow.close()
}
/**
* Show the notes box and the fixed node check box
* @param {integer} nodeId
*/
function showNodeData(nodeId) {
const panel = elem('nodeNotePanel')
nodeId = nodeId || network.getSelectedNodes()[0]
const node = data.nodes.get(nodeId)
elem('fixed').style.display = node.fixed && !viewOnly ? 'inline' : 'none'
elem('unfixed').style.display = node.fixed || viewOnly ? 'none' : 'inline'
elem('nodeLabel').innerHTML = node.label ? shorten(node.label) : ''
if (node.created) {
elem('nodeCreated').innerHTML = `${timeAndDate(node.created.time)} by ${node.created.user}`
elem('nodeCreation').style.display = 'flex'
} else elem('nodeCreation').style.display = 'none'
if (node.modified) {
elem('nodeModified').innerHTML = `${timeAndDate(node.modified.time)} by ${node.modified.user}`
elem('nodeModification').style.display = 'flex'
} else elem('nodeModification').style.display = 'none'
editor = new Quill('#node-notes', {
modules: {
toolbar: viewOnly
? null
: [
'bold',
'italic',
'underline',
'link',
{ list: 'ordered' },
{ list: 'bullet' },
{ indent: '-1' },
{ indent: '+1' },
],
},
placeholder: 'Notes',
theme: 'snow',
readOnly: viewOnly,
})
window.editor = editor // used by popupEditor to access this editor
editor.id = node.id
if (node.note) {
if (node.note instanceof Object) editor.setContents(node.note)
else editor.setText(node.note)
} else editor.setText('')
editor.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
data.nodes.update({
id: nodeId,
note: isQuillEmpty(editor) ? '' : editor.getContents(),
modified: timestamp(),
})
if (popupWindow) {
popupEditor = popupWindow.popupEditor
if (popupEditor) popupEditor.setContents(editor.getContents())
}
}
})
panel.classList.remove('hide')
positionNotes()
}
/**
* Make the notes panel resizeable by dragging its corner handle
* @param {HTMLElement} notePanel
*/
function makeNotesPanelResizeable(notePanel) {
const notePanelCornerHandle = notePanel.querySelector('.corner-handle')
let isResizingCorner = false
let startX = 0
let startY = 0
let startWidth = 0
let startHeight = 0
notePanelCornerHandle.addEventListener('pointerdown', (e) => {
isResizingCorner = true
startX = e.clientX
startY = e.clientY
const styles = window.getComputedStyle(notePanel)
startWidth = parseInt(styles.width, 10)
startHeight = parseInt(styles.height, 10)
document.body.style.userSelect = 'none'
// Prevent default touch behaviors like scrolling
notePanelCornerHandle.style.touchAction = 'none'
})
document.addEventListener('pointermove', (e) => {
if (!isResizingCorner) return
const dx = e.clientX - startX
const dy = e.clientY - startY
const newWidth = startWidth + dx
const newHeight = startHeight + dy
if (newWidth > 150) notePanel.style.width = newWidth + 'px'
if (newHeight > 200) notePanel.style.height = newHeight + 'px'
})
document.addEventListener('pointerup', () => {
isResizingCorner = false
document.body.style.userSelect = 'auto'
positionNotes()
})
}
/**
* Show the notes box for an edge
* @param {integer} edgeId
*/
function showEdgeData(edgeId) {
const panel = elem('edgeNotePanel')
edgeId = edgeId || network.getSelectedEdges()[0]
const edge = data.edges.get(edgeId)
elem('edgeLabel').innerHTML = edge.label?.trim().length
? edge.label
: `Link from "${shorten(data.nodes.get(edge.from).label)}" to "${shorten(data.nodes.get(edge.to).label)}"`
if (edge.created) {
elem('edgeCreated').innerHTML = `${timeAndDate(edge.created.time)} by ${edge.created.user}`
elem('edgeCreation').style.display = 'flex'
} else elem('edgeCreation').style.display = 'none'
if (edge.modified) {
elem('edgeModified').innerHTML = `${timeAndDate(edge.modified.time)} by ${edge.modified.user}`
elem('edgeModification').style.display = 'flex'
} else elem('edgeModification').style.display = 'none'
editor = new Quill('#edge-notes', {
modules: {
toolbar: viewOnly
? null
: [
'bold',
'italic',
'underline',
'link',
{ list: 'ordered' },
{ list: 'bullet' },
{ indent: '-1' },
{ indent: '+1' },
],
},
placeholder: 'Notes',
theme: 'snow',
readOnly: viewOnly,
})
editor.id = edge.id
window.editor = editor // used by popupEditor to access this editor
if (edge.note) {
if (edge.note instanceof Object) editor.setContents(edge.note)
else editor.setText(edge.note)
} else editor.setText('')
editor.on('text-change', (delta, oldDelta, source) => {
if (source === 'user') {
data.edges.update({
id: edgeId,
note: isQuillEmpty(editor) ? '' : editor.getContents(),
modified: timestamp(),
})
if (popupWindow) {
popupEditor = popupWindow.popupEditor
if (popupEditor) popupEditor.setContents(editor.getContents())
}
}
})
panel.classList.remove('hide')
positionNotes()
}
/* // Statistics specific to a node
function displayStatistics(nodeId) {
// leverage (outDegree / inDegree)
let inDegree = network.getConnectedNodes(nodeId, 'from').length
let outDegree = network.getConnectedNodes(nodeId, 'to').length
let leverage = inDegree === 0 ? '--' : (outDegree / inDegree).toPrecision(3)
elem('leverage').textContent = leverage
let node = data.nodes.get(nodeId)
elem('bc').textContent = node.bc >= 0 ? parseFloat(node.bc).toFixed(2) : '--'
} */
/**
* ensure that the panel is not outside the net pane, nor obscuring the Settings panel
* @param {HTMLElement} notesPanel
*/
function positionNotes() {
let notesPanel = document.getElementById('nodeNotePanel')
if (notesPanel.classList.contains('hide')) {
notesPanel = document.getElementById('edgeNotePanel')
}
if (notesPanel.classList.contains('hide')) return
const netPane = document.getElementById('net-pane')
const settings = document.getElementById('panel')
let notesPanelRect = notesPanel.getBoundingClientRect()
const settingsRect = settings.getBoundingClientRect()
const netPaneRect = netPane.getBoundingClientRect()
// if the notes would cover up the settings panel, move the notes to the left of the settings panel
if (notesPanelRect.right > settingsRect.left && notesPanelRect.top < settingsRect.bottom) {
notesPanel.style.left = `${settingsRect.left - notesPanelRect.width - 10}px`
notesPanelRect = notesPanel.getBoundingClientRect()
}
// if the notes panel is taller than the net pane, increase its width and reduce its height
if (notesPanelRect.height > netPaneRect.height - 20) {
notesPanel.style.width = `$({
(notesPanelRect.width * notesPanelRect.height) / (netPaneRect.height - 20)
}px`
notesPanel.style.height = `${netPaneRect.height - 20}px`
notesPanel.style.left = `${notesPanelRect.right - notesPanelRect.width}px`
notesPanel.style.top = 10
notesPanelRect = notesPanel.getBoundingClientRect()
}
// if the notes panel is wider than the net pane, reduce its width
if (notesPanelRect.width > netPaneRect.width) {
notesPanel.style.width = `${netPaneRect.width - 20}px`
notesPanel.style.left = 10
notesPanelRect = notesPanel.getBoundingClientRect()
}
// if the notes panel is outside the boundary of the net pane, shift it into the pane
if (notesPanelRect.left < netPaneRect.left + 10) {
notesPanel.style.left = `${netPaneRect.left + 10}px`
}
if (notesPanelRect.right > netPaneRect.right - 10) {
notesPanel.style.left = `${netPaneRect.right - notesPanelRect.width - 10}px`
}
const visibleBottom = Math.min(
notesPanelRect.bottom,
notesPanelRect.top + notesPanel.offsetHeight
)
if (visibleBottom > netPaneRect.bottom - 10) {
notesPanel.style.top = `${netPaneRect.bottom - notesPanel.offsetHeight - 10}px`
}
if (notesPanelRect.top < netPaneRect.top + 10) {
notesPanel.style.top = `${netPaneRect.top + 10}px`
}
}
// Network tab
/**
* Choose and apply a layout algorithm
*/
function autoLayout(e) {
const option = e.target.value
const selectElement = elem('layoutSelect')
selectElement.value = option
const label = selectElement.options[selectElement.selectedIndex].innerText
network.storePositions() // record current positions so it can be undone
if (network.physics.options.enabled) {
// another layout already in progress - cancel it first
network.off('stabilized')
network.stopSimulation()
network.setOptions({ physics: { enabled: false } })
network.storePositions()
alertMsg('Previous layout cancelled', 'warn')
}
doc.transact(() => {
switch (option) {
case 'off': {
network.setOptions({ physics: { enabled: false } })
break
}
case 'trophic': {
try {
trophic(data)
trophicDistribute()
data.nodes.update(data.nodes.get())
elem('layoutSelect').value = 'off'
statusMsg('Trophic layout applied')
} catch (e) {
alertMsg(`Trophic layout: ${e.message}`, 'error')
}
break
}
case 'fan': {
{
const nodes = data.nodes.get().filter((n) => !n.nodeHidden)
nodes.forEach((n) => (n.level = undefined))
const selectedNodes = getSelectedAndFixedNodes().map((nId) => data.nodes.get(nId))
if (selectedNodes.length === 0) {
alertMsg('At least one Factor needs to be selected', 'error')
elem('layoutSelect').value = 'off'
return
}
// if Up or Down stream are selected, use those for the direction
let direction = 'from'
if (getRadioVal('stream') === 'downstream') direction = 'to'
else if (getRadioVal('stream') === 'upstream') direction = 'from'
else {
// if neither,
// and more links from the selected nodes are going upstream then downstream,
// put the selected nodes on the right, else on the left
let nUp = 0
let nDown = 0
selectedNodes.forEach((sl) => {
nUp += network
.getConnectedNodes(sl.id, 'to')
.filter((nId) => !data.nodes.get(nId).nodeHidden).length
nDown += network
.getConnectedNodes(sl.id, 'from')
.filter((nId) => !data.nodes.get(nId).nodeHidden).length
})
direction = nUp > nDown ? 'to' : 'from'
}
const minX = Math.min(...nodes.map((n) => n.x))
const maxX = Math.max(...nodes.map((n) => n.x))
selectedNodes.forEach((n) => {
setZLevel(n, direction)
})
nodes.forEach((n) => {
if (n.level === undefined) n.level = 0
})
const maxLevel = Math.max(...nodes.map((n) => n.level))
const gap = (maxX - minX) / maxLevel
for (let l = 0; l <= maxLevel; l++) {
let x = l * gap + minX
if (direction === 'from') x = maxX - l * gap
const nodesOnLevel = nodes.filter((n) => n.level === l)
nodesOnLevel.forEach((n) => (n.x = x))
const ySpaceNeeded = nodesOnLevel
.map((n) => {
const box = network.getBoundingBox(n.id)
return box.bottom - box.top + 10
})
.reduce((a, b) => a + b, 0)
const yGap = ySpaceNeeded / nodesOnLevel.length
let newY = -ySpaceNeeded / 2
nodesOnLevel
.sort((a, b) => a.y - b.y)
.forEach((n) => {
n.y = newY
newY += yGap
})
}
data.nodes.update(nodes)
elem('layoutSelect').value = 'off'
statusMsg('Fan layout applied')
}
break
}
case 'barnesHut':
case 'repulsion':
{
statusMsg('Working...')
const options = { physics: { solver: option, stabilization: true } }
options.physics[option] = {}
options.physics[option].springLength = avEdgeLength()
network.setOptions(options)
// cancel the iterative algorithms as soon as they have stabilized
network.on('stabilized', () => cancelLayout())
}
break
case 'forceAtlas2Based': {
statusMsg('Working...')
const options = {
physics: {
solver: 'forceAtlas2Based',
forceAtlas2Based: {
theta: 2, // Boundary between consolidated long range forces and individual short range forces
gravitationalConstant: -500, // Repulsion force (-ve values push nodes apart)
centralGravity: 0.01, // Pulls nodes toward the center
springConstant: 0.3, // Controls edge length
springLength: 0, // Edge attraction force
damping: 0.8, // Reduces oscillation
avoidOverlap: 1, // Prevents node overlap
},
},
}
network.setOptions(options)
// cancel the iterative algorithms as soon as they have stabilized
network.on('stabilized', () => cancelLayout())
network.on('stabilizationProgress', (obj) => {
statusMsg(`Working... ${obj.iterations} iterations of ${obj.total}`)
})
break
}
default: {
console.log('Unknown layout option')
break
}
}
})
// if the layout doesn't stabilize, cancel it after 30 seconds
setTimeout(() => {
cancelLayout()
}, 30000)
logHistory(`applied ${label} layout`)
/**
* cancel the iterative layout algorithms
*/
function cancelLayout() {
network.setOptions({ physics: { enabled: false } })
network.storePositions()
elem('layoutSelect').value = 'off'
statusMsg(`${label} layout applied`)
data.nodes.update(data.nodes.get())
}
/**
* set the levels for fan, using a breadth first search
* @param {object} node root node
* @param {string} direction either 'from' or 'to', depending on whether the links to use are point from or to the node
*/
function setZLevel(node, direction) {
let q = [node]
let level = 0
node.level = 0
while (q.length > 0) {
const currentNode = q.shift()
const connectedNodes = data.nodes
.get(network.getConnectedNodes(currentNode.id, direction))
.filter((n) => !n.nodeHidden && n.level === undefined)
if (connectedNodes.length > 0) {
level = currentNode.level + 1
connectedNodes.forEach((n) => {
n.level = level
})
q = q.concat(connectedNodes)
}
}
}
/**
* find the average length of all edges, as a guide to the layout spring length
* so that map is roughly as spaced out as before layout
* @returns average length (in canvas units)
*/
function avEdgeLength() {
let edgeSum = 0
data.edges.forEach((e) => {
const from = data.nodes.get(e.from)
const to = data.nodes.get(e.to)
edgeSum += Math.sqrt((from.x - to.x) ** 2 + (from.y - to.y) ** 2)
})
return edgeSum / data.edges.length
}
/**
* At each level for a trophic layout, distribute the Factors equally along the vertical axis,
* avoiding overlaps
*/
function trophicDistribute() {
for (let level = 0; level <= NLEVELS; level++) {
const nodesOnLevel = data.nodes.get().filter((n) => n.level === level)
const ySpaceNeeded = nodesOnLevel
.map((n) => {
const box = network.getBoundingBox(n.id)
return box.bottom - box.top + 10
})
.reduce((a, b) => a + b, 0)
const gap = ySpaceNeeded / nodesOnLevel.length
let newY = -ySpaceNeeded / 2
nodesOnLevel
.sort((a, b) => a.y - b.y)
.forEach((n) => {
n.y = newY
newY += gap
})
}
}
}
function snapToGridSwitch(e) {
snapToGridToggle = e.target.checked
doSnapToGrid(snapToGridToggle)
yNetMap.set('snapToGrid', snapToGridToggle)
}
export function doSnapToGrid(toggle) {
elem('snaptogridswitch').checked = toggle
if (toggle) {
data.nodes.update(
data.nodes.get().map((n) => {
snapToGrid(n)
return n
})
)
}
}
function selectCurve(e) {
const option = e.target.value
setCurve(option)
yNetMap.set('curve', option)
}
export function setCurve(option) {
elem('curveSelect').value = option
network.setOptions({ edges: { smooth: option === 'Curved' ? { type: 'cubicBezier' } : false } })
}
function updateNetBack(color) {
const ul = elem('underlay')
ul.style.backgroundColor = color
// if in drawing mode, make the underlay translucent so that network shows through
if (elem('toolbox').style.display === 'block') makeTranslucent(ul)
yNetMap.set('background', color)
}
const backgroundOpacity = 0.6
function makeTranslucent(el) {
el.style.backgroundColor = getComputedStyle(el)
.backgroundColor.replace(')', `, ${backgroundOpacity})`)
.replace('rgb', 'rgba')
}
function makeSolid(el) {
el.style.backgroundColor = getComputedStyle(el)
.backgroundColor.replace(`, ${backgroundOpacity})`, ')')
.replace('rgba', 'rgb')
}
export function setBackground(color) {
elem('underlay').style.backgroundColor = color
if (elem('toolbox').style.display === 'block') {
makeTranslucent(elem('underlay'))
}
elem('netBackColorWell').style.backgroundColor = color
}
function toggleDrawingLayer() {
drawingSwitch = elem('toolbox').style.display === 'block'
const ul = elem('underlay')
if (drawingSwitch) {
// close drawing layer
deselectTool()
elem('toolbox').style.display = 'none'
elem('underlay').style.zIndex = 0
makeSolid(ul)
document.querySelector('.upper-canvas').style.zIndex = 0
inAddMode = false
elem('buttons').style.visibility = 'visible'
setButtonDisabledStatus('addNode', false)
setButtonDisabledStatus('addLink', false)
undoRedoButtonStatus()
if (elem('showLegendSwitch').checked) legend()
if (nChanges) logHistory('drew on the background layer')
changeCursor('default')
} else {
// expose drawing layer
elem('toolbox').style.display = 'block'
ul.style.zIndex = 1000
ul.style.cursor = 'default'
document.querySelector('.upper-canvas').style.zIndex = 1001
// make the underlay (which is now overlay) translucent
makeTranslucent(ul)
clearLegend()
inAddMode = 'disabled'
elem('buttons').style.visibility = 'hidden'
elem('help-button').style.visibility = 'visible'
setButtonDisabledStatus('addNode', true)
setButtonDisabledStatus('addLink', true)
setButtonDisabledStatus('undo', true)
setButtonDisabledStatus('redo', true)
}
drawingSwitch = !drawingSwitch
network.redraw()
}
function ensureNotDrawing() {
if (!drawingSwitch) return
toggleDrawingLayer()
elem('drawing').checked = false
}
function selectAllFactors() {
selectFactors(data.nodes.getIds({ filter: (n) => !n.nodeHidden }))
showSelected()
}
export function selectFactors(nodeIds) {
network.selectNodes(nodeIds, false)
showSelected()
}
function selectAllLinks() {
selectLinks(data.edges.getIds({ filter: (e) => !e.edgeHidden }))
showSelected()
}
export function selectLinks(edgeIds) {
network.selectEdges(edgeIds)
showSelected()
}
/**
* Selects all the nodes and edges that have been created or modified by a user
*/
function selectUsersItems(event) {
event.preventDefault()
const userName = event.target.dataset.userName
const usersNodes = data.nodes
.get()
.filter((n) => n.created?.user === userName || n.modified?.user === userName)
.map((n) => n.id)
const userEdges = data.edges
.get()
.filter((e) => e.created?.user === userName || e.modified?.user === userName)
.map((e) => e.id)
network.setSelection({ nodes: usersNodes, edges: userEdges })
showSelected()
}
function legendSwitch(e) {
const on = e.target.checked
setLegend(on, true)
yNetMap.set('legend', on)
}
export function setLegend(on, warn) {
elem('showLegendSwitch').checked = on
if (on) legend(warn)
else clearLegend()
}
function votingSwitch(e) {
const on = e.target.checked
setVoting(on)
yNetMap.set('voting', on)
}
function setVoting(on) {
elem('showVotingSwitch').checked = on
showVotingToggle = on
network.redraw()
}
/** ************************************************************ Analysis tab ************************************************* */
/**
*
* @param {String} name of Radio button group
* @returns value of the button that is checked
*/
function getRadioVal(name) {
// get list of radio buttons with specified name
const radios = document.getElementsByName(name)
// loop through list of radio buttons
for (let i = 0, len = radios.length; i < len; i++) {
if (radios[i].checked) return radios[i].value
}
}
/**
*
* @param {String} name of the radio button group
* @param {*} value check the button with this value
*/
function setRadioVal(name, value) {
// get list of radio buttons with specified name
const radios = document.getElementsByName(name)
// loop through list of radio buttons and set the check on the one with the value
for (let i = 0, len = radios.length; i < len; i++) {
radios[i].checked = radios[i].value === value
}
}
/**
* Return an array of the node Ids of Factors that are selected or are locked
* @returns Array
*/
function getSelectedAndFixedNodes() {
return [
...new Set(
network.getSelectedNodes().concat(
data.nodes
.get()
.filter((n) => n.fixed)
.map((n) => n.id)
)
),
]
}
/**
* Sets the Analysis radio buttons and Factor selection according to values in global hiddenNodes
* (which is set when yNetMap is loaded, or when a file is read in)
*/
function setAnalysisButtonsFromRemote() {
if (netLoaded) {
const selectedNodes = [].concat(hiddenNodes.selected) // ensure that hiddenNodes.selected is an array
if (
hiddenNodes.radiusSetting !== 'All' ||
hiddenNodes.streamSetting !== 'All' ||
hiddenNodes.pathsSetting !== 'All'
) {
network.selectNodes(selectedNodes, false) // in viewing only mode, this does nothing
if (selectedNodes.length > 0) {
if (!viewOnly) {
statusMsg(`${listFactors(getSelectedAndFixedNodes())} selected`)
}
} else clearStatusBar()
}
showNodeOrEdgeData()
if (hiddenNodes.radiusSetting) {
setRadioVal('radius', hiddenNodes.radiusSetting)
}
if (hiddenNodes.streamSetting) {
setRadioVal('stream', hiddenNodes.streamSetting)
}
if (hiddenNodes.pathsSetting) setRadioVal('paths', hiddenNodes.pathsSetting)
}
}
function setYMapAnalysisButtons() {
const selectedNodes = getSelectedAndFixedNodes()
yNetMap.set('radius', { radiusSetting: getRadioVal('radius'), selected: selectedNodes })
yNetMap.set('stream', { streamSetting: getRadioVal('stream'), selected: selectedNodes })
yNetMap.set('paths', { pathsSetting: getRadioVal('paths'), selected: selectedNodes })
}
/**
* Hide factors and links to show only those closest to the selected factors and/or
* those up/downstream and/or those on paths between the selected factors
*/
function analyse() {
const selectedNodes = getSelectedAndFixedNodes()
setYMapAnalysisButtons()
// get all nodes and edges and unhide them before hiding those not wanted to be visible
const nodes = data.nodes
.get()
.filter((n) => !n.isCluster)
.map((n) => {
setNodeHidden(n, false)
return n
})
const edges = data.edges
.get()
.filter((e) => !e.isClusterEdge)
.map((e) => {
setEdgeHidden(e, false)
return e
})
cancelHiddenStyles()
// if showing everything, we are done
if (
getRadioVal('radius') === 'All' &&
getRadioVal('stream') === 'All' &&
getRadioVal('paths') === 'All'
) {
resetAll()
showSelected()
showNodeOrEdgeData()
return
}
// check that at least one factor is selected
if (selectedNodes.length === 0 && getRadioVal('paths') === 'All') {
alertMsg('A Factor needs to be selected', 'error')
resetAll()
return
}
// but paths between factors needs at least two
if (getRadioVal('paths') !== 'All' && selectedNodes.length < 2) {
alertMsg('Select at least 2 factors to show paths between them', 'warn')
resetAll()
return
}
hideNotes()
// these operations are not commutative (at least for networks with loops), so do them all in order
if (getRadioVal('radius') !== 'All') {
hideNodesByRadius(selectedNodes, parseInt(getRadioVal('radius')))
}
if (getRadioVal('stream') !== 'All') {
hideNodesByStream(selectedNodes, getRadioVal('stream'))
}
if (getRadioVal('paths') !== 'All') {
hideNodesByPaths(selectedNodes, getRadioVal('paths'))
}
// finally display the map with its hidden factors and edges
data.nodes.update(nodes)
data.edges.update(edges)
// announce what has been done
let streamMsg = ''
if (getRadioVal('stream') === 'upstream') streamMsg = 'upstream'
if (getRadioVal('stream') === 'downstream') streamMsg = 'downstream'
let radiusMsg = ''
if (getRadioVal('radius') === '1') radiusMsg = 'within one link'
if (getRadioVal('radius') === '2') radiusMsg = 'within two links'
if (getRadioVal('radius') === '3') radiusMsg = 'within three links'
let pathsMsg = ''
if (getRadioVal('paths') === 'allPaths') pathsMsg = ': showing all paths'
if (getRadioVal('paths') === 'shortestPath') {
pathsMsg = ': showing shortest paths'
}
if (getRadioVal('stream') === 'All' && getRadioVal('radius') === 'All') {
statusMsg(
`Showing ${getRadioVal('paths') === 'allPaths' ? 'all paths' : 'shortest paths'} between ${listFactors(
getSelectedAndFixedNodes(),
true
)}`
)
} else {
statusMsg(
`Factors ${streamMsg} ${streamMsg && radiusMsg ? ' and ' : ''} ${radiusMsg} of ${listFactors(
getSelectedAndFixedNodes(),
true
)}${pathsMsg}`
)
}
/**
* return all to neutral analysis state
*/
function resetAll() {
setRadioVal('radius', 'All')
setRadioVal('stream', 'All')
setRadioVal('paths', 'All')
setYMapAnalysisButtons()
data.nodes.update(nodes)
data.edges.update(edges)
}
/**
* Hide factors that are more than radius links distant from those selected
* @param {string[]} selectedNodes
* @param {Integer} radius
*/
function hideNodesByRadius(selectedNodes, radius) {
const nodeIdsInRadiusSet = new Set()
const linkIdsInRadiusSet = new Set()
// put those factors and links within radius links into these sets
if (getRadioVal('stream') === 'upstream' || getRadioVal('stream') === 'All') {
inSet(selectedNodes, radius, 'to')
}
if (getRadioVal('stream') === 'downstream' || getRadioVal('stream') === 'All') {
inSet(selectedNodes, radius, 'from')
}
// hide all nodes and edges not in radius
nodes.forEach((n) => {
if (!nodeIdsInRadiusSet.has(n.id)) setNodeHidden(n, true)
})
edges.forEach((e) => {
if (!linkIdsInRadiusSet.has(e.id)) setEdgeHidden(e, true)
})
// add links between factors that are in radius set, to give an ego network
nodeIdsInRadiusSet.forEach((f) => {
network.getConnectedEdges(f).forEach((e) => {
const edge = data.edges.get(e)
if (nodeIdsInRadiusSet.has(edge.from) && nodeIdsInRadiusSet.has(edge.to)) {
setEdgeHidden(edge, false)
}
})
})
/**
* recursive function to collect Factors and Links within radius links from any of the nodes listed in nodeIds
* Factor ids are collected in nodeIdsInRadiusSet and links in linkIdsInRadiusSet
* Links are followed in a consistent direction, i.e. if 'to', only links directed away from the the nodes are followed
* @param {string[]} nodeIds
* @param {number} radius
* @param {string} direction - either 'from' or 'to'
*/
function inSet(nodeIds, radius, direction) {
if (radius < 0) return
nodeIds.forEach((nId) => {
const linked = []
nodeIdsInRadiusSet.add(nId)
const links = network
.getConnectedEdges(nId)
.filter((e) => data.edges.get(e)[direction] === nId)
if (links && radius > 0) {
links.forEach((lId) => {
linkIdsInRadiusSet.add(lId)
linked.push(data.edges.get(lId)[direction === 'to' ? 'from' : 'to'])
})
}
if (linked) inSet(linked, radius - 1, direction)
})
}
}
/**
* Hide factors that are not up or downstream from the selected factors.
* Does not include links or factors that are already hidden
* @param {string[]} selectedNodes
* @param {string} direction - 'upstream' or 'downstream'
*/
function hideNodesByStream(selectedNodes, upOrDown) {
const nodeIdsInStreamSet = new Set()
const linkIdsInStreamSet = new Set()
const radiusVal = getRadioVal('radius')
let radius = Infinity
if (radiusVal !== 'All') {
radius = parseInt(radiusVal)
}
let direction = 'to'
if (upOrDown === 'upstream') direction = 'from'
// breadth first search for all Factors that are downstream and less than or equal to radius links away
data.nodes.map((n) => (n.level = undefined))
selectedNodes.forEach((nodeId) => {
nodeIdsInStreamSet.add(nodeId)
const node = data.nodes.get(nodeId)
let q = [node]
let level = 0
node.level = 0
while (q.length > 0 && level <= radius) {
const currentNode = q.shift()
const connectedNodes = data.nodes
.get(network.getConnectedNodes(currentNode.id, direction))
.filter((n) => !(n.nodeHidden || nodeIdsInStreamSet.has(n.id)))
if (connectedNodes.length > 0) {
level = currentNode.level + 1
connectedNodes.forEach((n) => {
nodeIdsInStreamSet.add(n.id)
n.level = level
})
q = q.concat(connectedNodes)
}
}
})
// hide all nodes and edges not up or down stream
nodes.forEach((n) => {
if (!nodeIdsInStreamSet.has(n.id)) setNodeHidden(n, true)
})
edges.forEach((e) => {
if (!linkIdsInStreamSet.has(e.id)) setEdgeHidden(e, true)
})
// add links between factors that are in radius set, to give an ego network
nodeIdsInStreamSet.forEach((f) => {
network.getConnectedEdges(f).forEach((e) => {
const edge = data.edges.get(e)
if (nodeIdsInStreamSet.has(edge.from) && nodeIdsInStreamSet.has(edge.to)) {
setEdgeHidden(edge, false)
}
})
})
}
/**
* Hide all factors and links that are not on the shortest path (or all paths) between the selected factors
* Avoids factors or links that are hidden
* @param {string[]} selectedNodes
* @param {string} pathType - either 'allPaths' or 'shortestPath'
*/
function hideNodesByPaths(selectedNodes, pathType) {
// paths is an array of objects with from and to node ids, or an empty array if there is no path
const paths = shortestPaths(selectedNodes, pathType === 'allPaths')
if (paths.length === 0) {
alertMsg('No path between the selected Factors', 'info')
setRadioVal('paths', 'All')
setYMapAnalysisButtons()
return
}
// hide nodes and links that are not included in paths
const nodeIdsInPathsSet = new Set()
const linkIdsInPathsSet = new Set()
paths.forEach((links) => {
links.forEach((link) => {
const edge = data.edges.get({
filter: (e) => e.to === link.to && e.from === link.from,
})[0]
linkIdsInPathsSet.add(edge.id)
nodeIdsInPathsSet.add(edge.from)
nodeIdsInPathsSet.add(edge.to)
})
})
// hide all factors and links that are not in the set of paths
nodes.forEach((n) => {
if (!nodeIdsInPathsSet.has(n.id)) setNodeHidden(n, true)
})
edges.forEach((e) => {
if (!linkIdsInPathsSet.has(e.id)) setEdgeHidden(e, true)
})
/**
* Given two or more selected factors, return a list of all the links that are either on any path between them, or just the ones on the shortest paths between them
* @param {Boolean} all when true, find all the links that connect to the selected factors; when false, find the shortest paths between the selected factors
* @returns Arrays of objects with from: and to: properties for all the links (an empty array if there is no path between any of the selected factors)
*/
function shortestPaths(selectedNodes, all) {
const visited = new Map()
const allPaths = []
// list of all pairs of the selected factors
const combos = selectedNodes.flatMap((v, i) => selectedNodes.slice(i + 1).map((w) => [v, w]))
// for each pair, find the sequences of links in both directions and combine them
combos.forEach((combo) => {
const source = combo[0]
const dest = combo[1]
let links = pathList(source, dest, all)
if (links.length > 0) allPaths.push(links)
links = pathList(dest, source, all)
if (links.length > 0) allPaths.push(links)
})
return allPaths
/**
* find the paths (as a list of links) that connect the source and destination
* @param {String} source
* @param {String} dest
* @param {Boolean} all true of all paths between Source and Destination are wanted; false if just the shortest path
* @returns an array of lists of links that connect the paths
*/
function pathList(source, dest, all) {
visited.clear()
const links = []
let paths = getPaths(source, dest)
// if no path found, getPaths return an array of length greater than the total number of factors in the map, or a string
// in this case, return an empty list
if (!Array.isArray(paths) || paths.length === data.nodes.length + 1) {
paths = []
}
if (!all) {
for (let i = 0; i < paths.length - 1; i++) {
links.push({ from: paths[i], to: paths[i + 1] })
}
}
return links
/**
* recursively explore the map starting from source until destination is reached.
* stop if a factor has already been visited, or at a dead end (zero out-degree)
* @param {String} source
* @param {String} dest
* @returns an array of factors, the path so far followed
*/
function getPaths(source, dest) {
if (source === dest) return [dest]
visited.set(source, true)
let path = [source]
// only consider nodes and edges that are not hidden
const connectedNodes = network
.getConnectedEdges(source)
.filter((e) => {
const edge = data.edges.get(e)
return !edge.edgeHidden && edge.from === source
})
.map((e) => data.edges.get(e).to)
if (connectedNodes.length === 0) return 'deadend'
if (all) {
// all paths between the source and destination
connectedNodes.forEach((next) => {
const vis = visited.get(next)
if (vis === 'onpath') {
links.push({ from: source, to: next })
path = path.concat([next])
} else if (!vis) {
const p = getPaths(next, dest)
if (Array.isArray(p) && p.length > 0) {
links.push({ from: source, to: next })
visited.set(next, 'onpath')
path = path.concat(p)
}
}
})
} else {
// shortest path between the source and destination
let bestPath = []
let bestPathLength = data.nodes.length
connectedNodes.forEach((next) => {
let p = visited.get(next)
if (!p) {
p = getPaths(next, dest)
visited.set(next, p)
}
if (Array.isArray(p) && p.length > 0) {
if (p.length < bestPathLength) {
bestPath = p
bestPathLength = p.length
}
}
})
path = path.concat(bestPath)
}
// if no progress has been made (the path is just the initial source factor), return an empty path
if (path.length === 1) path = []
return path
}
}
}
}
}
/**
* Unset the indicators on the Settings Factor and Link tabs that show that Factors/Links with
* these styles are hidden
* Assumes that the factors and links have already been unhidden - this just removes the UI indicators
*/
function cancelHiddenStyles() {
Array.from(document.getElementsByClassName('sampleNode'))
.filter((n) => n.dataset.hide === 'hidden')
.forEach((n) => {
n.dataset.hide = 'visible'
n.style.opacity = 1.0
})
Array.from(document.getElementsByClassName('sampleLink'))
.filter((e) => e.dataset.hide === 'hidden')
.forEach((e) => {
e.dataset.hide = 'visible'
e.style.opacity = 1.0
})
}
function sizingSwitch(e) {
const metric = e.target.value
sizing(metric)
yNetMap.set('sizing', metric)
}
/**
* set the size of the nodes proportional to the selected metric
* @param {String} metric none, all the same size, in degree, out degree or betweenness centrality
*/
// constants for sizes of nodes
const MIN_WIDTH = 50
const EQUAL_WIDTH = 100
const MAX_WIDTH = 200
export function sizing(metric) {
const nodesToUpdate = []
let min = Number.MAX_VALUE
let max = 0
data.nodes.forEach((node) => {
const oldValue = node.val
switch (metric) {
case 'Off':
case 'Equal': {
node.val = 0
break
}
case 'Inputs': {
node.val = network.getConnectedNodes(node.id, 'from').length
break
}
case 'Outputs': {
node.val = network.getConnectedNodes(node.id, 'to').length
break
}
case 'Leverage': {
const inDegree = network.getConnectedNodes(node.id, 'from').length
const outDegree = network.getConnectedNodes(node.id, 'to').length
node.val = inDegree === 0 ? 0 : outDegree / inDegree
break
}
case 'Centrality': {
node.val = node.bc
break
}
}
if (node.val < min) min = node.val
if (node.val > max) max = node.val
if (metric === 'Off' || metric === 'Equal' || node.val !== oldValue) {
nodesToUpdate.push(node)
}
})
data.nodes.forEach((node) => {
switch (metric) {
case 'Off': {
node.widthConstraint = node.heightConstraint = false
node.size = 25
break
}
case 'Equal': {
node.widthConstraint = node.heightConstraint = node.size = EQUAL_WIDTH
break
}
default:
node.widthConstraint =
node.heightConstraint =
node.size =
MIN_WIDTH + MAX_WIDTH * scale(min, max, node.val)
}
})
data.nodes.update(nodesToUpdate)
elem('sizing').value = metric
function scale(min, max, value) {
if (max === min) {
return 0.5
} else {
return Math.max(0, (value - min) * (1 / (max - min)))
}
}
}
// Note: most of the clustering functionality is in cluster.js
/**
* User has chosen a clustering option
* @param {Event} e
*/
function selectClustering(e) {
const option = e.target.value
// it doesn't make much sense to cluster while the factors are hidden, so undo that
setRadioVal('radius', 'All')
setRadioVal('stream', 'All')
setRadioVal('paths', 'All')
setYMapAnalysisButtons()
doc.transact(() => {
data.nodes.update(
data.nodes.get().map((n) => {
setNodeHidden(n, false)
return n
})
)
data.edges.update(
data.edges.get().map((e) => {
setEdgeHidden(e, false)
return e
})
)
})
cluster(option)
fit()
yNetMap.set('cluster', option)
}
export function setCluster(option) {
elem('clustering').value = option
}
/**
* recreate the Clustering drop down menu to include user attributes as clustering options
* @param {object} obj {menu value, menu text}
*/
export function recreateClusteringMenu(obj) {
// remove any old select items, other than the standard ones (which are the first 4: None, Style, Color, Community)
const select = elem('clustering')
for (let i = 4, len = select.options.length; i < len; i++) {
select.remove()
}
// append the ones provided
for (const property in obj) {
if (obj[property] !== '*deleted*') {
const opt = document.createElement('option')
opt.value = property
opt.text = shorten(obj[property], 12)
select.add(opt, null)
}
}
}
/* ---------------------------------------history window -------------------------------- */
/**
* display the history log in a window
*/
function showHistory() {
elem('history-window').style.display = 'block'
const log = elem('history-log')
log.innerHTML = yHistory
.toArray()
.map(
(rec) => `<div class="history-time">${timeAndDate(rec.time)}: </div>
<div class="history-action">${rec.user} ${rec.action}</div>
<div class="history-rollback" data-time="${rec.time}"></div>`
)
.join(' ')
document.querySelectorAll('div.history-rollback').forEach((e) => addRollbackIcon(e))
if (log.children.length > 0) {
// without the timeout, the window does not scroll fully to the bottom
setTimeout(() => log.lastChild.scrollIntoView(false), 20)
}
}
/**
* add a button for rolling back if there is state data corresponding to this log record
* @param {HTMLElement} e - history record
* */
async function addRollbackIcon(e) {
await localForage.getItem(timekey(parseInt(e.dataset.time))).then((state) => {
if (state) {
e.id = `hist${e.dataset.time}`
e.innerHTML = `<div class="tooltip">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bootstrap-reboot" viewBox="0 0 16 16">
<path d="M1.161 8a6.84 6.84 0 1 0 6.842-6.84.58.58 0 1 1 0-1.16 8 8 0 1 1-6.556 3.412l-.663-.577a.58.58 0 0 1 .227-.997l2.52-.69a.58.58 0 0 1 .728.633l-.332 2.592a.58.58 0 0 1-.956.364l-.643-.56A6.812 6.812 0 0 0 1.16 8z"/>
<path d="M6.641 11.671V8.843h1.57l1.498 2.828h1.314L9.377 8.665c.897-.3 1.427-1.106 1.427-2.1 0-1.37-.943-2.246-2.456-2.246H5.5v7.352h1.141zm0-3.75V5.277h1.57c.881 0 1.416.499 1.416 1.32 0 .84-.504 1.324-1.386 1.324h-1.6z"/>
</svg>
<span class="tooltiptext rollbacktip">Rollback to before this action</span>
</div>
</div>`
if (elem(e.id)) listen(e.id, 'click', rollback)
}
})
}
/**
* Restores the state of the map to a previous one
* @param {Event} event
* @returns null if no rollback possible or cancelled
*/
function rollback(event) {
const rbTime = parseInt(event.currentTarget.dataset.time)
localForage.getItem(timekey(rbTime)).then((rb) => {
if (!rb) return
if (!confirm(`Roll back the map to what it was before ${timeAndDate(rbTime)}?`)) {
return
}
const state = JSON.parse(decompressFromUTF16(rb))
data.nodes.clear()
data.edges.clear()
data.nodes.update(state.nodes)
data.edges.update(state.edges)
doc.transact(() => {
for (const k in state.net) {
yNetMap.set(k, state.net[k])
}
setMapTitle(state.net.mapTitle)
for (const k in state.samples) {
ySamplesMap.set(k, state.samples[k])
}
if (state.paint) {
yPointsArray.delete(0, yPointsArray.length)
yPointsArray.insert(0, state.paint)
}
if (state.drawing) {
yDrawingMap.clear()
for (const k in state.drawing) {
yDrawingMap.set(k, state.drawing[k])
}
updateFromDrawingMap()
}
})
localForage.removeItem(timekey(rbTime))
logHistory(
`rolled back the map to what it was before ${timeAndDate(rbTime, true)}`,
null,
'rollback'
)
})
}
function showHistorySwitch() {
if (elem('showHistorySwitch').checked) showHistory()
else elem('history-window').style.display = 'none'
}
listen('history-close', 'click', historyClose)
function historyClose() {
elem('history-window').style.display = 'none'
elem('showHistorySwitch').checked = false
}
dragElement(elem('history-window'), elem('history-header'))
/* --------------------------------------- avatars and shared cursors-------------------------------- */
let oldViewOnly = viewOnly // save the viewOnly state
/* tell user if they are offline and disconnect websocket server */
window.addEventListener('offline', () => {
alertMsg('No network connection - working offline (view only)', 'info')
wsProvider.shouldConnect = false
network.setOptions({ interaction: { dragNodes: false, hover: false } })
hideNavButtons()
sideDrawEditor.enable(false)
oldViewOnly = viewOnly
viewOnly = true
})
window.addEventListener('online', () => {
wsProvider.connect()
alertMsg('Network connection re-established', 'info')
viewOnly = oldViewOnly
if (!viewOnly) showNavButtons()
sideDrawEditor.enable(true)
network.setOptions({ interaction: { dragNodes: true, hover: true } })
showAvatars()
})
/**
* set up user monitoring (awareness)
*/
function setUpAwareness() {
showAvatars()
yAwareness.on('change', (event) => receiveEvent(event))
// regularly broadcast our own state, every 20 seconds
setInterval(() => {
yAwareness.setLocalStateField('pkt', { time: Date.now() })
}, 20000)
// if debug = fake, generate fake mouse events every 200 ms for testing
if (/fake/.test(debug)) {
setInterval(() => {
yAwareness.setLocalStateField('cursor', {
x: Math.random() * 1000 - 500,
y: Math.random() * 1000 - 500,
})
}, 200)
}
// fade out avatar when there has been no movement of the mouse for 15 minutes
asleep(false)
let sleepTimer = setTimeout(() => asleep(true), TIMETOSLEEP)
// throttle mousemove broadcast to avoid overloading server
let throttled = false
const THROTTLETIME = 200
window.addEventListener('mousemove', (e) => {
// broadcast my mouse movements
if (throttled) return
throttled = true
setTimeout(() => (throttled = false), THROTTLETIME)
clearTimeout(sleepTimer)
asleep(false)
sleepTimer = setTimeout(() => asleep(true), TIMETOSLEEP)
// broadcast current position of mouse in canvas coordinates
const box = netPane.getBoundingClientRect()
yAwareness.setLocalStateField(
'cursor',
network.DOMtoCanvas({
x: Math.round(e.clientX - box.left),
y: Math.round(e.clientY - box.top),
})
)
})
}
/**
* Set the awareness local state to show whether this client is sleeping (no mouse movement for 15 minutes)
* @param {Boolean} isSleeping
*/
function asleep(isSleeping) {
if (myNameRec.asleep === isSleeping) return
myNameRec.asleep = isSleeping
yAwareness.setLocalState({ user: myNameRec })
showAvatars()
// disconnect from websocket server to save resources when sleeping
if (isSleeping) wsProvider.disconnect()
else wsProvider.connect()
}
/**
* display the awareness events
* @param {object} event
*/
function traceUsers(event) {
let msg = ''
event.added.forEach((id) => {
msg += `Added ${user(id)} (${id}) `
})
event.updated.forEach((id) => {
msg += `Updated ${user(id)} (${id}) `
})
event.removed.forEach((id) => {
msg += `Removed (${id}) `
})
console.log('yAwareness', exactTime(), msg)
function user(id) {
const userRec = yAwareness.getStates().get(id)
return isEmpty(userRec.user) ? id : userRec.user.name
}
}
const lastMicePositions = new Map()
const lastAvatarStatus = new Map()
let refreshAvatars = true
/**
* Despatch to deal with event
* @param {object} event - from yAwareness.on('change')
*/
function receiveEvent(event) {
if (/aware/.test(debug)) traceUsers(event)
if (elem('showUsersSwitch').checked) {
const box = netPane.getBoundingClientRect()
const changed = event.added.concat(event.updated)
changed.forEach((userId) => {
const rec = yAwareness.getStates().get(userId)
if (
userId !== clientID &&
rec.cursor &&
!objectEquals(rec.cursor, lastMicePositions.get(userId))
) {
showOtherMouse(userId, rec.cursor, box)
lastMicePositions.set(userId, rec.cursor)
}
if (rec.user) {
// if anything has changed, redisplay the avatars
if (refreshAvatars || !objectEquals(rec.user, lastAvatarStatus.get(userId))) {
showAvatars()
}
lastAvatarStatus.set(userId, rec.user)
// set a timer for this avatar to self-destruct if no update has been received for a minute
const ava = elem(`ava${userId}`)
if (ava) {
clearTimeout(ava.timer)
ava.timer = setTimeout(removeAvatar, 60000, ava)
}
}
if (userId !== clientID && rec.addingFactor) {
showGhostFactor(userId, rec.addingFactor)
}
})
}
if (followme) followUser()
}
/**
* Display another user's mouse pointers (if they are inside the canvas)
*/
function showOtherMouse(userId, cursor, box) {
const cursorDiv = elem(userId.toString())
if (cursorDiv) {
const p = network.canvasToDOM(cursor)
p.x += box.left
p.y += box.top
cursorDiv.style.top = `${p.y}px`
cursorDiv.style.left = `${p.x}px`
cursorDiv.style.display =
p.x < box.left || p.x > box.right || p.y > box.bottom || p.y < box.top ? 'none' : 'block'
}
}
/**
* Place a circle at the top left of the net pane to represent each user who is online
* Also create a cursor (a div) for each of the users
*/
function showAvatars() {
refreshAvatars = false
const recs = Array.from(yAwareness.getStates())
// remove and save myself (using clientID as the id, not name)
const me = recs.splice(
recs.findIndex((a) => a[0] === clientID),
1
)
const nameRecs = recs
.map(([, value]) => value.user || null)
.filter((e) => e) // remove any recs without a user record
.filter((v, i, a) => a.findIndex((t) => t.name === v.name) === i) // remove duplicates, by name
.sort((a, b) => (a.name.charAt(0).toUpperCase() > b.name.charAt(0).toUpperCase() ? 1 : -1)) // sort names
if (me.length === 0) return // app is unloading
nameRecs.unshift(me[0][1].user) // push myself on to the front
const avatars = elem('avatars')
const currentCursors = []
// check that an avatar exists for each name; if not create one. If it does, check that it is still looking right
nameRecs.forEach((nameRec) => {
const ava = elem(`ava${nameRec.id}`)
const shortName = initials(nameRec.name)
if (ava === null) {
makeAvatar(nameRec)
refreshAvatars = true
} else {
// to avoid flashes, don't touch anything that is already correct
if (ava.dataset.tooltip !== nameRec.name) {
ava.dataset.tooltip = nameRec.name
}
const circle = ava.firstChild
if (circle.style.backgroundColor !== nameRec.color) {
circle.style.backgroundColor = nameRec.color
}
const circleBorderColor = nameRec.anon ? 'white' : 'black'
if (circle.style.borderColor !== circleBorderColor) {
circle.style.borderColor = circleBorderColor
}
if (circle.innerText !== shortName) circle.innerText = shortName
const circleFontColor = circle.style.color
if (circleFontColor !== (nameRec.isLight ? 'black' : 'white')) {
circle.style.color = nameRec.isLight ? 'black' : 'white'
}
const opacity = nameRec.asleep ? 0.2 : 1.0
if (circle.style.opacity !== opacity) circle.style.opacity = opacity
}
if (nameRec.id !== clientID) {
// don't create a cursor for myself
let cursorDiv = elem(nameRec.id)
if (cursorDiv === null) {
cursorDiv = makeCursor(nameRec)
} else {
if (nameRec.asleep) cursorDiv.style.display = 'none'
if (cursorDiv.innerText !== shortName) cursorDiv.innerText = shortName
if (cursorDiv.style.backgroundColor !== nameRec.color) {
cursorDiv.style.backgroundColor = nameRec.color
}
}
currentCursors.push(cursorDiv)
}
})
// re-order the avatars into alpha order, without gaps, with me at the start
const df = document.createDocumentFragment()
nameRecs.forEach((nameRec) => {
df.appendChild(elem(`ava${nameRec.id}`))
})
avatars.replaceChildren(df)
// delete any cursors that remain from before
const cursorsToDelete = Array.from(document.querySelectorAll('.shared-cursor')).filter(
(a) => !currentCursors.includes(a)
)
cursorsToDelete.forEach((e) => e.remove())
/**
* create an avatar as a div with initials inside
* @param {object} nameRec
*/
function makeAvatar(nameRec) {
const ava = document.createElement('div')
ava.classList.add('hoverme')
if (followme === nameRec.id) ava.classList.add('followme')
ava.id = `ava${nameRec.id}`
ava.dataset.tooltip = nameRec.name
// the broadcast awareness sometimes loses a client (i.e. broadcasts that it has been removed)
// when it actually hasn't (e.g. if there is a comms glitch). So instead, we set a timer
// and delete the avatar only if nothing is heard from that user for a minute
ava.timer = setTimeout(removeAvatar, 60000, ava)
const circle = document.createElement('div')
circle.classList.add('round')
circle.style.backgroundColor = nameRec.color
if (nameRec.anon) circle.style.borderColor = 'white'
circle.innerText = initials(nameRec.name)
circle.style.color = nameRec.isLight ? 'black' : 'white'
circle.style.opacity = nameRec.asleep ? 0.2 : 1.0
circle.dataset.client = nameRec.id
circle.dataset.userName = nameRec.name
ava.appendChild(circle)
avatars.appendChild(ava)
circle.addEventListener('click', nameRec.id === clientID ? renameUser : follow)
circle.addEventListener('contextmenu', selectUsersItems)
circle.addEventListener('mouseover', () =>
statusMsg(
nameRec.id === clientID
? 'Click to change your name. Right click to select all your edits'
: "Click to follow this person. Right click to select all this person's edits"
)
)
circle.addEventListener('mouseout', () => clearStatusBar())
}
/**
* make a pseudo cursor (a div)
* @param {object} nameRec
* @returns a div
*/
function makeCursor(nameRec) {
const cursorDiv = document.createElement('div')
cursorDiv.className = 'shared-cursor'
cursorDiv.id = nameRec.id
cursorDiv.style.backgroundColor = nameRec.color
cursorDiv.innerText = initials(nameRec.name)
cursorDiv.style.color = nameRec.isLight ? 'black' : 'white'
cursorDiv.style.display = 'none' // hide it until we get coordinates at next mousemove
container.appendChild(cursorDiv)
return cursorDiv
}
}
/**
* destroy the avatar - the user is no longer on line
* @param {HTMLelement} ava
*/
function removeAvatar(ava) {
refreshAvatars = true
ava.remove()
}
function showUsersSwitch() {
const on = elem('showUsersSwitch').checked
document.querySelectorAll('div.shared-cursor').forEach((node) => {
node.style.display = on ? 'block' : 'none'
})
elem('avatars').style.display = on ? 'flex' : 'none'
}
/**
* User has clicked on an avatar. Start following this avatar
* @param {event} event
*/
function follow(event) {
if (followme) unFollow()
const user = parseInt(event.target.dataset.client, 10)
if (user === clientID) return
followme = user
elem(`ava${followme}`).classList.add('followme')
const userName = elem(`ava${user}`).dataset.tooltip
alertMsg(`Following ${userName}`, 'info')
}
/**
* User was following another user, but has now clicked off the avatar, so stop following
*/
function unFollow() {
if (!followme) return
elem(`ava${followme}`).classList.remove('followme')
followme = undefined
elem('errMsg').classList.remove('fadeInAndOut')
clearStatusBar()
}
/**
* move the map so that the followed cursor is always in the centre of the pane
*/
function followUser() {
const userRec = yAwareness.getStates().get(followme)
if (!userRec) return
if (userRec.user.asleep) unFollow()
const userPosition = userRec.cursor
if (userPosition) network.moveTo({ position: userPosition })
}
/**
* User has clicked on their own avatar. Prompt them to change their own name.
*/
function renameUser() {
const newName = prompt('Enter your new name', myNameRec.name)
if (newName) {
myNameRec.name = newName
myNameRec.anon = false
yAwareness.setLocalState({ user: myNameRec })
showAvatars()
}
clearStatusBar()
}
/**
* show a ghost box where another user is adding a factor
* addingFactor is an object with properties:
* state: adding', or 'done' to indicate that the ghost box should be removed
* pos: a position (of the Add Factor dialog); 'done'
* name: the name of the other user
* @param {Integer} userId other user's client Id
* @param {object} addingFactor
*/
function showGhostFactor(userId, addingFactor) {
const id = `ghost-factor${userId}`
switch (addingFactor.state) {
case 'done': {
const ghostDiv = elem(id)
if (ghostDiv) ghostDiv.remove()
break
}
case 'adding': {
if (!elem(id)) {
const ghostDiv = document.createElement('div')
ghostDiv.className = 'ghost-factor'
ghostDiv.id = `ghost-factor${userId}`
ghostDiv.innerText = `[New factor\nbeing added by\n${addingFactor.name}]`
const p = network.canvasToDOM(addingFactor.pos)
const box = container.getBoundingClientRect()
p.x += box.left
p.y += box.top
ghostDiv.style.top = `${p.y - 50}px`
ghostDiv.style.left = `${p.x - 187}px`
ghostDiv.style.display =
p.x < box.left || p.x > box.right || p.y > box.bottom || p.y < box.top ? 'none' : 'block'
netPane.appendChild(ghostDiv)
}
break
}
default:
console.log(`Bad adding factor: ${addingFactor}`)
}
}