nebulosity 1.0.0 → 1.1.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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +11 -11
- package/.next/diagnostics/route-bundle-stats.json +10 -10
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +5 -5
- package/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
- package/.next/server/app/_not-found/page.js +3 -2
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +27 -20
- package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +29 -23
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
- package/.next/server/app/index.segments/_full.segment.rsc +29 -23
- package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
- package/.next/server/app/page/build-manifest.json +3 -3
- package/.next/server/app/page/next-font-manifest.json +2 -3
- package/.next/server/app/page.js +4 -4
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
- package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
- package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
- package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
- package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
- package/.next/server/middleware-build-manifest.js +11 -11
- package/.next/server/next-font-manifest.js +1 -1
- package/.next/server/next-font-manifest.json +4 -6
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +3 -3
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
- package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
- package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
- package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
- package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
- package/.next/static/chunks/3w00e1w2125pp.js +11 -0
- package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
- package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
- package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
- package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
- package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
- package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
- package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
- package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
- package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
- package/.next/trace +1 -1
- package/.next/trace-build +1 -1
- package/README.md +13 -4
- package/app/globals.css +91 -54
- package/app/layout.tsx +20 -11
- package/app/map/BackButton.tsx +5 -5
- package/app/map/BoxActions.tsx +20 -12
- package/app/map/Breadcrumb.tsx +132 -46
- package/app/map/DetailPanel.tsx +408 -297
- package/app/map/Explorer.tsx +268 -0
- package/app/map/Header.tsx +105 -0
- package/app/map/HelpDialog.tsx +37 -34
- package/app/map/Legend.tsx +19 -75
- package/app/map/LineCard.tsx +187 -0
- package/app/map/MapBox.tsx +73 -38
- package/app/map/MapCanvas.tsx +344 -115
- package/app/map/MapToolbar.tsx +377 -0
- package/app/map/MapView.tsx +217 -213
- package/app/map/RepoMenu.tsx +13 -12
- package/app/map/RoutedEdge.tsx +48 -16
- package/app/map/SearchPalette.tsx +6 -6
- package/app/map/SourceViewer.tsx +19 -17
- package/app/map/StatusBar.tsx +166 -0
- package/app/map/embed.ts +22 -2
- package/app/map/emphasis.ts +94 -8
- package/app/map/explorerRows.ts +85 -0
- package/app/map/flowNodes.ts +12 -7
- package/app/map/format.ts +2 -2
- package/app/map/history.ts +4 -3
- package/app/map/inspector.ts +113 -0
- package/app/map/labels.ts +66 -7
- package/app/map/narrow.ts +165 -85
- package/app/map/opening.ts +16 -0
- package/app/map/portals.ts +2 -2
- package/app/map/routes.ts +1060 -55
- package/app/map/runs.ts +108 -0
- package/app/map/shell.ts +91 -0
- package/app/map/shortcuts.ts +2 -1
- package/app/map/summaries.ts +41 -5
- package/app/map/text.ts +9 -1
- package/app/map/theme.ts +197 -0
- package/app/map/useDismiss.ts +20 -6
- package/app/map/useEditorBridge.ts +12 -1
- package/app/map/useThemeKind.ts +28 -0
- package/app/map/useWindowSize.ts +37 -0
- package/app/map/viewport.ts +134 -16
- package/app/map/weights.ts +80 -0
- package/app/page.tsx +3 -5
- package/lib/background/child.ts +2 -0
- package/lib/background/service.ts +26 -10
- package/lib/contracts.ts +25 -2
- package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
- package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
- package/lib/db/migrations/meta/_journal.json +7 -0
- package/lib/db/schema.ts +2 -1
- package/lib/db/store/map.ts +6 -0
- package/lib/db/store/summaries.ts +14 -8
- package/lib/engine/describe.ts +2 -0
- package/lib/index/summarize/budget.ts +5 -2
- package/lib/index/summarize/claude.ts +19 -4
- package/lib/index/summarize/compare.ts +2 -2
- package/lib/index/summarize/plan.ts +73 -13
- package/lib/index/summarize/preview.ts +12 -9
- package/lib/index/summarize/prompts.ts +122 -25
- package/lib/index/summarize/run.ts +48 -18
- package/lib/index/summarize/snapshot.ts +7 -2
- package/lib/index/summarize/status.ts +3 -2
- package/lib/tools/text.ts +3 -2
- package/mcp/server.ts +1 -1
- package/package.json +4 -2
- package/scripts/commands/summarize-command.ts +27 -9
- package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
- package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
- package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
- package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
- package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
- package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
- package/.next/static/chunks/10u7p2gj02qay.js +0 -1
- package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
- package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
- package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
- package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
- package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
- package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
- package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
- package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
- package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
- package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
- package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
- package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
- package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
- package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
- package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
- package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
- package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
- package/app/map/ActivityBar.tsx +0 -81
- package/app/map/StatusPill.tsx +0 -93
- package/app/map/TopBar.tsx +0 -84
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
package/app/map/MapCanvas.tsx
CHANGED
|
@@ -2,50 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
ReactFlow, useEdgesState, useNodesState, useReactFlow, useStore, ViewportPortal,
|
|
6
6
|
type EdgeMouseHandler, type EdgeTypes, type NodeMouseHandler, type NodeTypes,
|
|
7
7
|
} from "@xyflow/react";
|
|
8
8
|
import "@xyflow/react/dist/style.css";
|
|
9
|
-
import {
|
|
10
|
-
import type { LevelResponse } from "@/lib/contracts";
|
|
9
|
+
import type { LevelResponse, MapEdge } from "@/lib/contracts";
|
|
11
10
|
import BoxActions from "./BoxActions";
|
|
12
|
-
import { edgeKey, mapEmphasis, type
|
|
11
|
+
import { edgeKey, lineLook, mapEmphasis, type BoxTone, type LineDirection } from "./emphasis";
|
|
13
12
|
import { reuseUnchanged, sameBoxNode, sameRoutedEdge, toBoxNode } from "./flowNodes";
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
13
|
+
import { formatCount } from "./format";
|
|
14
|
+
import { runWords } from "./labels";
|
|
15
|
+
import { BOX_HEIGHT, BOX_WIDTH, layoutLevel, looseDivider, perRowFor, type Divider, type Position } from "./levelLayout";
|
|
16
|
+
import LineCard, { type CardAt, type CardPlace } from "./LineCard";
|
|
17
|
+
import MapBox, { borderOf, shade, type BoxData, type BoxNode } from "./MapBox";
|
|
18
|
+
import { openingOf } from "./opening";
|
|
19
19
|
import { portalCaption, portalDirection, portalLinks, portalTitle } from "./portals";
|
|
20
20
|
import type { Pulse } from "./pulses";
|
|
21
21
|
import RoutedEdge, { type RoutedEdgeType } from "./RoutedEdge";
|
|
22
|
-
import { routeEdges } from "./routes";
|
|
23
|
-
import {
|
|
22
|
+
import { headRooms, nextRoutes, placeCounts, routeEdges, type Point, type Rect } from "./routes";
|
|
23
|
+
import { linesSharing, pointedBits } from "./runs";
|
|
24
|
+
import { isLightKind } from "./theme";
|
|
25
|
+
import { useThemeKind } from "./useThemeKind";
|
|
26
|
+
import { boundsOf, drawnLinesBounds, initialViewport } from "./viewport";
|
|
27
|
+
import { arrowHead, boxHeadWidths, dotSize, labelledLines, lineWidth, maxFilesOf, maxImportsOf } from "./weights";
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
29
|
+
/** The line ids a key of ids joined by newlines names. */
|
|
30
|
+
const keySet = (key: string): Set<string> => new Set(key === "" ? [] : key.split("\n"));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a page's counts are placed by: its lines, each one's width and label size, and the boxes a count keeps off.
|
|
34
|
+
*/
|
|
35
|
+
type CountPage = { edges: readonly MapEdge[]; widths: Map<string, number>; sizes: Map<string, { width: number; height: number }>; boxes: Rect[] };
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
38
|
+
* Where the counts of `labelled` go among the lines of `routes` that are `drawn`, from the places `kept` gives them
|
|
39
|
+
* (placeCounts), clear of the boxes and of the arrowheads those lines draw (headRooms).
|
|
32
40
|
*/
|
|
33
|
-
|
|
41
|
+
function countsOn(page: CountPage, routes: ReadonlyMap<string, Point[]>, drawn: ReadonlySet<string>, labelled: ReadonlySet<string>, kept: ReadonlyMap<string, Point>): Map<string, Point> {
|
|
42
|
+
const on = new Map([...routes].filter(([id]) => drawn.has(id)));
|
|
43
|
+
const labels = new Map([...on.keys()].filter((id) => labelled.has(id)).map((id) => [id, page.sizes.get(id)!]));
|
|
44
|
+
return placeCounts(on, page.widths, labels, [...page.boxes, ...headRooms(on, page.edges)], kept);
|
|
45
|
+
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
48
|
+
* What's on screen, and what it was worked out from: the rest routes, the lines drawn and their routes, and the counts.
|
|
49
|
+
* `settled` is the lines drawn without the hover, `restCounts` rest's counts and `settledCounts` the counts without
|
|
50
|
+
* the hover (the selection's, from rest's), which a hover's `counts` start from.
|
|
39
51
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
type Shown = {
|
|
53
|
+
rest: Map<string, Point[]>;
|
|
54
|
+
drawn: string;
|
|
55
|
+
routes: Map<string, Point[]>;
|
|
56
|
+
selected: string | null;
|
|
57
|
+
settled: string;
|
|
58
|
+
restCounts: Map<string, Point>;
|
|
59
|
+
settledCounts: Map<string, Point>;
|
|
60
|
+
counts: Map<string, Point>;
|
|
46
61
|
};
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The lines under the pointer (runs.ts's linesSharing), by `key`, their ids joined by newlines, and where the pointer
|
|
65
|
+
* is on their run, with the bit of line there and what's over the canvas then, which the hover card keeps off.
|
|
66
|
+
*/
|
|
67
|
+
type LineHover = { key: string; lines: string[] } & CardAt;
|
|
68
|
+
|
|
69
|
+
// at module scope: a new object per render would make React Flow remount every box and line
|
|
70
|
+
const nodeTypes: NodeTypes = { box: MapBox };
|
|
71
|
+
const edgeTypes: EdgeTypes = { routed: RoutedEdge };
|
|
49
72
|
|
|
50
73
|
type MapCanvasProps = {
|
|
51
74
|
level: LevelResponse;
|
|
@@ -61,70 +84,142 @@ type MapCanvasProps = {
|
|
|
61
84
|
onDrawn: () => void;
|
|
62
85
|
/** A busy level: only the hovered or selected box's lines are drawn (emphasis.ts). */
|
|
63
86
|
quietLines: boolean;
|
|
64
|
-
/**
|
|
65
|
-
|
|
87
|
+
/** Strong links only: a weak line is drawn only when it touches the selection, the hovered box or a lit box (emphasis.ts). */
|
|
88
|
+
strongOnly: boolean;
|
|
66
89
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
90
|
+
* Which side of the selection's lines is highlighted, the other drawn gray (emphasis.ts). It changes no line drawn,
|
|
91
|
+
* so no route and no count moves, only colors.
|
|
92
|
+
*/
|
|
93
|
+
direction: LineDirection;
|
|
94
|
+
/**
|
|
95
|
+
* The inspector covers the whole canvas (a window both narrow and short, shell.ts): the boxes can't be reached by Tab
|
|
96
|
+
* then, until its × brings the canvas back.
|
|
69
97
|
*/
|
|
70
98
|
covered: boolean;
|
|
71
|
-
/** How far down the Map's error banner reaches on the canvas, 0 with none: the open legend stops short of it. */
|
|
72
|
-
bannerBottom: number;
|
|
73
99
|
/**
|
|
74
100
|
* The selected box that shows its actions beside it (BoxActions), or null: while the panel would cover the canvas,
|
|
75
101
|
* a click only selects, and Details asks for the panel.
|
|
76
102
|
*/
|
|
77
103
|
actionsFor: string | null;
|
|
78
104
|
onDetails: (path: string) => void;
|
|
105
|
+
/** Inside VS Code's panel only: a file box double-clicked, or its Open beside it, opens it in the editor. */
|
|
106
|
+
onOpenFile?: (path: string) => void;
|
|
79
107
|
};
|
|
80
108
|
|
|
81
109
|
/**
|
|
82
110
|
* The canvas (VISUAL_REFRESH.md §4): one level's boxes, laid out by dagre, and an arrow for every import line, routed
|
|
83
111
|
* around the boxes (routes.ts). A click selects (where the panel would cover the canvas, the selected box shows its
|
|
84
|
-
* actions beside it: BoxActions); a double click opens a folder,
|
|
85
|
-
*
|
|
86
|
-
*
|
|
112
|
+
* actions beside it: BoxActions); a double click opens a folder, turns to a portal's page, or inside VS Code's panel opens a
|
|
113
|
+
* file in the editor (opening.ts). The selection's lines turn purple (what it uses) and gold (what uses it), and unrelated boxes fade;
|
|
114
|
+
* hovering a box previews its lines without selecting it, and hovering a line names every line on that run beside the
|
|
115
|
+
* pointer, a click on it keeping the list (LineCard). React Flow stays controlled with the StrictMode workaround in
|
|
116
|
+
* next.config.ts.
|
|
87
117
|
*/
|
|
88
|
-
function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPage, onDrawn, quietLines,
|
|
118
|
+
function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPage, onDrawn, quietLines, strongOnly, direction, covered, actionsFor, onDetails, onOpenFile }: MapCanvasProps){
|
|
89
119
|
// a wide canvas wraps layers at 8 a row instead of 6, so dense levels fit larger (levelLayout.ts)
|
|
90
120
|
const perRow = useStore((s) => perRowFor(s.width));
|
|
121
|
+
// React Flow's own parts follow a light VS Code theme (theme.ts)
|
|
122
|
+
const themeKind = useThemeKind();
|
|
91
123
|
const positions = useMemo(() => layoutLevel(level.nodes, level.edges, undefined, perRow), [level, perRow]);
|
|
92
124
|
const divider = useMemo(() => looseDivider(level.nodes, level.edges, positions), [level, positions]);
|
|
93
125
|
const byPath = useMemo(() => new Map(level.nodes.map((n) => [n.path, n])), [level]);
|
|
94
|
-
// each portal's arrow, "Lines to page 2" and "44 folders and files
|
|
126
|
+
// each portal's arrow, "Lines to page 2" and "44 folders and files, 6 imports", worked out once per level, not on every hover
|
|
95
127
|
const portals = useMemo(() => new Map<string, BoxData["portal"]>(level.nodes.filter((n) => n.kind === "portal").map((n) => [
|
|
96
128
|
n.path,
|
|
97
129
|
{ direction: portalDirection(n, level.page), title: portalTitle(n), caption: portalCaption(n, portalLinks(level.edges, n.path)) },
|
|
98
130
|
])), [level]);
|
|
99
|
-
|
|
100
|
-
const
|
|
131
|
+
// dots and lines are scaled against the page's largest folder and heaviest line (weights.ts)
|
|
132
|
+
const maxFiles = useMemo(() => maxFilesOf(level.nodes), [level]);
|
|
133
|
+
const maxImports = useMemo(() => maxImportsOf(level.edges), [level]);
|
|
134
|
+
// each box's one head, sized from every line on the level, drawn or not, as routing sized the room above it
|
|
135
|
+
const heads = useMemo(() => boxHeadWidths(level.edges), [level]);
|
|
136
|
+
const labelled = useMemo(() => labelledLines(level.edges, selected), [level, selected]);
|
|
137
|
+
const [lineHover, setLineHover] = useState<LineHover | null>(null);
|
|
138
|
+
// the hover card a click on a run pinned, every line listed (LineCard)
|
|
139
|
+
const [pinned, setPinned] = useState<(LineHover & { held: CardPlace | null }) | null>(null);
|
|
101
140
|
const [hoveredBox, setHoveredBox] = useState<string | null>(null);
|
|
102
141
|
// A hover belongs to the level it happened on. Going into a folder unmounts the old boxes before their mouseleave
|
|
103
142
|
// fires, so the hover outlived the level, and coming back (a cached level, the same object as before) lit that box
|
|
104
143
|
// up with nothing under the pointer (deferred minor). So it's cleared whenever another level is drawn: React's way
|
|
105
144
|
// to reset state when a prop changes, during render rather than in an effect, so no frame shows the stale hover.
|
|
145
|
+
// A pinned card goes with it.
|
|
106
146
|
const [drawnLevel, setDrawnLevel] = useState(level);
|
|
107
|
-
|
|
147
|
+
const levelChanged = drawnLevel !== level;
|
|
148
|
+
if (levelChanged){
|
|
108
149
|
setDrawnLevel(level);
|
|
109
150
|
setHoveredBox(null);
|
|
110
|
-
|
|
151
|
+
setLineHover(null);
|
|
152
|
+
setPinned(null);
|
|
111
153
|
}
|
|
112
154
|
const emphasis = useMemo(
|
|
113
|
-
|
|
114
|
-
|
|
155
|
+
// the boxes Claude Code or the editor has lit keep their lines, whatever hides the rest
|
|
156
|
+
() => mapEmphasis(level.nodes.map((n) => n.path), level.edges, selected, hoveredBox, { quietLines, strongOnly, pinned: agent, direction }),
|
|
157
|
+
[level, selected, hoveredBox, quietLines, strongOnly, agent, direction],
|
|
115
158
|
);
|
|
159
|
+
// The lines drawn at rest (nothing selected, hovered or lit) are routed once, among themselves (routes.ts). A line a
|
|
160
|
+
// selection, a hover or a lit box adds is routed when it appears: it takes its boxes' points (a box has one a side)
|
|
161
|
+
// and, where it fits, the lanes and tracks their drawn lines are on, and goes around every other line on screen. It
|
|
162
|
+
// keeps that route while it stays, so pointing at a box or clicking one never moves a line that's drawn (the owner,
|
|
163
|
+
// 2026-10-27). Keyed by content: a hover that draws the same lines keeps every route.
|
|
164
|
+
const restKey = useMemo(() => {
|
|
165
|
+
const rest = mapEmphasis(level.nodes.map((n) => n.path), level.edges, null, null, { quietLines, strongOnly });
|
|
166
|
+
return level.edges.map(edgeKey).filter((id) => rest.edges.get(id) !== "hidden").join("\n");
|
|
167
|
+
}, [level, quietLines, strongOnly]);
|
|
168
|
+
const restRoutes = useMemo(() => routeEdges(positions, level.edges, keySet(restKey)), [positions, level, restKey]);
|
|
169
|
+
const drawnKey = level.edges.map(edgeKey).filter((id) => emphasis.edges.get(id) !== "hidden").join("\n");
|
|
170
|
+
// the lines drawn without the hover: rest's, and the selection's and the lit boxes' on top
|
|
171
|
+
const settledKey = useMemo(() => {
|
|
172
|
+
const settled = mapEmphasis(level.nodes.map((n) => n.path), level.edges, selected, null, { quietLines, strongOnly, pinned: agent });
|
|
173
|
+
return level.edges.map(edgeKey).filter((id) => settled.edges.get(id) !== "hidden").join("\n");
|
|
174
|
+
}, [level, selected, quietLines, strongOnly, agent]);
|
|
175
|
+
// what a count keeps clear of: every other line by its width, every other count, its label sized from its count's
|
|
176
|
+
// text as RoutedEdge writes it (7 px a character and 14 of padding and border, by 18), every box with its back card,
|
|
177
|
+
// and every arrowhead drawn (countsOn)
|
|
178
|
+
const countPage = useMemo((): CountPage => ({
|
|
179
|
+
edges: level.edges,
|
|
180
|
+
widths: new Map(level.edges.map((e) => [edgeKey(e), lineWidth(e.count, maxImports)])),
|
|
181
|
+
sizes: new Map(level.edges.map((e) => [edgeKey(e), { width: formatCount(e.count).length * 7 + 14, height: 18 }])),
|
|
182
|
+
boxes: level.nodes.flatMap((box) => {
|
|
183
|
+
const at = positions.get(box.path);
|
|
184
|
+
return at === undefined ? [] : [{ x: at.x + (BOX_WIDTH + 5) / 2, y: at.y + (BOX_HEIGHT + 5) / 2, width: BOX_WIDTH + 5, height: BOX_HEIGHT + 5 }];
|
|
185
|
+
}),
|
|
186
|
+
}), [level, positions, maxImports]);
|
|
187
|
+
// What's on screen, and what it was worked out from: React's way to keep state that follows a prop, set during render.
|
|
188
|
+
// A count is placed from a base, so lines that appear move only the counts they run into (placeCounts): rest's are
|
|
189
|
+
// placed afresh, the screen without a hover (a selection, or a lit box) starts from rest's, and a hover from the
|
|
190
|
+
// screen without it. Rest's and the selection's are held beside the routes, so a count a hovered line pushed aside
|
|
191
|
+
// goes back when the hover ends, and nothing chains from one hover to the next. New rest routes (another level, or
|
|
192
|
+
// Strong links only or Show all lines turned) start the counts afresh with them.
|
|
193
|
+
const follow = (previous: Shown | null): Shown => {
|
|
194
|
+
const fresh = previous === null || previous.rest !== restRoutes;
|
|
195
|
+
// the same lines keep the routes they have, rest's lines included, which nextRoutes would give back as they are
|
|
196
|
+
const before = fresh ? restRoutes : previous.routes;
|
|
197
|
+
const routes = drawnKey === (fresh ? restKey : previous.drawn) ? before : nextRoutes(positions, level.edges, restRoutes, before, keySet(drawnKey));
|
|
198
|
+
const restCounts = fresh ? countsOn(countPage, restRoutes, keySet(restKey), labelledLines(level.edges, null), new Map()) : previous.restCounts;
|
|
199
|
+
// a hover draws more lines and never hides one, so the lines without it are on screen, where they'll stay
|
|
200
|
+
const settledCounts = !fresh && previous.selected === selected && previous.settled === settledKey
|
|
201
|
+
? previous.settledCounts
|
|
202
|
+
: countsOn(countPage, routes, keySet(settledKey), labelled, restCounts);
|
|
203
|
+
const counts = drawnKey === settledKey ? settledCounts : countsOn(countPage, routes, keySet(drawnKey), labelled, settledCounts);
|
|
204
|
+
return { rest: restRoutes, drawn: drawnKey, routes, selected, settled: settledKey, restCounts, settledCounts, counts };
|
|
205
|
+
};
|
|
206
|
+
const [shown, setShown] = useState(() => follow(null));
|
|
207
|
+
if (shown.rest !== restRoutes || shown.drawn !== drawnKey || shown.selected !== selected || shown.settled !== settledKey) setShown(follow(shown));
|
|
208
|
+
const { routes, counts } = shown;
|
|
116
209
|
const [nodes, setNodes, onNodesChange] = useNodesState<BoxNode>([]);
|
|
117
210
|
const [edges, setEdges, onEdgesChange] = useEdgesState<RoutedEdgeType>([]);
|
|
118
|
-
const { setViewport } = useReactFlow();
|
|
211
|
+
const { setViewport, getViewport, screenToFlowPosition } = useReactFlow();
|
|
119
212
|
const canvasWidth = useStore((s) => s.width);
|
|
120
213
|
const canvasHeight = useStore((s) => s.height);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
const openRef = useRef({ setViewport, onDrawn, positions, canvas: { width: canvasWidth, height: canvasHeight }
|
|
214
|
+
const canvas = useStore((s) => s.domNode);
|
|
215
|
+
// the latest of each, for the opening view below: it runs when the level changes, not when they do. The lines on
|
|
216
|
+
// screen are framed with the boxes (viewport.ts), measured only when a level opens or refits, not on every hover
|
|
217
|
+
const openRef = useRef({ setViewport, onDrawn, positions, routes, edges: level.edges, canvas: { width: canvasWidth, height: canvasHeight } });
|
|
125
218
|
useEffect(() => {
|
|
126
|
-
openRef.current = { setViewport, onDrawn, positions, canvas: { width: canvasWidth, height: canvasHeight }
|
|
127
|
-
}, [setViewport, onDrawn, positions, canvasWidth, canvasHeight
|
|
219
|
+
openRef.current = { setViewport, onDrawn, positions, routes, edges: level.edges, canvas: { width: canvasWidth, height: canvasHeight } };
|
|
220
|
+
}, [setViewport, onDrawn, positions, routes, level.edges, canvasWidth, canvasHeight]);
|
|
221
|
+
// whether the person has panned or zoomed since the level opened (a programmatic move has no event)
|
|
222
|
+
const moved = useRef(false);
|
|
128
223
|
|
|
129
224
|
useEffect(() => {
|
|
130
225
|
// with their size and attach points given, the lines never wait for React Flow to measure (flowNodes.ts); a box
|
|
@@ -134,10 +229,11 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
|
|
|
134
229
|
tone: emphasis.boxes.get(box.path) ?? "normal",
|
|
135
230
|
agent: agent.has(box.path),
|
|
136
231
|
pulseKey: pulses.get(box.path)?.key ?? 0,
|
|
232
|
+
dot: dotSize(box.fileCount, maxFiles),
|
|
137
233
|
portal: portals.get(box.path),
|
|
138
234
|
}));
|
|
139
235
|
setNodes((previous) => reuseUnchanged(previous, next, sameBoxNode));
|
|
140
|
-
}, [level, positions, portals, emphasis, agent, pulses, setNodes]);
|
|
236
|
+
}, [level, positions, portals, emphasis, agent, pulses, maxFiles, setNodes]);
|
|
141
237
|
|
|
142
238
|
useEffect(() => {
|
|
143
239
|
// a busy level's hidden lines aren't handed to React Flow at all: fewer lines to draw, and none to hover
|
|
@@ -145,31 +241,38 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
|
|
|
145
241
|
const id = edgeKey(edge);
|
|
146
242
|
const tone = emphasis.edges.get(id) ?? "normal";
|
|
147
243
|
if (tone === "hidden") return [];
|
|
148
|
-
const look =
|
|
244
|
+
const look = lineLook(tone, edge, selected);
|
|
149
245
|
const drawnEdge: RoutedEdgeType = {
|
|
150
246
|
id,
|
|
151
247
|
type: "routed",
|
|
152
|
-
data: {
|
|
248
|
+
data: {
|
|
249
|
+
points: routes.get(id) ?? [],
|
|
250
|
+
width: lineWidth(edge.count, maxImports),
|
|
251
|
+
headWidth: heads.get(edge.target),
|
|
252
|
+
color: look.color,
|
|
253
|
+
// the page's heaviest lines and the selection's carry their count where it has a place clear of
|
|
254
|
+
// everything else, the selection's in the text color
|
|
255
|
+
count: counts.has(id) ? formatCount(edge.count) : undefined,
|
|
256
|
+
countStrong: tone === "selected",
|
|
257
|
+
labelAt: counts.get(id),
|
|
258
|
+
},
|
|
153
259
|
source: edge.source,
|
|
154
260
|
target: edge.target,
|
|
155
261
|
focusable: false,
|
|
156
|
-
// in words ("editor uses base · 12 imports"): the three-task test's tester read an arrow backwards
|
|
157
|
-
label: hoveredEdge === id ? lineLabel(byPath.get(edge.source)!, byPath.get(edge.target)!, edge.count) : undefined,
|
|
158
|
-
style: { stroke: look.stroke, strokeWidth: look.width, opacity: look.opacity },
|
|
159
|
-
// userSpaceOnUse: the arrowhead stays the same size whatever the line's width
|
|
160
|
-
markerEnd: { type: MarkerType.ArrowClosed, width: ARROW_SIZE, height: ARROW_SIZE, color: look.arrow, markerUnits: "userSpaceOnUse" },
|
|
161
262
|
};
|
|
162
263
|
return [{ layer: look.layer, edge: drawnEdge }];
|
|
163
264
|
});
|
|
164
|
-
// the emphasized lines go last, so they're drawn over the quiet ones they cross. Order, not
|
|
165
|
-
// lifts an edge with a zIndex above every box, where it would take the box's clicks (final
|
|
265
|
+
// the emphasized lines go last, so they're drawn over the quiet ones they cross or share a run with. Order, not
|
|
266
|
+
// zIndex: React Flow lifts an edge with a zIndex above every box, where it would take the box's clicks (final
|
|
267
|
+
// review I2)
|
|
166
268
|
const next = drawn.sort((a, b) => a.layer - b.layer).map((d) => d.edge);
|
|
167
269
|
setEdges((previous) => reuseUnchanged(previous, next, sameRoutedEdge));
|
|
168
|
-
}, [level,
|
|
270
|
+
}, [level, routes, counts, emphasis, selected, maxImports, heads, setEdges]);
|
|
169
271
|
|
|
170
|
-
// A new level (another folder, or another repo): open it at a readable size (viewport.ts: fitted,
|
|
171
|
-
//
|
|
172
|
-
//
|
|
272
|
+
// A new level (another folder, or another repo): open it at a readable size (viewport.ts: fitted, its drawn lines
|
|
273
|
+
// too unless that would shrink the boxes under 85% of their own fit or past 75%, and from its top when the boxes
|
|
274
|
+
// alone would shrink past 75%), then report it drawn. The boxes reach React Flow a render after the level does
|
|
275
|
+
// (setNodes runs in an effect), so this waits until `nodes` holds this level's boxes: placing the view sooner
|
|
173
276
|
// frames the previous level's, which is what a level from the cache got. The same level refetched after the index
|
|
174
277
|
// moved keeps its key, so the user's pan and zoom stay put.
|
|
175
278
|
// the row width is in the key: a window resized across WIDE_CANVAS lays the level out again, so it's fitted again
|
|
@@ -181,30 +284,122 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
|
|
|
181
284
|
if (!arrived) return;
|
|
182
285
|
const frame = requestAnimationFrame(() => {
|
|
183
286
|
fittedKey.current = levelKey;
|
|
287
|
+
moved.current = false;
|
|
184
288
|
const open = openRef.current;
|
|
185
|
-
void open.setViewport(initialViewport(boundsOf(open.positions), open.canvas, open.
|
|
289
|
+
void open.setViewport(initialViewport(boundsOf(open.positions), open.canvas, 0, drawnLinesBounds(open.routes, open.edges)), { duration: 0 });
|
|
186
290
|
open.onDrawn();
|
|
187
291
|
});
|
|
188
292
|
return () => cancelAnimationFrame(frame);
|
|
189
293
|
}, [levelKey, nodes, level.nodes]);
|
|
190
294
|
|
|
295
|
+
// The canvas resized (the window or VS Code's panel dragged, the inspector moving under or beside it): a level nobody
|
|
296
|
+
// has moved since it opened opens again for the new size, and one the person has moved keeps its middle where it
|
|
297
|
+
// was, at the same zoom, so the view stays steady and the selection stays in sight (the owner, 2026-10-27).
|
|
298
|
+
const lastSize = useRef<{ width: number; height: number } | null>(null);
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
const last = lastSize.current;
|
|
301
|
+
lastSize.current = { width: canvasWidth, height: canvasHeight };
|
|
302
|
+
if (last === null || canvasWidth === 0 || canvasHeight === 0 || fittedKey.current !== levelKey) return;
|
|
303
|
+
if (last.width === canvasWidth && last.height === canvasHeight) return;
|
|
304
|
+
// the lines move under the hover card and a pinned one, which were placed for the old view
|
|
305
|
+
setLineHover(null);
|
|
306
|
+
setPinned(null);
|
|
307
|
+
if (!moved.current){
|
|
308
|
+
const lines = drawnLinesBounds(openRef.current.routes, openRef.current.edges);
|
|
309
|
+
void setViewport(initialViewport(boundsOf(positions), { width: canvasWidth, height: canvasHeight }, 0, lines), { duration: 0 });
|
|
310
|
+
} else {
|
|
311
|
+
const view = getViewport();
|
|
312
|
+
void setViewport({ x: view.x + (canvasWidth - last.width) / 2, y: view.y + (canvasHeight - last.height) / 2, zoom: view.zoom }, { duration: 0 });
|
|
313
|
+
}
|
|
314
|
+
}, [canvasWidth, canvasHeight, levelKey, positions, setViewport, getViewport]);
|
|
315
|
+
|
|
191
316
|
const onNodeClick: NodeMouseHandler<BoxNode> = (_, node) => onSelect(node.id);
|
|
192
317
|
const onNodeDoubleClick: NodeMouseHandler<BoxNode> = (_, node) => {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
else if (
|
|
318
|
+
const opening = openingOf(node.data.box, onOpenFile !== undefined);
|
|
319
|
+
if (opening?.type === "folder") onOpen(opening.path);
|
|
320
|
+
else if (opening?.type === "page") onTurnPage(opening.page);
|
|
321
|
+
else if (opening?.type === "editor") onOpenFile?.(opening.path);
|
|
197
322
|
};
|
|
198
323
|
const onNodeMouseEnter: NodeMouseHandler<BoxNode> = (_, node) => setHoveredBox(node.id);
|
|
199
324
|
const onNodeMouseLeave: NodeMouseHandler<BoxNode> = () => setHoveredBox(null);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
325
|
+
|
|
326
|
+
// The lines under the pointer (the owner, 2026-10-29): every line on the run it's on (runs.ts), named by the hover
|
|
327
|
+
// card at the pointer (LineCard). Another run is another card, and along the same run the card is handed the pointer
|
|
328
|
+
// itself (followRef), so it goes with the pointer without drawing the canvas again.
|
|
329
|
+
const drawnLines = useMemo(() => keySet(drawnKey), [drawnKey]);
|
|
330
|
+
const edgeById = useMemo(() => new Map(level.edges.map((e) => [edgeKey(e), e])), [level]);
|
|
331
|
+
const followCard = useRef<((at: CardAt) => void) | null>(null);
|
|
332
|
+
const hoverPlace = useRef<CardPlace | null>(null);
|
|
333
|
+
const hoverAt = (event: { clientX: number; clientY: number }, id: string): LineHover | null => {
|
|
334
|
+
const route = routes.get(id);
|
|
335
|
+
const found = linesSharing(id, screenToFlowPosition({ x: event.clientX, y: event.clientY }), routes, drawnLines);
|
|
336
|
+
const edge = edgeById.get(id);
|
|
337
|
+
if (found === null || canvas === null || edge === undefined || route === undefined) return null;
|
|
338
|
+
// the card points at the spot on the line nearest the pointer, across the line there (LineCard)
|
|
339
|
+
const box = canvas.getBoundingClientRect();
|
|
340
|
+
const view = getViewport();
|
|
341
|
+
const spot = found.point;
|
|
342
|
+
const point = { x: spot.x * view.zoom + view.x, y: spot.y * view.zoom + view.y };
|
|
343
|
+
const a = route[found.segment], b = route[found.segment + 1];
|
|
344
|
+
const width = lineWidth(edge.count, maxImports);
|
|
345
|
+
const line = pointedBits(route, point, view, width, arrowHead(heads.get(edge.target) ?? width));
|
|
346
|
+
// what's over the canvas says so (a drawer, a box's actions, the banner: MapView, BoxActions)
|
|
347
|
+
const clear = [...(canvas.parentElement ?? canvas).querySelectorAll("[data-keep-clear]")].map((el) => {
|
|
348
|
+
const r = el.getBoundingClientRect();
|
|
349
|
+
return { left: r.left - box.left, top: r.top - box.top, width: r.width, height: r.height };
|
|
350
|
+
});
|
|
351
|
+
const upright = Math.abs(b.y - a.y) > Math.abs(b.x - a.x);
|
|
352
|
+
return { key: found.lines.join("\n"), lines: found.lines, point, spot, upright, half: (width * view.zoom) / 2, line, clear };
|
|
353
|
+
};
|
|
354
|
+
const onEdgeMouseMove: EdgeMouseHandler = (event, edge) => {
|
|
355
|
+
const next = hoverAt(event, edge.id);
|
|
356
|
+
if (next === null) return;
|
|
357
|
+
if (lineHover !== null && lineHover.key === next.key) followCard.current?.(next);
|
|
358
|
+
else setLineHover(next);
|
|
359
|
+
};
|
|
360
|
+
const onEdgeMouseLeave: EdgeMouseHandler = () => setLineHover(null);
|
|
361
|
+
// a click on the run pins its hover card just as it is, to list every line (LineCard)
|
|
362
|
+
const onEdgeClick: EdgeMouseHandler = (event, edge) => {
|
|
363
|
+
const next = hoverAt(event, edge.id);
|
|
364
|
+
if (next !== null) setPinned({ ...next, held: lineHover !== null && lineHover.key === next.key ? hoverPlace.current : null });
|
|
206
365
|
};
|
|
207
366
|
|
|
367
|
+
// A pinned card closes on Escape, a click anywhere outside it (a drag of the canvas among them), another page
|
|
368
|
+
// (above), or a resize of the canvas. A zoom with the wheel keeps it (LineCard).
|
|
369
|
+
// Escape is caught first, on the way down, and stopped there, so it doesn't also let go of the selection (Explorer's
|
|
370
|
+
// rail does the same). A click on the empty canvas that closes it does only that, as Escape does, rather than letting
|
|
371
|
+
// go of the selection too: the click after that pointerdown is stopped before the pane sees it.
|
|
372
|
+
const pinnedCard = useRef<HTMLDivElement>(null);
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (pinned === null) return;
|
|
375
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
376
|
+
if (event.key !== "Escape") return;
|
|
377
|
+
event.stopPropagation();
|
|
378
|
+
setPinned(null);
|
|
379
|
+
};
|
|
380
|
+
const onPointer = (event: PointerEvent): void => {
|
|
381
|
+
if (event.target instanceof Node && pinnedCard.current?.contains(event.target)) return;
|
|
382
|
+
setPinned(null);
|
|
383
|
+
if (!(event.target instanceof Element && event.target.closest(".react-flow__pane"))) return;
|
|
384
|
+
const stop = (click: MouseEvent): void => {
|
|
385
|
+
if (click.target instanceof Element && click.target.closest(".react-flow__pane")) click.stopPropagation();
|
|
386
|
+
};
|
|
387
|
+
document.addEventListener("click", stop, { capture: true, once: true });
|
|
388
|
+
// a drag that pans has no click: the next press takes the stopper away before its own click comes
|
|
389
|
+
document.addEventListener("pointerdown", () => document.removeEventListener("click", stop, { capture: true }), { capture: true, once: true });
|
|
390
|
+
};
|
|
391
|
+
window.addEventListener("keydown", onKey, true);
|
|
392
|
+
document.addEventListener("pointerdown", onPointer, true);
|
|
393
|
+
return () => {
|
|
394
|
+
window.removeEventListener("keydown", onKey, true);
|
|
395
|
+
document.removeEventListener("pointerdown", onPointer, true);
|
|
396
|
+
};
|
|
397
|
+
}, [pinned]);
|
|
398
|
+
// React runs the rest of the render that clears the cards (above) with them still set, so on a jump with a card
|
|
399
|
+
// open (the editor following a file, Claude Code's show_on_map, Back) it named the old level's lines from the new
|
|
400
|
+
// one, which hasn't got them, and threw. That render draws no card.
|
|
401
|
+
const card = levelChanged ? null : pinned ?? lineHover;
|
|
402
|
+
|
|
208
403
|
return (
|
|
209
404
|
<ReactFlow
|
|
210
405
|
nodes={nodes}
|
|
@@ -218,24 +413,32 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
|
|
|
218
413
|
onNodeMouseEnter={onNodeMouseEnter}
|
|
219
414
|
onNodeMouseLeave={onNodeMouseLeave}
|
|
220
415
|
onPaneClick={() => onSelect(null)}
|
|
221
|
-
|
|
416
|
+
onMoveStart={(event) => {
|
|
417
|
+
if (event === null) return;
|
|
418
|
+
moved.current = true;
|
|
419
|
+
// the lines move under the hover card, placed for where they were. A pinned card stays, beside the spot
|
|
420
|
+
// it was clicked at as the wheel zooms (LineCard), and a drag starts with a press outside it, which
|
|
421
|
+
// closes it (above)
|
|
422
|
+
setLineHover(null);
|
|
423
|
+
}}
|
|
424
|
+
onEdgeMouseEnter={onEdgeMouseMove}
|
|
425
|
+
onEdgeMouseMove={onEdgeMouseMove}
|
|
222
426
|
onEdgeMouseLeave={onEdgeMouseLeave}
|
|
427
|
+
onEdgeClick={onEdgeClick}
|
|
223
428
|
nodesDraggable={false}
|
|
224
429
|
nodesConnectable={false}
|
|
225
430
|
zoomOnDoubleClick={false}
|
|
226
431
|
minZoom={0.1}
|
|
227
432
|
maxZoom={2}
|
|
228
|
-
colorMode="dark"
|
|
433
|
+
colorMode={isLightKind(themeKind) ? "light" : "dark"}
|
|
229
434
|
proOptions={{ hideAttribution: true }}
|
|
230
435
|
inert={covered}
|
|
231
436
|
>
|
|
232
|
-
{/*
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
<
|
|
236
|
-
|
|
237
|
-
</ViewportPortal>
|
|
238
|
-
)}
|
|
437
|
+
{/* under the lines (globals.css puts this layer beneath them): the folders' back cards, and the divider */}
|
|
438
|
+
<ViewportPortal>
|
|
439
|
+
<BackCards nodes={level.nodes} positions={positions} tones={emphasis.boxes} />
|
|
440
|
+
{divider && <LooseDivider divider={divider} />}
|
|
441
|
+
</ViewportPortal>
|
|
239
442
|
{actionsFor !== null && byPath.has(actionsFor) && positions.has(actionsFor) && (
|
|
240
443
|
<BoxActions
|
|
241
444
|
box={byPath.get(actionsFor)!}
|
|
@@ -243,40 +446,66 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
|
|
|
243
446
|
onOpen={onOpen}
|
|
244
447
|
onTurnPage={onTurnPage}
|
|
245
448
|
onDetails={onDetails}
|
|
449
|
+
onOpenFile={onOpenFile}
|
|
246
450
|
/>
|
|
247
451
|
)}
|
|
248
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
452
|
+
{card !== null && (
|
|
453
|
+
<LineCard
|
|
454
|
+
// a hover card is one a run, and a pinned one is one a click
|
|
455
|
+
key={pinned === null ? `hover\n${card.key}` : `pinned\n${card.key}\n${card.point.x},${card.point.y}`}
|
|
456
|
+
words={runWords(card.lines.map((id) => edgeById.get(id)!), byPath)}
|
|
457
|
+
at={card}
|
|
458
|
+
pinned={pinned !== null}
|
|
459
|
+
cardRef={pinned === null ? undefined : pinnedCard}
|
|
460
|
+
held={pinned?.held}
|
|
461
|
+
followRef={pinned === null ? followCard : undefined}
|
|
462
|
+
placedRef={pinned === null ? hoverPlace : undefined}
|
|
256
463
|
/>
|
|
257
464
|
)}
|
|
258
|
-
{/* while the panel is open it covers the canvas's right edge, so the controls move left of it (final review I1) */}
|
|
259
|
-
{!covered && (
|
|
260
|
-
<Controls
|
|
261
|
-
position="bottom-right"
|
|
262
|
-
showInteractive={false}
|
|
263
|
-
fitViewOptions={FIT}
|
|
264
|
-
style={panelWidth > 0 ? { right: panelWidth } : undefined}
|
|
265
|
-
className="overflow-hidden rounded-lg"
|
|
266
|
-
>
|
|
267
|
-
<ControlButton onClick={resetFocus} title="Reset focus" aria-label="Reset focus">
|
|
268
|
-
<IconFocusCentered aria-hidden size={16} stroke={1.75} />
|
|
269
|
-
</ControlButton>
|
|
270
|
-
</Controls>
|
|
271
|
-
)}
|
|
272
465
|
</ReactFlow>
|
|
273
466
|
);
|
|
274
467
|
}
|
|
275
468
|
|
|
469
|
+
/** A back card's color: 70% of the way from the background to its front card's, so it reads as the card behind. */
|
|
470
|
+
const behind = (color: string): string => `color-mix(in srgb, ${color} 70%, var(--canvas))`;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Every folder's back card, a second card 5 px behind it (the owner's pick of 2026-10-27, compare-files.png option C):
|
|
474
|
+
* a folder opens into more boxes, and a file has none. Opaque, its fill and border 70% of the way from the background
|
|
475
|
+
* to its own card's, faded with its card, and drawn under the lines, so a line leaving a box's bottom edge stays whole.
|
|
476
|
+
*/
|
|
477
|
+
function BackCards({ nodes, positions, tones }: {
|
|
478
|
+
nodes: LevelResponse["nodes"];
|
|
479
|
+
positions: ReadonlyMap<string, Position>;
|
|
480
|
+
tones: ReadonlyMap<string, BoxTone>;
|
|
481
|
+
}){
|
|
482
|
+
return (
|
|
483
|
+
<div aria-hidden data-testid="back-cards" className="pointer-events-none">
|
|
484
|
+
{nodes.filter((n) => n.kind === "folder").map((n) => {
|
|
485
|
+
const at = positions.get(n.path);
|
|
486
|
+
if (at === undefined) return null;
|
|
487
|
+
const tone = tones.get(n.path) ?? "normal";
|
|
488
|
+
const faded = tone === "faded";
|
|
489
|
+
return (
|
|
490
|
+
<div
|
|
491
|
+
key={n.path}
|
|
492
|
+
data-back-card={n.path}
|
|
493
|
+
style={{
|
|
494
|
+
position: "absolute", left: at.x + 5, top: at.y + 5, width: BOX_WIDTH, height: BOX_HEIGHT, borderRadius: 5,
|
|
495
|
+
background: behind(shade("var(--node)", faded)), border: `1px solid ${behind(shade(borderOf(tone, false), faded))}`,
|
|
496
|
+
}}
|
|
497
|
+
/>
|
|
498
|
+
);
|
|
499
|
+
})}
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
276
504
|
/**
|
|
277
|
-
* The
|
|
278
|
-
*
|
|
279
|
-
* A hairline in the
|
|
505
|
+
* The label over a level's loose boxes, "Not linked to anything here", on a hairline (levelLayout.ts's looseDivider;
|
|
506
|
+
* owner's design, 2026-10-13, restyled quiet by the Workbench redesign, ADR-070). It lives in the Map's own
|
|
507
|
+
* coordinates, so it pans and zooms with the boxes. A hairline in the divider's color, never ending in an arrow, so it
|
|
508
|
+
* can't be read as a line between boxes.
|
|
280
509
|
*/
|
|
281
510
|
function LooseDivider({ divider }: { divider: Divider }){
|
|
282
511
|
return (
|
|
@@ -284,11 +513,11 @@ function LooseDivider({ divider }: { divider: Divider }){
|
|
|
284
513
|
data-testid="loose-divider"
|
|
285
514
|
aria-hidden
|
|
286
515
|
style={{ position: "absolute", left: divider.x, top: divider.y - 11, width: divider.width, height: 22 }}
|
|
287
|
-
className="pointer-events-none flex items-center gap-3
|
|
516
|
+
className="pointer-events-none flex items-center gap-3"
|
|
288
517
|
>
|
|
289
|
-
<span className="h-px flex-1 bg-
|
|
290
|
-
<span className="whitespace-nowrap text-[
|
|
291
|
-
<span className="h-px flex-1 bg-
|
|
518
|
+
<span className="h-px flex-1 bg-divider" />
|
|
519
|
+
<span className="whitespace-nowrap text-[12px] text-text-3">Not linked to anything here</span>
|
|
520
|
+
<span className="h-px flex-1 bg-divider" />
|
|
292
521
|
</div>
|
|
293
522
|
);
|
|
294
523
|
}
|