// Import Pixi.js library if( 'function' === typeof importScripts) { importScripts('https://d157l7jdn8e5sf.cloudfront.net/v7.2.0/webworker.js', './tinycolor.js'); addEventListener('message', onMessage); let app; let container; let graphics; isDrawing = false; let linkCount = 0; let linkSources = []; let linkTargets = []; let nodeCount = 0; let radii = []; let labels = []; let labelFade = []; let labelWidths = []; let pixiLabels = []; let cameraOffset = {x: 0, y: 0}; let positions = new Float32Array(0); let linkLength = 0; let edgePruning = 0; let colors = { background: 0x232323, link: 0xAAAAAA, node: 0xCCCCCC, outline: 0xAAAAAA, text: 0xFFFFFF, accent: 0x4023AA } let hoveredNode = -1; let lastHoveredNode = -1; let grabbedNode = -1; let updateAttached = false; let attachedToGrabbed = []; let activeNode = -1; let attachedToActive = []; let cameraScale = 1; let cameraScaleRoot = 1; function toScreenSpace(x, y, floor = true) { if (floor) { return {x: Math.floor((x * cameraScale) + cameraOffset.x), y: Math.floor((y * cameraScale) + cameraOffset.y)}; } else { return {x: (x * cameraScale) + cameraOffset.x, y: (y * cameraScale) + cameraOffset.y}; } } function vecToScreenSpace({x, y}, floor = true) { return toScreenSpace(x, y, floor); } function toWorldspace(x, y) { return {x: (x - cameraOffset.x) / cameraScale, y: (y - cameraOffset.y) / cameraScale}; } function vecToWorldspace({x, y}) { return toWorldspace(x, y); } function setCameraCenterWorldspace({x, y}) { cameraOffset.x = (canvas.width / 2) - (x * cameraScale); cameraOffset.y = (canvas.height / 2) - (y * cameraScale); } function getCameraCenterWorldspace() { return toWorldspace(canvas.width / 2, canvas.height / 2); } function getNodeScreenRadius(radius) { return radius * cameraScaleRoot; } function getNodeWorldspaceRadius(radius) { return radius / cameraScaleRoot; } function getPosition(index) { return {x: positions[index * 2], y: positions[index * 2 + 1]}; } function mixColors(hexStart, hexEnd, factor) { return tinycolor.mix(tinycolor(hexStart.toString(16)), tinycolor(hexEnd.toString(16)), factor).toHexNumber() } function darkenColor(hexColor, factor) { return tinycolor(hexColor.toString(16)).darken(factor).toHexNumber(); } function lightenColor(hexColor, factor) { return tinycolor(hexColor.toString(16)).lighten(factor).toHexNumber(); } function invertColor(hex, bw) { hex = hex.toString(16); // force conversion // fill extra space up to 6 characters with 0 while (hex.length < 6) hex = "0" + hex; if (hex.indexOf('#') === 0) { hex = hex.slice(1); } // convert 3-digit hex to 6-digits. if (hex.length === 3) { hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; } if (hex.length !== 6) { throw new Error('Invalid HEX color:' + hex); } var r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16); if (bw) { // https://stackoverflow.com/a/3943023/112731 return (r * 0.299 + g * 0.587 + b * 0.114) > 186 ? '#000000' : '#FFFFFF'; } // invert color components r = (255 - r).toString(16); g = (255 - g).toString(16); b = (255 - b).toString(16); // pad each with zeros and return return "#" + padZero(r) + padZero(g) + padZero(b); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function lerp(a, b, t) { return a + (b - a) * t; } let hoverFade = 0; let hoverFadeSpeed = 0.2; let hoverFontSize = 15; let normalFontSize = 12; let fontRatio = hoverFontSize / normalFontSize; function showLabel(index, fade, hovered = false) { let label = pixiLabels[index]; if (!label) return; labelFade[index] = fade; if(fade > 0.01) label.visible = true; else { hideLabel(index); return; } if (hovered) label.style.fontSize = hoverFontSize; else label.style.fontSize = normalFontSize; let nodePos = vecToScreenSpace(getPosition(index)); let width = (labelWidths[index] * (hovered ? fontRatio : 1)) / 2; label.x = nodePos.x - width; label.y = nodePos.y + getNodeScreenRadius(radii[index]) + 9; label.alpha = fade; } function hideLabel(index) { let label = pixiLabels[index]; label.visible = false; } function draw() { graphics.clear(); let topLines = []; if (updateAttached) { attachedToGrabbed = []; // hoverFade = 0; } if (hoveredNode != -1 || grabbedNode != -1) { hoverFade = Math.min(1, hoverFade + hoverFadeSpeed); } else { hoverFade = Math.max(0, hoverFade - hoverFadeSpeed); } graphics.lineStyle(1, mixColors(colors.link, colors.background, hoverFade * 50), 0.7); for (let i = 0; i < linkCount; i++) { let target = linkTargets[i]; let source = linkSources[i]; if (hoveredNode == source || hoveredNode == target || ((lastHoveredNode == source || lastHoveredNode == target) && hoverFade != 0)) { if (updateAttached && hoveredNode == source) attachedToGrabbed.push(target); else if (updateAttached && hoveredNode == target) attachedToGrabbed.push(source); topLines.push(i); } let startWorld = getPosition(source); let endWorld = getPosition(target); let start = vecToScreenSpace(startWorld); let end = vecToScreenSpace(endWorld); let dist = Math.sqrt(Math.pow(startWorld.x - endWorld.x, 2) + Math.pow(startWorld.y - endWorld.y, 2)); if (dist < (radii[source] + radii[target]) * edgePruning) { graphics.moveTo(start.x, start.y); graphics.lineTo(end.x, end.y); } } let opacity = 1 - (hoverFade * 0.5); graphics.beginFill(mixColors(colors.node, colors.background, hoverFade * 50), opacity); graphics.lineStyle(0, 0xffffff); for (let i = 0; i < nodeCount; i++) { let screenRadius = getNodeScreenRadius(radii[i]); if (hoveredNode != i) { if (screenRadius > 2) { let labelFade = lerp(0, (screenRadius - 4) / 8 - (1/cameraScaleRoot)/6 * 0.9, Math.max(1 - hoverFade, 0.2)); showLabel(i, labelFade); } else { hideLabel(i); } } if (hoveredNode == i || (lastHoveredNode == i && hoverFade != 0) || (hoveredNode != -1 && attachedToGrabbed.includes(i))) continue; let pos = vecToScreenSpace(getPosition(i)); graphics.drawCircle(pos.x, pos.y, screenRadius); } graphics.endFill(); opacity = hoverFade * 0.7; graphics.lineStyle(1, mixColors(mixColors(colors.link, colors.accent, hoverFade * 100), colors.background, 20), opacity); for (let i = 0; i < topLines.length; i++) { let target = linkTargets[topLines[i]]; let source = linkSources[topLines[i]]; // draw lines on top when hovered let start = vecToScreenSpace(getPosition(source)); let end = vecToScreenSpace(getPosition(target)); graphics.moveTo(start.x, start.y); graphics.lineTo(end.x, end.y); } if(hoveredNode != -1 || (lastHoveredNode != -1 && hoverFade != 0)) { graphics.beginFill(mixColors(colors.node, colors.accent, hoverFade * 20), 0.9); graphics.lineStyle(0, 0xffffff); for (let i = 0; i < attachedToGrabbed.length; i++) { let point = attachedToGrabbed[i]; let pos = vecToScreenSpace(getPosition(point)); graphics.drawCircle(pos.x, pos.y, getNodeScreenRadius(radii[point])); showLabel(point, Math.max(hoverFade * 0.6, labelFade[point])); } graphics.endFill(); let index = hoveredNode != -1 ? hoveredNode : lastHoveredNode; let pos = vecToScreenSpace(getPosition(index)); graphics.beginFill(mixColors(colors.node, colors.accent, hoverFade * 100), 1); graphics.lineStyle(hoverFade, mixColors(invertColor(colors.background, true), colors.accent, 50)); graphics.drawCircle(pos.x, pos.y, getNodeScreenRadius(radii[index])); graphics.endFill(); showLabel(index, Math.max(hoverFade, labelFade[index]), true); } updateAttached = false; graphics.lineStyle(2, colors.accent); // draw the active node if (activeNode != -1) { let pos = vecToScreenSpace(getPosition(activeNode)); graphics.drawCircle(pos.x, pos.y, getNodeScreenRadius(radii[activeNode]) + 4); } } function onMessage(event) { if(event.data.type == "draw") { positions = new Float32Array(event.data.positions); draw(); } else if(event.data.type == "update_camera") { cameraOffset = event.data.cameraOffset; cameraScale = event.data.cameraScale; cameraScaleRoot = Math.sqrt(cameraScale); } else if(event.data.type == "update_interaction") { if(hoveredNode != event.data.hoveredNode && event.data.hoveredNode != -1) updateAttached = true; if(grabbedNode != event.data.grabbedNode && event.data.hoveredNode != -1) updateAttached = true; if(event.data.hoveredNode == -1) lastHoveredNode = hoveredNode; else lastHoveredNode = -1; hoveredNode = event.data.hoveredNode; grabbedNode = event.data.grabbedNode; } else if(event.data.type == "resize") { app.renderer.resize(event.data.width, event.data.height); } else if(event.data.type == "set_active") { activeNode = event.data.active; } else if(event.data.type == "update_colors") { colors = event.data.colors; for (let label of pixiLabels) { label.style.fill = invertColor(colors.background, true); } } else if(event.data.type == "init") { // Extract data from message linkCount = event.data.linkCount; linkSources = event.data.linkSources; linkTargets = event.data.linkTargets; nodeCount = event.data.nodeCount; radii = event.data.radii; labels = event.data.labels; linkLength = event.data.linkLength; edgePruning = event.data.edgePruning; app = new PIXI.Application({... event.data.options, antialias: true, resolution: 2, backgroundAlpha: 0, transparent: true}); container = new PIXI.Container(); graphics = new PIXI.Graphics(); app.stage.addChild(container); container.addChild(graphics); pixiLabels = []; for (let i = 0; i < nodeCount; i++) { let label = new PIXI.Text(labels[i], {fontFamily : 'Arial', fontSize: 12, fontWeight: "normal", fill : invertColor(colors.background, true), align : 'center', anchor: 0.5}); pixiLabels.push(label); labelWidths.push(label.width); labelFade.push(0); app.stage.addChild(label); } } else { console.log("Unknown message type sent to graph worker: " + event.data.type); } } }