diff --git a/src/authoring_service/templates.py b/src/authoring_service/templates.py index 52243ac..dbe1643 100755 --- a/src/authoring_service/templates.py +++ b/src/authoring_service/templates.py @@ -1355,6 +1355,8 @@ HIDDEN_APP_HTML = r""" .memory-node .fragment-star { stroke-width: 1.1; } .memory-node .fragment-ring { fill: none; stroke: rgba(255, 246, 223, 0.24); stroke-width: 1; stroke-dasharray: 2 7; } .memory-node .node-breathe, .cluster-node .node-breathe { animation: avatar-breathe 8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } + .sequence-badge { fill: rgba(18, 16, 13, 0.88); stroke: rgba(255, 243, 199, 0.68); stroke-width: 1; } + .sequence-number { fill: #ffe7ad; font-size: 10px; font-weight: 850; paint-order: stroke; stroke: rgba(14, 11, 8, 0.72); stroke-width: 2px; } .selection-halo { fill: rgba(255, 231, 173, 0.1); stroke: rgba(255, 243, 199, 0.95); stroke-width: 3; stroke-dasharray: 4 9; filter: drop-shadow(0 0 22px rgba(255, 231, 173, 0.62)); animation: selection-pulse 2.8s ease-in-out infinite; pointer-events: none; } .memory-node:hover .node-shell, .memory-node:hover .node-portrait, .memory-node:hover .fragment-star { filter: drop-shadow(0 0 20px var(--node-glow, rgba(255, 232, 177, 0.4))); } .memory-node.selected .node-shell, .memory-node.selected .node-portrait, .memory-node.selected .fragment-star { stroke: #fff3c7; stroke-width: 3; } @@ -2631,6 +2633,74 @@ HIDDEN_APP_HTML = r""" function relationshipPairsForClusters(clusters) { return []; } + function storyLayoutItems(story, route, visibleEntries) { + const visible = new Set(visibleEntries.map((entry) => entry.id)); + const routeIds = new Set(route.map((entry) => entry.id)); + const items = []; + let sectionIndex = 0; + let sequenceIndex = 0; + storyItems(story).forEach((item) => { + if (item.kind === "section") { + sectionIndex += 1; + return; + } + if (!visible.has(item.id) || !routeIds.has(item.id)) return; + items.push({ id: item.id, sectionIndex, sequenceIndex }); + sequenceIndex += 1; + }); + return items; + } + function storyConstellationPoint(layout, item) { + const count = layout.count; + if (count <= 6) { + const step = item.sequenceIndex - (count - 1) / 2; + const arc = Math.sin(item.sequenceIndex / Math.max(1, count - 1) * Math.PI) * 58; + return { + x: step * 156, + y: arc + (item.sectionIndex % 2) * 18, + }; + } + + const row = Math.floor(item.sequenceIndex / layout.columns); + const indexInRow = item.sequenceIndex % layout.columns; + const reversed = row % 2 === 1; + const actualColumns = row === layout.rows - 1 && count % layout.columns ? count % layout.columns : layout.columns; + const col = reversed ? actualColumns - 1 - indexInRow : indexInRow; + const wave = Math.sin((indexInRow / Math.max(1, actualColumns - 1)) * Math.PI) * layout.arc; + const sectionLift = (item.sectionIndex % 3 - 1) * 18; + return { + x: (col - (actualColumns - 1) / 2) * layout.columnGap, + y: (row - (layout.rows - 1) / 2) * layout.rowGap + wave + sectionLift, + }; + } + function storyConstellationLayout(story, route, entries, storyIndex, storyCount) { + const angle = (Math.PI * 2 * storyIndex) / Math.max(1, storyCount) + hash(story.id) / 9000; + const baseRadius = 520 + (storyIndex % 4) * 250; + const count = route.length; + const columns = count > 18 ? Math.ceil(Math.sqrt(count * 1.9)) : count > 10 ? Math.ceil(Math.sqrt(count * 1.65)) : Math.max(1, count); + const rows = Math.max(1, Math.ceil(count / Math.max(1, columns))); + const layout = { + count, + columns: Math.max(1, columns), + rows, + columnGap: count > 18 ? 178 : 160, + rowGap: count > 18 ? 134 : 122, + arc: count > 18 ? 38 : 52, + }; + const anchorX = WORLD.cx + Math.cos(angle) * baseRadius; + const anchorY = WORLD.cy + Math.sin(angle) * baseRadius * 0.68; + const driftX = Math.cos(angle) * Math.min(130, 30 + count * 4); + const driftY = Math.sin(angle) * Math.min(92, 20 + count * 2.8); + const layoutItems = storyLayoutItems(story, route, entries); + layoutItems.forEach((item) => { + const point = storyConstellationPoint(layout, item); + state.positions.set(item.id, { + x: anchorX + driftX + point.x, + y: anchorY + driftY + point.y, + sequenceIndex: item.sequenceIndex, + }); + }); + } function layoutEntries(entries) { const cx = WORLD.cx; const cy = WORLD.cy; @@ -2638,23 +2708,7 @@ HIDDEN_APP_HTML = r""" const stories = filteredStories().filter((story) => (story.nodes || []).some((id) => entries.some((entry) => entry.id === id))); stories.forEach((story, storyIndex) => { const route = storyEntries(story).filter((entry) => entries.some((item) => item.id === entry.id)); - const angle = (Math.PI * 2 * storyIndex) / Math.max(1, stories.length) + hash(story.id) / 9000; - const baseRadius = 520 + (storyIndex % 4) * 250; - const anchorX = cx + Math.cos(angle) * baseRadius; - const anchorY = cy + Math.sin(angle) * baseRadius * 0.68; - const denseRoute = route.length > 12; - const stepX = denseRoute ? 188 : 150; - const stepY = denseRoute ? 138 : 110; - const curveLift = denseRoute ? 150 : 90; - route.forEach((entry, index) => { - const step = index - (route.length - 1) / 2; - const curve = Math.sin(index / Math.max(1, route.length - 1) * Math.PI) * curveLift; - const direction = angle + Math.PI / 2; - const lane = denseRoute ? ((index % 2) - 0.5) * 70 : 0; - const x = anchorX + Math.cos(direction) * step * stepX + Math.cos(angle) * curve + Math.cos(angle) * lane; - const y = anchorY + Math.sin(direction) * step * stepY + Math.sin(angle) * curve * 0.7 + Math.sin(angle) * lane * 0.55; - state.positions.set(entry.id, { x, y }); - }); + storyConstellationLayout(story, route, entries, storyIndex, stories.length); }); const loose = entries.filter((entry) => !state.positions.has(entry.id)); const byLayer = new Map(); @@ -2840,6 +2894,11 @@ HIDDEN_APP_HTML = r""" const clipId = `clip-node-${slugify(entry.id)}`; defs.push(``); const distant = state.zoom === 0; + const sequenceNumber = Number.isInteger(pos.sequenceIndex) ? pos.sequenceIndex + 1 : 0; + const badgeRadius = sequenceNumber > 99 ? 14 : sequenceNumber > 9 ? 12 : 10; + const sequenceBadge = sequenceNumber && (state.camera.k > 0.26 || activeStoryIds.has(entry.id) || related) + ? `${sequenceNumber}` + : ""; return ` ${isSelected ? `` : ""} @@ -2848,6 +2907,7 @@ HIDDEN_APP_HTML = r""" : distant ? `${html(characterSymbol(character))}` : svgAvatar(character, size, clipId, avatarExpressionForEntry(entry), state.zoom >= 2 ? "node-breathe" : "")} + ${sequenceBadge} ${label}`; }).join(""); graph.innerHTML = `${defs.join("")}${rings}${storyRegions}${clusterEdges}${edges}${clusterNodes}${nodes}`;