@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-beta.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.
- package/OVERVIEW.md +20 -2
- package/README.md +91 -5
- package/client/access.tsx +66 -0
- package/client/browser.tsx +12 -9
- package/client/graph-camera.tsx +219 -0
- package/client/graph-labels.ts +147 -0
- package/client/launch-choices.ts +15 -4
- package/client/launch-guidance.tsx +86 -0
- package/client/launch-plan.ts +86 -16
- package/client/launch.tsx +19 -5
- package/client/live-agents.tsx +522 -0
- package/client/live-cursor.tsx +293 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +518 -0
- package/client/live-header.tsx +341 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +465 -0
- package/client/live-timeline.ts +376 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +145 -0
- package/client/settings-projects.tsx +9 -0
- package/client/tool-buttons.tsx +68 -0
- package/client/web.ts +45 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +29 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +240 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +65 -0
- package/server/links.ts +153 -0
- package/server/live.ts +360 -0
- package/server/mcp-http.ts +152 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +312 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +130 -0
- package/shared/graph-camera.ts +93 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +243 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +65 -2
- package/shared/settings.ts +88 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Points, routes along them, and label placement for the Linear Live graph. Pure, so tests can
|
|
2
|
+
// check them without a renderer.
|
|
3
|
+
|
|
4
|
+
export interface Point {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Route {
|
|
10
|
+
points: Point[];
|
|
11
|
+
/** Each point's share of the whole distance, from 0 to 1. */
|
|
12
|
+
stops: number[];
|
|
13
|
+
length: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Where a route is at a share of its distance, from 0 to 1. */
|
|
17
|
+
export function pointAt(path: Route, share: number): Point {
|
|
18
|
+
const at = Math.min(1, Math.max(0, share));
|
|
19
|
+
for (let index = 1; index < path.stops.length; index += 1) {
|
|
20
|
+
const stop = path.stops[index] as number;
|
|
21
|
+
if (at > stop) continue;
|
|
22
|
+
const before = path.stops[index - 1] as number;
|
|
23
|
+
const from = path.points[index - 1] as Point;
|
|
24
|
+
const to = path.points[index] as Point;
|
|
25
|
+
const part = stop > before ? (at - before) / (stop - before) : 1;
|
|
26
|
+
return { x: from.x + (to.x - from.x) * part, y: from.y + (to.y - from.y) * part };
|
|
27
|
+
}
|
|
28
|
+
return path.points[path.points.length - 1] as Point;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A route through the points without zero-length steps, or null when there is nowhere to go. */
|
|
32
|
+
export function route(points: readonly Point[]): Route | null {
|
|
33
|
+
const kept: Point[] = [];
|
|
34
|
+
const steps = [0];
|
|
35
|
+
for (const point of points) {
|
|
36
|
+
const last = kept[kept.length - 1];
|
|
37
|
+
if (!last) kept.push(point);
|
|
38
|
+
else {
|
|
39
|
+
const step = Math.hypot(point.x - last.x, point.y - last.y);
|
|
40
|
+
if (step < 0.5) continue;
|
|
41
|
+
kept.push(point);
|
|
42
|
+
steps.push((steps[steps.length - 1] as number) + step);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const length = steps[steps.length - 1] as number;
|
|
46
|
+
if (kept.length < 2) return null;
|
|
47
|
+
return { points: kept, stops: steps.map((step) => step / length), length };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type LabelSide = "below" | "above" | "left" | "right";
|
|
51
|
+
|
|
52
|
+
export interface Placed extends Point {
|
|
53
|
+
side: LabelSide;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where a label of this size goes on its node's side. */
|
|
57
|
+
export function labelBox(at: Placed, radius: number, width: number, height: number): { left: number; top: number } {
|
|
58
|
+
if (at.side === "right") return { left: at.x + radius + 5, top: at.y - height / 2 };
|
|
59
|
+
if (at.side === "left") return { left: at.x - radius - 5 - width, top: at.y - height / 2 };
|
|
60
|
+
if (at.side === "above") return { left: at.x - width / 2, top: at.y - radius - 3 - height };
|
|
61
|
+
return { left: at.x - width / 2, top: at.y + radius + 3 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The sides to try for a label, best first: the outward side, then the others. */
|
|
65
|
+
export function labelSides(side: LabelSide): LabelSide[] {
|
|
66
|
+
if (side === "left" || side === "right") return [side, "above", "below"];
|
|
67
|
+
return side === "below" ? ["below", "above", "right", "left"] : ["above", "below", "right", "left"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LabelRequest {
|
|
71
|
+
id: string;
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
/** Where the label may go, best first. */
|
|
75
|
+
spots: readonly { left: number; top: number }[];
|
|
76
|
+
/** A shorter label to try when the full one has no free spot. */
|
|
77
|
+
fallback?: { width: number; height: number; spots: readonly { left: number; top: number }[] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PlacedLabel {
|
|
81
|
+
left: number;
|
|
82
|
+
top: number;
|
|
83
|
+
/** The fallback was used. */
|
|
84
|
+
short: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Places labels in request order at the first spot that overlaps no node or placed label,
|
|
88
|
+
// moved inside the box when it would cross an edge. A label with no free spot is hidden.
|
|
89
|
+
export function placeLabels(
|
|
90
|
+
requests: readonly LabelRequest[],
|
|
91
|
+
width: number,
|
|
92
|
+
height: number,
|
|
93
|
+
nodes: readonly { at: Point; radius: number }[] = [],
|
|
94
|
+
gap = 3,
|
|
95
|
+
): Map<string, PlacedLabel> {
|
|
96
|
+
const placed = nodes.map(({ at, radius }) => ({
|
|
97
|
+
left: at.x - radius,
|
|
98
|
+
right: at.x + radius,
|
|
99
|
+
top: at.y - radius,
|
|
100
|
+
bottom: at.y + radius,
|
|
101
|
+
}));
|
|
102
|
+
const shown = new Map<string, PlacedLabel>();
|
|
103
|
+
for (const request of requests) {
|
|
104
|
+
const tries = [{ ...request, short: false }];
|
|
105
|
+
if (request.fallback) tries.push({ ...request, ...request.fallback, short: true });
|
|
106
|
+
find: for (const attempt of tries) {
|
|
107
|
+
for (const spot of attempt.spots) {
|
|
108
|
+
const left = Math.min(Math.max(0, width - attempt.width), Math.max(0, spot.left));
|
|
109
|
+
const top = Math.min(Math.max(0, height - attempt.height), Math.max(0, spot.top));
|
|
110
|
+
const box = { left: left - gap, right: left + attempt.width + gap, top, bottom: top + attempt.height };
|
|
111
|
+
const hit = placed.some(
|
|
112
|
+
(other) => box.left < other.right && other.left < box.right && box.top < other.bottom && other.top < box.bottom,
|
|
113
|
+
);
|
|
114
|
+
if (hit) continue;
|
|
115
|
+
placed.push(box);
|
|
116
|
+
shown.set(request.id, { left, top, short: attempt.short });
|
|
117
|
+
break find;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return shown;
|
|
122
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { AgentFocus } from "./activity";
|
|
2
|
+
import type { LabelSide, Placed } from "./graph-geometry";
|
|
3
|
+
import type { FileLink, LinkKind } from "./links";
|
|
4
|
+
import type { MapTile } from "./map-model";
|
|
5
|
+
|
|
6
|
+
// The Linear Live graph: files are vertices and the links between them, such as imports, are
|
|
7
|
+
// edges. Files sit near others in their folder and take their sub-issue's color; the issue and
|
|
8
|
+
// its sub-issues join the files the plan gave them. Pure, so tests can check the layout.
|
|
9
|
+
|
|
10
|
+
export type GraphNodeKind = "issue" | "subissue" | "file";
|
|
11
|
+
export type GraphEdgeKind = "owns" | LinkKind;
|
|
12
|
+
|
|
13
|
+
export interface GraphNode {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: GraphNodeKind;
|
|
16
|
+
label: string;
|
|
17
|
+
/** Issue key whose color the node takes. */
|
|
18
|
+
owner: string | null;
|
|
19
|
+
/** Repo path, for file nodes. */
|
|
20
|
+
path: string | null;
|
|
21
|
+
tile: MapTile | null;
|
|
22
|
+
/** Up to two folder levels, which files of one neighborhood share. */
|
|
23
|
+
folder: string | null;
|
|
24
|
+
/** Files left out to keep the graph readable. */
|
|
25
|
+
folded: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphEdge {
|
|
29
|
+
id: string;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
kind: GraphEdgeKind;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Graph {
|
|
36
|
+
root: string;
|
|
37
|
+
nodes: GraphNode[];
|
|
38
|
+
edges: GraphEdge[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GraphInput {
|
|
42
|
+
issue: string;
|
|
43
|
+
children: readonly string[];
|
|
44
|
+
tiles: readonly MapTile[];
|
|
45
|
+
links?: readonly FileLink[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_UNTOUCHED_PER_OWNER = 6;
|
|
49
|
+
export const MAX_GRAPH_FILES = 120;
|
|
50
|
+
const STRENGTH: Record<GraphEdgeKind, number> = { import: 4, link: 3, mention: 2, owns: 1 };
|
|
51
|
+
|
|
52
|
+
export const issueNodeId = (key: string) => `issue:${key.toUpperCase()}`;
|
|
53
|
+
export const subIssueNodeId = (key: string) => `sub:${key.toUpperCase()}`;
|
|
54
|
+
export const fileNodeId = (path: string) => `file:${path}`;
|
|
55
|
+
|
|
56
|
+
export function folderOf(path: string): string {
|
|
57
|
+
const parts = path.split("/").slice(0, -1);
|
|
58
|
+
return parts.length ? parts.slice(0, 2).join("/") : ".";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The files the graph shows: every touched file, then predicted ones up to a cap per issue. */
|
|
62
|
+
export function graphFiles(input: GraphInput): { tiles: MapTile[]; folded: Map<string, number> } {
|
|
63
|
+
const children = new Set(input.children.map((key) => key.toUpperCase()));
|
|
64
|
+
const parent = input.issue.toUpperCase();
|
|
65
|
+
const ownerOf = (tile: MapTile) => (tile.owner && children.has(tile.owner.toUpperCase()) ? tile.owner.toUpperCase() : parent);
|
|
66
|
+
const tiles = input.tiles.filter((tile) => !tile.ghost);
|
|
67
|
+
const chosen = tiles.filter((tile) => tile.touch !== "none").slice(0, MAX_GRAPH_FILES);
|
|
68
|
+
const folded = new Map<string, number>();
|
|
69
|
+
const perOwner = new Map<string, number>();
|
|
70
|
+
for (const tile of tiles) {
|
|
71
|
+
if (tile.touch !== "none" || !tile.onMap) continue;
|
|
72
|
+
const owner = ownerOf(tile);
|
|
73
|
+
const count = perOwner.get(owner) ?? 0;
|
|
74
|
+
if (count >= MAX_UNTOUCHED_PER_OWNER || chosen.length >= MAX_GRAPH_FILES) {
|
|
75
|
+
folded.set(owner, (folded.get(owner) ?? 0) + 1);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
perOwner.set(owner, count + 1);
|
|
79
|
+
chosen.push(tile);
|
|
80
|
+
}
|
|
81
|
+
return { tiles: chosen, folded };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildGraph(input: GraphInput): Graph {
|
|
85
|
+
const parentKey = input.issue.toUpperCase();
|
|
86
|
+
const root = issueNodeId(parentKey);
|
|
87
|
+
const children = new Set(input.children.map((key) => key.toUpperCase()));
|
|
88
|
+
const nodes = new Map<string, GraphNode>();
|
|
89
|
+
const edges = new Map<string, GraphEdge>();
|
|
90
|
+
const { tiles, folded } = graphFiles(input);
|
|
91
|
+
const add = (node: GraphNode) => nodes.set(node.id, node);
|
|
92
|
+
const join = (from: string, to: string, kind: GraphEdgeKind) => {
|
|
93
|
+
if (from === to || !nodes.has(from) || !nodes.has(to)) return;
|
|
94
|
+
const [a, b] = from < to ? [from, to] : [to, from];
|
|
95
|
+
const id = `${a}>${b}`;
|
|
96
|
+
const known = edges.get(id);
|
|
97
|
+
if (!known || STRENGTH[kind] > STRENGTH[known.kind]) edges.set(id, { id, from, to, kind });
|
|
98
|
+
};
|
|
99
|
+
const hub = (key: string) => (children.has(key) ? subIssueNodeId(key) : root);
|
|
100
|
+
add({ id: root, kind: "issue", label: parentKey, owner: parentKey, path: null, tile: null, folder: null, folded: folded.get(parentKey) ?? 0 });
|
|
101
|
+
for (const key of children) {
|
|
102
|
+
add({ id: subIssueNodeId(key), kind: "subissue", label: key, owner: key, path: null, tile: null, folder: null, folded: folded.get(key) ?? 0 });
|
|
103
|
+
join(root, subIssueNodeId(key), "owns");
|
|
104
|
+
}
|
|
105
|
+
for (const tile of tiles) {
|
|
106
|
+
const id = fileNodeId(tile.path);
|
|
107
|
+
const label = tile.path.split("/").pop() ?? tile.path;
|
|
108
|
+
add({ id, kind: "file", label, owner: tile.owner, path: tile.path, tile, folder: folderOf(tile.path), folded: 0 });
|
|
109
|
+
if (tile.onMap && tile.owner) join(hub(tile.owner.toUpperCase()), id, "owns");
|
|
110
|
+
}
|
|
111
|
+
for (const link of input.links ?? []) join(fileNodeId(link.from), fileNodeId(link.to), link.kind);
|
|
112
|
+
return { root, nodes: [...nodes.values()], edges: [...edges.values()] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Changes only when a node or an edge comes or goes; then the layout runs again. */
|
|
116
|
+
export function graphKey(graph: Graph): string {
|
|
117
|
+
const ids = graph.nodes.map((node) => node.id).sort();
|
|
118
|
+
const links = graph.edges.map((edge) => edge.id).sort();
|
|
119
|
+
return `${ids.join("|")}#${links.join("|")}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const MAX_HOPS = 6;
|
|
123
|
+
|
|
124
|
+
/** The shortest walk between two nodes along edges, or a straight hop when there is none. */
|
|
125
|
+
export function graphPath(graph: Graph, from: string, to: string): string[] {
|
|
126
|
+
const ids = new Set(graph.nodes.map((node) => node.id));
|
|
127
|
+
if (!ids.has(to)) return [];
|
|
128
|
+
if (!ids.has(from) || from === to) return [to];
|
|
129
|
+
const next = new Map<string, string[]>();
|
|
130
|
+
for (const edge of graph.edges) {
|
|
131
|
+
next.set(edge.from, [...(next.get(edge.from) ?? []), edge.to]);
|
|
132
|
+
next.set(edge.to, [...(next.get(edge.to) ?? []), edge.from]);
|
|
133
|
+
}
|
|
134
|
+
const came = new Map<string, string>([[from, from]]);
|
|
135
|
+
const queue = [from];
|
|
136
|
+
for (let index = 0; index < queue.length && !came.has(to); index += 1) {
|
|
137
|
+
const at = queue[index] as string;
|
|
138
|
+
for (const neighbor of [...(next.get(at) ?? [])].sort()) {
|
|
139
|
+
if (came.has(neighbor)) continue;
|
|
140
|
+
came.set(neighbor, at);
|
|
141
|
+
queue.push(neighbor);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!came.has(to)) return [from, to];
|
|
145
|
+
const path = [to];
|
|
146
|
+
while (path[0] !== from) path.unshift(came.get(path[0] as string) as string);
|
|
147
|
+
return path.length - 1 > MAX_HOPS ? [from, to] : path;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The node an agent works on: its file or sub-issue when the graph has it, else the issue. */
|
|
151
|
+
export function focusNode(graph: Graph, focus: AgentFocus | null): string {
|
|
152
|
+
if (!focus) return graph.root;
|
|
153
|
+
const id = focus.kind === "issue" ? subIssueNodeId(focus.key) : fileNodeId(focus.path);
|
|
154
|
+
return graph.nodes.some((node) => node.id === id) ? id : graph.root;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mulberry32(seed: number) {
|
|
158
|
+
let state = seed >>> 0;
|
|
159
|
+
return () => {
|
|
160
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
161
|
+
let value = state;
|
|
162
|
+
value = Math.imul(value ^ (value >>> 15), value | 1);
|
|
163
|
+
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
|
164
|
+
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hash(text: string): number {
|
|
169
|
+
let value = 2166136261;
|
|
170
|
+
for (let index = 0; index < text.length; index += 1) value = Math.imul(value ^ text.charCodeAt(index), 16777619);
|
|
171
|
+
return value >>> 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Edge length as a share of the ideal spacing, and how hard the edge pulls.
|
|
175
|
+
const SPRINGS: Record<GraphEdgeKind, { length: number; weight: number }> = {
|
|
176
|
+
import: { length: 0.6, weight: 1 },
|
|
177
|
+
link: { length: 0.7, weight: 0.7 },
|
|
178
|
+
mention: { length: 0.9, weight: 0.35 },
|
|
179
|
+
owns: { length: 1, weight: 0.3 },
|
|
180
|
+
};
|
|
181
|
+
const GRAVITY = 0.05;
|
|
182
|
+
const COHESION = 0.35;
|
|
183
|
+
const ANCHOR = 6;
|
|
184
|
+
|
|
185
|
+
function sideOf(point: { x: number; y: number }, center: { x: number; y: number }): LabelSide {
|
|
186
|
+
if (point.x > center.x + 30) return "right";
|
|
187
|
+
if (point.x < center.x - 30) return "left";
|
|
188
|
+
return point.y > center.y ? "below" : "above";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Force layout: nodes push apart, edges pull together, files drift toward their folder's middle,
|
|
192
|
+
// and the issue stays fixed in the center. A warm start holds nodes near where they were.
|
|
193
|
+
export function layoutGraph(
|
|
194
|
+
graph: Graph,
|
|
195
|
+
width: number,
|
|
196
|
+
height: number,
|
|
197
|
+
previous: ReadonlyMap<string, { x: number; y: number }> = new Map(),
|
|
198
|
+
): Map<string, Placed> {
|
|
199
|
+
const random = mulberry32(hash(graphKey(graph)));
|
|
200
|
+
const center = { x: width / 2, y: height / 2 };
|
|
201
|
+
const padX = Math.min(96, width * 0.14);
|
|
202
|
+
const padY = 40;
|
|
203
|
+
const clamp = (x: number, y: number) => ({
|
|
204
|
+
x: Math.min(width - padX, Math.max(padX, x)),
|
|
205
|
+
y: Math.min(height - padY, Math.max(padY, y)),
|
|
206
|
+
});
|
|
207
|
+
const nodes = graph.nodes;
|
|
208
|
+
const k = Math.sqrt((width * height) / Math.max(1, nodes.length)) * 0.72;
|
|
209
|
+
const folders = [...new Set(nodes.flatMap((node) => (node.folder ? [node.folder] : [])))].sort();
|
|
210
|
+
const position = new Map<string, { x: number; y: number }>();
|
|
211
|
+
let warm = 0;
|
|
212
|
+
for (const node of nodes) {
|
|
213
|
+
const before = previous.get(node.id);
|
|
214
|
+
if (node.id === graph.root) position.set(node.id, { ...center });
|
|
215
|
+
else if (before) {
|
|
216
|
+
position.set(node.id, clamp(before.x, before.y));
|
|
217
|
+
warm += 1;
|
|
218
|
+
} else {
|
|
219
|
+
// Each folder starts in its own direction, so neighborhoods form quickly.
|
|
220
|
+
const slot = node.folder ? folders.indexOf(node.folder) : folders.length + (hash(node.id) % 7);
|
|
221
|
+
const angle = (slot / Math.max(1, folders.length + 1)) * Math.PI * 2 + random() * 0.6;
|
|
222
|
+
const reach = (node.kind === "file" ? 0.55 + random() * 0.35 : 0.3) * Math.min(width, height) * 0.5;
|
|
223
|
+
position.set(node.id, clamp(center.x + Math.cos(angle) * reach * (width / height), center.y + Math.sin(angle) * reach));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const coldStart = warm < nodes.length / 2;
|
|
227
|
+
const iterations = coldStart ? 300 : 90;
|
|
228
|
+
let temperature = coldStart ? Math.min(width, height) / 5 : k / 4;
|
|
229
|
+
const cooling = temperature / (iterations + 1);
|
|
230
|
+
const short = Math.min(width, height);
|
|
231
|
+
const pull = { x: (GRAVITY * k * short) / width, y: (GRAVITY * k * short) / height };
|
|
232
|
+
const ids = nodes.map((node) => node.id);
|
|
233
|
+
for (let step = 0; step < iterations; step += 1) {
|
|
234
|
+
const moves = new Map(ids.map((id) => [id, { x: 0, y: 0 }]));
|
|
235
|
+
for (let a = 0; a < ids.length; a += 1) {
|
|
236
|
+
const pa = position.get(ids[a] as string) as { x: number; y: number };
|
|
237
|
+
const ma = moves.get(ids[a] as string) as { x: number; y: number };
|
|
238
|
+
for (let b = a + 1; b < ids.length; b += 1) {
|
|
239
|
+
const pb = position.get(ids[b] as string) as { x: number; y: number };
|
|
240
|
+
const mb = moves.get(ids[b] as string) as { x: number; y: number };
|
|
241
|
+
const dx = pa.x - pb.x || (random() - 0.5) * 0.01;
|
|
242
|
+
const dy = pa.y - pb.y || (random() - 0.5) * 0.01;
|
|
243
|
+
const distance = Math.max(0.5, Math.hypot(dx, dy));
|
|
244
|
+
const force = (k * k) / distance;
|
|
245
|
+
ma.x += (dx / distance) * force;
|
|
246
|
+
ma.y += (dy / distance) * force;
|
|
247
|
+
mb.x -= (dx / distance) * force;
|
|
248
|
+
mb.y -= (dy / distance) * force;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const edge of graph.edges) {
|
|
252
|
+
const pa = position.get(edge.from);
|
|
253
|
+
const pb = position.get(edge.to);
|
|
254
|
+
if (!pa || !pb) continue;
|
|
255
|
+
const spring = SPRINGS[edge.kind];
|
|
256
|
+
const dx = pa.x - pb.x;
|
|
257
|
+
const dy = pa.y - pb.y;
|
|
258
|
+
const distance = Math.max(0.5, Math.hypot(dx, dy));
|
|
259
|
+
const force = (spring.weight * distance * distance) / (k * spring.length);
|
|
260
|
+
const ma = moves.get(edge.from) as { x: number; y: number };
|
|
261
|
+
const mb = moves.get(edge.to) as { x: number; y: number };
|
|
262
|
+
ma.x -= (dx / distance) * force;
|
|
263
|
+
ma.y -= (dy / distance) * force;
|
|
264
|
+
mb.x += (dx / distance) * force;
|
|
265
|
+
mb.y += (dy / distance) * force;
|
|
266
|
+
}
|
|
267
|
+
const middles = new Map<string, { x: number; y: number; count: number }>();
|
|
268
|
+
for (const node of nodes) {
|
|
269
|
+
if (!node.folder) continue;
|
|
270
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
271
|
+
const sum = middles.get(node.folder) ?? { x: 0, y: 0, count: 0 };
|
|
272
|
+
middles.set(node.folder, { x: sum.x + point.x, y: sum.y + point.y, count: sum.count + 1 });
|
|
273
|
+
}
|
|
274
|
+
for (const node of nodes) {
|
|
275
|
+
if (node.id === graph.root) continue;
|
|
276
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
277
|
+
const move = moves.get(node.id) as { x: number; y: number };
|
|
278
|
+
move.x += (center.x - point.x) * pull.x;
|
|
279
|
+
move.y += (center.y - point.y) * pull.y;
|
|
280
|
+
const middle = node.folder ? middles.get(node.folder) : undefined;
|
|
281
|
+
if (middle && middle.count > 1) {
|
|
282
|
+
const dx = middle.x / middle.count - point.x;
|
|
283
|
+
const dy = middle.y / middle.count - point.y;
|
|
284
|
+
const distance = Math.hypot(dx, dy);
|
|
285
|
+
move.x += (dx * distance * COHESION) / k;
|
|
286
|
+
move.y += (dy * distance * COHESION) / k;
|
|
287
|
+
}
|
|
288
|
+
const home = coldStart ? undefined : previous.get(node.id);
|
|
289
|
+
if (home) {
|
|
290
|
+
move.x += (home.x - point.x) * ANCHOR;
|
|
291
|
+
move.y += (home.y - point.y) * ANCHOR;
|
|
292
|
+
}
|
|
293
|
+
const length = Math.max(0.01, Math.hypot(move.x, move.y));
|
|
294
|
+
const limit = Math.min(length, temperature);
|
|
295
|
+
position.set(node.id, clamp(point.x + (move.x / length) * limit, point.y + (move.y / length) * limit));
|
|
296
|
+
}
|
|
297
|
+
temperature = Math.max(0.5, temperature - cooling);
|
|
298
|
+
}
|
|
299
|
+
return new Map(
|
|
300
|
+
nodes.map((node) => {
|
|
301
|
+
const point = position.get(node.id) as { x: number; y: number };
|
|
302
|
+
return [node.id, { ...point, side: node.kind === "file" ? sideOf(point, center) : "below" }];
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Where each folder's files gather, for folders with enough files to name. */
|
|
308
|
+
export function folderCenters(graph: Graph, positions: ReadonlyMap<string, { x: number; y: number }>, minimum = 3) {
|
|
309
|
+
const sums = new Map<string, { x: number; y: number; top: number; count: number }>();
|
|
310
|
+
for (const node of graph.nodes) {
|
|
311
|
+
const at = node.folder ? positions.get(node.id) : undefined;
|
|
312
|
+
if (!at || !node.folder) continue;
|
|
313
|
+
const sum = sums.get(node.folder) ?? { x: 0, y: 0, top: Number.POSITIVE_INFINITY, count: 0 };
|
|
314
|
+
sums.set(node.folder, { x: sum.x + at.x, y: sum.y + at.y, top: Math.min(sum.top, at.y), count: sum.count + 1 });
|
|
315
|
+
}
|
|
316
|
+
return [...sums.entries()]
|
|
317
|
+
.filter(([, sum]) => sum.count >= minimum)
|
|
318
|
+
.map(([folder, sum]) => ({ folder, x: sum.x / sum.count, y: sum.y / sum.count, top: sum.top, count: sum.count }));
|
|
319
|
+
}
|