/*********************************************************************************************
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.
********************************************************************************************/
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,
cancelAlertMsg,
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 { getAIresponse } from './ai.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
* prompt - show AI prompts
*/
export let debug = ''
const allowAI = false // set to false to hide 'sparkle' buttons and so make AI features inaccessible to users
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)
listen('sparklesNode', 'click', genAINode)
listen('sparklesEdge', 'click', genAIEdge)
listen('sparklesSideNote', 'click', genAISideNote)
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()
// remove AI sparkle buttons if not providing AI features
if (!allowAI) {
Array.from(document.getElementsByClassName('sparkle')).forEach((elem) => {
elem.style.display = 'none'
})
}
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() {
sideDrawEditor = new Quill(elem('drawer-editor'), {
modules: {
//we need to have this in HTML, to add the AI sparkle icon to the tools
toolbar: viewOnly ? null : '#sideNotesToolbar',
},
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'
}
async function genAISideNote() {
if (data.nodes.length === 0) {
alertMsg('Add some factors and causal links to the map first', 'error')
return
}
alertMsg('Processing...', 'info', true)
sideDrawEditor.setText('Processing...\n')
const sparklesElem = elem('sparklesSideNote')
sparklesElem.classList.add('rotating')
const causes = data.edges
.get()
.map(
(e) =>
data.nodes.get(e.from).label.replaceAll('\n', ' ') +
' causes ' +
data.nodes.get(e.to).label.replaceAll('\n', ' ')
)
.join('; ')
let title = elem('maptitle').innerText
if (title === 'Untitled map') title = 'System Map'
/* let aiResponse = await getAIresponse(`A system map includes the following causal relationships. Write a description of the system map that will help a non-expert understand it. Use no more than 300 words. >>>${causes}<<<`) */
const aiResponse =
await getAIresponse(`I want you to generate a compact, readable narrative description of a system map.
I will provide:
• A title for the system map.
• A list of causal links, each given as a pair of factors in the form “A causes B”, where A is the cause and B is the effect.
Your task is to:
1. Produce a short, coherent, well-structured markdown description explaining the main dynamics of the system.
2. Cluster related factors, highlight feedback loops if present, and describe the overall behaviour of the system.
3. Avoid restating every causal link individually. Instead, synthesise them into a readable explanation.
4. Use bullet points where appropriate but do *not* include a title or any section headings.
5. Keep the description concise and accessible, suitable for a briefing note. Use no more than 300 words.
Output only the final description in markdown.
Here is the title and list of causal links:
Title: >>>${title}<<<
Causal Links:
>>>${causes}<<<`)
sideDrawEditor.setContents(aiResponse)
yNetMap.set('mapDescription', { text: sideDrawEditor.getContents() })
sparklesElem.classList.remove('rotating')
cancelAlertMsg()
}
/************************************************************* 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) {
const tabcontent = document.getElementsByClassName('tabcontent')
// Get all elements with class="tabcontent" and hide them by moving them off screen
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()
}
/**
* user has clicked on the sparkles icon in a Factor window
* return the output from an LLM asked to explain the factor
*/
async function genAINode() {
alertMsg('Processing...', 'info', true)
editor.setText('Processing...\n')
const sparklesElem = elem('sparklesNode')
sparklesElem.classList.add('rotating')
const nodeId = network.getSelectedNodes()[0]
const node = data.nodes.get(nodeId)
const context = data.nodes
.get()
.map((n) => n.label.replaceAll('\n', ' '))
.join(', ')
const systemPrompt = `You are to explain a single factor, A.
Follow all the instructions below for your response:
Write a compact, clear explanation of what A is, what it measures and how it may vary.
Structure the output in the following order without using any section headings or a title:
1 Introduction
2 Bullet points explaining features of A.
3 Evidence section containing academic citations and links to relevant web pages
4 Summary
Always begin the answer with: > ***This text has been generated by AI. It needs to be checked carefully.***
Do not begin by repeating the factor that is being explained.
The introduction should be no more than 50 words.
The bullet points should clearly explain features of A in no more than 150 words.
The evidence section should provide academic references that support the explanation, with a brief explanation of how each reference supports it.
All academic references must include full citations (author, date, title, source). Do not include DOIs. You must verify that all academic references that you mention actually exist.
Always embed links for any referenced web pages.
The summary should concisely restate the main points in no more than 50 words.
Maximum total output length: 200 words.
Format everything in Markdown.`
const aiResponse = await getAIresponse(
`Explain ${node.label}. Use these keywords as context: ${context}`,
systemPrompt
)
editor.setContents(aiResponse)
const modified = timestamp()
data.nodes.update({
id: nodeId,
note: isQuillEmpty(editor) ? '' : editor.getContents(),
modified,
})
elem('nodeModified').innerHTML = `${timeAndDate(modified.time)} by ${modified.user}`
positionNotes()
sparklesElem.classList.remove('rotating')
if (popupWindow) {
popupEditor = popupWindow.popupEditor
if (popupEditor) popupEditor.setContents(editor.getContents())
}
cancelAlertMsg()
}
/**
* Make the notes panel resizeable by dragging its corner handles
* @param {HTMLElement} notePanel
*/
function makeNotesPanelResizeable(notePanel) {
const notePanelCornerHandle = notePanel.querySelector('.corner-handle')
const notePanelTopLeftHandle = notePanel.querySelector('.corner-handle-top-left')
let isResizingCorner = false
let isResizingTopLeft = false
let startX = 0
let startY = 0
let startWidth = 0
let startHeight = 0
let startLeft = 0
let startTop = 0
// Bottom-right corner handle
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'
})
// Top-left corner handle
notePanelTopLeftHandle.addEventListener('pointerdown', (e) => {
isResizingTopLeft = true
startX = e.clientX
startY = e.clientY
const styles = window.getComputedStyle(notePanel)
startWidth = parseInt(styles.width, 10)
startHeight = parseInt(styles.height, 10)
startLeft = parseInt(styles.left, 10)
startTop = parseInt(styles.top, 10)
document.body.style.userSelect = 'none'
// Prevent default touch behaviors like scrolling
notePanelTopLeftHandle.style.touchAction = 'none'
})
document.addEventListener('pointermove', (e) => {
if (isResizingCorner) {
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'
} else if (isResizingTopLeft) {
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'
notePanel.style.left = startLeft + dx + 'px'
}
if (newHeight > 200) {
notePanel.style.height = newHeight + 'px'
notePanel.style.top = startTop + dy + 'px'
}
}
})
document.addEventListener('pointerup', () => {
isResizingCorner = false
isResizingTopLeft = 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()
}
/**
* user has clicked on the sparkles icon in a Link node window
* return the output from an LLM asked to elaborate on the causal
* relationship between the two linked factors
*/
async function genAIEdge() {
alertMsg('Processing...', 'info', true)
editor.setText('Processing...\n')
const sparklesElem = elem('sparklesEdge')
sparklesElem.classList.add('rotating')
const edgeId = network.getSelectedEdges()[0]
const edge = data.edges.get(edgeId)
const context = data.nodes
.get()
.map((n) => n.label.replaceAll('\n', ' '))
.join(', ')
const systemPrompt = `You are to explain a single causal link of the form A causes B.
Follow all the instructions below for your response:
Write a compact, clear explanation of why or how A causes B.
Do not introduce new factors that are not present in the inputs.
Do not restate or describe the whole system — focus only on the causal pathway from A to B, optionally referencing intermediary factors if necessary to provide a clear causal pathway.
Structure the output in the following order without using any section headings or a title:
1 Introduction
2 Bullet points explaining the causal reasoning
3 Evidence section containing academic citations and links to relevant web pages
4 Summary
Always begin the answer with: > ***This text has been generated by AI. It needs to be checked carefully.***
Do not begin by repeating the causal link that is being explained.
The introduction should be no more than 50 words.
The bullet points should clearly explain the causal reasoning in no more than 150 words.
The evidence section should provide academic references that support the causal link, with a brief explanation of how each reference supports the link.
All academic references must include full citations (author, date, title, source). Do not include DOIs. You must verify that all academic references that you mention actually exist.
Always embed links for any referenced web pages.
The summary should concisely restate the main points in no more than 50 words.
Maximum total output length: 200 words.
Format everything in Markdown.`
const aiResponse = await getAIresponse(
`
Explain the causal link from ${data.nodes.get(edge.from).label} to ${data.nodes.get(edge.to).label}.
Use these keywords as context: ${context}`,
systemPrompt
)
editor.setContents(aiResponse)
const modified = timestamp()
data.edges.update({
id: edgeId,
note: isQuillEmpty(editor) ? '' : editor.getContents(),
modified,
})
elem('edgeModified').innerHTML = `${timeAndDate(modified.time)} by ${modified.user}`
positionNotes()
sparklesElem.classList.remove('rotating')
if (popupWindow) {
popupEditor = popupWindow.popupEditor
if (popupEditor) popupEditor.setContents(editor.getContents())
}
cancelAlertMsg()
}
/**
* 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}`)
}
}