beadcyte 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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- package/src/web/wip.ts +83 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// BeadSubGraph.vue — mini-DAG for the drawer. Two pictures, one renderer:
|
|
3
|
+
//
|
|
4
|
+
// - "neighbourhood" (default): the focal bead's transitive blockers
|
|
5
|
+
// (leftward, up to N hops) and dependents (rightward), one column per
|
|
6
|
+
// hop, the focal bead in the middle.
|
|
7
|
+
// - "children": every child of an epic, one column per dependency depth
|
|
8
|
+
// within that set, chains stacked as bands, isolated children listed
|
|
9
|
+
// below them (bp-67g.59).
|
|
10
|
+
//
|
|
11
|
+
// The layouts live in ../subgraph.ts so they can be tested without a DOM;
|
|
12
|
+
// this file paints nodes — id, truncated title, status, priority, coloured
|
|
13
|
+
// by status — and cubic-bezier edges between them. Clicking a node switches
|
|
14
|
+
// the drawer to that bead; hovering one floats the same BeadTooltip the Gantt
|
|
15
|
+
// uses (bp-67g.42), so an id on a node can be read as a title, status and
|
|
16
|
+
// assignee without leaving the picture.
|
|
17
|
+
|
|
18
|
+
import { ArrowRight } from "@lucide/vue";
|
|
19
|
+
import { computed, ref, watch } from "vue";
|
|
20
|
+
import type { Bead } from "../store";
|
|
21
|
+
import { useBeadTooltip } from "../composables/useBeadTooltip";
|
|
22
|
+
import BeadTooltip from "./BeadTooltip.vue";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_MAX_HOPS,
|
|
25
|
+
GEOMETRY,
|
|
26
|
+
layoutChildren,
|
|
27
|
+
layoutNeighbourhood,
|
|
28
|
+
} from "../subgraph";
|
|
29
|
+
import type { GraphEdge, GraphNode } from "../subgraph";
|
|
30
|
+
|
|
31
|
+
const props = defineProps<{
|
|
32
|
+
rootBead: Bead;
|
|
33
|
+
beads: Bead[];
|
|
34
|
+
maxHops?: number;
|
|
35
|
+
/** Which picture to draw; see the header comment. */
|
|
36
|
+
mode?: "neighbourhood" | "children";
|
|
37
|
+
/** Children mode: isolated children per row. See ChildrenLayoutOptions. */
|
|
38
|
+
isolatedColumns?: number;
|
|
39
|
+
/** Children mode: the child to draw as the focal (root-styled) node. */
|
|
40
|
+
focalId?: string;
|
|
41
|
+
}>();
|
|
42
|
+
|
|
43
|
+
const emit = defineEmits<{
|
|
44
|
+
select: [id: string];
|
|
45
|
+
}>();
|
|
46
|
+
|
|
47
|
+
const NODE_W = GEOMETRY.nodeW;
|
|
48
|
+
const NODE_H = GEOMETRY.nodeH;
|
|
49
|
+
// The hit area runs this far past the node box on every side: the marks are
|
|
50
|
+
// small, so the target is bigger than the mark. Half the row gap, so two
|
|
51
|
+
// stacked nodes' hit areas meet without overlapping.
|
|
52
|
+
const HIT_PAD = GEOMETRY.rowGap / 2;
|
|
53
|
+
|
|
54
|
+
// ── node hover (bp-67g.42) ───────────────────────────────────────────────
|
|
55
|
+
// The same hover machine as the Gantt's bars. This host sits in a column
|
|
56
|
+
// that scrolls — the drawer body, or this element's own overflow — so a
|
|
57
|
+
// scroll dismisses the panel rather than leaving it fixed over nothing, and
|
|
58
|
+
// the panel is kept inside this element's horizontal extent.
|
|
59
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
60
|
+
const {
|
|
61
|
+
props: tipProps,
|
|
62
|
+
enter: tipEnter,
|
|
63
|
+
cancelLeave: tipStay,
|
|
64
|
+
scheduleLeave: tipLeave,
|
|
65
|
+
dismiss: tipDismiss,
|
|
66
|
+
toggleWatch: tipWatch,
|
|
67
|
+
} = useBeadTooltip({
|
|
68
|
+
dismissOnScroll: true,
|
|
69
|
+
bounds: () => {
|
|
70
|
+
const r = rootEl.value?.getBoundingClientRect();
|
|
71
|
+
return r ? { left: r.left, right: r.right } : null;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function onNodeEnter(n: GraphNode, e: MouseEvent) {
|
|
76
|
+
// The band is the node box, not the padded hit area, so the panel sits
|
|
77
|
+
// just under the mark.
|
|
78
|
+
const box = (e.currentTarget as Element | null)?.querySelector(".node-box") ?? null;
|
|
79
|
+
tipEnter(n.id, e, box);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onNodeClick(id: string) {
|
|
83
|
+
tipDismiss();
|
|
84
|
+
emit("select", id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Details on the panel does what clicking the node does: the drawer moves.
|
|
88
|
+
function onDetails(id: string) {
|
|
89
|
+
tipDismiss();
|
|
90
|
+
emit("select", id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const maxHops = computed(() => props.maxHops ?? DEFAULT_MAX_HOPS);
|
|
94
|
+
|
|
95
|
+
const graph = computed(() =>
|
|
96
|
+
props.mode === "children"
|
|
97
|
+
? layoutChildren(props.rootBead, props.beads, {
|
|
98
|
+
isolatedColumns: props.isolatedColumns ?? 1,
|
|
99
|
+
focalId: props.focalId,
|
|
100
|
+
})
|
|
101
|
+
: layoutNeighbourhood(props.rootBead, props.beads, maxHops.value),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// A new picture under the pointer — a node click moved the drawer — has no
|
|
105
|
+
// node to leave, so mouseleave never fires; close the panel explicitly.
|
|
106
|
+
watch(graph, () => tipDismiss());
|
|
107
|
+
|
|
108
|
+
// A neighbourhood always contains its root, so one node means no edges. A
|
|
109
|
+
// children graph with one node is a real, if lonely, child and is drawn.
|
|
110
|
+
const isEmpty = computed(() =>
|
|
111
|
+
props.mode === "children"
|
|
112
|
+
? graph.value.nodes.length === 0
|
|
113
|
+
: graph.value.nodes.length <= 1,
|
|
114
|
+
);
|
|
115
|
+
const emptyText = computed(() =>
|
|
116
|
+
props.mode === "children"
|
|
117
|
+
? "No children."
|
|
118
|
+
: `No dependencies within ${maxHops.value} hops.`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
function edgePath(e: GraphEdge): string {
|
|
122
|
+
const dx = e.x2 - e.x1;
|
|
123
|
+
const cx1 = e.x1 + Math.max(20, dx * 0.4);
|
|
124
|
+
const cx2 = e.x2 - Math.max(20, dx * 0.4);
|
|
125
|
+
return `M ${e.x1} ${e.y1} C ${cx1} ${e.y1}, ${cx2} ${e.y2}, ${e.x2} ${e.y2}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The curve's last control point shares the endpoint's y, so it always
|
|
130
|
+
* arrives horizontally — the terminal icon needs no rotation here.
|
|
131
|
+
*/
|
|
132
|
+
function edgeTerminal(e: GraphEdge) {
|
|
133
|
+
return { x: e.x2, y: e.y2 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function nodeClass(n: GraphNode): string {
|
|
137
|
+
return `status-${n.bead.status} is-${n.role}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function truncate(s: string, n: number): string {
|
|
141
|
+
if (!s) return "";
|
|
142
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<div ref="rootEl" class="bead-subgraph">
|
|
148
|
+
<div v-if="isEmpty" class="empty">{{ emptyText }}</div>
|
|
149
|
+
<!-- The canvas is padded by the hit pad on every side: an outer <svg>
|
|
150
|
+
clips to its own bounds, so without it the nodes along the edges
|
|
151
|
+
would lose the part of their hit area that hangs past the graph. -->
|
|
152
|
+
<svg
|
|
153
|
+
v-else
|
|
154
|
+
class="subgraph-svg"
|
|
155
|
+
:viewBox="`${-HIT_PAD} ${-HIT_PAD} ${graph.width + 2 * HIT_PAD} ${graph.height + 2 * HIT_PAD}`"
|
|
156
|
+
:width="graph.width + 2 * HIT_PAD"
|
|
157
|
+
:height="graph.height + 2 * HIT_PAD"
|
|
158
|
+
>
|
|
159
|
+
<g class="edges">
|
|
160
|
+
<template v-for="e in graph.edges" :key="`term-${e.fromId}-${e.toId}`">
|
|
161
|
+
<!-- Terminal icon instead of a <marker>: currentColor inside a
|
|
162
|
+
marker resolves against <defs>, not the referencing path, so
|
|
163
|
+
a marker can't take the edge's colour. -->
|
|
164
|
+
<ArrowRight
|
|
165
|
+
class="edge-terminal-icon"
|
|
166
|
+
:x="edgeTerminal(e).x - 15"
|
|
167
|
+
:y="edgeTerminal(e).y - 6.5"
|
|
168
|
+
/>
|
|
169
|
+
</template>
|
|
170
|
+
<path
|
|
171
|
+
v-for="e in graph.edges"
|
|
172
|
+
:key="`${e.fromId}->${e.toId}`"
|
|
173
|
+
class="edge"
|
|
174
|
+
:d="edgePath(e)"
|
|
175
|
+
/>
|
|
176
|
+
</g>
|
|
177
|
+
|
|
178
|
+
<g class="nodes">
|
|
179
|
+
<g
|
|
180
|
+
v-for="n in graph.nodes"
|
|
181
|
+
:key="n.id"
|
|
182
|
+
class="node"
|
|
183
|
+
:class="nodeClass(n)"
|
|
184
|
+
:transform="`translate(${n.x} ${n.y})`"
|
|
185
|
+
@click="onNodeClick(n.id)"
|
|
186
|
+
@mouseenter="onNodeEnter(n, $event)"
|
|
187
|
+
@mouseleave="tipLeave"
|
|
188
|
+
>
|
|
189
|
+
<!-- Hit area first, so the box and its text paint over it. -->
|
|
190
|
+
<rect
|
|
191
|
+
class="node-hit"
|
|
192
|
+
:x="-HIT_PAD"
|
|
193
|
+
:y="-HIT_PAD"
|
|
194
|
+
:width="NODE_W + 2 * HIT_PAD"
|
|
195
|
+
:height="NODE_H + 2 * HIT_PAD"
|
|
196
|
+
/>
|
|
197
|
+
<rect
|
|
198
|
+
class="node-box"
|
|
199
|
+
:width="NODE_W"
|
|
200
|
+
:height="NODE_H"
|
|
201
|
+
rx="3"
|
|
202
|
+
/>
|
|
203
|
+
<text class="node-id" x="8" y="14">{{ n.bead.id }}</text>
|
|
204
|
+
<text class="node-title" x="8" y="28">
|
|
205
|
+
{{ truncate(n.bead.title, 18) }}
|
|
206
|
+
</text>
|
|
207
|
+
<text class="node-status" :x="NODE_W - 8" y="14" text-anchor="end">
|
|
208
|
+
{{ n.bead.status.replace(/_/g, " ") }}
|
|
209
|
+
</text>
|
|
210
|
+
<text class="node-pri" :x="NODE_W - 8" y="28" text-anchor="end">
|
|
211
|
+
P{{ n.bead.priority ?? 4 }}
|
|
212
|
+
</text>
|
|
213
|
+
</g>
|
|
214
|
+
</g>
|
|
215
|
+
</svg>
|
|
216
|
+
<BeadTooltip
|
|
217
|
+
v-bind="tipProps"
|
|
218
|
+
@details="onDetails"
|
|
219
|
+
@watch="tipWatch"
|
|
220
|
+
@tooltip-enter="tipStay"
|
|
221
|
+
@tooltip-leave="tipLeave"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
</template>
|
|
225
|
+
|
|
226
|
+
<style>
|
|
227
|
+
.bead-subgraph {
|
|
228
|
+
overflow: auto;
|
|
229
|
+
padding: 8px 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.bead-subgraph .empty {
|
|
233
|
+
color: var(--c-dim);
|
|
234
|
+
font-style: italic;
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
padding: 12px 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.subgraph-svg {
|
|
240
|
+
display: block;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* One colour for the whole edge layer: the path paints with `stroke`, the
|
|
244
|
+
terminal icon with currentColor, so both are set here. The glyph is a step
|
|
245
|
+
brighter than the hairline it terminates — a stroked arrow at this size
|
|
246
|
+
needs more contrast than the filled triangle it replaced. */
|
|
247
|
+
.bead-subgraph .edges {
|
|
248
|
+
color: var(--c-dim);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.bead-subgraph .edge {
|
|
252
|
+
fill: none;
|
|
253
|
+
stroke: var(--c-border);
|
|
254
|
+
stroke-width: 1.5;
|
|
255
|
+
stroke-opacity: 0.7;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.bead-subgraph .edge-terminal-icon {
|
|
259
|
+
width: 13px;
|
|
260
|
+
height: 13px;
|
|
261
|
+
stroke-width: 2.5;
|
|
262
|
+
pointer-events: none;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.bead-subgraph .node {
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Invisible, but a target: a transparent fill still counts as painted for
|
|
270
|
+
pointer-events, so the pointer is caught this far outside the box. */
|
|
271
|
+
.bead-subgraph .node .node-hit {
|
|
272
|
+
fill: transparent;
|
|
273
|
+
stroke: none;
|
|
274
|
+
pointer-events: all;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.bead-subgraph .node .node-box {
|
|
278
|
+
fill: var(--c-panel);
|
|
279
|
+
stroke: var(--c-border);
|
|
280
|
+
stroke-width: 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.bead-subgraph .node.is-root .node-box {
|
|
284
|
+
fill: var(--c-accent-wash-strong);
|
|
285
|
+
stroke: var(--c-accent);
|
|
286
|
+
stroke-width: 1.5;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.bead-subgraph .node.status-closed .node-box {
|
|
290
|
+
stroke: var(--c-shipped);
|
|
291
|
+
}
|
|
292
|
+
.bead-subgraph .node.status-in_progress .node-box {
|
|
293
|
+
stroke: var(--c-in-progress);
|
|
294
|
+
}
|
|
295
|
+
.bead-subgraph .node.status-deferred .node-box {
|
|
296
|
+
stroke: var(--c-closed-noship);
|
|
297
|
+
fill-opacity: 0.6;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.bead-subgraph .node:hover .node-box {
|
|
301
|
+
fill: var(--c-accent-wash);
|
|
302
|
+
stroke: var(--c-accent);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.bead-subgraph .node-id {
|
|
306
|
+
fill: var(--c-accent);
|
|
307
|
+
font-family: ui-monospace, monospace;
|
|
308
|
+
font-size: 10px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.bead-subgraph .node-title {
|
|
312
|
+
fill: var(--c-text);
|
|
313
|
+
font-size: 11px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.bead-subgraph .node-status {
|
|
317
|
+
fill: var(--c-dim);
|
|
318
|
+
font-size: 9px;
|
|
319
|
+
text-transform: lowercase;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.bead-subgraph .node-pri {
|
|
323
|
+
fill: var(--c-dim);
|
|
324
|
+
font-size: 9px;
|
|
325
|
+
}
|
|
326
|
+
</style>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// BeadSubGraphOverlay.vue — an epic's children graph at full viewport.
|
|
3
|
+
//
|
|
4
|
+
// The drawer is a narrow column, and an epic with a few dozen children has
|
|
5
|
+
// more graph than fits in it. This is the same picture the drawer's
|
|
6
|
+
// Subgraph section draws — BeadSubGraph in children mode, beads inside the
|
|
7
|
+
// epic only — given the whole window. Clicking a node does what it does in
|
|
8
|
+
// the drawer: the drawer moves to that bead, and this closes.
|
|
9
|
+
//
|
|
10
|
+
// Escape is taken in the capture phase and stopped there, ahead of App.vue's
|
|
11
|
+
// document handler, so one press closes this and not the drawer under it.
|
|
12
|
+
|
|
13
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
14
|
+
import { X } from "@lucide/vue";
|
|
15
|
+
import type { Bead } from "../store";
|
|
16
|
+
import { GEOMETRY } from "../subgraph";
|
|
17
|
+
import BeadSubGraph from "./BeadSubGraph.vue";
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
epic: Bead;
|
|
21
|
+
beads: Bead[];
|
|
22
|
+
childCount: number;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
close: [];
|
|
27
|
+
select: [id: string];
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
function onKeydown(e: KeyboardEvent) {
|
|
31
|
+
if (e.key !== "Escape") return;
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
e.stopImmediatePropagation();
|
|
34
|
+
emit("close");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// How many isolated children fit side by side in the room we have. The
|
|
38
|
+
// drawer lists them in one column because it scrolls vertically for free;
|
|
39
|
+
// here the width is the whole window, and forty unconnected children as a
|
|
40
|
+
// forty-row column would leave most of it empty.
|
|
41
|
+
const bodyEl = ref<HTMLElement | null>(null);
|
|
42
|
+
const bodyWidth = ref(0);
|
|
43
|
+
let observer: ResizeObserver | null = null;
|
|
44
|
+
const isolatedColumns = computed(() =>
|
|
45
|
+
Math.max(
|
|
46
|
+
1,
|
|
47
|
+
Math.floor(
|
|
48
|
+
(bodyWidth.value + GEOMETRY.colGap) / (GEOMETRY.nodeW + GEOMETRY.colGap),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
onMounted(() => {
|
|
54
|
+
document.addEventListener("keydown", onKeydown, true);
|
|
55
|
+
if (bodyEl.value) {
|
|
56
|
+
bodyWidth.value = bodyEl.value.clientWidth;
|
|
57
|
+
observer = new ResizeObserver((entries) => {
|
|
58
|
+
bodyWidth.value = entries[0]?.contentRect.width ?? 0;
|
|
59
|
+
});
|
|
60
|
+
observer.observe(bodyEl.value);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
onUnmounted(() => {
|
|
64
|
+
document.removeEventListener("keydown", onKeydown, true);
|
|
65
|
+
observer?.disconnect();
|
|
66
|
+
});
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<Teleport to="body">
|
|
71
|
+
<div
|
|
72
|
+
class="subgraph-overlay"
|
|
73
|
+
role="dialog"
|
|
74
|
+
aria-modal="true"
|
|
75
|
+
:aria-label="`${props.epic.id} — all children`"
|
|
76
|
+
@click.self="emit('close')"
|
|
77
|
+
>
|
|
78
|
+
<div class="subgraph-overlay-panel">
|
|
79
|
+
<header class="subgraph-overlay-head">
|
|
80
|
+
<code class="id">{{ props.epic.id }}</code>
|
|
81
|
+
<span class="title">{{ props.epic.title }}</span>
|
|
82
|
+
<span class="hint">
|
|
83
|
+
all {{ props.childCount }} children · blocks edges between them
|
|
84
|
+
</span>
|
|
85
|
+
<button
|
|
86
|
+
class="close"
|
|
87
|
+
type="button"
|
|
88
|
+
title="Close (Esc)"
|
|
89
|
+
aria-label="Close"
|
|
90
|
+
@click="emit('close')"
|
|
91
|
+
>
|
|
92
|
+
<X class="icon" />
|
|
93
|
+
</button>
|
|
94
|
+
</header>
|
|
95
|
+
<div ref="bodyEl" class="subgraph-overlay-body">
|
|
96
|
+
<BeadSubGraph
|
|
97
|
+
:root-bead="props.epic"
|
|
98
|
+
:beads="props.beads"
|
|
99
|
+
mode="children"
|
|
100
|
+
:isolated-columns="isolatedColumns"
|
|
101
|
+
@select="(id) => emit('select', id)"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</Teleport>
|
|
107
|
+
</template>
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
/* Above the drawer (500), below the search palette (1000): the palette is
|
|
111
|
+
summoned from anywhere and should still land on top. */
|
|
112
|
+
.subgraph-overlay {
|
|
113
|
+
position: fixed;
|
|
114
|
+
inset: 0;
|
|
115
|
+
z-index: 900;
|
|
116
|
+
background: var(--c-scrim);
|
|
117
|
+
padding: 24px;
|
|
118
|
+
display: flex;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.subgraph-overlay-panel {
|
|
122
|
+
flex: 1;
|
|
123
|
+
min-width: 0;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
background: var(--c-panel);
|
|
127
|
+
border: 1px solid var(--c-border);
|
|
128
|
+
border-radius: var(--radius);
|
|
129
|
+
box-shadow: var(--shadow-pop);
|
|
130
|
+
color: var(--c-text);
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.subgraph-overlay-head {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
gap: 10px;
|
|
139
|
+
padding: 10px 14px;
|
|
140
|
+
border-bottom: 1px solid var(--c-border);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.subgraph-overlay-head .id {
|
|
144
|
+
color: var(--c-accent);
|
|
145
|
+
font-family: var(--font-mono);
|
|
146
|
+
font-size: 12px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.subgraph-overlay-head .title {
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
text-overflow: ellipsis;
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.subgraph-overlay-head .hint {
|
|
157
|
+
color: var(--c-dim);
|
|
158
|
+
white-space: nowrap;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.subgraph-overlay-head .close {
|
|
162
|
+
margin-left: auto;
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
padding: 4px;
|
|
166
|
+
border: 1px solid transparent;
|
|
167
|
+
border-radius: var(--radius-sm);
|
|
168
|
+
background: transparent;
|
|
169
|
+
color: var(--c-dim);
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.subgraph-overlay-head .close:hover {
|
|
174
|
+
background: var(--c-dim-wash);
|
|
175
|
+
color: var(--c-text);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.subgraph-overlay-head .close .icon {
|
|
179
|
+
width: 16px;
|
|
180
|
+
height: 16px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* The graph scrolls inside the panel; the panel itself never does. Padding
|
|
184
|
+
sits on the scroller so the edges of a wide graph are not flush with the
|
|
185
|
+
border when scrolled to either end. */
|
|
186
|
+
.subgraph-overlay-body {
|
|
187
|
+
flex: 1;
|
|
188
|
+
min-height: 0;
|
|
189
|
+
overflow: auto;
|
|
190
|
+
padding: 8px 14px;
|
|
191
|
+
}
|
|
192
|
+
</style>
|