3d.js

/**
 *
 * PRSM 3D map view
 * generates a 'three dimensional'view of a PRSM map, build on threejs
 *
 *
 * */

import * as Y from 'yjs'
import {WebsocketProvider} from 'y-websocket'
import {Network} from 'vis-network/peer/'
import {DataSet} from 'vis-data/peer'
import {elem, listen, deepMerge, standardize_color} from './utils.js'
import {version} from '../package.json'
// For unknown reasons, ForceGraph3D crashes if loaded from node_modules, but works if loaded from a CDN
//import ForceGraph3D from '3d-force-graph'
/*global ForceGraph3D */
import SpriteText from 'three-spritetext'
import * as THREE from 'three'

const shortAppName = 'PRSM'

var debug = ''
window.debug = debug
var room
const doc = new Y.Doc()
var websocket = 'wss://www.prsm.uk/wss' // web socket server URL
var clientID // unique ID for this browser
var yNodesMap // shared map of nodes
var yEdgesMap // shared map of edges
var yNetMap // shared map of network state
var ySamplesMap // shared map of styles
var myNameRec // my name etc.
var loadingDelayTimer // timer to delay the start of the loading animation for few moments
var graph // the 3D map

window.addEventListener('load', () => {
	loadingDelayTimer = setTimeout(() => {
		elem('loading').style.display = 'block'
	}, 100)
	elem('version').innerHTML = version
	let searchParams = new URL(document.location).searchParams
	if (searchParams.has('debug')) debug = searchParams.get('debug')
	startY()
})
/**
 * create a new shared document and connect to the WebSocket provider
 */
function startY() {
	// get the room number from the URL, or if none, complain
	let url = new URL(document.location)
	room = url.searchParams.get('room')
	if (room == null || room == '') alert('No room name')
	else room = room.toUpperCase()
	debug = [url.searchParams.get('debug')]
	document.title = document.title + ' ' + room
	// don't bother with a local storage provider
	const wsProvider = new WebsocketProvider(websocket, 'prsm' + room, doc)
	wsProvider.on('sync', () => {
		console.log(exactTime() + ' remote content loaded')
		display('white')
	})
	wsProvider.on('status', (event) => {
		console.log(exactTime() + event.status + (event.status == 'connected' ? ' to' : ' from') + ' room ' + room) // logs when websocket is "connected" or "disconnected"
	})

	yNodesMap = doc.getMap('nodes')
	yEdgesMap = doc.getMap('edges')
	yNetMap = doc.getMap('network')
	ySamplesMap = doc.getMap('samples')

	clientID = doc.clientID
	console.log('My client ID: ' + clientID)

	/* 
	for convenience when debugging
	 */
	window.debug = debug
	window.clientID = clientID
	window.yNodesMap = yNodesMap
	window.yEdgesMap = yEdgesMap
	window.yNetMap = yNetMap
	window.ySamplesMap = ySamplesMap

	// we only read changes made by other clients; this client never writes anything to the shared document
	yNodesMap.observe(() => {
		if (graph) {
			graph.graphData(convertData())
		}
	})
	yEdgesMap.observe(() => {
		if (graph) {
			graph.graphData(convertData())
		}
	})
	ySamplesMap.observe((event) => {
		yjsTrace('ySamplesMap.observe', event)
		legend()
	})
	yNetMap.observe((event) => {
		yjsTrace('YNetMap.observe', event.transaction.local, event)
		for (let key of event.keysChanged) {
			let obj = yNetMap.get(key)
			switch (key) {
				case 'mapTitle':
				case 'maptitle': {
					let title = obj
					let div = elem('maptitle')
					if (title == 'Untitled map') {
						div.classList.add('unsetmaptitle')
					} else {
						div.classList.remove('unsetmaptitle')
						document.title = `${title}: ${shortAppName} 3D View`
					}
					if (title !== div.innerText) div.innerText = title
					break
				}
				default:
					break
			}
		}
	})
	myNameRec = JSON.parse(localStorage.getItem('myName'))
	myNameRec.id = clientID
	console.log('My name: ' + myNameRec.name)
} // end startY()

function yjsTrace(where, source, what) {
	if (window.debug.includes('yjs')) {
		console.log(exactTime(), source ? 'local' : 'non-local', where, what)
	}
}
function exactTime() {
	let d = new Date()
	return `${d.toLocaleTimeString()}:${d.getMilliseconds()} `
}
function cancelLoading() {
	elem('loading').style.display = 'none'
	clearTimeout(loadingDelayTimer)
}

/**
 * Convert a node from the normal PRSM format to the one required by the 3d display
 * @param {Object} node
 * @returns Object
 */
function convertNode(node) {
	return {id: node.id, label: node.label, color: node.color.background, fontColor: node.font.color, val: 5}
}
/**
 * Convert an edge from the normal PRSM format to the one required by the 3d display
 * @param {object} edge
 * @returns object
 */
function convertEdge(edge) {
	return {
		source: edge.from,
		target: edge.to,
		// black links are invisible on a black background, so change them to grey,
		// so that they are visible on either white or black backgrounds
		color: standardize_color(edge.color.color) === '#000000' ? 'lightgrey' : edge.color.color,
	}
}
/**
 * Convert the map data from PRSM format to 3d display format
 * Avoid cluster nodes
 * Add lists of links and neighbours to each node
 * @return {object} {nodes; links}
 */
function convertData() {
	let graphNodes = Array.from(yNodesMap.values())
		.filter((n) => !n.isCluster)
		.map((n) => {
			return convertNode(n)
		})
	let graphEdges = Array.from(yEdgesMap.values()).map((e) => {
		return convertEdge(e)
	})
	// note neighbouring nodes and links to those for each node
	// (used for highlighting links when hovering over nodes)
	graphEdges.forEach((link) => {
		const a = graphNodes.find((n) => n.id === link.source)
		const b = graphNodes.find((n) => n.id === link.target)
		!a.neighbors && (a.neighbors = [])
		!b.neighbors && (b.neighbors = [])
		a.neighbors.push(b)
		b.neighbors.push(a)
		!a.links && (a.links = [])
		!b.links && (b.links = [])
		a.links.push(link)
		b.links.push(link)
	})
	return {nodes: graphNodes, links: graphEdges}
}

/**
 * Display the map as a 3D network
 * @param {string} backColor - background color: white or black
 */
function display() {
	cancelLoading()
	let threeDGraphDiv = elem('3dgraph')
	let width = threeDGraphDiv.clientWidth
	let height = threeDGraphDiv.clientHeight
	const highlightNodes = new Set()
	const highlightLinks = new Set()
	let hoverNode = null
	let backColor = elem('mode').value === 'light' ? 'white' : 'black'
	elem('info').style.color = elem('mode').value === 'light' ? 'black' : 'white'

	graph = ForceGraph3D()(threeDGraphDiv)
		//		.cooldownTicks(0)
		.width(width)
		.height(height)
		.graphData(convertData())
		.showNavInfo(false)
		.linkWidth((link) => (highlightLinks.has(link) ? 1 : 0))
		.linkDirectionalArrowLength(2)
		.linkDirectionalArrowRelPos(1)
		.backgroundColor(backColor)
		.nodeOpacity(1.0)
		.linkOpacity(0.7)
		.linkDirectionalParticles(5)
		.nodeThreeObjectExtend(false)
		.nodeThreeObject((node) => {
			// extend node with text sprite
			const sprite = new SpriteText(`${node.label}`)
			sprite.color = node.fontColor
			sprite.textHeight = 3
			sprite.padding = 1
			sprite.backgroundColor = node.color
			sprite.borderWidth = 1
			sprite.borderRadius = 4
			return sprite
		})
		.onNodeDragEnd((node) => {
			if (node.fx) {
				// unfix fixed node
				node.fx = null
				node.fy = null
				node.fz = null
			} else {
				// fix nodes if they have been dragged
				node.fx = node.x
				node.fy = node.y
				node.fz = node.z
			}
		})
		.onNodeHover(doHover)
		.onNodeClick((node) => {
			// Aim at node from outside it
			const distance = 80
			const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z)
			graph.cameraPosition(
				{x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio}, // new position
				node, // lookAt ({ x, y, z })
				3000 // ms transition duration
			)
		})
	axes()
	legend()
	/**
	 * Highlight those links (i.e. makes them thicker) that connect to the node being hovered over
	 * @param {object} node
	 */
	function doHover(node) {
		// no state change
		if ((!node && !highlightNodes.size) || (node && hoverNode === node)) return

		highlightNodes.clear()
		highlightLinks.clear()
		if (node) {
			highlightNodes.add(node)
			if (node.neighbors) node.neighbors.forEach((neighbor) => highlightNodes.add(neighbor))
			if (node.links) node.links.forEach((link) => highlightLinks.add(link))
		}
		hoverNode = node || null

		// trigger update of highlighted objects in scene
		graph
			.nodeColor(graph.nodeColor())
			.linkWidth(graph.linkWidth())
			.linkDirectionalParticles(graph.linkDirectionalParticles())
	}
	// fit graph to window on double click
	listen('3dgraph', 'dblclick', () => {
		let width = threeDGraphDiv.clientWidth
		let height = threeDGraphDiv.clientHeight
		graph.width(width).height(height).zoomToFit(200, 0)
	})
	// change background colour using select menu in nav bar
	listen('mode', 'change', (e) => {
		if (e.target.value === 'dark') {
			graph.backgroundColor('black')
			elem('info').style.color = 'white'
		} else {
			graph.backgroundColor('white')
			elem('info').style.color = 'black'
		}
	})
	var timeout = false // debounce timer
	var delay = 250 // delay after event is "complete" to run callback
	window.addEventListener('resize', () => {
		clearTimeout(timeout)
		timeout = setTimeout(() => {
			setvh()
			let backColor = elem('mode').value === 'light' ? 'white' : 'black'
			elem('info').style.color = elem('mode').value === 'light' ? 'black' : 'white'
			let width = window.innerWidth
			let height = window.innerHeight - elem('navbar').clientHeight
			graph.width(width).height(height).backgroundColor(backColor)
		}, delay)
	})
	window.addEventListener('orientationchange', () => setvh())

	/**
	 * 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
		let 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`)
	}
	/**
	 * draw axes across scene
	 */
	function axes() {
		graph
			.scene()
			.add(
				new THREE.Line(
					new THREE.BufferGeometry().setFromPoints([
						new THREE.Vector3(0, 0, -1000),
						new THREE.Vector3(0, 0, 1000),
					]),
					new THREE.LineBasicMaterial({color: 'blue'})
				)
			)
			.add(
				new THREE.Line(
					new THREE.BufferGeometry().setFromPoints([
						new THREE.Vector3(-1000, 0, 0),
						new THREE.Vector3(1000, 0, 0),
					]),
					new THREE.LineBasicMaterial({color: 'red'})
				)
			)
			.add(
				new THREE.Line(
					new THREE.BufferGeometry().setFromPoints([
						new THREE.Vector3(0, -1000, 0),
						new THREE.Vector3(0, 1000, 0),
					]),
					new THREE.LineBasicMaterial({color: 'green'})
				)
			)
	}
}

/**
 * Draw the legend floating above the 3d display
 */
var legendData = {nodes: new DataSet(), edges: new DataSet()}
var legendNetwork = null
const LEGENDSPACING = 60
const HALFLEGENDWIDTH = 60
function legend() {
	let nodes = Array.from(ySamplesMap)
		.filter((a) => a[1].node)
		.map((a) => a[1].node.groupLabel)
		.filter((a) => a !== 'Sample')
	let edges = Array.from(ySamplesMap)
		.filter((a) => a[1].edge)
		.map((a) => a[1].edge.groupLabel)
		.filter((a) => a !== 'Sample')
	let nItems = nodes.length + edges.length
	if (nItems == 0) return
	let legendBox = document.createElement('div')
	legendBox.className = 'legend'
	legendBox.id = 'legendBox'
	elem('3dgraph').appendChild(legendBox)
	let title = document.createElement('p')
	title.id = 'Legend'
	title.className = 'legendTitle'
	title.appendChild(document.createTextNode('Legend'))
	legendBox.appendChild(title)
	let boxheight = LEGENDSPACING * nItems + title.offsetHeight
	let threeDGraphDiv = elem('3dgraph')
	legendBox.style.height =
		(boxheight < threeDGraphDiv.clientHeight - 100 ? boxheight : threeDGraphDiv.clientHeight - 100) + 'px'
	legendBox.style.width = HALFLEGENDWIDTH * 2 + 'px'
	let canvas = document.createElement('div')
	canvas.className = 'legendCanvas'
	canvas.style.height = LEGENDSPACING * nItems + 'px'
	legendBox.appendChild(canvas)

	legendNetwork = new Network(canvas, legendData, {
		physics: {enabled: false},
		interaction: {zoomView: false, dragView: false},
	})
	let height = legendNetwork.DOMtoCanvas({x: 0, y: 0}).y
	for (let i = 0; i < nodes.length; i++) {
		let node = deepMerge(
			Array.from(ySamplesMap)
				.filter((a) => a[1].node)
				.find((a) => a[1].node.groupLabel == nodes[i])[1].node
		)
		node.id = i + 10000
		node.label = node.groupLabel
		node.fixed = true
		node.chosen = false
		node.margin = 10
		node.x = 0
		node.y = 0
		node.widthConstraint = 40
		node.heightConstraint = 40
		node.font.size = 10
		legendData.nodes.update(node)
		let bbox = legendNetwork.getBoundingBox(node.id)
		node.y = (bbox.bottom - bbox.top) / 2 + height
		height += bbox.bottom - bbox.top
		legendData.nodes.update(node)
	}
	height += 50
	for (let i = 0; i < edges.length; i++) {
		let edge = deepMerge(
			Array.from(ySamplesMap)
				.filter((a) => a[1].edge)
				.find((a) => a[1].edge.groupLabel == edges[i])[1].edge
		)
		edge.label = edge.groupLabel
		edge.id = i + 10000
		edge.from = i + 20000
		edge.to = i + 30000
		edge.smooth = {type: 'straightCross'}
		edge.font = {size: 12, color: 'black', align: 'top', vadjust: -10}
		edge.widthConstraint = 80
		edge.chosen = false
		let nodes = [
			{
				id: edge.from,
				size: 5,
				shape: 'dot',
				x: -25,
				y: height,
				fixed: true,
				chosen: false,
			},
			{
				id: edge.to,
				shape: 'dot',
				size: 5,
				x: +25,
				y: height,
				fixed: true,
				chosen: false,
			},
		]
		legendData.nodes.update(nodes)
		legendData.edges.update(edge)
		height += 50
	}
	legendNetwork.fit({})
}