Add attention scaling to VN graph nodes

This commit is contained in:
Meta Vens 2026-05-31 15:37:53 +01:00
commit 8b98dadc00
4 changed files with 218 additions and 16 deletions

2
dist/sai.css vendored
View file

@ -330,6 +330,7 @@
.sai-graph-node {
cursor: pointer;
outline: none;
transition: opacity 140ms ease;
}
.sai-graph-node circle {
@ -353,6 +354,7 @@
stroke: rgba(0, 0, 0, 0.76);
stroke-width: 4px;
pointer-events: none;
transition: opacity 140ms ease;
}
.sai-graph-node.is-visited circle {

115
dist/sai.js vendored
View file

@ -798,7 +798,28 @@
return stageState.graphLayout;
}
function projectGraphNodes(nodes) {
function graphViewport(graph) {
return {
width: Number(graph?.viewport_width || graph?.viewportWidth || 760),
height: Number(graph?.viewport_height || graph?.viewportHeight || 420),
pad: Number(graph?.viewport_padding || graph?.viewportPadding || 68),
};
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function smoothstep(edge0, edge1, value) {
const t = clamp((value - edge0) / Math.max(0.0001, edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function lerp(start, end, amount) {
return start + (end - start) * amount;
}
function projectGraphNodes(nodes, graph) {
if (!nodes || nodes.length === 0) return [];
if (
nodes.some(
@ -813,9 +834,7 @@
const maxY = Math.max(...nodes.map((node) => node.y));
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const pad = 68;
const width = 760;
const height = 420;
const { width, height, pad } = graphViewport(graph);
return nodes.map((node) => ({
...node,
@ -824,6 +843,51 @@
}));
}
function graphNodeRadius(node) {
if (node.radius) return Number(node.radius);
if (node.core) return 26;
return Number(node.weight || 1) <= 0.7 ? 12 : 18;
}
function graphNodeFontSize(node) {
if (node.label_size || node.labelSize) {
return Number(node.label_size || node.labelSize);
}
if (node.core) return 15;
return Number(node.weight || 1) <= 0.7 ? 10.5 : 12.5;
}
function applyGraphAttention(nodeElements, viewport, focus) {
const radius = Math.min(viewport.width, viewport.height);
for (const item of nodeElements) {
const distance = Math.hypot(item.node.viewX - focus.x, item.node.viewY - focus.y);
const proximity = 1 - smoothstep(radius * 0.1, radius * 0.48, distance);
const routeBoost =
item.group.classList.contains("is-current") ||
item.group.classList.contains("is-available")
? 0.28
: 0;
const attention = clamp(proximity + routeBoost, 0, 1);
const maxScale = item.node.core ? 1.56 : 1.92;
const minScale = Number(item.node.weight || 1) <= 0.7 ? 0.64 : 0.78;
const scale = lerp(minScale, maxScale, attention);
item.group.setAttribute(
"transform",
`translate(${item.node.viewX} ${item.node.viewY}) scale(${scale})`,
);
item.group.style.opacity = String(lerp(0.54, 1, attention));
item.text.style.opacity = String(smoothstep(0.22, 0.62, attention));
}
}
function graphFocusFromEvent(svg, viewport, event) {
const bounds = svg.getBoundingClientRect();
return {
x: ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * viewport.width,
y: ((event.clientY - bounds.top) / Math.max(1, bounds.height)) * viewport.height,
};
}
function chooseStoryPath(story, target) {
if (!target) return false;
@ -849,7 +913,8 @@
const graph = resolveAdventureGraph(manifest);
if (!stageState.graphLayer || !graph) return;
const nodes = projectGraphNodes(stageState.graphLayout || graph.nodes || []);
const viewport = graphViewport(graph);
const nodes = projectGraphNodes(stageState.graphLayout || graph.nodes || [], graph);
if (nodes.length === 0) return;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
@ -868,7 +933,7 @@
);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 760 420");
svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
svg.setAttribute("aria-label", graph.label || "Ink knot graph");
svg.setAttribute("role", "img");
@ -889,6 +954,7 @@
const nodeGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeGroup.setAttribute("class", "sai-graph-nodes");
const nodeElements = [];
for (const node of nodes) {
const target = node.target || node.knot || node.id;
const key = graphNodeKey(node);
@ -908,11 +974,15 @@
group.setAttribute("aria-label", node.label || node.id);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", String(node.radius || (node.core ? 28 : 22)));
circle.setAttribute("r", String(graphNodeRadius(node)));
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.textContent = node.label || node.id;
text.setAttribute("y", "5");
group.append(circle, text);
text.style.fontSize = `${graphNodeFontSize(node)}px`;
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = node.label || node.id;
group.append(title, circle, text);
nodeElements.push({ group, node, text });
const activate = () => {
if (!chooseStoryPath(story, target)) return;
@ -924,9 +994,38 @@
event.preventDefault();
activate();
});
group.addEventListener("pointerenter", () => {
applyGraphAttention(nodeElements, viewport, {
x: node.viewX,
y: node.viewY,
});
});
group.addEventListener("focus", () => {
applyGraphAttention(nodeElements, viewport, {
x: node.viewX,
y: node.viewY,
});
});
nodeGroup.append(group);
}
svg.append(nodeGroup);
applyGraphAttention(nodeElements, viewport, {
x: viewport.width / 2,
y: viewport.height / 2,
});
svg.addEventListener("pointermove", (event) => {
applyGraphAttention(
nodeElements,
viewport,
graphFocusFromEvent(svg, viewport, event),
);
});
svg.addEventListener("pointerleave", () => {
applyGraphAttention(nodeElements, viewport, {
x: viewport.width / 2,
y: viewport.height / 2,
});
});
stageState.graphLayer.replaceChildren(svg);
stageState.graphLayer.hidden = false;

View file

@ -330,6 +330,7 @@
.sai-graph-node {
cursor: pointer;
outline: none;
transition: opacity 140ms ease;
}
.sai-graph-node circle {
@ -353,6 +354,7 @@
stroke: rgba(0, 0, 0, 0.76);
stroke-width: 4px;
pointer-events: none;
transition: opacity 140ms ease;
}
.sai-graph-node.is-visited circle {

View file

@ -798,7 +798,28 @@
return stageState.graphLayout;
}
function projectGraphNodes(nodes) {
function graphViewport(graph) {
return {
width: Number(graph?.viewport_width || graph?.viewportWidth || 760),
height: Number(graph?.viewport_height || graph?.viewportHeight || 420),
pad: Number(graph?.viewport_padding || graph?.viewportPadding || 68),
};
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function smoothstep(edge0, edge1, value) {
const t = clamp((value - edge0) / Math.max(0.0001, edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function lerp(start, end, amount) {
return start + (end - start) * amount;
}
function projectGraphNodes(nodes, graph) {
if (!nodes || nodes.length === 0) return [];
if (
nodes.some(
@ -813,9 +834,7 @@
const maxY = Math.max(...nodes.map((node) => node.y));
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(1, maxY - minY);
const pad = 68;
const width = 760;
const height = 420;
const { width, height, pad } = graphViewport(graph);
return nodes.map((node) => ({
...node,
@ -824,6 +843,51 @@
}));
}
function graphNodeRadius(node) {
if (node.radius) return Number(node.radius);
if (node.core) return 26;
return Number(node.weight || 1) <= 0.7 ? 12 : 18;
}
function graphNodeFontSize(node) {
if (node.label_size || node.labelSize) {
return Number(node.label_size || node.labelSize);
}
if (node.core) return 15;
return Number(node.weight || 1) <= 0.7 ? 10.5 : 12.5;
}
function applyGraphAttention(nodeElements, viewport, focus) {
const radius = Math.min(viewport.width, viewport.height);
for (const item of nodeElements) {
const distance = Math.hypot(item.node.viewX - focus.x, item.node.viewY - focus.y);
const proximity = 1 - smoothstep(radius * 0.1, radius * 0.48, distance);
const routeBoost =
item.group.classList.contains("is-current") ||
item.group.classList.contains("is-available")
? 0.28
: 0;
const attention = clamp(proximity + routeBoost, 0, 1);
const maxScale = item.node.core ? 1.56 : 1.92;
const minScale = Number(item.node.weight || 1) <= 0.7 ? 0.64 : 0.78;
const scale = lerp(minScale, maxScale, attention);
item.group.setAttribute(
"transform",
`translate(${item.node.viewX} ${item.node.viewY}) scale(${scale})`,
);
item.group.style.opacity = String(lerp(0.54, 1, attention));
item.text.style.opacity = String(smoothstep(0.22, 0.62, attention));
}
}
function graphFocusFromEvent(svg, viewport, event) {
const bounds = svg.getBoundingClientRect();
return {
x: ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * viewport.width,
y: ((event.clientY - bounds.top) / Math.max(1, bounds.height)) * viewport.height,
};
}
function chooseStoryPath(story, target) {
if (!target) return false;
@ -849,7 +913,8 @@
const graph = resolveAdventureGraph(manifest);
if (!stageState.graphLayer || !graph) return;
const nodes = projectGraphNodes(stageState.graphLayout || graph.nodes || []);
const viewport = graphViewport(graph);
const nodes = projectGraphNodes(stageState.graphLayout || graph.nodes || [], graph);
if (nodes.length === 0) return;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
@ -868,7 +933,7 @@
);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 760 420");
svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`);
svg.setAttribute("aria-label", graph.label || "Ink knot graph");
svg.setAttribute("role", "img");
@ -889,6 +954,7 @@
const nodeGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeGroup.setAttribute("class", "sai-graph-nodes");
const nodeElements = [];
for (const node of nodes) {
const target = node.target || node.knot || node.id;
const key = graphNodeKey(node);
@ -908,11 +974,15 @@
group.setAttribute("aria-label", node.label || node.id);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", String(node.radius || (node.core ? 28 : 22)));
circle.setAttribute("r", String(graphNodeRadius(node)));
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.textContent = node.label || node.id;
text.setAttribute("y", "5");
group.append(circle, text);
text.style.fontSize = `${graphNodeFontSize(node)}px`;
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
title.textContent = node.label || node.id;
group.append(title, circle, text);
nodeElements.push({ group, node, text });
const activate = () => {
if (!chooseStoryPath(story, target)) return;
@ -924,9 +994,38 @@
event.preventDefault();
activate();
});
group.addEventListener("pointerenter", () => {
applyGraphAttention(nodeElements, viewport, {
x: node.viewX,
y: node.viewY,
});
});
group.addEventListener("focus", () => {
applyGraphAttention(nodeElements, viewport, {
x: node.viewX,
y: node.viewY,
});
});
nodeGroup.append(group);
}
svg.append(nodeGroup);
applyGraphAttention(nodeElements, viewport, {
x: viewport.width / 2,
y: viewport.height / 2,
});
svg.addEventListener("pointermove", (event) => {
applyGraphAttention(
nodeElements,
viewport,
graphFocusFromEvent(svg, viewport, event),
);
});
svg.addEventListener("pointerleave", () => {
applyGraphAttention(nodeElements, viewport, {
x: viewport.width / 2,
y: viewport.height / 2,
});
});
stageState.graphLayer.replaceChildren(svg);
stageState.graphLayer.hidden = false;