import * as THREE from "three"; import ForceGraph3D from "3d-force-graph"; import SpriteText from "three-spritetext"; import { union } from "./utils/set_utils.js"; import { randint, choice, pad } from "./utils/index.js"; import { addSkyGradient } from "./sky.js"; const IMG_SCALE = 16; const MAIN_IMG_SCALE = 100; const PAINTING_SCALE = 30; const VIDEO_SCALE = 40; const OBJECT_SCALE = 10; const OBJECT_SCALES = { 39: 20, 31: 20, }; const PAINTINGS = new Set([2, 10, 21, 22, 23, 24, 27, 40, 42]); export default function buildGraph({ db, handlers }) { const linkable = {}; const groups = {}; const data = { nodes: [], links: [] }; // console.log(db); /** * load nodes and links */ db.page.forEach((item, index) => { const node = { title: item.short_title, id: index, data: item, groups: [], }; data.nodes.push(node); for (let tagIndex = 0; tagIndex < 8; tagIndex += 1) { const group = item["tag_" + tagIndex]; if (!group) continue; group -= 1; node.groups.push(group); if (group in groups) { groups[group].push(index); } else { groups[group] = [index]; } if (group in linkable) { data.links.push({ source: choice(linkable[group]), // source: linkable[group][0], target: index, }); // option: don't link to the root node more than once // if (window.location.hash === "#dense" && linkable[group][0]) { if (linkable[group][0]) { linkable[group].push(index); } else { linkable[group] = [index]; } } else { linkable[group] = [index]; } } }); /** * find common links */ data.links.forEach((link) => { const a = data.nodes[link.source]; const b = data.nodes[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); }); const highlightNodes = new Set(); const highlightLinks = new Set(); let selectedNode = null; let objects = []; /** build group */ let graph = ForceGraph3D({ controlType: "orbit" }); graph(document.querySelector("#graph")) .graphData(data) .showNavInfo(false) .nodeLabel((node) => node.title) .nodeThreeObject((node) => { let sprite, material, texture, video; let catNumber = node.id + 1; // if (catNumber !== 48) return null; // 3D object if (node.data.object) { const object = node.data.object.clone(); const objectHandle = { catNumber, object }; objects.push(objectHandle); var box = new THREE.Box3().setFromObject(object); var center = new THREE.Vector3(); box.getCenter(center); object.position.sub(center); if (OBJECT_SCALES[catNumber]) { object.scale.set( OBJECT_SCALES[catNumber], OBJECT_SCALES[catNumber], OBJECT_SCALES[catNumber] ); } else { object.scale.set(OBJECT_SCALE, OBJECT_SCALE, OBJECT_SCALE); } const quaternion = graph.camera().quaternion; reorientObject(quaternion)(objectHandle); return object; } // Videos and images else if (node.data.thumbnail?.uri) { // Video thumbs const isVideo = node.data.thumbnail.type === "video"; if (isVideo) { video = document.createElement("video"); video.src = node.data.thumbnail.uri; video.muted = true; video.loop = true; video.autoplay = true; video.play(); texture = new THREE.VideoTexture(video); } // Image thumbs else { texture = new THREE.TextureLoader().load(node.data.thumbnail.uri); } const aspect = node.data.thumbnail.width / node.data.thumbnail.height; material = new THREE.SpriteMaterial({ map: texture }); sprite = new THREE.Sprite(material); // First image if (node.id === 0) { sprite.scale.set(MAIN_IMG_SCALE, MAIN_IMG_SCALE / aspect); } // Paintings else if (PAINTINGS.has(catNumber)) { sprite.scale.set(PAINTING_SCALE, PAINTING_SCALE / aspect); } // Videos else if (isVideo) { sprite.scale.set(VIDEO_SCALE, VIDEO_SCALE / aspect); } // Other images else { sprite.scale.set(IMG_SCALE, IMG_SCALE / aspect); } return sprite; } // Texts else { sprite = new SpriteText( node.title.split(/[ :]+/).slice(0, 3).join(" ") ); sprite.material.depthWrite = false; // make sprite background transparent sprite.color = "#888888"; // colors[node.groups[0]]; // node.groups.length - 1]]; sprite.textHeight = 4; return sprite; } }) .onNodeClick((node) => { // no state change if (!node && !highlightNodes.size) return; highlightNodes.clear(); highlightLinks.clear(); if (node) { highlightNodes.add(node); node.neighbors.forEach((neighbor) => highlightNodes.add(neighbor)); node.links.forEach((link) => highlightLinks.add(link)); } updateHighlight(); handlers.click(node); }) .linkWidth((link) => (highlightLinks.has(link) ? 2 : 0.75)); // graph.backgroundColor("#000000"); // graph.renderer().setClearColor(0x000000, 0); addSkyGradient(graph.scene()); const reorient = () => { const quaternion = graph.camera().quaternion; objects.forEach(reorientObject(quaternion)); }; const reorientObject = (quaternion) => ({ catNumber, object }) => { object.setRotationFromQuaternion(quaternion); // the clock object is turned 90 degrees if (catNumber === 33) { object.rotateY(Math.PI / 2); } if (catNumber === 48) { object.rotateZ(Math.PI * -0.25); object.rotateX(Math.PI * -0.45); object.rotateY(Math.PI * 0.5); } }; graph.controls().addEventListener("change", reorient); const updateHighlight = () => { // trigger update of highlighted objects in scene graph.linkWidth(graph.linkWidth()); }; const initialZoom = () => { const distance = 20000; let angle = 0; graph.cameraPosition( { x: distance * Math.sin(angle), z: distance * Math.cos(angle), }, { x: 0, y: 0, z: 0 }, 0 ); setTimeout(() => zoomOut(1000), 1000); }; const zoomOut = (duration = 1000) => { const distance = 415; let angle = 0; graph.cameraPosition( { x: distance * Math.sin(angle), z: distance * Math.cos(angle), }, { x: 0, y: 0, z: 0 }, duration ); }; const zoomIn = () => { const distance = 200; let angle = 0; graph.cameraPosition( { x: distance * Math.sin(angle), z: distance * Math.cos(angle), }, { x: 0, y: 0, z: 0 }, 1000 ); }; const handleSelect = (category) => { if (!category) { graph.graphData(data); zoomOut(); return; } const { nodes, links } = data; const visible = new Set(); objects = []; const selectedData = {}; selectedData.nodes = nodes.filter((node) => { for (let tagIndex = 0; tagIndex < 8; tagIndex += 1) { const group = node.data["tag_" + tagIndex]; if (!group) continue; if (group === category) { visible.add(node.id); return true; } } return false; }); selectedData.links = links.filter((link) => { const { source, target } = link; return visible.has(source.id) && visible.has(target.id); }); // console.log(selectedData); graph.graphData(selectedData); zoomIn(); }; graph.d3Force("charge").strength(-100); // camera orbit initialZoom(); return { onSelect: handleSelect, }; // stars(); } const commonGroups = (a, b) => union(a.groups, b.groups);