@anthusai/papyrus 0.0.0 → 1.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,934 @@
1
+ import {
2
+ BLOG_DEFENSE_CORE_NODE_ID,
3
+ BLOG_DEFENSE_EDGES,
4
+ BLOG_DEFENSE_NODES,
5
+ BLOG_DEFENSE_VIEWBOX_HEIGHT,
6
+ BLOG_DEFENSE_VIEWBOX_WIDTH,
7
+ type BlogDefenseEdge,
8
+ type BlogDefenseNode,
9
+ } from "./graph";
10
+
11
+ export type LayoutRect = {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ };
17
+
18
+ export type LayoutDefenseGraphInput = {
19
+ width: number;
20
+ height: number;
21
+ obstacles?: LayoutRect[];
22
+ padX?: number;
23
+ padTop?: number;
24
+ padBottom?: number;
25
+ };
26
+
27
+ export type LayoutDefenseNode = BlogDefenseNode;
28
+
29
+ export type LayoutDefenseEdge = {
30
+ id: string;
31
+ from: string;
32
+ to: string;
33
+ x1: number;
34
+ y1: number;
35
+ x2: number;
36
+ y2: number;
37
+ };
38
+
39
+ export type LayoutDefenseGraphResult = {
40
+ nodes: LayoutDefenseNode[];
41
+ edges: LayoutDefenseEdge[];
42
+ scale: number;
43
+ };
44
+
45
+ function uniformNodeRadius(scale: number): number {
46
+ return Math.max(4.4, scale * 6);
47
+ }
48
+
49
+ const RIGHT_ARM_BASE_CANDIDATE_IDS = ["right_attach_bottom", "right_c0"] as const;
50
+ const LEFT_ARM_ATTACH_CANDIDATE_IDS = [
51
+ "left_bridge_top",
52
+ "left_attach_top",
53
+ "left_attach_bottom",
54
+ "left_a0",
55
+ "left_b0",
56
+ "left_c0",
57
+ ] as const;
58
+ const LEFT_ARM_CORE_TARGET_IDS = [
59
+ "halo_nw",
60
+ "halo_n",
61
+ "halo_w",
62
+ ] as const;
63
+ const LEFT_ARM_ROW_NODE_IDS = [
64
+ ["left_tail_4", "left_tail_3", "left_tail_2", "left_tail_1", "left_tip", "left_a3", "left_a2", "left_a1", "left_a0", "left_bridge_top"],
65
+ ["left_b3", "left_b0", "left_attach_top"],
66
+ ["left_c3", "left_c0", "left_attach_bottom"],
67
+ ] as const;
68
+ const RIGHT_ARM_CORE_TARGET_IDS = [
69
+ "halo_e",
70
+ "halo_se",
71
+ "halo_s",
72
+ "halo_sw",
73
+ ] as const;
74
+ const RIGHT_ARM_ROW_NODE_IDS = [
75
+ ["right_c0", "right_b0", "right_a0"],
76
+ ["right_attach_bottom", "right_c1", "right_b1", "right_a1"],
77
+ ["right_c2", "right_b2", "right_a2"],
78
+ ["right_c3", "right_b3", "right_a3"],
79
+ ["right_c4", "right_b4", "right_a4"],
80
+ ["right_c5", "right_b5", "right_a5"],
81
+ ] as const;
82
+
83
+ function pointInRect(x: number, y: number, rect: LayoutRect, inflate = 0): boolean {
84
+ return x >= rect.x - inflate
85
+ && x <= rect.x + rect.width + inflate
86
+ && y >= rect.y - inflate
87
+ && y <= rect.y + rect.height + inflate;
88
+ }
89
+
90
+ function orientation(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): number {
91
+ const value = (by - ay) * (cx - bx) - (bx - ax) * (cy - by);
92
+ if (Math.abs(value) < 0.0001) return 0;
93
+ return value > 0 ? 1 : 2;
94
+ }
95
+
96
+ function onSegment(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): boolean {
97
+ return bx <= Math.max(ax, cx) + 0.0001
98
+ && bx + 0.0001 >= Math.min(ax, cx)
99
+ && by <= Math.max(ay, cy) + 0.0001
100
+ && by + 0.0001 >= Math.min(ay, cy);
101
+ }
102
+
103
+ function segmentsIntersect(
104
+ x1: number,
105
+ y1: number,
106
+ x2: number,
107
+ y2: number,
108
+ x3: number,
109
+ y3: number,
110
+ x4: number,
111
+ y4: number,
112
+ ): boolean {
113
+ const o1 = orientation(x1, y1, x2, y2, x3, y3);
114
+ const o2 = orientation(x1, y1, x2, y2, x4, y4);
115
+ const o3 = orientation(x3, y3, x4, y4, x1, y1);
116
+ const o4 = orientation(x3, y3, x4, y4, x2, y2);
117
+ if (o1 !== o2 && o3 !== o4) return true;
118
+ if (o1 === 0 && onSegment(x1, y1, x3, y3, x2, y2)) return true;
119
+ if (o2 === 0 && onSegment(x1, y1, x4, y4, x2, y2)) return true;
120
+ if (o3 === 0 && onSegment(x3, y3, x1, y1, x4, y4)) return true;
121
+ if (o4 === 0 && onSegment(x3, y3, x2, y2, x4, y4)) return true;
122
+ return false;
123
+ }
124
+
125
+ function segmentIntersectsRect(
126
+ x1: number,
127
+ y1: number,
128
+ x2: number,
129
+ y2: number,
130
+ rect: LayoutRect,
131
+ inflate = 0,
132
+ ): boolean {
133
+ const inflated = {
134
+ x: rect.x - inflate,
135
+ y: rect.y - inflate,
136
+ width: rect.width + inflate * 2,
137
+ height: rect.height + inflate * 2,
138
+ };
139
+ if (pointInRect(x1, y1, inflated) || pointInRect(x2, y2, inflated)) return true;
140
+ const left = inflated.x;
141
+ const right = inflated.x + inflated.width;
142
+ const top = inflated.y;
143
+ const bottom = inflated.y + inflated.height;
144
+ return segmentsIntersect(x1, y1, x2, y2, left, top, right, top)
145
+ || segmentsIntersect(x1, y1, x2, y2, right, top, right, bottom)
146
+ || segmentsIntersect(x1, y1, x2, y2, right, bottom, left, bottom)
147
+ || segmentsIntersect(x1, y1, x2, y2, left, bottom, left, top);
148
+ }
149
+
150
+ function edgeIntersectsObstacles(
151
+ x1: number,
152
+ y1: number,
153
+ x2: number,
154
+ y2: number,
155
+ obstacles: LayoutRect[],
156
+ ): boolean {
157
+ return obstacles.some((obstacle) => segmentIntersectsRect(x1, y1, x2, y2, obstacle, 2));
158
+ }
159
+
160
+ function nodeIntersectsObstacles(
161
+ x: number,
162
+ y: number,
163
+ radius: number,
164
+ obstacles: LayoutRect[],
165
+ ): boolean {
166
+ return obstacles.some((obstacle) => {
167
+ const nearestX = Math.max(obstacle.x, Math.min(x, obstacle.x + obstacle.width));
168
+ const nearestY = Math.max(obstacle.y, Math.min(y, obstacle.y + obstacle.height));
169
+ const dx = x - nearestX;
170
+ const dy = y - nearestY;
171
+ return (dx * dx) + (dy * dy) <= (radius * radius);
172
+ });
173
+ }
174
+
175
+ function coreAnchor(
176
+ width: number,
177
+ height: number,
178
+ padX: number,
179
+ scale: number,
180
+ coreNode: BlogDefenseNode,
181
+ ): { x: number; y: number } {
182
+ const coreZoneNodes = BLOG_DEFENSE_NODES.filter((node) => node.zone === "core");
183
+ const maxCoreDx = coreZoneNodes.reduce((maxDx, node) => Math.max(maxDx, node.x - coreNode.x), 0);
184
+ const rightAlignedX = width - padX - maxCoreDx * scale;
185
+ return {
186
+ x: rightAlignedX,
187
+ y: Math.max(76, Math.min(height * 0.24, 208)),
188
+ };
189
+ }
190
+
191
+ function bestCoreAnchor(input: {
192
+ width: number;
193
+ height: number;
194
+ padX: number;
195
+ padTop: number;
196
+ padBottom: number;
197
+ scale: number;
198
+ coreNode: BlogDefenseNode;
199
+ nodeRadius: number;
200
+ obstacles: LayoutRect[];
201
+ }): { x: number; y: number } {
202
+ const {
203
+ width,
204
+ height,
205
+ padX,
206
+ padTop,
207
+ padBottom,
208
+ scale,
209
+ coreNode,
210
+ nodeRadius,
211
+ obstacles,
212
+ } = input;
213
+ const base = coreAnchor(width, height, padX, scale, coreNode);
214
+ if (!obstacles.length) return base;
215
+
216
+ const coreZoneNodes = BLOG_DEFENSE_NODES.filter((node) => node.zone === "core");
217
+ const maxAnchorX = width - padX - nodeRadius;
218
+ const step = Math.max(4, Math.round(width * 0.012));
219
+ let best = base;
220
+ let bestScore = -Infinity;
221
+
222
+ for (let x = base.x; x <= maxAnchorX + 0.001; x += step) {
223
+ let score = 0;
224
+ for (const node of coreZoneNodes) {
225
+ const candidateX = x + (node.x - coreNode.x) * scale;
226
+ const candidateY = base.y + (node.y - coreNode.y) * scale;
227
+ const inBounds = candidateX > padX + nodeRadius
228
+ && candidateX < width - padX - nodeRadius
229
+ && candidateY > padTop + nodeRadius
230
+ && candidateY < height - padBottom - nodeRadius;
231
+ if (!inBounds) continue;
232
+ if (nodeIntersectsObstacles(candidateX, candidateY, nodeRadius + 2, obstacles)) continue;
233
+ score += node.id === BLOG_DEFENSE_CORE_NODE_ID ? 100 : 1;
234
+ }
235
+
236
+ const distancePenalty = Math.abs(x - base.x) / Math.max(1, width);
237
+ const adjustedScore = score - distancePenalty;
238
+ if (adjustedScore > bestScore) {
239
+ bestScore = adjustedScore;
240
+ best = { x, y: base.y };
241
+ }
242
+ }
243
+
244
+ return best;
245
+ }
246
+
247
+ function bestCorridorX(
248
+ width: number,
249
+ height: number,
250
+ padX: number,
251
+ padTop: number,
252
+ padBottom: number,
253
+ scale: number,
254
+ coreNode: BlogDefenseNode,
255
+ anchor: { x: number; y: number },
256
+ obstacles: LayoutRect[],
257
+ ): number {
258
+ const preferred = width - padX - Math.max(12, 22 * scale);
259
+ const minX = Math.max(padX + 10, preferred - Math.max(40, width * 0.13));
260
+ const maxX = preferred;
261
+ const step = Math.max(6, Math.round(width * 0.01));
262
+ let bestX = preferred;
263
+ let bestScore = -1;
264
+
265
+ for (let x = maxX; x >= minX; x -= step) {
266
+ let clearCount = 0;
267
+ for (const node of BLOG_DEFENSE_NODES) {
268
+ if (node.zone !== "right_corridor") continue;
269
+ const dy = node.y - coreNode.y;
270
+ const y = Math.min(
271
+ height - padBottom - node.radius * scale,
272
+ Math.max(padTop + node.radius * scale, anchor.y + dy * scale),
273
+ );
274
+ const blocked = obstacles.some((obstacle) => pointInRect(x, y, obstacle, node.radius * scale + 2));
275
+ if (!blocked) clearCount += 1;
276
+ }
277
+ if (clearCount > bestScore || (clearCount === bestScore && x > bestX)) {
278
+ bestScore = clearCount;
279
+ bestX = x;
280
+ }
281
+ }
282
+
283
+ return bestX;
284
+ }
285
+
286
+ function templateNodeById(id: string): BlogDefenseNode | undefined {
287
+ return BLOG_DEFENSE_NODES.find((node) => node.id === id);
288
+ }
289
+
290
+ function normalizeAngle(angle: number): number {
291
+ while (angle > Math.PI) angle -= Math.PI * 2;
292
+ while (angle < -Math.PI) angle += Math.PI * 2;
293
+ return angle;
294
+ }
295
+
296
+ function buildEdgeRecord(from: LayoutDefenseNode, to: LayoutDefenseNode, id: string): LayoutDefenseEdge {
297
+ return {
298
+ id,
299
+ from: from.id,
300
+ to: to.id,
301
+ x1: from.x,
302
+ y1: from.y,
303
+ x2: to.x,
304
+ y2: to.y,
305
+ };
306
+ }
307
+
308
+ function keepNodeOutsideCoreEnvelope(input: {
309
+ nextX: number;
310
+ nextY: number;
311
+ node: LayoutDefenseNode;
312
+ coreCenter?: LayoutDefenseNode;
313
+ outerCoreRadius: number;
314
+ preferredHorizontalDirection: -1 | 1;
315
+ }): { x: number; y: number } {
316
+ const {
317
+ nextX,
318
+ nextY,
319
+ node,
320
+ coreCenter,
321
+ outerCoreRadius,
322
+ preferredHorizontalDirection,
323
+ } = input;
324
+ if (!coreCenter) {
325
+ return { x: nextX, y: nextY };
326
+ }
327
+
328
+ const clearance = Math.max(4, node.radius * 0.5);
329
+ const minDistanceFromCore = outerCoreRadius + node.radius + clearance;
330
+ const dx = nextX - coreCenter.x;
331
+ const dy = nextY - coreCenter.y;
332
+ const distance = Math.hypot(dx, dy);
333
+ if (distance >= minDistanceFromCore) {
334
+ return { x: nextX, y: nextY };
335
+ }
336
+
337
+ const verticalDistance = Math.abs(dy);
338
+ if (verticalDistance < minDistanceFromCore) {
339
+ const horizontalDistance = Math.sqrt((minDistanceFromCore ** 2) - (verticalDistance ** 2));
340
+ return {
341
+ x: coreCenter.x + preferredHorizontalDirection * horizontalDistance,
342
+ y: nextY,
343
+ };
344
+ }
345
+
346
+ const ux = distance > 0.0001 ? dx / distance : preferredHorizontalDirection;
347
+ const uy = distance > 0.0001 ? dy / distance : 0;
348
+ return {
349
+ x: coreCenter.x + ux * minDistanceFromCore,
350
+ y: coreCenter.y + uy * minDistanceFromCore,
351
+ };
352
+ }
353
+
354
+ function leftArmIrregularityOffset(nodeId: string, rowIndex: number, scale: number): number {
355
+ if (rowIndex === 0) {
356
+ const offsets: Record<string, number> = {
357
+ left_tail_4: -24,
358
+ left_tail_3: -18,
359
+ left_tail_2: -12,
360
+ left_tail_1: -7,
361
+ left_tip: -2,
362
+ left_a3: 1,
363
+ left_a2: 2,
364
+ left_a1: 5,
365
+ left_a0: 1,
366
+ left_bridge_top: 14,
367
+ };
368
+ return (offsets[nodeId] ?? 0) * scale;
369
+ }
370
+
371
+ if (rowIndex === 1) {
372
+ const offsets: Record<string, number> = {
373
+ left_b3: -10,
374
+ left_b0: -4,
375
+ left_attach_top: 12,
376
+ };
377
+ return (offsets[nodeId] ?? 0) * scale;
378
+ }
379
+
380
+ const offsets: Record<string, number> = {
381
+ left_c3: -7,
382
+ left_c0: -3,
383
+ left_attach_bottom: 11,
384
+ };
385
+ return (offsets[nodeId] ?? 0) * scale;
386
+ }
387
+
388
+ function layoutLeftArmRows(input: {
389
+ coreNodes: LayoutDefenseNode[];
390
+ leftArmNodes: LayoutDefenseNode[];
391
+ positionedNodes: Map<string, LayoutDefenseNode>;
392
+ padX: number;
393
+ padTop: number;
394
+ scale: number;
395
+ coreCenter?: LayoutDefenseNode;
396
+ }): void {
397
+ const {
398
+ coreNodes,
399
+ leftArmNodes,
400
+ positionedNodes,
401
+ padX,
402
+ padTop,
403
+ scale,
404
+ coreCenter,
405
+ } = input;
406
+
407
+ if (!leftArmNodes.length) return;
408
+
409
+ const coreLeftEdge = coreNodes.reduce((min, node) => Math.min(min, node.x - node.radius), Infinity);
410
+ const coreTopY = coreNodes.reduce((min, node) => Math.min(min, node.y), Infinity);
411
+ const outerCoreRadius = coreCenter
412
+ ? coreNodes.reduce((maxRadius, node) => (
413
+ Math.max(maxRadius, Math.hypot(node.x - coreCenter.x, node.y - coreCenter.y) + node.radius)
414
+ ), 0)
415
+ : 0;
416
+
417
+ const visibleRows = LEFT_ARM_ROW_NODE_IDS
418
+ .map((rowIds, templateIndex) => ({
419
+ templateIndex,
420
+ nodes: rowIds
421
+ .map((id) => positionedNodes.get(id))
422
+ .filter((node): node is LayoutDefenseNode => Boolean(node)),
423
+ fullIds: [...rowIds] as string[],
424
+ }))
425
+ .filter((row) => row.nodes.length > 0);
426
+ if (!visibleRows.length) return;
427
+
428
+ const topRow = visibleRows[0];
429
+ const topRightNode = topRow.nodes
430
+ .slice()
431
+ .sort((left, right) => right.x - left.x)[0];
432
+ const topRowY = Math.max(padTop + (topRightNode?.radius ?? 0), coreTopY);
433
+ const rowGap = Math.max(20 * scale, Math.min(34 * scale, outerCoreRadius * 0.32));
434
+ const topRowLeft = topRow.nodes.reduce((min, node) => Math.min(min, node.x), Infinity) - (62 * scale);
435
+ const topRowRight = topRightNode?.x ?? coreLeftEdge - (10 * scale);
436
+ const topRowWidth = Math.max(12 * scale, topRowRight - topRowLeft);
437
+
438
+ for (let visibleIndex = 0; visibleIndex < visibleRows.length; visibleIndex += 1) {
439
+ const row = visibleRows[visibleIndex];
440
+ const rowY = topRowY + rowGap * visibleIndex;
441
+ const slotCount = row.fullIds.length;
442
+ const rawRowLeft = row.nodes.reduce((min, node) => Math.min(min, node.x), Infinity);
443
+ const rawRowRight = row.nodes.reduce((max, node) => Math.max(max, node.x), -Infinity);
444
+ const rawRowWidth = Math.max(1, rawRowRight - rawRowLeft);
445
+ const rowRightNode = row.nodes
446
+ .slice()
447
+ .sort((left, right) => right.x - left.x)[0];
448
+ const rowRightCenter = visibleIndex === 0
449
+ ? topRowRight
450
+ : Math.min(
451
+ rowRightNode?.x ?? (coreLeftEdge - (10 * scale)),
452
+ coreLeftEdge - Math.max(8 * scale, (rowRightNode?.radius ?? 0) + (4 * scale)),
453
+ );
454
+ const taperInset = visibleIndex === 0 ? 0 : topRowWidth * (0.16 + (visibleIndex - 1) * 0.07);
455
+ const currentRowLeft = rawRowLeft;
456
+ const monotonicLeft = topRowLeft + taperInset;
457
+ const rowLeftCenter = Math.min(
458
+ rowRightCenter - Math.max(10 * scale, row.nodes[0]?.radius ?? 0),
459
+ Math.max(
460
+ padX + (row.nodes[0]?.radius ?? 0),
461
+ visibleIndex === 0
462
+ ? Math.min(currentRowLeft - (10 * scale), monotonicLeft)
463
+ : Math.max(currentRowLeft - (4 * scale), monotonicLeft),
464
+ ),
465
+ );
466
+ const rowSpan = Math.max(1, rowRightCenter - rowLeftCenter);
467
+ const templateBlend = visibleIndex === 0 ? 0.76 : visibleIndex === 1 ? 0.56 : 0.42;
468
+
469
+ for (const node of row.nodes) {
470
+ const slotIndex = row.fullIds.indexOf(node.id);
471
+ if (slotIndex < 0) continue;
472
+ const slotProgress = slotCount === 1 ? 1 : slotIndex / (slotCount - 1);
473
+ const rawProgress = rawRowWidth <= 1 ? slotProgress : (node.x - rawRowLeft) / rawRowWidth;
474
+ let blendedProgress = (slotProgress * (1 - templateBlend)) + (rawProgress * templateBlend);
475
+ if (visibleIndex === 0) {
476
+ if (slotProgress <= 0.45) blendedProgress -= 0.03;
477
+ if (slotProgress >= 0.82) blendedProgress += 0.015;
478
+ } else if (visibleIndex === 1) {
479
+ if (slotProgress <= 0.3) blendedProgress -= 0.02;
480
+ if (slotProgress >= 0.72) blendedProgress += 0.02;
481
+ } else {
482
+ if (slotProgress <= 0.35) blendedProgress -= 0.015;
483
+ }
484
+ blendedProgress = Math.max(0, Math.min(1, blendedProgress));
485
+ let nextX = rowLeftCenter + rowSpan * blendedProgress + leftArmIrregularityOffset(node.id, visibleIndex, scale);
486
+ let nextY = rowY;
487
+
488
+ const outsideCore = keepNodeOutsideCoreEnvelope({
489
+ nextX,
490
+ nextY,
491
+ node,
492
+ coreCenter,
493
+ outerCoreRadius,
494
+ preferredHorizontalDirection: -1,
495
+ });
496
+ nextX = outsideCore.x;
497
+ nextY = outsideCore.y;
498
+
499
+ positionedNodes.set(node.id, {
500
+ ...node,
501
+ x: Math.max(padX + node.radius, nextX),
502
+ y: Math.max(padTop + node.radius, nextY),
503
+ });
504
+ }
505
+ }
506
+ }
507
+
508
+ function layoutRightArmRows(input: {
509
+ coreNodes: LayoutDefenseNode[];
510
+ rightArmNodes: LayoutDefenseNode[];
511
+ positionedNodes: Map<string, LayoutDefenseNode>;
512
+ padX: number;
513
+ padTop: number;
514
+ padBottom: number;
515
+ height: number;
516
+ scale: number;
517
+ coreCenter?: LayoutDefenseNode;
518
+ }): void {
519
+ const {
520
+ coreNodes,
521
+ rightArmNodes,
522
+ positionedNodes,
523
+ padX,
524
+ padTop,
525
+ padBottom,
526
+ height,
527
+ scale,
528
+ coreCenter,
529
+ } = input;
530
+ const drawableNodes = rightArmNodes.filter((node) => !node.id.startsWith("right_bridge_"));
531
+ if (!drawableNodes.length) return;
532
+
533
+ const coreRightEdge = coreNodes.reduce((max, node) => Math.max(max, node.x + node.radius), -Infinity);
534
+ const coreLeftEdge = coreNodes.reduce((min, node) => Math.min(min, node.x - node.radius), Infinity);
535
+ const baseEnvelopeWidth = Math.max(16, (coreRightEdge - coreLeftEdge) * 0.82);
536
+ const outerCoreRadius = coreCenter
537
+ ? coreNodes.reduce((maxRadius, node) => (
538
+ Math.max(maxRadius, Math.hypot(node.x - coreCenter.x, node.y - coreCenter.y) + node.radius)
539
+ ), 0)
540
+ : 0;
541
+ const minRadius = drawableNodes.reduce((min, node) => Math.min(min, node.radius), Infinity);
542
+ const tailWidthTarget = Math.max(2.2 * scale, minRadius * 2);
543
+
544
+ const visibleRows = RIGHT_ARM_ROW_NODE_IDS
545
+ .map((rowIds, templateIndex) => ({
546
+ templateIndex,
547
+ nodes: rowIds
548
+ .map((id) => positionedNodes.get(id))
549
+ .filter((node): node is LayoutDefenseNode => Boolean(node)),
550
+ fullIds: [...rowIds] as string[],
551
+ }))
552
+ .filter((row) => row.nodes.length > 0);
553
+ if (!visibleRows.length) return;
554
+
555
+ const topY = visibleRows.reduce((min, row) => Math.min(min, ...row.nodes.map((node) => node.y)), Infinity);
556
+ const bottomY = visibleRows.reduce((max, row) => Math.max(max, ...row.nodes.map((node) => node.y)), -Infinity);
557
+ const rowSpan = Math.max(1, bottomY - topY);
558
+ const rowGap = visibleRows.length === 1 ? 0 : rowSpan / (visibleRows.length - 1);
559
+ const rowTilt = Math.min(Math.max(10 * scale, rowGap * 0.44), 24 * scale);
560
+
561
+ for (let visibleIndex = 0; visibleIndex < visibleRows.length; visibleIndex += 1) {
562
+ const row = visibleRows[visibleIndex];
563
+ const rowProgress = visibleRows.length === 1 ? 0 : visibleIndex / (visibleRows.length - 1);
564
+ const rowWidth = baseEnvelopeWidth - (baseEnvelopeWidth - tailWidthTarget) * rowProgress;
565
+ const rowY = topY + rowSpan * rowProgress;
566
+ const slotCount = row.fullIds.length;
567
+
568
+ for (const node of row.nodes) {
569
+ const slotIndex = row.fullIds.indexOf(node.id);
570
+ if (slotIndex < 0) continue;
571
+ const slotProgress = slotCount === 1 ? 1 : slotIndex / (slotCount - 1);
572
+ const rowRightCenter = coreRightEdge - node.radius;
573
+ const rowLeftCenter = Math.max(padX + node.radius, rowRightCenter - rowWidth);
574
+ let nextX = rowLeftCenter + (rowRightCenter - rowLeftCenter) * slotProgress;
575
+ const slotOffset = slotCount === 1 ? 0 : ((slotProgress - 0.5) * rowTilt);
576
+ let nextY = rowY + slotOffset;
577
+
578
+ if (node.id === "right_c0") {
579
+ nextX += 112 * scale;
580
+ nextY += 118 * scale;
581
+ }
582
+
583
+ if (coreCenter) {
584
+ const clearance = Math.max(4 * scale, 3);
585
+ const minDistanceFromCore = outerCoreRadius + node.radius + clearance;
586
+ const dx = nextX - coreCenter.x;
587
+ const dy = nextY - coreCenter.y;
588
+ const distance = Math.hypot(dx, dy);
589
+ if (distance < minDistanceFromCore) {
590
+ const verticalDistance = Math.abs(dy);
591
+ if (verticalDistance < minDistanceFromCore) {
592
+ const horizontalDistance = Math.sqrt((minDistanceFromCore ** 2) - (verticalDistance ** 2));
593
+ const direction = dx <= 0 ? -1 : 1;
594
+ nextX = coreCenter.x + direction * horizontalDistance;
595
+ } else {
596
+ const ux = distance > 0.0001 ? dx / distance : 1;
597
+ const uy = distance > 0.0001 ? dy / distance : 0;
598
+ nextX = coreCenter.x + ux * minDistanceFromCore;
599
+ nextY = coreCenter.y + uy * minDistanceFromCore;
600
+ }
601
+ }
602
+ }
603
+
604
+ nextX = Math.min(Math.max(nextX, padX + node.radius), coreRightEdge - node.radius);
605
+ nextY = Math.min(height - padBottom - node.radius, Math.max(padTop + node.radius, nextY));
606
+ positionedNodes.set(node.id, {
607
+ ...node,
608
+ x: nextX,
609
+ y: nextY,
610
+ });
611
+ }
612
+ }
613
+
614
+ for (const node of rightArmNodes.filter((candidate) => candidate.id.startsWith("right_bridge_"))) {
615
+ positionedNodes.set(node.id, {
616
+ ...node,
617
+ x: Math.min(node.x, coreRightEdge - node.radius),
618
+ });
619
+ }
620
+ }
621
+
622
+ type BuildRightArmAttachmentEdgesInput = {
623
+ positionedNodes: Map<string, LayoutDefenseNode>;
624
+ coreCenter: LayoutDefenseNode;
625
+ obstacles: LayoutRect[];
626
+ usedEdgeIds: Set<string>;
627
+ };
628
+
629
+ function buildRightArmAttachmentEdges(input: BuildRightArmAttachmentEdgesInput): LayoutDefenseEdge[] {
630
+ const { positionedNodes, coreCenter, obstacles, usedEdgeIds } = input;
631
+ const candidateNodes = RIGHT_ARM_BASE_CANDIDATE_IDS
632
+ .map((id) => positionedNodes.get(id))
633
+ .filter((node): node is LayoutDefenseNode => Boolean(node));
634
+ const preferredExtraCandidateIds = RIGHT_ARM_ROW_NODE_IDS[1]
635
+ .filter((id) => id !== "right_attach_bottom");
636
+ const extraCandidate = preferredExtraCandidateIds
637
+ .map((id) => positionedNodes.get(id))
638
+ .filter((node): node is LayoutDefenseNode => Boolean(node))
639
+ .sort((a, b) => b.x - a.x || a.y - b.y)[0]
640
+ ?? Array.from(positionedNodes.values())
641
+ .filter((node) => (
642
+ node.zone === "right_arm"
643
+ && !RIGHT_ARM_BASE_CANDIDATE_IDS.includes(node.id as (typeof RIGHT_ARM_BASE_CANDIDATE_IDS)[number])
644
+ && !node.id.startsWith("right_bridge_")
645
+ ))
646
+ .sort((a, b) => a.y - b.y || b.x - a.x)[0];
647
+ if (extraCandidate) {
648
+ candidateNodes.push(extraCandidate);
649
+ }
650
+
651
+ const targets = RIGHT_ARM_CORE_TARGET_IDS
652
+ .map((id) => positionedNodes.get(id))
653
+ .filter((node): node is LayoutDefenseNode => Boolean(node));
654
+ if (!candidateNodes.length || !targets.length) {
655
+ return [];
656
+ }
657
+
658
+ const usedTargetIds = new Set<string>();
659
+ const attachmentEdges: LayoutDefenseEdge[] = [];
660
+ const orderedCandidates = candidateNodes
661
+ .slice()
662
+ .sort((a, b) => a.x - b.x || a.y - b.y);
663
+
664
+ for (const candidate of orderedCandidates) {
665
+ const candidateAngle = Math.atan2(candidate.y - coreCenter.y, candidate.x - coreCenter.x);
666
+ const bestTarget = targets
667
+ .filter((target) => candidate.id === "right_c0" || !usedTargetIds.has(target.id))
668
+ .filter((target) => !edgeIntersectsObstacles(candidate.x, candidate.y, target.x, target.y, obstacles))
669
+ .sort((left, right) => {
670
+ const leftDistance = Math.hypot(candidate.x - left.x, candidate.y - left.y);
671
+ const rightDistance = Math.hypot(candidate.x - right.x, candidate.y - right.y);
672
+ if (Math.abs(leftDistance - rightDistance) > 0.001) {
673
+ return leftDistance - rightDistance;
674
+ }
675
+
676
+ const leftAngleDelta = Math.abs(normalizeAngle(candidateAngle - Math.atan2(left.y - coreCenter.y, left.x - coreCenter.x)));
677
+ const rightAngleDelta = Math.abs(normalizeAngle(candidateAngle - Math.atan2(right.y - coreCenter.y, right.x - coreCenter.x)));
678
+ if (Math.abs(leftAngleDelta - rightAngleDelta) > 0.001) {
679
+ return leftAngleDelta - rightAngleDelta;
680
+ }
681
+
682
+ return left.x - right.x;
683
+ })[0];
684
+
685
+ if (!bestTarget) continue;
686
+
687
+ const edgeId = `right_dynamic_attach_${candidate.id}_${bestTarget.id}`;
688
+ if (usedEdgeIds.has(edgeId)) continue;
689
+ attachmentEdges.push(buildEdgeRecord(candidate, bestTarget, edgeId));
690
+ usedEdgeIds.add(edgeId);
691
+ usedTargetIds.add(bestTarget.id);
692
+ }
693
+
694
+ return attachmentEdges;
695
+ }
696
+
697
+ type BuildLeftArmAttachmentEdgesInput = {
698
+ positionedNodes: Map<string, LayoutDefenseNode>;
699
+ coreCenter: LayoutDefenseNode;
700
+ obstacles: LayoutRect[];
701
+ usedEdgeIds: Set<string>;
702
+ scale: number;
703
+ };
704
+
705
+ function buildLeftArmAttachmentEdges(input: BuildLeftArmAttachmentEdgesInput): LayoutDefenseEdge[] {
706
+ const {
707
+ positionedNodes,
708
+ coreCenter,
709
+ obstacles,
710
+ usedEdgeIds,
711
+ scale,
712
+ } = input;
713
+ const candidateNodes = LEFT_ARM_ATTACH_CANDIDATE_IDS
714
+ .map((id) => positionedNodes.get(id))
715
+ .filter((node): node is LayoutDefenseNode => Boolean(node))
716
+ .sort((a, b) => {
717
+ const priority = ["left_a0", "left_b0", "left_c0", "left_attach_top", "left_attach_bottom", "left_bridge_top"];
718
+ const aIndex = priority.indexOf(a.id);
719
+ const bIndex = priority.indexOf(b.id);
720
+ if (aIndex !== bIndex) return aIndex - bIndex;
721
+ return a.y - b.y || b.x - a.x;
722
+ });
723
+ const targets = LEFT_ARM_CORE_TARGET_IDS
724
+ .map((id) => positionedNodes.get(id))
725
+ .filter((node): node is LayoutDefenseNode => Boolean(node));
726
+ if (!candidateNodes.length || !targets.length) {
727
+ return [];
728
+ }
729
+
730
+ const usedTargetIds = new Set<string>();
731
+ const attachmentEdges: LayoutDefenseEdge[] = [];
732
+
733
+ for (const candidate of candidateNodes) {
734
+ const targetPool = targets.filter((target) => !edgeIntersectsObstacles(candidate.x, candidate.y, target.x, target.y, obstacles));
735
+ if (!targetPool.length) continue;
736
+ const preferredTargets = targetPool.filter((target) => !usedTargetIds.has(target.id));
737
+ const shouldIgnoreDedupe = candidate.id === "left_a0" || candidate.id === "left_b0" || candidate.id === "left_c0";
738
+ const availableTargets = shouldIgnoreDedupe || !preferredTargets.length ? targetPool : preferredTargets;
739
+ const verticalBias = candidate.y <= coreCenter.y - (16 * scale)
740
+ ? ["halo_n", "halo_nw", "halo_w"]
741
+ : candidate.y >= coreCenter.y + (16 * scale)
742
+ ? ["halo_w", "halo_nw", "halo_n"]
743
+ : ["halo_nw", "halo_w", "halo_n"];
744
+
745
+ const bestTarget = availableTargets
746
+ .slice()
747
+ .sort((left, right) => {
748
+ const leftDistance = Math.hypot(candidate.x - left.x, candidate.y - left.y);
749
+ const rightDistance = Math.hypot(candidate.x - right.x, candidate.y - right.y);
750
+ if (Math.abs(leftDistance - rightDistance) > 0.001) {
751
+ return leftDistance - rightDistance;
752
+ }
753
+
754
+ const leftPreference = verticalBias.indexOf(left.id);
755
+ const rightPreference = verticalBias.indexOf(right.id);
756
+ if (leftPreference !== rightPreference) {
757
+ return leftPreference - rightPreference;
758
+ }
759
+
760
+ return right.x - left.x;
761
+ })[0];
762
+
763
+ if (!bestTarget) continue;
764
+
765
+ const edgeId = `left_dynamic_attach_${candidate.id}_${bestTarget.id}`;
766
+ if (usedEdgeIds.has(edgeId)) continue;
767
+ attachmentEdges.push(buildEdgeRecord(candidate, bestTarget, edgeId));
768
+ usedEdgeIds.add(edgeId);
769
+ usedTargetIds.add(bestTarget.id);
770
+ }
771
+
772
+ return attachmentEdges;
773
+ }
774
+
775
+ export function layoutDefenseGraph(input: LayoutDefenseGraphInput): LayoutDefenseGraphResult {
776
+ const width = Math.max(1, input.width);
777
+ const height = Math.max(1, input.height);
778
+ const padX = input.padX ?? 18;
779
+ const padTop = input.padTop ?? 8;
780
+ const padBottom = input.padBottom ?? 12;
781
+ const obstacles = input.obstacles ?? [];
782
+ const core = templateNodeById(BLOG_DEFENSE_CORE_NODE_ID) ?? BLOG_DEFENSE_NODES[0];
783
+ const baseScale = Math.min(width / BLOG_DEFENSE_VIEWBOX_WIDTH, height / BLOG_DEFENSE_VIEWBOX_HEIGHT);
784
+ const scaleBoost = width >= 720
785
+ ? 1.08
786
+ : width >= 520
787
+ ? 1.05
788
+ : 1.02;
789
+ const scale = Math.max(0.48, Math.min(1.18, baseScale * scaleBoost));
790
+ const nodeRadius = uniformNodeRadius(scale);
791
+ const anchor = bestCoreAnchor({
792
+ width,
793
+ height,
794
+ padX,
795
+ padTop,
796
+ padBottom,
797
+ scale,
798
+ coreNode: core,
799
+ nodeRadius,
800
+ obstacles,
801
+ });
802
+ const corridorX = bestCorridorX(width, height, padX, padTop, padBottom, scale, core, anchor, obstacles);
803
+
804
+ const positionedNodes = new Map<string, LayoutDefenseNode>();
805
+ for (const node of BLOG_DEFENSE_NODES) {
806
+ const dx = node.x - core.x;
807
+ const dy = node.y - core.y;
808
+ let x = anchor.x + dx * scale;
809
+ let y = anchor.y + dy * scale;
810
+ const radius = nodeRadius;
811
+
812
+ if (node.zone === "right_corridor") {
813
+ x = corridorX;
814
+ y = Math.min(height - padBottom - radius, Math.max(padTop + radius, y));
815
+ }
816
+
817
+ const inBounds = x > padX + radius
818
+ && x < width - padX - radius
819
+ && y > padTop + radius
820
+ && y < height - padBottom - radius;
821
+ if (!inBounds) continue;
822
+
823
+ positionedNodes.set(node.id, {
824
+ ...node,
825
+ x,
826
+ y,
827
+ radius,
828
+ });
829
+ }
830
+
831
+ const coreNodes = Array.from(positionedNodes.values()).filter((node) => node.zone === "core");
832
+ const leftArmNodes = Array.from(positionedNodes.values()).filter((node) => node.zone === "left_arm");
833
+ const rightArmNodes = Array.from(positionedNodes.values()).filter((node) => node.zone === "right_arm");
834
+ if (coreNodes.length && leftArmNodes.length) {
835
+ layoutLeftArmRows({
836
+ coreNodes,
837
+ leftArmNodes,
838
+ positionedNodes,
839
+ padX,
840
+ padTop,
841
+ scale,
842
+ coreCenter: positionedNodes.get(BLOG_DEFENSE_CORE_NODE_ID),
843
+ });
844
+ }
845
+ if (coreNodes.length && rightArmNodes.length) {
846
+ layoutRightArmRows({
847
+ coreNodes,
848
+ rightArmNodes,
849
+ positionedNodes,
850
+ padX,
851
+ padTop,
852
+ padBottom,
853
+ height,
854
+ scale,
855
+ coreCenter: positionedNodes.get(BLOG_DEFENSE_CORE_NODE_ID),
856
+ });
857
+ }
858
+
859
+ for (const [nodeId, node] of positionedNodes.entries()) {
860
+ if (nodeIntersectsObstacles(node.x, node.y, node.radius + 2, obstacles)) {
861
+ positionedNodes.delete(nodeId);
862
+ }
863
+ }
864
+
865
+ const phaseAEdges: LayoutDefenseEdge[] = [];
866
+ const usedEdgeIds = new Set<string>();
867
+ for (const edge of BLOG_DEFENSE_EDGES) {
868
+ if (edge.id.startsWith("left_to_outer_")) continue;
869
+ const from = positionedNodes.get(edge.from);
870
+ const to = positionedNodes.get(edge.to);
871
+ if (!from || !to) continue;
872
+ if (edgeIntersectsObstacles(from.x, from.y, to.x, to.y, obstacles)) continue;
873
+ phaseAEdges.push(buildEdgeRecord(from, to, edge.id));
874
+ usedEdgeIds.add(edge.id);
875
+ }
876
+
877
+ const coreCenter = positionedNodes.get(BLOG_DEFENSE_CORE_NODE_ID);
878
+ if (coreCenter) {
879
+ phaseAEdges.push(...buildLeftArmAttachmentEdges({
880
+ positionedNodes,
881
+ coreCenter,
882
+ obstacles,
883
+ usedEdgeIds,
884
+ scale,
885
+ }));
886
+ phaseAEdges.push(...buildRightArmAttachmentEdges({
887
+ positionedNodes,
888
+ coreCenter,
889
+ obstacles,
890
+ usedEdgeIds,
891
+ }));
892
+ }
893
+
894
+ const corridorNodes = Array.from(positionedNodes.values())
895
+ .filter((node) => node.zone === "right_corridor")
896
+ .sort((a, b) => a.y - b.y);
897
+
898
+ const phaseBEdges = [...phaseAEdges];
899
+ for (let index = 0; index < corridorNodes.length - 1; index += 1) {
900
+ const from = corridorNodes[index];
901
+ const to = corridorNodes[index + 1];
902
+ if (edgeIntersectsObstacles(from.x, from.y, to.x, to.y, obstacles)) continue;
903
+ const templateEdge = BLOG_DEFENSE_EDGES.find((edge) => (
904
+ (edge.from === from.id && edge.to === to.id) || (edge.from === to.id && edge.to === from.id)
905
+ ));
906
+ const id = templateEdge ? templateEdge.id : `corridor_relink_${from.id}_${to.id}`;
907
+ if (usedEdgeIds.has(id)) continue;
908
+ phaseBEdges.push(buildEdgeRecord(from, to, id));
909
+ usedEdgeIds.add(id);
910
+ }
911
+
912
+ const corridorCoreTargets = corridorNodes
913
+ .slice()
914
+ .sort((a, b) => Math.abs(a.y - anchor.y) - Math.abs(b.y - anchor.y))
915
+ .slice(0, 2);
916
+ const rightArmCandidates = Array.from(positionedNodes.values())
917
+ .filter((node) => node.zone === "right_arm")
918
+ .sort((a, b) => Math.abs(a.y - anchor.y) - Math.abs(b.y - anchor.y));
919
+ for (let index = 0; index < Math.min(corridorCoreTargets.length, rightArmCandidates.length); index += 1) {
920
+ const from = corridorCoreTargets[index];
921
+ const to = rightArmCandidates[index];
922
+ if (edgeIntersectsObstacles(from.x, from.y, to.x, to.y, obstacles)) continue;
923
+ const id = `corridor_bridge_${from.id}_${to.id}`;
924
+ if (usedEdgeIds.has(id)) continue;
925
+ phaseBEdges.push(buildEdgeRecord(from, to, id));
926
+ usedEdgeIds.add(id);
927
+ }
928
+
929
+ return {
930
+ nodes: Array.from(positionedNodes.values()),
931
+ edges: phaseBEdges,
932
+ scale,
933
+ };
934
+ }