@shortlink-org/portolan 0.2.4 → 0.4.0

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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -21,7 +21,7 @@ import {
21
21
  } from "@xyflow/react";
22
22
  import type { Edge } from "@xyflow/react";
23
23
  import "@xyflow/react/dist/style.css";
24
- import { Columns3, Eye, FileCode2, ImageDown, Maximize2, Search, Waypoints } from "lucide-react";
24
+ import { Columns3, Eye, FileCode2, ImageDown, LayoutGrid, ListFilter, Maximize2, Search, Waypoints, Workflow } from "lucide-react";
25
25
  import type { Store } from "../catalog";
26
26
  import { storeViews } from "../catalog";
27
27
  import { index } from "../data";
@@ -29,10 +29,14 @@ import { DiagramSkeleton } from "../components/DiagramSkeleton";
29
29
  import { useSelectionStore } from "../selection/store";
30
30
  import { TableNodeCard } from "./TableNode";
31
31
  import { ViewNodeCard } from "./ViewNode";
32
+ import { GroupNodeCard } from "./GroupNode";
33
+ import type { ErGroupNode } from "./GroupNode";
32
34
  import type { ErFlowNode } from "./RelationCard";
33
35
  import { EDGE_W, EDGE_W_LIT } from "../graph/theme";
34
36
  import { ErMarkers, MARKER_FLOW, MARKER_MANY, MARKER_ONE } from "./markers";
35
- import { layoutEr } from "./layout";
37
+ import { canGroup, groupsOf, hideGroups, layoutEr } from "./layout";
38
+ import type { ErGroupFrame, ErGrouping } from "./layout";
39
+ import { CTX_SLOTS } from "../lib/context-color";
36
40
  import { lineageChain } from "./lineage";
37
41
  import type { LineageMaps } from "./lineage";
38
42
  import { erSpec, matchingNodes } from "./spec";
@@ -42,10 +46,16 @@ import { saveCanvasImage, viewportOf } from "../lib/export-canvas";
42
46
  import type { ImageKind } from "../lib/export-canvas";
43
47
 
44
48
  /** Stable across renders: React Flow re-mounts every node when this changes. */
45
- const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard };
49
+ const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard, erGroup: GroupNodeCard };
50
+
51
+ /** One flow, roots first; or the cards by model group, the groups packed. */
52
+ type Arrangement = "flow" | "groups";
46
53
 
47
54
  const DIM = 0.25;
48
55
 
56
+ /** More hits than this is most of a schema; framing them is framing everything. */
57
+ const SEARCH_FRAME_LIMIT = 30;
58
+
49
59
  /** The catalog's lineage graph, walked on hover. Built once, with the index. */
50
60
  const LINEAGE: LineageMaps = {
51
61
  from: index.lineageFrom,
@@ -90,14 +100,18 @@ function Canvas({
90
100
  const [hoverEdge, setHoverEdge] = useState<string | null>(null);
91
101
  const [layout, setLayout] = useState<{
92
102
  positions: Record<string, { x: number; y: number }>;
103
+ groups: ErGroupFrame[];
93
104
  ready: boolean;
94
- }>({ positions: {}, ready: false });
105
+ }>({ positions: {}, groups: [], ready: false });
106
+ // Null until the reader chooses: a schema big enough is grouped, the rest
107
+ // flow, and the toggle only appears where the choice exists.
108
+ const [chosen, setChosen] = useState<Arrangement | null>(null);
95
109
 
96
110
  const select = useSelectionStore((s) => s.select);
97
111
  const clear = useSelectionStore((s) => s.clear);
98
112
  const selectionId = useSelectionStore((s) => s.selection?.id ?? null);
99
113
 
100
- const spec: ErSpec = useMemo(
114
+ const full: ErSpec = useMemo(
101
115
  () =>
102
116
  erSpec(index, store, {
103
117
  mode,
@@ -108,19 +122,53 @@ function Canvas({
108
122
  }),
109
123
  [store, mode, expanded, ghost, showViews, showLineage],
110
124
  );
125
+ // Groups the reader switched off. Everything below reads the spec with
126
+ // them gone, so a hidden group is hidden from the search and the layout
127
+ // too, not only from the eye.
128
+ const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
129
+ const spec: ErSpec = useMemo(() => hideGroups(full, hidden), [full, hidden]);
130
+ const nameOf = useCallback(
131
+ (aggregate: string) => index.aggregateById.get(aggregate)?.name ?? (aggregate.split(".").at(-1) ?? aggregate),
132
+ [],
133
+ );
134
+ const groupList = useMemo(() => groupsOf(full.nodes, nameOf), [full, nameOf]);
135
+ const onHide = useCallback((id: string, hide: boolean) => {
136
+ setHidden((prev) => {
137
+ const next = new Set(prev);
138
+ if (hide) next.add(id);
139
+ else next.delete(id);
140
+ return next;
141
+ });
142
+ }, []);
111
143
 
112
144
  const matched = useMemo(() => matchingNodes(spec, term), [spec, term]);
145
+ const matchedIds = useMemo(() => [...matched], [matched]);
146
+ // Which match the reader is standing on: -1 is "all of them", what typing
147
+ // gives; Enter steps through them one at a time.
148
+ const [cursor, setCursor] = useState(-1);
149
+ useEffect(() => setCursor(-1), [term]);
150
+
151
+ const groupable = useMemo(() => canGroup(full), [full]);
152
+ const arrangement: Arrangement = groupable ? (chosen ?? "groups") : "flow";
113
153
 
114
154
  useEffect(() => {
115
155
  let cancelled = false;
116
156
  setLayout((prev) => ({ ...prev, ready: false }));
117
- void layoutEr(spec).then((result) => {
118
- if (!cancelled) setLayout({ positions: result.positions, ready: true });
157
+ // The packing aims at the box the picture is drawn in; a box not yet on
158
+ // screen has no shape, and 2:1 is what the service page's canvas is.
159
+ const box = wrapper.current;
160
+ const aspectRatio = box && box.clientWidth > 0 && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 2;
161
+ void layoutEr(spec, {
162
+ grouped: arrangement === "groups",
163
+ aspectRatio,
164
+ nameOf,
165
+ }).then((result) => {
166
+ if (!cancelled) setLayout({ positions: result.positions, groups: result.groups, ready: true });
119
167
  });
120
168
  return () => {
121
169
  cancelled = true;
122
170
  };
123
- }, [spec]);
171
+ }, [spec, arrangement, nameOf]);
124
172
 
125
173
  // Which columns need an anchor, per table. A column with no relationship
126
174
  // gets no handle: React Flow measures every handle it is given, and a wide
@@ -198,6 +246,34 @@ function Canvas({
198
246
  return () => observer.disconnect();
199
247
  }, [flow, layout.ready]);
200
248
 
249
+ // A search moves the camera, not only the lights. One hit is brought up
250
+ // close; a handful are framed together; more than that is most of the
251
+ // schema, so the view stays and the dimming does the work. Clearing the box
252
+ // fits everything again, so the reader is not left zoomed into a corner.
253
+ const hadTerm = useRef(false);
254
+ useEffect(() => {
255
+ if (!layout.ready) return;
256
+ if (term.trim() === "") {
257
+ if (hadTerm.current) flow.fitView({ padding: 0.1, duration: 250 });
258
+ hadTerm.current = false;
259
+ return;
260
+ }
261
+ hadTerm.current = true;
262
+ const target = cursor >= 0 ? matchedIds.slice(cursor, cursor + 1) : matchedIds;
263
+ if (target.length === 0 || target.length > SEARCH_FRAME_LIMIT) return;
264
+ void flow.fitView({
265
+ nodes: target.map((id) => ({ id })),
266
+ padding: target.length === 1 ? 0.35 : 0.2,
267
+ maxZoom: 1.25,
268
+ duration: 250,
269
+ });
270
+ }, [flow, layout.ready, term, matchedIds, cursor]);
271
+
272
+ const onJump = useCallback(() => {
273
+ if (matchedIds.length === 0) return;
274
+ setCursor((prev) => (prev + 1) % matchedIds.length);
275
+ }, [matchedIds]);
276
+
201
277
  const onToggle = useCallback((tableId: string) => {
202
278
  setExpanded((prev) => {
203
279
  const next = new Set(prev);
@@ -219,7 +295,35 @@ function Canvas({
219
295
  : null;
220
296
  }, [selectionId]);
221
297
 
222
- const nodes: ErFlowNode[] = useMemo(
298
+ // The frames go first so that they are drawn under the cards; nothing
299
+ // about them is interactive but the label, so a click on one reaches the
300
+ // pane and clears the selection like empty canvas would.
301
+ const frames: ErGroupNode[] = useMemo(
302
+ () =>
303
+ layout.groups.map((group) => ({
304
+ id: `group:${group.id}`,
305
+ type: "erGroup" as const,
306
+ position: { x: group.x, y: group.y },
307
+ width: group.width,
308
+ height: group.height,
309
+ draggable: false,
310
+ selectable: false,
311
+ connectable: false,
312
+ focusable: false,
313
+ zIndex: -1,
314
+ data: {
315
+ name: group.name,
316
+ aggregate: group.aggregate,
317
+ count: group.count,
318
+ // By the group's place in the full list, so that hiding a
319
+ // neighbour does not recolour the rest.
320
+ tint: `var(--ctx-${Math.max(0, groupList.findIndex((g) => g.id === group.id)) % CTX_SLOTS})`,
321
+ },
322
+ })),
323
+ [layout.groups, groupList],
324
+ );
325
+
326
+ const cards: ErFlowNode[] = useMemo(
223
327
  () =>
224
328
  spec.nodes.map((node) => ({
225
329
  id: node.id,
@@ -256,6 +360,7 @@ function Canvas({
256
360
  onColumnClick,
257
361
  ],
258
362
  );
363
+ const nodes: (ErFlowNode | ErGroupNode)[] = useMemo(() => [...frames, ...cards], [frames, cards]);
259
364
 
260
365
  const edges: Edge[] = useMemo(
261
366
  () =>
@@ -315,7 +420,7 @@ function Canvas({
315
420
  [spec, layout.positions, litEdges],
316
421
  );
317
422
 
318
- const fitKey = layout.ready ? `fit-${nodes.length}-${mode}` : "pending";
423
+ const fitKey = layout.ready ? `fit-${nodes.length}-${mode}-${arrangement}` : "pending";
319
424
  const views = storeViews(store).length;
320
425
 
321
426
  return (
@@ -334,6 +439,13 @@ function Canvas({
334
439
  mode={mode}
335
440
  onMode={setMode}
336
441
  hits={matched.size}
442
+ cursor={cursor}
443
+ onJump={onJump}
444
+ arrangement={groupable ? arrangement : null}
445
+ onArrangement={setChosen}
446
+ groups={groupable ? groupList : []}
447
+ hidden={hidden}
448
+ onHide={onHide}
337
449
  views={views}
338
450
  showViews={showViews}
339
451
  onShowViews={setShowViews}
@@ -348,7 +460,9 @@ function Canvas({
348
460
  nodes={nodes}
349
461
  edges={edges}
350
462
  nodeTypes={erNodeTypes}
351
- onNodeClick={(_, node) => select(node.id, "diagram")}
463
+ onNodeClick={(_, node) => {
464
+ if (node.type !== "erGroup") select(node.id, "diagram");
465
+ }}
352
466
  onPaneClick={() => clear("diagram")}
353
467
  onEdgeMouseEnter={(_, edge) => setHoverEdge(edge.id)}
354
468
  onEdgeMouseLeave={() => setHoverEdge(null)}
@@ -377,6 +491,13 @@ function Toolbar({
377
491
  mode,
378
492
  onMode,
379
493
  hits,
494
+ cursor,
495
+ onJump,
496
+ arrangement,
497
+ onArrangement,
498
+ groups,
499
+ hidden,
500
+ onHide,
380
501
  views,
381
502
  showViews,
382
503
  onShowViews,
@@ -390,6 +511,16 @@ function Toolbar({
390
511
  mode: ColumnMode;
391
512
  onMode: (value: ColumnMode) => void;
392
513
  hits: number;
514
+ /** The hit the reader stepped to with Enter, -1 for none yet. */
515
+ cursor: number;
516
+ onJump: () => void;
517
+ /** How the cards are laid out; null when the schema is too small for the choice to matter. */
518
+ arrangement: Arrangement | null;
519
+ onArrangement: (value: Arrangement) => void;
520
+ /** The model groups a reader can switch off; empty when the schema is too small to bother. */
521
+ groups: ErGrouping[];
522
+ hidden: ReadonlySet<string>;
523
+ onHide: (id: string, hide: boolean) => void;
393
524
  /** How many views this store has; with none, the toggle is not a choice. */
394
525
  views: number;
395
526
  showViews: boolean;
@@ -428,13 +559,27 @@ function Toolbar({
428
559
  <input
429
560
  value={term}
430
561
  onChange={(e) => onTerm(e.target.value)}
562
+ onKeyDown={(e) => {
563
+ // Enter walks the hits, best first; Escape lets go of the search
564
+ // and the camera with it.
565
+ if (e.key === "Enter") {
566
+ e.preventDefault();
567
+ onJump();
568
+ } else if (e.key === "Escape") {
569
+ e.preventDefault();
570
+ onTerm("");
571
+ }
572
+ }}
431
573
  placeholder="find a table or column"
432
574
  spellCheck={false}
433
575
  aria-label="Find a table or column"
576
+ title="Enter steps through the hits, Escape clears"
434
577
  className="mono w-44 bg-transparent outline-none placeholder:text-muted"
435
578
  />
436
579
  {term ? (
437
- <span className="mono tnum shrink-0 text-muted">{hits}</span>
580
+ <span className="mono tnum shrink-0 text-muted" aria-live="polite">
581
+ {cursor >= 0 ? `${cursor + 1}/${hits}` : hits}
582
+ </span>
438
583
  ) : null}
439
584
  </label>
440
585
 
@@ -459,6 +604,67 @@ function Toolbar({
459
604
  </button>
460
605
  </div>
461
606
 
607
+ {/* Only a schema big enough to come out as a column offers the choice;
608
+ on a small one the flow is the picture and a frame would be noise. */}
609
+ {arrangement ? (
610
+ <div className="seg bg-canvas" role="group" aria-label="Arrangement">
611
+ <button
612
+ type="button"
613
+ onClick={() => onArrangement("flow")}
614
+ aria-pressed={arrangement === "flow"}
615
+ className={arrangement === "flow" ? "is-on" : ""}
616
+ title="One flow, roots on the left"
617
+ >
618
+ <Workflow size={11} aria-hidden className="inline" /> flow
619
+ </button>
620
+ <button
621
+ type="button"
622
+ onClick={() => onArrangement("groups")}
623
+ aria-pressed={arrangement === "groups"}
624
+ className={arrangement === "groups" ? "is-on" : ""}
625
+ title="Tables by the model group they persist, groups packed to the canvas"
626
+ >
627
+ <LayoutGrid size={11} aria-hidden className="inline" /> groups
628
+ </button>
629
+ </div>
630
+ ) : null}
631
+
632
+ {/* Which groups are on the canvas. A big schema is read one module at a
633
+ time, and a module is easier to read with its neighbours gone. */}
634
+ {groups.length > 0 ? (
635
+ <details className="relative">
636
+ <summary
637
+ className="seg mono cursor-pointer list-none bg-canvas px-2 py-1 text-muted hover:bg-surface"
638
+ title="Show or hide model groups"
639
+ >
640
+ <ListFilter size={11} aria-hidden className="inline" /> {groups.length - hidden.size}/{groups.length}
641
+ </summary>
642
+ <div className="absolute left-0 top-full z-20 mt-1 max-h-72 w-64 overflow-y-auto rounded-card border border-line bg-surface p-1 shadow-card">
643
+ <div className="flex gap-2 px-2 py-1">
644
+ <button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, false))}>
645
+ all
646
+ </button>
647
+ <button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, true))}>
648
+ none
649
+ </button>
650
+ </div>
651
+ {groups.map((group, i) => (
652
+ <label key={group.id} className="flex cursor-pointer items-center gap-2 rounded-control px-2 py-1 hover:bg-canvas">
653
+ <input
654
+ type="checkbox"
655
+ checked={!hidden.has(group.id)}
656
+ onChange={(e) => onHide(group.id, !e.target.checked)}
657
+ aria-label={`Show ${group.name}`}
658
+ />
659
+ <span className="dot" style={{ color: `var(--ctx-${i % CTX_SLOTS})` }} />
660
+ <span className="mono min-w-0 flex-1 truncate">{group.name}</span>
661
+ <span className="mono tnum text-muted">{group.nodes.length}</span>
662
+ </label>
663
+ ))}
664
+ </div>
665
+ </details>
666
+ ) : null}
667
+
462
668
  {/* Two toggles rather than one: a reader who wants the tables alone and a
463
669
  reader who wants the views without the web of lines that joins them
464
670
  are asking different questions, and answering both with one switch
@@ -0,0 +1,57 @@
1
+ // The frame around one model group's tables, when a schema is laid out by
2
+ // group. It is a background, not a card: it takes no clicks, so the pane
3
+ // behind it still clears the selection, and the cards inside it are drawn on
4
+ // top. Only its label is live, and leads to the group's own page.
5
+ //
6
+ // Each frame has a tint of its own, cycled through the same six hues the
7
+ // contexts use, so that two neighbouring groups read as two at a glance and
8
+ // the label is not the only thing telling them apart.
9
+
10
+ import type { Node, NodeProps } from "@xyflow/react";
11
+ import { Link } from "react-router";
12
+ import { aggregatePath } from "../routes";
13
+
14
+ export interface GroupNodeData extends Record<string, unknown> {
15
+ name: string;
16
+ aggregate: string | null;
17
+ count: number;
18
+ /** A CSS colour the frame is washed with. */
19
+ tint: string;
20
+ }
21
+
22
+ export type ErGroupNode = Node<GroupNodeData, "erGroup">;
23
+
24
+ export function GroupNodeCard({ data, width, height }: NodeProps<ErGroupNode>) {
25
+ const to = data.aggregate ? aggregatePath(data.aggregate) : null;
26
+ const label = `${data.name} · ${data.count}`;
27
+ return (
28
+ <div
29
+ className="rounded-card border"
30
+ style={{
31
+ width,
32
+ height,
33
+ background: `color-mix(in srgb, ${data.tint} 9%, transparent)`,
34
+ borderColor: `color-mix(in srgb, ${data.tint} 45%, transparent)`,
35
+ pointerEvents: "none",
36
+ }}
37
+ data-er-group={data.aggregate ?? "other"}
38
+ >
39
+ <div
40
+ className="mono inline-block rounded-br-card px-2 py-0.5"
41
+ style={{
42
+ pointerEvents: "auto",
43
+ color: data.tint,
44
+ background: `color-mix(in srgb, ${data.tint} 14%, transparent)`,
45
+ }}
46
+ >
47
+ {to ? (
48
+ <Link to={to} className="hover:underline" title={`Open ${data.name}`}>
49
+ {label}
50
+ </Link>
51
+ ) : (
52
+ <span title="tables that persist no model">{label}</span>
53
+ )}
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -31,6 +31,7 @@ export const STORE_KIND_LABEL: Record<StoreKind, string> = {
31
31
  mongodb: "MongoDB",
32
32
  clickhouse: "ClickHouse",
33
33
  s3: "S3",
34
+ dynamodb: "DynamoDB",
34
35
  other: "store",
35
36
  };
36
37
 
@@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest";
8
8
  import { buildIndex, validateCatalog } from "../catalog";
9
9
  import { pathologicalCatalog, wideCatalog } from "../lib/scenarios";
10
10
  import { erSpec } from "./spec";
11
- import { layoutEr, layoutInput } from "./layout";
11
+ import type { ErNode, ErSpec } from "./spec";
12
+ import { canGroup, groupsOf, hideGroups, layoutEr, layoutInput } from "./layout";
12
13
 
13
14
  describe("layoutInput", () => {
14
15
  const bad = pathologicalCatalog();
@@ -82,7 +83,89 @@ describe("layoutEr", () => {
82
83
 
83
84
  it("answers for an empty store without asking elk anything", async () => {
84
85
  const result = await layoutEr({ nodes: [], edges: [] });
85
- expect(result).toEqual({ positions: {}, width: 0, height: 0 });
86
+ expect(result).toEqual({ positions: {}, width: 0, height: 0, groups: [] });
87
+ });
88
+ });
89
+
90
+ describe("grouping a big schema", () => {
91
+ /** A card with only what grouping and packing read off it. */
92
+ const card = (id: string, aggregate: string | null): ErNode =>
93
+ ({ id, aggregate, width: 208, height: 120, name: id.split(".").at(-1) }) as unknown as ErNode;
94
+ /** `count` tables over `aggregates` groups, plus `loose` that persist nothing. */
95
+ const schema = (count: number, aggregates: string[], loose = 0): ErSpec => {
96
+ const nodes: ErNode[] = [];
97
+ for (let i = 0; i < count; i += 1) {
98
+ nodes.push(card(`s.t${i}`, aggregates[i % aggregates.length] ?? null));
99
+ }
100
+ for (let i = 0; i < loose; i += 1) nodes.push(card(`s.l${i}`, null));
101
+ return { nodes, edges: [] };
102
+ };
103
+
104
+ it("groups the cards by the model they persist, biggest first, the loose ones last", () => {
105
+ const spec = schema(7, ["a.big", "a.small", "a.big"], 2);
106
+ const groups = groupsOf(spec.nodes, (a) => a.split(".").at(-1)?.toUpperCase() ?? a);
107
+ expect(groups.map((g) => `${g.name}:${g.nodes.length}`)).toEqual(["BIG:5", "SMALL:2", "other:2"]);
108
+ expect(groups[2]?.aggregate).toBeNull();
109
+ });
110
+
111
+ it("offers grouping only past the size where one flow becomes a column", () => {
112
+ expect(canGroup(schema(39, ["a", "b", "c"]))).toBe(false);
113
+ expect(canGroup(schema(60, ["a", "b"]))).toBe(false);
114
+ expect(canGroup(schema(60, ["a", "b", "c"]))).toBe(true);
115
+ // Loose tables count as size but not as a group.
116
+ expect(canGroup(schema(10, ["a", "b", "c"], 40))).toBe(true);
117
+ expect(canGroup(schema(10, ["a", "b"], 40))).toBe(false);
118
+ });
119
+
120
+ it("packs the groups and keeps every card inside its own frame", async () => {
121
+ const spec = schema(48, ["a.x", "a.y", "a.z"], 6);
122
+ // A key inside a group and one across, which the packing ignores.
123
+ spec.edges.push(
124
+ { id: "in", kind: "fk", from: "s.t3", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
125
+ { id: "across", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
126
+ );
127
+ const laid = await layoutEr(spec, { grouped: true, aspectRatio: 2 });
128
+ expect(laid.groups.map((g) => g.id)).toEqual(["a.x", "a.y", "a.z", "other"]);
129
+ for (const node of spec.nodes) {
130
+ const at = laid.positions[node.id];
131
+ expect(at).toBeDefined();
132
+ const frame = laid.groups.find((g) => g.id === (node.aggregate ?? "other"));
133
+ expect(frame).toBeDefined();
134
+ if (!at || !frame) continue;
135
+ expect(at.x).toBeGreaterThanOrEqual(frame.x);
136
+ expect(at.y).toBeGreaterThanOrEqual(frame.y);
137
+ expect(at.x + node.width).toBeLessThanOrEqual(frame.x + frame.width + 0.5);
138
+ expect(at.y + node.height).toBeLessThanOrEqual(frame.y + frame.height + 0.5);
139
+ }
140
+ // Frames do not overlap.
141
+ for (const a of laid.groups) {
142
+ for (const b of laid.groups) {
143
+ if (a.id === b.id) continue;
144
+ const apart = a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y;
145
+ expect(apart).toBe(true);
146
+ }
147
+ }
148
+ // Packed near the box's shape rather than as a column.
149
+ expect(laid.width / laid.height).toBeGreaterThan(1);
150
+ // The key inside a group put its parent left of its child.
151
+ expect(laid.positions["s.t0"]?.x ?? 0).toBeLessThan(laid.positions["s.t3"]?.x ?? 0);
152
+ });
153
+
154
+ it("hides a group with every edge that touched it, and leaves the rest as it was", () => {
155
+ const spec = schema(6, ["a.x", "a.y"], 2);
156
+ spec.edges.push(
157
+ { id: "in-x", kind: "fk", from: "s.t2", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
158
+ { id: "x-y", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
159
+ );
160
+ expect(hideGroups(spec, new Set())).toBe(spec);
161
+ const without = hideGroups(spec, new Set(["a.y", "other"]));
162
+ expect(without.nodes.map((n) => n.id)).toEqual(["s.t0", "s.t2", "s.t4"]);
163
+ expect(without.edges.map((e) => e.id)).toEqual(["in-x"]);
164
+ });
165
+
166
+ it("stays one flow unless asked", async () => {
167
+ const laid = await layoutEr(schema(45, ["a", "b", "c"]));
168
+ expect(laid.groups).toEqual([]);
86
169
  });
87
170
  });
88
171