import { forceLink, forceManyBody, forceSimulation, forceX, forceY, } from "./vendor/d3-force.js"; const DEFAULT_POSTER_WIDTH = 215; const DEFAULT_POSTER_HEIGHT = 330; const DEFAULT_COLLISION_GAP = 24; const DEFAULT_BUCKET_SIZE = 320; const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback; const positive = (value, fallback) => Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback; const idOf = value => value && typeof value === "object" ? value.id : value; const compareIds = (left, right) => String(left).localeCompare(String(right)); function nodeId(node) { return String(node?.id ?? node?.index ?? ""); } function nodeDimensions(node) { const fallbackWidth = node?.kind === "work" ? DEFAULT_POSTER_WIDTH : 24; const fallbackHeight = node?.kind === "work" ? DEFAULT_POSTER_HEIGHT : 24; return { halfWidth: positive(node?.width, positive(node?.posterWidth, fallbackWidth)) / 2, halfHeight: positive(node?.height, positive(node?.posterHeight, fallbackHeight)) / 2, angle: finite(node?.rotation) * Math.PI / 180, }; } function rectangleAt(node, x, y, gap = 0, axisAligned = false) { const { halfWidth, halfHeight, angle } = nodeDimensions(node); const cos = Math.cos(angle); const sin = Math.sin(angle); const extentX = Math.abs(cos) * halfWidth + Math.abs(sin) * halfHeight; const extentY = Math.abs(sin) * halfWidth + Math.abs(cos) * halfHeight; const paddedWidth = (axisAligned ? extentX : halfWidth) + gap / 2; const paddedHeight = (axisAligned ? extentY : halfHeight) + gap / 2; const rectangleCos = axisAligned ? 1 : cos; const rectangleSin = axisAligned ? 0 : sin; const collisionExtentX = Math.abs(rectangleCos) * paddedWidth + Math.abs(rectangleSin) * paddedHeight; const collisionExtentY = Math.abs(rectangleSin) * paddedWidth + Math.abs(rectangleCos) * paddedHeight; return { x, y, cos: rectangleCos, sin: rectangleSin, halfWidth: paddedWidth, halfHeight: paddedHeight, left: x - collisionExtentX, right: x + collisionExtentX, top: y - collisionExtentY, bottom: y + collisionExtentY, }; } function projectedHalfExtent(rect, axisX, axisY) { const alongWidth = rect.cos * axisX + rect.sin * axisY; const alongHeight = -rect.sin * axisX + rect.cos * axisY; return rect.halfWidth * Math.abs(alongWidth) + rect.halfHeight * Math.abs(alongHeight); } function separatingPush(left, right) { const axes = [ [left.cos, left.sin], [-left.sin, left.cos], [right.cos, right.sin], [-right.sin, right.cos], ]; const dx = right.x - left.x; const dy = right.y - left.y; let best = null; for (const [axisX, axisY] of axes) { const signedDistance = dx * axisX + dy * axisY; const overlap = projectedHalfExtent(left, axisX, axisY) + projectedHalfExtent(right, axisX, axisY) - Math.abs(signedDistance); if (overlap <= 0) return null; if (!best || overlap < best.depth) { best = { x: axisX, y: axisY, depth: overlap, signedDistance }; } } if (!best) return null; let direction = best.signedDistance < 0 ? -1 : 1; if (best.signedDistance === 0) { // Give coincident rectangles a stable direction instead of relying on random jitter. direction = compareIds(left.id, right.id) <= 0 ? 1 : -1; } return { x: best.x * direction, y: best.y * direction, depth: best.depth }; } function bucketKeys(rect, size) { const left = Math.floor(rect.left / size); const right = Math.floor(rect.right / size); const top = Math.floor(rect.top / size); const bottom = Math.floor(rect.bottom / size); const keys = []; for (let x = left; x <= right; x++) { for (let y = top; y <= bottom; y++) keys.push(`${x}:${y}`); } return keys; } function makeRectangleCollisionForce({ gap, bucketSize, strength, fixedIds }) { let nodes = []; function force() { const sorted = [...nodes].sort((a, b) => compareIds(nodeId(a), nodeId(b))); const buckets = new Map(); const checkedPairs = new Set(); for (const node of sorted) { const rect = rectangleAt(node, finite(node.x) + finite(node.vx), finite(node.y) + finite(node.vy), gap); const keys = bucketKeys(rect, bucketSize); const movable = !fixedIds.has(nodeId(node)); for (const key of keys) { const bucket = buckets.get(key) || []; for (const other of bucket) { const pairKey = `${nodeId(other)}\u0000${nodeId(node)}`; if (checkedPairs.has(pairKey)) continue; checkedPairs.add(pairKey); const otherRect = rectangleAt(other, finite(other.x) + finite(other.vx), finite(other.y) + finite(other.vy), gap); const push = separatingPush(otherRect, rect); if (!push) continue; const otherMovable = !fixedIds.has(nodeId(other)); if (!movable && !otherMovable) continue; const share = movable && otherMovable ? 0.5 : 1; const amount = push.depth * strength * share; if (movable) { node.vx += push.x * amount; node.vy += push.y * amount; } if (otherMovable) { other.vx -= push.x * amount; other.vy -= push.y * amount; } } bucket.push(node); buckets.set(key, bucket); } } } force.initialize = initializedNodes => { nodes = initializedNodes; }; return force; } function coordinate(node, key, fallback) { return Number.isFinite(Number(node?.[key])) ? Number(node[key]) : fallback; } function initialCoordinate(node, axis) { const value = node?.[axis]; if (Number.isFinite(Number(value))) return Number(value); const id = nodeId(node); let hash = 2166136261; for (let index = 0; index < id.length; index++) { hash ^= id.charCodeAt(index); hash = Math.imul(hash, 16777619); } const angle = ((hash >>> 0) / 4294967296) * Math.PI * 2; const radius = 180 + ((hash >>> 8) % 160); return axis === "x" ? Math.cos(angle) * radius : Math.sin(angle) * radius; } function normalizeFixedIds(nodes, requested) { const ids = requested instanceof Set ? requested : new Set(requested || []); const fixed = new Set([...ids].map(String)); for (const node of nodes) { if (node?.kind === "root" || node?.kind === "format") fixed.add(nodeId(node)); } return fixed; } function defaultLinkDistance(link) { if (Number.isFinite(Number(link.restLength)) && Number(link.restLength) >= 0) return Number(link.restLength); const source = link.source; const target = link.target; const radius = node => { const width = positive(node?.width, node?.kind === "work" ? DEFAULT_POSTER_WIDTH : 24); const height = positive(node?.height, node?.kind === "work" ? DEFAULT_POSTER_HEIGHT : 24); return Math.hypot(width, height) / 2; }; return Math.max(100, Math.min(640, (radius(source) + radius(target)) * 1.15)); } function defaultAnchor(node, axis) { return coordinate(node, axis === "x" ? "ax" : "ay", finite(node?.[axis])); } function dynamicPositionForce(baseForce, accessor, dimension) { const force = alpha => { // d3-force caches forceX/Y targets. Refresh the accessor so changing ax/ay // during a drag is reflected by the next manual tick. baseForce[dimension](accessor); baseForce(alpha); }; force.initialize = (nodes, random) => baseForce.initialize(nodes, random); return force; } /** * Create a manually ticked D3 force layout for the orbit graph. * The simulation mutates node x/y/vx/vy/index as documented by D3, but keeps * root, format and fixedIds positions pinned without assigning fx/fy. Links are * copied before forceLink initializes because D3 rewrites endpoint IDs in-place. */ export function createD3PosterSimulation(nodes, links, options = {}) { if (!Array.isArray(nodes)) throw new TypeError("nodes must be an array"); const graphNodes = nodes; const fixedIds = normalizeFixedIds(graphNodes, options.fixedIds); const pinPositions = new Map(); for (const node of graphNodes) { const id = nodeId(node); if (node?.kind === "root") { node.x = 0; node.y = 0; } if (!Number.isFinite(Number(node.x))) node.x = initialCoordinate(node, "x"); if (!Number.isFinite(Number(node.y))) node.y = initialCoordinate(node, "y"); if (!Number.isFinite(Number(node.vx))) node.vx = 0; if (!Number.isFinite(Number(node.vy))) node.vy = 0; if (fixedIds.has(id)) pinPositions.set(id, { x: Number(node.x), y: Number(node.y) }); } const nodeIds = new Set(graphNodes.map(nodeId)); const simulationLinks = (Array.isArray(links) ? links : []) .filter(link => link && nodeIds.has(String(idOf(link.source))) && nodeIds.has(String(idOf(link.target)))) .map(link => ({ ...link, source: String(idOf(link.source)), target: String(idOf(link.target)) })); const charge = finite(options.charge, -24); const anchorStrength = Math.max(0, finite(options.anchorStrength, 0.08)); const linkStrength = Math.max(0, finite(options.linkStrength, 0.025)); const gap = Math.max(0, finite(options.gap, DEFAULT_COLLISION_GAP)); const bucketSize = Math.max(48, finite(options.bucketSize, DEFAULT_BUCKET_SIZE)); const collisionStrength = Math.max(0, Math.min(1, finite(options.collisionStrength, 0.9))); const simulation = forceSimulation(graphNodes).stop(); const xBase = forceX(node => defaultAnchor(node, "x")).strength(anchorStrength); const yBase = forceY(node => defaultAnchor(node, "y")).strength(anchorStrength); const linksForce = forceLink(simulationLinks) .id(node => String(node.id)) .distance(defaultLinkDistance) .strength(link => { const edgeWeight = Number.isFinite(Number(link.weight)) ? Math.max(0, Math.min(1, Number(link.weight))) : 1; const edgeStrength = Number.isFinite(Number(link.strength)) ? Math.max(0, Math.min(1, Number(link.strength))) : 1; return linkStrength * edgeWeight * edgeStrength; }); const pinForce = () => { for (const node of graphNodes) { const position = pinPositions.get(nodeId(node)); if (!position) continue; node.x = position.x; node.y = position.y; node.vx = 0; node.vy = 0; } }; simulation .alpha(finite(options.alpha, 1)) .alphaMin(Math.max(0, finite(options.alphaMin, 0.001))) .alphaDecay(Math.max(0, Math.min(1, finite(options.alphaDecay, 0.0228)))) .velocityDecay(Math.max(0, Math.min(1, finite(options.velocityDecay, 0.4)))) .force("charge", forceManyBody() .strength(charge) .distanceMin(Math.max(1, finite(options.chargeDistanceMin, 16))) .distanceMax(Math.max(1, finite(options.chargeDistanceMax, 900)))) .force("link", linksForce) .force("anchor-x", dynamicPositionForce(xBase, node => defaultAnchor(node, "x"), "x")) .force("anchor-y", dynamicPositionForce(yBase, node => defaultAnchor(node, "y"), "y")) .force("rectangle-collide", makeRectangleCollisionForce({ gap, bucketSize, strength: collisionStrength, fixedIds })) .force("pin", pinForce); // Preserve the caller's fixed-id collection and expose a snapshot for callers // that need to know which nodes this simulation will keep stationary. simulation.fixedIds = new Set(fixedIds); simulation.linkData = simulationLinks; return simulation; } function rectangleOverlaps(leftNode, leftX, leftY, rightNode, rightX, rightY, gap, axisAligned = false) { return Boolean(separatingPush( rectangleAt(leftNode, leftX, leftY, gap, axisAligned), rectangleAt(rightNode, rightX, rightY, gap, axisAligned), )); } function kindPriority(node) { if (node?.kind === "work") return 0; if (node?.kind === "world" || node?.kind === "series") return 1; if (node?.kind === "director" || node?.kind === "author") return 2; return 3; } /** * Deterministically place movable rectangles after simulation ticks. Fixed * nodes retain their exact positions; all other nodes move only as far as * needed to avoid overlaps. Set axisAligned for a conservative rotated-AABB * guarantee; otherwise the finalizer uses the less restrictive OBB SAT test. * Returns the same node array. */ export function settleD3Rectangles(nodes, options = {}) { if (!Array.isArray(nodes) || nodes.length < 2) return nodes; const fixedIds = normalizeFixedIds(nodes, options.fixedIds); const gap = Math.max(0, finite(options.gap, DEFAULT_COLLISION_GAP)); const bucketSize = Math.max(48, finite(options.bucketSize, DEFAULT_BUCKET_SIZE)); const axisAligned = options.axisAligned === true; const movable = []; const placed = []; const buckets = new Map(); const candidateStep = Math.max(36, finite(options.searchStep, 72)); let rightmost = -Infinity; const addPlaced = node => { const rect = rectangleAt(node, finite(node.x), finite(node.y), gap, axisAligned); placed.push({ node, rect }); rightmost = Math.max(rightmost, rect.right); for (const key of bucketKeys(rect, bucketSize)) { const bucket = buckets.get(key) || []; bucket.push(placed[placed.length - 1]); buckets.set(key, bucket); } }; for (const node of nodes) { if (node?.kind === "root") { node.x = 0; node.y = 0; } if (!Number.isFinite(Number(node.x))) node.x = initialCoordinate(node, "x"); if (!Number.isFinite(Number(node.y))) node.y = initialCoordinate(node, "y"); if (fixedIds.has(nodeId(node))) addPlaced(node); else movable.push(node); } movable.sort((a, b) => kindPriority(a) - kindPriority(b) || compareIds(nodeId(a), nodeId(b))); for (const node of movable) { const originX = Number(node.x); const originY = Number(node.y); let accepted = false; const attempts = Math.max(1, Math.floor(finite(options.maxSearchAttempts, 8192))); for (let attempt = 0; attempt < attempts; attempt++) { const radius = candidateStep * Math.sqrt(attempt); const angle = attempt * GOLDEN_ANGLE; const x = originX + Math.cos(angle) * radius; const y = originY + Math.sin(angle) * radius; const rect = rectangleAt(node, x, y, gap, axisAligned); let overlaps = false; for (const key of bucketKeys(rect, bucketSize)) { for (const other of buckets.get(key) || []) { if (rectangleOverlaps(node, x, y, other.node, other.rect.x, other.rect.y, gap, axisAligned)) { overlaps = true; break; } } if (overlaps) break; } if (overlaps) continue; node.x = x; node.y = y; node.vx = 0; node.vy = 0; addPlaced(node); accepted = true; break; } if (!accepted) { const own = rectangleAt(node, originX, originY, gap, axisAligned); const halfExtentX = own.right - own.x; node.x = Math.max(rightmost, own.right) + halfExtentX + 1; node.y = originY; node.vx = 0; node.vy = 0; addPlaced(node); } } return nodes; }