Skip to content

Commit

Permalink
rewrite the canvas layout engine to use springy renderer loop
Browse files Browse the repository at this point in the history
  • Loading branch information
lmorchard committed May 16, 2024
1 parent 54649cd commit ab0e43d
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 302 deletions.
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<script type="module" src="./index.js"></script>
</head>
<body>
<sticky-notes-canvas id="notes-canvas" zoom="1.0" originX="0" originY="0">
<sticky-notes-canvas id="notes-canvas" zoom="0.5" originX="0" originY="0">
</sticky-notes-canvas>
</body>
</html>
32 changes: 26 additions & 6 deletions public/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const USER_PROMPT = (items) => `
Please generate a concise, descriptive label for this list. Thanks in advance!
`;

const CLUSTER_LAYOUT_RADIUS = 1250;
const CLUSTER_LAYOUT_RADIUS = 1200;

async function main() {
console.log("READY.");
Expand All @@ -41,14 +41,29 @@ async function main() {
canvasEl.appendChild(
createElement("sticky-note", {
id: item.id,
x: Math.random() * 300 - 150,
y: Math.random() * 300 - 150,
x: Math.random() * 200 - 100,
y: Math.random() * 200 - 100,
color: `#${Math.floor(Math.random() * 16777215).toString(16)}`,
".innerHTML": item.item.substring(2),
})
);
}

canvasEl.appendChild(createElement("sticky-notes-cluster-topic", {
id: `cluster-main`,
x: 0,
y: 0,
width: 100,
height: 100,
title: `unorganized`,
color: `#eee`,
children: itemsWithIds.map((item) =>
createElement("sticky-notes-cluster-link", {
href: `${item.id}`,
})
),
}));

const embeddingsResponse = await llamafile("embedding", { content: items });
const embeddings = embeddingsResponse.results.map((r) => r.embedding);

Expand All @@ -75,14 +90,19 @@ async function main() {
const clusterX = Math.cos(clusterAngle) * CLUSTER_LAYOUT_RADIUS;
const clusterY = Math.sin(clusterAngle) * CLUSTER_LAYOUT_RADIUS;

for (const item of cluster) {
const linkEl = document.querySelector(`sticky-notes-cluster-link[href="${item.id}"]`);
linkEl.parentElement.removeChild(linkEl);
}

const clusterGroupEl = createElement("sticky-notes-cluster-topic", {
id: `cluster-${i}`,
x: clusterX,
y: clusterY,
width: 150,
height: 125,
width: 350,
height: 200,
title: `${result.content.trim()}`,
color: `#999`,
color: `#eee`,
children: cluster.map((item) =>
createElement("sticky-notes-cluster-link", {
href: `${item.id}`,
Expand Down
207 changes: 83 additions & 124 deletions public/lib/components/StickyNotesCanvas.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { html, BaseElement } from "../dom.js";
import { DraggableMixin } from "../mixins/DraggableMixin.js";
import Springy from "../springy.js";
import { StickyNotesClusterTopic, StickyNotesClusterLink } from "./StickyNotesClusterTopic.js";
import { StickyNote } from "./StickyNote.js";

export class StickyNotesCanvas extends DraggableMixin(BaseElement) {
static observedAttributes = [
Expand Down Expand Up @@ -44,39 +46,70 @@ export class StickyNotesCanvas extends DraggableMixin(BaseElement) {
this.zoomOrigin = { x: 0, y: 0 };

this.graphLayoutScale = 50;
this.lastTimeStamp = null;
this._rendering = false;

this.mutationObserver = new MutationObserver((records) =>
this.handleMutations(records)
);
this.mutationObserver = new MutationObserver(this.handleMutations.bind(this));
}

connectedCallback() {
super.connectedCallback();

this.addEventListener("wheel", this.onWheel);

this.graph = new Springy.Graph();

this.layout = new Springy.Layout.ForceDirected(
this.graph,
1000.0, // Spring stiffness
2500.0, // Node repulsion
0.75, // Damping
300.0, // Spring stiffness
200.0, // Node repulsion
0.5, // Damping
0.01 // minEnergyThreshold
);

this.mainNode = new Springy.Node("main");
const layoutTick = this.layout.tick.bind(this.layout);
this.layout.tick = (timestep) => {
this.rendererBeforeTick(timestep);
layoutTick(timestep);
};

this.renderer = new Springy.Renderer(
this.layout,
() => { }, // clear
this.rendererDrawEdge.bind(this),
this.rendererDrawNode.bind(this),
() => { this.rendering = false },
() => { this.rendering = true },
);

this.mainNode = new Springy.Node("main", { mass: 100000 });
this.graph.addNode(this.mainNode);

this.mutationObserver.observe(this, {
childList: true,
subtree: true,
this.mutationObserver.observe(this, { childList: true, subtree: true });

for (const noteEl of this.querySelectorAll("sticky-note")) {
this.upsertGraphNode(noteEl);
}
for (const topicEl of this.querySelectorAll("sticky-notes-cluster-topic")) {
this.addTopic(topicEl);
}
}

rendererBeforeTick() {
this.layout.eachNode((node, point) => {
const el = this.ownerDocument.getElementById(node.id);
if (!el) return;
point.p.x = parseFloat(el.attributes.x.value) / this.graphLayoutScale;
point.p.y = parseFloat(el.attributes.y.value) / this.graphLayoutScale;
});
}

const children = Array.from(
this.querySelectorAll("sticky-notes-cluster-topic")
);
this.updateGraphNodes(children);
rendererDrawEdge(edge, fromPointP, toPointP) { }

rendererDrawNode(node, pointP) {
const el = this.ownerDocument.getElementById(node.id);
if (!el || el.dragging) return;
el.attributes.x.value = pointP.x * this.graphLayoutScale;
el.attributes.y.value = pointP.y * this.graphLayoutScale;
}

disconnectedCallback() {
Expand Down Expand Up @@ -122,7 +155,6 @@ export class StickyNotesCanvas extends DraggableMixin(BaseElement) {

const parentEl = this;
const container = this.$(".container");
const innerCanvas = this.$(".inner-canvas");

const parentHalfWidth = parentEl.clientWidth / 2;
const parentHalfHeight = parentEl.clientHeight / 2;
Expand All @@ -139,61 +171,58 @@ export class StickyNotesCanvas extends DraggableMixin(BaseElement) {
}

handleMutations(records) {
const toAdd = [];
const toRemove = [];

for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
toAdd.push(node);
for (const node of record.removedNodes) {
if (node instanceof StickyNotesClusterLink) {
this.removeTopicLink(record.target, node);
} else if (node instanceof StickyNotesClusterTopic) {
this.graph.removeNode({ id: node.id });
} else if (node instanceof StickyNote) {
this.graph.removeNode({ id: node.id });
}
}
for (const node of record.removedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
toRemove.push(node);
for (const node of record.addedNodes) {
if (node instanceof StickyNotesClusterLink) {
this.addTopicLink(record.target, node);
} else if (node instanceof StickyNotesClusterTopic) {
this.addTopic(node);
} else if (node instanceof StickyNote) {
this.upsertGraphNode(node);
}
}
}
this.updateGraphNodes(toAdd, toRemove);
}

updateGraphNodes(toAdd = [], toRemove = []) {
const mainNode = this.mainNode;

for (const addEl of toAdd) {

let addNode = this.upsertGraphNode(addEl);
if (addEl.tagName === "STICKY-NOTE") {
// HACK add default edge to main center node
this.upsertGraphEdge(addNode.id, mainNode.id);
}

if (addEl.tagName === "STICKY-NOTES-CLUSTER-TOPIC") {
const linkEls = addEl.querySelectorAll("sticky-notes-cluster-link");
for (const linkEl of linkEls) {
const linkedId = linkEl.getAttribute("href");
const linkedEl = this.ownerDocument.getElementById(linkedId);
if (!linkedEl) continue;

let linkedNode = this.upsertGraphNode(linkedEl);
this.upsertGraphEdge(addNode.id, linkedNode.id);
}
}
addTopic(topicEl) {
this.upsertGraphNode(topicEl);
for (const linkEl of topicEl.querySelectorAll('sticky-notes-cluster-link')) {
this.addTopicLink(topicEl, linkEl);
}
}

for (const node of toRemove) {
const topicId = node.getAttribute("id");
this.graph.removeNode({ id: topicId });
}
addTopicLink(topicEl, linkEl) {
const linkedId = linkEl.getAttribute("href");
const linkedEl = this.ownerDocument.getElementById(linkedId);
if (!linkedEl) return;
let linkedNode = this.upsertGraphNode(linkedEl);
this.upsertGraphEdge(topicEl.id, linkedNode.id);
}

this.startUpdatingGraph();
removeTopicLink(topicEl, linkEl) {
const linkedId = linkEl.getAttribute("href");
const edgeId = `${topicEl.id}-${linkedId}`;
let edge = this.graph.edges.find((e) => e.id === edgeId);
if (edge) this.graph.removeEdge(edge);
}

upsertGraphNode(nodeEl) {
const nodeId = nodeEl.getAttribute("id");
let node = this.graph.nodeSet[nodeId];
if (!node) {
node = new Springy.Node(nodeId);
const mass = (nodeEl.tagName === "STICKY-NOTES-CLUSTER-TOPIC") ? 10000 : 1;
const x = parseFloat(nodeEl.attributes.x.value) / this.graphLayoutScale;
const y = parseFloat(nodeEl.attributes.y.value) / this.graphLayoutScale;
node = new Springy.Node(nodeId, { mass, x, y });
this.graph.addNode(node);
}
return node;
Expand All @@ -208,78 +237,8 @@ export class StickyNotesCanvas extends DraggableMixin(BaseElement) {
edge = new Springy.Edge(edgeId, fromNode, toNode, {});
this.graph.addEdge(edge);
}

// HACK: remove default edge to main
const mainEdgeId = `${toId}-main`;
const mainEdge = this.graph.edges.find((e) => e.id === mainEdgeId);
if (mainEdge) {
this.graph.removeEdge(mainEdge);
}
}

startUpdatingGraph() {
if (this._updating) return;
this.timeStart = null;
window.requestAnimationFrame((ts) => this.updateGraph(ts));
this._updating = true;
}

stopUpdatingGraph() {
this._updating = false;
}

updateGraph(timeStamp) {
if (!this._updating) return;
if (!this.lastTimeStamp) this.lastTimeStamp = timeStamp;

const deltaTime = Math.max(0.001, (timeStamp - this.lastTimeStamp) / 1000);
this.lastTimeStamp = timeStamp;

// HACK: force the main node to stay at the origin
const mainNode = this.mainNode;
const mainPoint = this.layout.point(mainNode);
mainPoint.p.x = 0;
mainPoint.p.y = 0;

this.updateLayoutFromChildren();
this.layout.tick(deltaTime);
this.updateChildrenFromLayout();

if (this.layout.totalEnergy() < this.layout.minEnergyThreshold) {
this.stopUpdatingGraph();
}

window.requestAnimationFrame((ts) => this.updateGraph(ts));
}

updateLayoutFromChildren() {
for (const [id, node] of Object.entries(this.graph.nodeSet)) {
const point = this.layout.point(node);
const el = this.ownerDocument.getElementById(id);
if (!el) continue;

if (el.tagName === "STICKY-NOTES-CLUSTER-TOPIC") {
point.m = 10000;
} else {
point.m = 1;
}

point.p.x = parseFloat(el.attributes.x.value) / this.graphLayoutScale;
point.p.y = parseFloat(el.attributes.y.value) / this.graphLayoutScale;
}
}

updateChildrenFromLayout() {
for (const [id, node] of Object.entries(this.graph.nodeSet)) {
const point = this.layout.point(node);
const el = this.ownerDocument.getElementById(id);
if (!el) continue;
if (el.dragging) continue;

el.attributes.x.value = point.p.x * this.graphLayoutScale;
el.attributes.y.value = point.p.y * this.graphLayoutScale;
}
}
}

customElements.define("sticky-notes-canvas", StickyNotesCanvas);

0 comments on commit ab0e43d

Please sign in to comment.