Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
292 lines
8.1 KiB
JavaScript
292 lines
8.1 KiB
JavaScript
// A slowly drifting wireframe of the cross-repository graph: one point per
|
|
// repository that carries a multi-repo pattern, one line for every pattern two
|
|
// repositories share. Rendered rather than tabulated because entanglement is
|
|
// the one shape on the record a table cannot show.
|
|
//
|
|
// Deliberately flat and line-based - no lighting, no materials, no depth fog -
|
|
// so it reads as an oscilloscope trace rather than a product render.
|
|
//
|
|
// Degrades to nothing: no WebGL, no data, or reduced-motion preference all
|
|
// leave the surrounding markup untouched, which already carries the real
|
|
// numbers.
|
|
|
|
const POINT_SIZE = 5.5
|
|
const RADIUS = 26
|
|
const DRIFT = 0.00035
|
|
|
|
export default {
|
|
mounted() {
|
|
this.start()
|
|
},
|
|
|
|
updated() {
|
|
// The graph is re-sent on registry changes; rebuild rather than mutate.
|
|
this.stop()
|
|
this.start()
|
|
},
|
|
|
|
// three.js is ~132 KB gzipped - four times the rest of the bundle combined.
|
|
// Importing it dynamically keeps it in its own chunk, so only a visitor who
|
|
// actually reaches a page with the field pays for it, and every other page
|
|
// loads the same 43 KB it always did.
|
|
async loadThree() {
|
|
if (!window.__tarakanThree) {
|
|
window.__tarakanThree = import("../../vendor/three.module.js")
|
|
}
|
|
return window.__tarakanThree
|
|
},
|
|
|
|
destroyed() {
|
|
this.stop()
|
|
},
|
|
|
|
async start() {
|
|
const graph = this.readGraph()
|
|
if (!graph || graph.nodes.length < 2) return
|
|
|
|
let THREE
|
|
try {
|
|
THREE = await this.loadThree()
|
|
} catch (_error) {
|
|
return
|
|
}
|
|
|
|
// The hook can be destroyed while the chunk is in flight.
|
|
if (!this.el.isConnected) return
|
|
this.THREE = THREE
|
|
|
|
this.graph = graph
|
|
this.draw()
|
|
|
|
// The field is hidden below the lg breakpoint, so at mount it can have no
|
|
// size at all and there is nothing to render into. Watch for it gaining
|
|
// one instead of giving up permanently.
|
|
if (!this.renderer && "ResizeObserver" in window) {
|
|
this.observer = new ResizeObserver(() => {
|
|
if (!this.renderer && this.el.clientWidth > 0 && this.el.clientHeight > 0) {
|
|
this.draw()
|
|
}
|
|
})
|
|
this.observer.observe(this.el)
|
|
}
|
|
},
|
|
|
|
draw() {
|
|
if (!this.graph || !this.THREE) return
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
this.renderStill(this.graph)
|
|
} else {
|
|
this.renderAnimated(this.graph)
|
|
}
|
|
},
|
|
|
|
readGraph() {
|
|
try {
|
|
const raw = this.el.dataset.graph
|
|
if (!raw) return null
|
|
const parsed = JSON.parse(raw)
|
|
if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
|
|
return parsed
|
|
} catch (_error) {
|
|
return null
|
|
}
|
|
},
|
|
|
|
// Colours come from the stylesheet so the field follows the theme toggle
|
|
// instead of hard-coding a palette that only works in dark mode.
|
|
token(name, fallback) {
|
|
const value = getComputedStyle(this.el).getPropertyValue(name).trim()
|
|
return value || fallback
|
|
},
|
|
|
|
build(graph) {
|
|
const { nodes, edges } = graph
|
|
|
|
// Fibonacci sphere: even coverage without clustering at the poles, and
|
|
// deterministic, so the same record always draws the same figure.
|
|
const positions = nodes.map((_node, i) => {
|
|
const offset = 2 / nodes.length
|
|
const y = i * offset - 1 + offset / 2
|
|
const r = Math.sqrt(Math.max(0, 1 - y * y))
|
|
const phi = i * Math.PI * (3 - Math.sqrt(5))
|
|
return [Math.cos(phi) * r * RADIUS, y * RADIUS, Math.sin(phi) * r * RADIUS]
|
|
})
|
|
|
|
const group = new this.THREE.Group()
|
|
|
|
const pointGeometry = new this.THREE.BufferGeometry()
|
|
pointGeometry.setAttribute(
|
|
"position",
|
|
new this.THREE.Float32BufferAttribute(positions.flat(), 3)
|
|
)
|
|
group.add(
|
|
new this.THREE.Points(
|
|
pointGeometry,
|
|
new this.THREE.PointsMaterial({
|
|
color: new this.THREE.Color(this.token("--color-ink-faint", "#8a8f93")),
|
|
size: POINT_SIZE,
|
|
sizeAttenuation: false,
|
|
transparent: true,
|
|
opacity: 0.9
|
|
})
|
|
)
|
|
)
|
|
|
|
if (edges.length > 0) {
|
|
const linePoints = []
|
|
edges.forEach(([a, b]) => {
|
|
if (positions[a] && positions[b]) linePoints.push(...positions[a], ...positions[b])
|
|
})
|
|
|
|
if (linePoints.length > 0) {
|
|
const lineGeometry = new this.THREE.BufferGeometry()
|
|
lineGeometry.setAttribute(
|
|
"position",
|
|
new this.THREE.Float32BufferAttribute(linePoints, 3)
|
|
)
|
|
group.add(
|
|
new this.THREE.LineSegments(
|
|
lineGeometry,
|
|
new this.THREE.LineBasicMaterial({
|
|
color: new this.THREE.Color(this.token("--color-phosphor", "#4ade80")),
|
|
transparent: true,
|
|
opacity: 0.42
|
|
})
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
return group
|
|
},
|
|
|
|
setup(graph) {
|
|
const width = this.el.clientWidth
|
|
const height = this.el.clientHeight
|
|
if (width === 0 || height === 0) return null
|
|
|
|
let renderer
|
|
try {
|
|
renderer = new this.THREE.WebGLRenderer({ alpha: true, antialias: true })
|
|
} catch (_error) {
|
|
return null
|
|
}
|
|
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
renderer.setSize(width, height, false)
|
|
renderer.domElement.setAttribute("aria-hidden", "true")
|
|
this.el.appendChild(renderer.domElement)
|
|
|
|
const scene = new this.THREE.Scene()
|
|
const camera = new this.THREE.PerspectiveCamera(45, width / height, 1, 400)
|
|
camera.position.z = 78
|
|
|
|
const group = this.build(graph)
|
|
scene.add(group)
|
|
|
|
this.renderer = renderer
|
|
this.scene = scene
|
|
this.camera = camera
|
|
this.group = group
|
|
|
|
this.onResize = () => {
|
|
const w = this.el.clientWidth
|
|
const h = this.el.clientHeight
|
|
if (w === 0 || h === 0) return
|
|
camera.aspect = w / h
|
|
camera.updateProjectionMatrix()
|
|
renderer.setSize(w, h, false)
|
|
renderer.render(scene, camera)
|
|
}
|
|
window.addEventListener("resize", this.onResize)
|
|
|
|
return { renderer, scene, camera, group }
|
|
},
|
|
|
|
renderStill(graph) {
|
|
const context = this.setup(graph)
|
|
if (!context) return
|
|
context.group.rotation.set(0.35, 0.6, 0)
|
|
context.renderer.render(context.scene, context.camera)
|
|
},
|
|
|
|
renderAnimated(graph) {
|
|
const context = this.setup(graph)
|
|
if (!context) return
|
|
|
|
const { renderer, scene, camera, group } = context
|
|
let last = performance.now()
|
|
|
|
const frame = (now) => {
|
|
// Rotation is time-based, not frame-based, so a 120Hz display does not
|
|
// spin twice as fast as a 60Hz one.
|
|
const delta = now - last
|
|
last = now
|
|
group.rotation.y += DRIFT * delta
|
|
group.rotation.x = Math.sin(now * 0.00007) * 0.22
|
|
renderer.render(scene, camera)
|
|
this.frameId = requestAnimationFrame(frame)
|
|
}
|
|
|
|
this.frameId = requestAnimationFrame(frame)
|
|
|
|
// A hidden tab still fires rAF in some browsers; stop drawing when the
|
|
// page is not visible so a background tab costs nothing.
|
|
this.onVisibility = () => {
|
|
if (document.hidden) {
|
|
this.cancelFrame()
|
|
} else if (!this.frameId) {
|
|
last = performance.now()
|
|
this.frameId = requestAnimationFrame(frame)
|
|
}
|
|
}
|
|
document.addEventListener("visibilitychange", this.onVisibility)
|
|
},
|
|
|
|
cancelFrame() {
|
|
if (this.frameId) {
|
|
cancelAnimationFrame(this.frameId)
|
|
this.frameId = null
|
|
}
|
|
},
|
|
|
|
stop() {
|
|
this.cancelFrame()
|
|
|
|
if (this.observer) {
|
|
this.observer.disconnect()
|
|
this.observer = null
|
|
}
|
|
|
|
if (this.onResize) {
|
|
window.removeEventListener("resize", this.onResize)
|
|
this.onResize = null
|
|
}
|
|
|
|
if (this.onVisibility) {
|
|
document.removeEventListener("visibilitychange", this.onVisibility)
|
|
this.onVisibility = null
|
|
}
|
|
|
|
// GPU buffers are not garbage collected with the JS objects that own them,
|
|
// and LiveView navigation destroys hooks constantly.
|
|
if (this.group) {
|
|
this.group.traverse((child) => {
|
|
if (child.geometry) child.geometry.dispose()
|
|
if (child.material) child.material.dispose()
|
|
})
|
|
this.group = null
|
|
}
|
|
|
|
if (this.renderer) {
|
|
this.renderer.dispose()
|
|
this.renderer.domElement.remove()
|
|
this.renderer = null
|
|
}
|
|
|
|
this.scene = null
|
|
this.camera = null
|
|
this.graph = null
|
|
}
|
|
}
|