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,2125 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// BeadsGantt.vue — thin renderer over DDV primitives.
|
|
3
|
+
//
|
|
4
|
+
// Consumes the view model (rows/bars/groups + meta) from the Pinia store
|
|
5
|
+
// and paints a SVG Gantt with the same visual vocabulary as the CLI SVG
|
|
6
|
+
// emitter (src/cli.mjs). Bar kinds map 1:1 to CSS classes so the palette
|
|
7
|
+
// tunes globally without touching the render path.
|
|
8
|
+
//
|
|
9
|
+
// Uses ddv#40 (<GanttChart>) once it lands — for now we assemble from
|
|
10
|
+
// <DDV> + <CartesianScaler> + <Axis> + <ThresholdLine>. See
|
|
11
|
+
// https://gitlab.com/incytestudios/ddv/-/work_items/40 for the eventual
|
|
12
|
+
// consolidation target. We manage layout + mouseState directly (via a
|
|
13
|
+
// ResizeObserver) rather than through useChart(), because our fixed
|
|
14
|
+
// row-label column and content-driven height don't fit its resize-driven
|
|
15
|
+
// model — DDV's primitives only require the Layout/MouseState shapes,
|
|
16
|
+
// and we build both from scratch.
|
|
17
|
+
|
|
18
|
+
import { computed, ref, reactive, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
|
19
|
+
import {
|
|
20
|
+
DDV,
|
|
21
|
+
CartesianScaler,
|
|
22
|
+
ThresholdLine,
|
|
23
|
+
fromRange,
|
|
24
|
+
fromTimeFrame,
|
|
25
|
+
} from "@incytestudios/ddv";
|
|
26
|
+
import type { Layout, MouseState } from "@incytestudios/ddv";
|
|
27
|
+
import {
|
|
28
|
+
eachDayOfInterval,
|
|
29
|
+
eachWeekOfInterval,
|
|
30
|
+
eachMonthOfInterval,
|
|
31
|
+
eachQuarterOfInterval,
|
|
32
|
+
eachYearOfInterval,
|
|
33
|
+
format as formatDate,
|
|
34
|
+
eachHourOfInterval,
|
|
35
|
+
} from "date-fns";
|
|
36
|
+
import "@incytestudios/ddv/style.css";
|
|
37
|
+
import { isWaitKind, type ViewModel, type Bar } from "../view-model";
|
|
38
|
+
import { describeDelta } from "../whatif";
|
|
39
|
+
import {
|
|
40
|
+
TriangleAlert,
|
|
41
|
+
ChevronDown,
|
|
42
|
+
ChevronUp,
|
|
43
|
+
ArrowRight,
|
|
44
|
+
Ban,
|
|
45
|
+
Pin,
|
|
46
|
+
} from "@lucide/vue";
|
|
47
|
+
import { useBeadsStore } from "../store";
|
|
48
|
+
import BeadTooltip from "./BeadTooltip.vue";
|
|
49
|
+
import { useBeadTooltip } from "../composables/useBeadTooltip";
|
|
50
|
+
import {
|
|
51
|
+
type Viewport,
|
|
52
|
+
clampViewport,
|
|
53
|
+
initialViewport,
|
|
54
|
+
panViewportPx,
|
|
55
|
+
zoomViewport,
|
|
56
|
+
centreViewport,
|
|
57
|
+
scrollMetrics,
|
|
58
|
+
viewportFromScrollLeft,
|
|
59
|
+
wheelZoomFactor,
|
|
60
|
+
} from "../gantt-viewport";
|
|
61
|
+
|
|
62
|
+
const props = defineProps<{
|
|
63
|
+
vm: ViewModel | null;
|
|
64
|
+
}>();
|
|
65
|
+
|
|
66
|
+
const store = useBeadsStore();
|
|
67
|
+
|
|
68
|
+
// ── layout constants ──────────────────────────────────────────────────────
|
|
69
|
+
// These roughly mirror cli.mjs's SVG constants; the interactive Gantt gets
|
|
70
|
+
// a narrower label column and tighter rows. LABEL_COL is user-adjustable
|
|
71
|
+
// (see labelColWidth below) — the other values stay fixed.
|
|
72
|
+
const ROW_H = 20;
|
|
73
|
+
/** Bar height inside a row; the rest is air. style-audit.mjs reads this. */
|
|
74
|
+
const BAR_H = ROW_H - 8;
|
|
75
|
+
const GROUP_H = 26;
|
|
76
|
+
const GROUP_GAP = 6;
|
|
77
|
+
// Left padding before the first text column, on both group headers and rows.
|
|
78
|
+
const WATCH_PIN = 11;
|
|
79
|
+
// Reserved gutter for the watch pin, left of the priority chip. Held open
|
|
80
|
+
// whether or not a row is pinned: a column that appears and disappears would
|
|
81
|
+
// reflow every title the moment you watch something.
|
|
82
|
+
const PIN_COL_W = WATCH_PIN + 10;
|
|
83
|
+
const ID_COL_X = 8;
|
|
84
|
+
const TITLE_COL_X = 78;
|
|
85
|
+
const PRIORITY_COL_W = 24; // reserved right-side gutter for "P#" text
|
|
86
|
+
const AXIS_TOP = 36;
|
|
87
|
+
const AXIS_BOTTOM = 16;
|
|
88
|
+
const RIGHT_PAD = 24;
|
|
89
|
+
|
|
90
|
+
// ── synthetic-assignee hatch ──────────────────────────────────────────────
|
|
91
|
+
// A bar whose assignee the scheduler inferred keeps its kind's fill and
|
|
92
|
+
// opacity and has diagonal stripes cut out of it by a mask (see <defs> in
|
|
93
|
+
// the template and .bar.synthetic in the styles). Texture rather than a
|
|
94
|
+
// third opacity level: the kinds already spend opacity — future 0.75,
|
|
95
|
+
// backlog 0.35 — and a level between them converging on one of those after
|
|
96
|
+
// compositing is exactly what style-audit.test.mjs keeps catching. The
|
|
97
|
+
// audit reads these two numbers and checks the stripes stay legible at
|
|
98
|
+
// BAR_H, so a "finer stripes" tweak cannot quietly turn the hatch into a
|
|
99
|
+
// paler bar.
|
|
100
|
+
// The least a bar may be drawn (bp-vsr). Spans are honest in the data — a
|
|
101
|
+
// seventeen-minute bead is 0.012 of a day — so at six pixels a day it would
|
|
102
|
+
// vanish; this keeps it a visible sliver. It is pixels, not days, so zooming
|
|
103
|
+
// in reveals the real length instead of a floor pretending to be one.
|
|
104
|
+
const MIN_BAR_PX = 3;
|
|
105
|
+
|
|
106
|
+
// Wait segments (bp-6cg) are drawn dulled and CUT SHORT: at most this many
|
|
107
|
+
// pixels, anchored where the wait ends (the landing, for review; the work's
|
|
108
|
+
// start, for unclaimed), with a dotted connector standing in for the rest.
|
|
109
|
+
// Their length is not to scale; the date they end on is.
|
|
110
|
+
const MAX_WAIT_PX = 24;
|
|
111
|
+
const WAIT_INSET = 3; // px shaved off top and bottom: secondary to the work
|
|
112
|
+
|
|
113
|
+
interface WaitGeo {
|
|
114
|
+
x: number;
|
|
115
|
+
w: number;
|
|
116
|
+
gap: { x1: number; x2: number } | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where a wait bar is drawn given the scaler, and the gap it elides. */
|
|
120
|
+
function waitGeo(bar: Bar, scale: (d: Date) => number): WaitGeo {
|
|
121
|
+
const x1 = scale(dateAt(bar.startDay));
|
|
122
|
+
const x2 = scale(dateAt(bar.endDay));
|
|
123
|
+
const full = Math.max(0, x2 - x1);
|
|
124
|
+
if (full <= MAX_WAIT_PX) return { x: x1, w: Math.max(MIN_BAR_PX, full), gap: null };
|
|
125
|
+
// Anchor at the end that means something: the landing for review, the
|
|
126
|
+
// work's start for unclaimed. Both are the wait's END.
|
|
127
|
+
return { x: x2 - MAX_WAIT_PX, w: MAX_WAIT_PX, gap: { x1, x2: x2 - MAX_WAIT_PX } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const HATCH_PITCH = 6; // px from one stripe to the next, across the stripes
|
|
131
|
+
const HATCH_STRIPE = 3; // px of bar kept per pitch; the rest is cut away
|
|
132
|
+
|
|
133
|
+
// ── label column width (user-adjustable, persisted) ──────────────────────
|
|
134
|
+
// Users drag the divider between the label column and the plot to make
|
|
135
|
+
// room for long titles. Width is clamped so the plot always retains a
|
|
136
|
+
// usable minimum, and the current value round-trips through localStorage
|
|
137
|
+
// so the choice sticks per browser.
|
|
138
|
+
const LABEL_COL_MIN = 180;
|
|
139
|
+
const LABEL_COL_MAX = 640;
|
|
140
|
+
const LABEL_COL_DEFAULT = 420;
|
|
141
|
+
const LABEL_COL_STORAGE_KEY = "beadcyte:label-col-width";
|
|
142
|
+
|
|
143
|
+
function loadStoredLabelCol(): number {
|
|
144
|
+
if (typeof localStorage === "undefined") return LABEL_COL_DEFAULT;
|
|
145
|
+
const raw = localStorage.getItem(LABEL_COL_STORAGE_KEY);
|
|
146
|
+
const n = raw ? parseInt(raw, 10) : NaN;
|
|
147
|
+
return Number.isFinite(n) && n >= LABEL_COL_MIN && n <= LABEL_COL_MAX
|
|
148
|
+
? n
|
|
149
|
+
: LABEL_COL_DEFAULT;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const labelColWidth = ref(loadStoredLabelCol());
|
|
153
|
+
const isResizingLabelCol = ref(false);
|
|
154
|
+
|
|
155
|
+
function onLabelColResizeStart(e: MouseEvent) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
isResizingLabelCol.value = true;
|
|
159
|
+
const startX = e.clientX;
|
|
160
|
+
const startW = labelColWidth.value;
|
|
161
|
+
const svgRect = wrapperEl.value?.getBoundingClientRect();
|
|
162
|
+
const maxAllowed = svgRect
|
|
163
|
+
? Math.min(LABEL_COL_MAX, svgRect.width - 200) // reserve at least 200px for plot
|
|
164
|
+
: LABEL_COL_MAX;
|
|
165
|
+
const onMove = (ev: MouseEvent) => {
|
|
166
|
+
const dx = ev.clientX - startX;
|
|
167
|
+
const next = Math.min(
|
|
168
|
+
Math.max(LABEL_COL_MIN, startW + dx),
|
|
169
|
+
maxAllowed,
|
|
170
|
+
);
|
|
171
|
+
labelColWidth.value = next;
|
|
172
|
+
};
|
|
173
|
+
const onUp = () => {
|
|
174
|
+
isResizingLabelCol.value = false;
|
|
175
|
+
localStorage.setItem(LABEL_COL_STORAGE_KEY, String(labelColWidth.value));
|
|
176
|
+
document.removeEventListener("mousemove", onMove);
|
|
177
|
+
document.removeEventListener("mouseup", onUp);
|
|
178
|
+
};
|
|
179
|
+
document.addEventListener("mousemove", onMove);
|
|
180
|
+
document.addEventListener("mouseup", onUp);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── content-driven geometry ───────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
// groupBy "none" puts every row in one synthetic group with nothing worth
|
|
186
|
+
// labelling, so the band collapses and the rows reclaim the space.
|
|
187
|
+
const isUngrouped = computed(() => store.groupBy === "none");
|
|
188
|
+
|
|
189
|
+
/** Height of the strip that carries the expand pill when ungrouped. */
|
|
190
|
+
const PILL_STRIP_H = 22;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Per-group band height. Ungrouped collapses to zero — except for the one
|
|
194
|
+
* case where the band still has a job: truncation applies to the single
|
|
195
|
+
* bucket just as it does to any other, and the "show N more" pill lives in
|
|
196
|
+
* the band. Losing it would strand the hidden rows with no way to reach
|
|
197
|
+
* them, so a slim strip survives to carry the pill and nothing else.
|
|
198
|
+
*/
|
|
199
|
+
const groupHeights = computed<number[]>(() => {
|
|
200
|
+
if (!props.vm) return [];
|
|
201
|
+
return props.vm.groups.map((g) => {
|
|
202
|
+
if (!isUngrouped.value) return GROUP_H;
|
|
203
|
+
const needsPill = g.truncatedCount > 0 || !!store.expandedGroups[g.id];
|
|
204
|
+
return needsPill ? PILL_STRIP_H : 0;
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const groupGap = computed(() => (isUngrouped.value ? 0 : GROUP_GAP));
|
|
209
|
+
|
|
210
|
+
const contentHeight = computed(() => {
|
|
211
|
+
if (!props.vm) return 200;
|
|
212
|
+
const rowsPx = props.vm.rows.length * ROW_H;
|
|
213
|
+
const bandsPx = groupHeights.value.reduce((a, b) => a + b, 0);
|
|
214
|
+
const gapsPx = props.vm.groups.length * groupGap.value;
|
|
215
|
+
return AXIS_TOP + AXIS_BOTTOM + rowsPx + bandsPx + gapsPx + 8;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const rowYByIndex = computed<number[]>(() => {
|
|
219
|
+
const out: number[] = [];
|
|
220
|
+
if (!props.vm) return out;
|
|
221
|
+
let y = 0;
|
|
222
|
+
props.vm.groups.forEach((g, gi) => {
|
|
223
|
+
y += groupHeights.value[gi] ?? 0;
|
|
224
|
+
for (let i = 0; i < g.rowCount; i++) {
|
|
225
|
+
out.push(y);
|
|
226
|
+
y += ROW_H;
|
|
227
|
+
}
|
|
228
|
+
y += groupGap.value;
|
|
229
|
+
});
|
|
230
|
+
return out;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
interface GroupBand {
|
|
234
|
+
id: string;
|
|
235
|
+
label: string;
|
|
236
|
+
top: number;
|
|
237
|
+
height: number;
|
|
238
|
+
rowCount: number;
|
|
239
|
+
truncatedCount: number;
|
|
240
|
+
syntheticCount: number;
|
|
241
|
+
progress: { closed: number; total: number };
|
|
242
|
+
}
|
|
243
|
+
const groupBands = computed<GroupBand[]>(() => {
|
|
244
|
+
const out: GroupBand[] = [];
|
|
245
|
+
if (!props.vm) return out;
|
|
246
|
+
let y = 0;
|
|
247
|
+
props.vm.groups.forEach((g, gi) => {
|
|
248
|
+
const h = groupHeights.value[gi] ?? 0;
|
|
249
|
+
// A zero-height band has nothing to draw and would still catch clicks.
|
|
250
|
+
if (h > 0) {
|
|
251
|
+
out.push({
|
|
252
|
+
id: g.id,
|
|
253
|
+
label: g.label,
|
|
254
|
+
top: y,
|
|
255
|
+
height: h,
|
|
256
|
+
rowCount: g.rowCount,
|
|
257
|
+
truncatedCount: g.truncatedCount,
|
|
258
|
+
syntheticCount: g.syntheticCount,
|
|
259
|
+
progress: g.progress,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
y += h + g.rowCount * ROW_H + groupGap.value;
|
|
263
|
+
});
|
|
264
|
+
return out;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Progress bar geometry — a slim horizontal bar spanning the label column
|
|
268
|
+
// showing closed/total ratio, plus a chip with the numeric split.
|
|
269
|
+
const PROGRESS_BAR_H = 3;
|
|
270
|
+
const PROGRESS_BAR_MARGIN_Y = 2;
|
|
271
|
+
const PROGRESS_CHIP_W = 56;
|
|
272
|
+
function progressFraction(p: { closed: number; total: number }): number {
|
|
273
|
+
return p.total > 0 ? p.closed / p.total : 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Hover text for a lane's inferred count. Same words as the grid's chip. */
|
|
277
|
+
function inferredTitle(g: GroupBand): string {
|
|
278
|
+
return (
|
|
279
|
+
`${g.syntheticCount} of ${g.rowCount} inferred by the scheduler, ` +
|
|
280
|
+
`not recorded on the bead`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── layout + mouse state (DDV inputs) ─────────────────────────────────────
|
|
285
|
+
// Watch the wrapper element's size and build a Layout DDV can consume.
|
|
286
|
+
|
|
287
|
+
const wrapperEl = ref<HTMLElement | null>(null);
|
|
288
|
+
const elementWidth = ref(0);
|
|
289
|
+
const elementHeight = ref(0);
|
|
290
|
+
|
|
291
|
+
let ro: ResizeObserver | null = null;
|
|
292
|
+
onMounted(() => {
|
|
293
|
+
if (!wrapperEl.value) return;
|
|
294
|
+
const measure = () => {
|
|
295
|
+
if (!wrapperEl.value) return;
|
|
296
|
+
const rect = wrapperEl.value.getBoundingClientRect();
|
|
297
|
+
elementWidth.value = rect.width;
|
|
298
|
+
elementHeight.value = Math.max(rect.height, contentHeight.value);
|
|
299
|
+
};
|
|
300
|
+
measure();
|
|
301
|
+
ro = new ResizeObserver(measure);
|
|
302
|
+
ro.observe(wrapperEl.value);
|
|
303
|
+
});
|
|
304
|
+
onBeforeUnmount(() => {
|
|
305
|
+
ro?.disconnect();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const layout = computed<Layout>(() => {
|
|
309
|
+
const w = Math.max(0, elementWidth.value - labelColWidth.value - RIGHT_PAD);
|
|
310
|
+
const h = Math.max(0, contentHeight.value - AXIS_TOP - AXIS_BOTTOM);
|
|
311
|
+
return {
|
|
312
|
+
plotWidth: w,
|
|
313
|
+
plotHeight: h,
|
|
314
|
+
elementWidth: elementWidth.value,
|
|
315
|
+
elementHeight: contentHeight.value,
|
|
316
|
+
computedHeight: contentHeight.value,
|
|
317
|
+
margins: {
|
|
318
|
+
top: AXIS_TOP,
|
|
319
|
+
right: RIGHT_PAD,
|
|
320
|
+
bottom: AXIS_BOTTOM,
|
|
321
|
+
left: labelColWidth.value,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// A minimal MouseState — DDV's <DDV> reads flags off it for styling, but
|
|
327
|
+
// the ThresholdLine + Axis + CartesianScaler don't require live coords for
|
|
328
|
+
// our read-only chart. Interactivity (hover/click) lands in bp-6y1.
|
|
329
|
+
const mouseState = reactive<MouseState>({
|
|
330
|
+
mouse: { x: 0, y: 0 },
|
|
331
|
+
normalizedMouse: { x: 0, y: 0 },
|
|
332
|
+
globalMouse: { x: 0, y: 0 },
|
|
333
|
+
isHovered: false,
|
|
334
|
+
isPlotHovered: false,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── scaler inputs ─────────────────────────────────────────────────────────
|
|
338
|
+
// The X axis is a DateDomain — DDV's Axis + useLinearScaler handle Dates
|
|
339
|
+
// natively, and solveTicksDateDomain auto-picks the nicest time unit
|
|
340
|
+
// (year → minute) for the current axis width. Bar day-offsets get converted
|
|
341
|
+
// to Date objects via `dateAt(day)` when they hit the scaler.
|
|
342
|
+
// ── horizontal viewport (bp-c6q) ─────────────────────────────────────────
|
|
343
|
+
// The x domain is the VISIBLE part of the window, not the window. Rules and
|
|
344
|
+
// arithmetic in gantt-viewport.ts; here: the state, and the gestures that
|
|
345
|
+
// move it. Null state means "auto": fit the window when it reads at a sane
|
|
346
|
+
// density, otherwise a readable span centred on today. Any preset change
|
|
347
|
+
// returns to auto.
|
|
348
|
+
const viewportState = ref<Viewport | null>(null);
|
|
349
|
+
const totalDays = computed(() => Math.max(1, props.vm?.meta.totalDays ?? 1));
|
|
350
|
+
const viewport = computed<Viewport>(() => {
|
|
351
|
+
const w = layout.value.plotWidth;
|
|
352
|
+
return viewportState.value
|
|
353
|
+
? clampViewport(viewportState.value, totalDays.value, w)
|
|
354
|
+
: initialViewport(totalDays.value, w, props.vm?.meta.todayDay ?? -1);
|
|
355
|
+
});
|
|
356
|
+
watch(
|
|
357
|
+
() => [props.vm?.meta.t0?.getTime(), props.vm?.meta.t1?.getTime()],
|
|
358
|
+
() => {
|
|
359
|
+
viewportState.value = null;
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
const hscroll = computed(() => scrollMetrics(viewport.value, totalDays.value, layout.value.plotWidth));
|
|
363
|
+
|
|
364
|
+
const MS_PER_DAY = 86_400_000;
|
|
365
|
+
const xDomain = computed(() => {
|
|
366
|
+
const t0 = props.vm?.meta.t0 ?? new Date();
|
|
367
|
+
const v = viewport.value;
|
|
368
|
+
return fromTimeFrame([
|
|
369
|
+
new Date(t0.getTime() + v.startDay * MS_PER_DAY),
|
|
370
|
+
new Date(t0.getTime() + (v.startDay + v.spanDays) * MS_PER_DAY),
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Drag on the axis strip pans; dragging right brings earlier days in.
|
|
375
|
+
let scrubLastX: number | null = null;
|
|
376
|
+
function onScrubDown(e: PointerEvent) {
|
|
377
|
+
if (!hscroll.value.scrollable) return;
|
|
378
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
379
|
+
scrubLastX = e.clientX;
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
}
|
|
382
|
+
function onScrubMove(e: PointerEvent) {
|
|
383
|
+
if (scrubLastX === null) return;
|
|
384
|
+
const dx = e.clientX - scrubLastX;
|
|
385
|
+
scrubLastX = e.clientX;
|
|
386
|
+
viewportState.value = panViewportPx(viewport.value, -dx, totalDays.value, layout.value.plotWidth);
|
|
387
|
+
}
|
|
388
|
+
function onScrubUp() {
|
|
389
|
+
scrubLastX = null;
|
|
390
|
+
}
|
|
391
|
+
// Wheel over the axis strip zooms around the pointer. Only here is the wheel
|
|
392
|
+
// taken: over the plot body a vertical wheel still scrolls the page.
|
|
393
|
+
function onScrubWheel(e: WheelEvent) {
|
|
394
|
+
if (!props.vm) return;
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
397
|
+
const frac = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
|
|
398
|
+
viewportState.value = zoomViewport(viewport.value, wheelZoomFactor(e.deltaY), frac, totalDays.value, layout.value.plotWidth);
|
|
399
|
+
}
|
|
400
|
+
// Over the plot, a horizontal wheel (a trackpad swipe, or shift+wheel) pans.
|
|
401
|
+
function onPlotWheel(e: WheelEvent) {
|
|
402
|
+
if (!hscroll.value.scrollable) return;
|
|
403
|
+
const dx = e.shiftKey ? e.deltaY : e.deltaX;
|
|
404
|
+
if (!e.shiftKey && Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
|
405
|
+
if (Math.abs(dx) < 1) return;
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
viewportState.value = panViewportPx(viewport.value, dx, totalDays.value, layout.value.plotWidth);
|
|
408
|
+
}
|
|
409
|
+
// The native scrollbar under the plot, kept in step both ways.
|
|
410
|
+
const hscrollEl = ref<HTMLElement | null>(null);
|
|
411
|
+
let syncingScroll = false;
|
|
412
|
+
function onHScroll(e: Event) {
|
|
413
|
+
if (syncingScroll) return;
|
|
414
|
+
const el = e.target as HTMLElement;
|
|
415
|
+
viewportState.value = viewportFromScrollLeft(viewport.value, el.scrollLeft, totalDays.value, layout.value.plotWidth);
|
|
416
|
+
}
|
|
417
|
+
watch(
|
|
418
|
+
() => hscroll.value.scrollLeft,
|
|
419
|
+
async (left) => {
|
|
420
|
+
await nextTick();
|
|
421
|
+
const el = hscrollEl.value;
|
|
422
|
+
if (!el || Math.abs(el.scrollLeft - left) < 0.5) return;
|
|
423
|
+
syncingScroll = true;
|
|
424
|
+
el.scrollLeft = left;
|
|
425
|
+
requestAnimationFrame(() => {
|
|
426
|
+
syncingScroll = false;
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
// t (keybindings.ts): centre today at the current zoom.
|
|
431
|
+
watch(
|
|
432
|
+
() => store.todayRequests,
|
|
433
|
+
() => {
|
|
434
|
+
const day = props.vm?.meta.todayDay;
|
|
435
|
+
if (day === undefined || day < 0 || day > totalDays.value) return;
|
|
436
|
+
viewportState.value = centreViewport(viewport.value, day, totalDays.value, layout.value.plotWidth);
|
|
437
|
+
},
|
|
438
|
+
);
|
|
439
|
+
const xScreenDomain = computed(() => fromRange(layout.value.plotWidth || 1));
|
|
440
|
+
const yDomain = computed(() => ({
|
|
441
|
+
min: 0,
|
|
442
|
+
max: Math.max(1, props.vm?.rows.length ?? 1),
|
|
443
|
+
}));
|
|
444
|
+
const yScreenDomain = computed(() => fromRange(layout.value.plotHeight || 1));
|
|
445
|
+
|
|
446
|
+
// Day-offset → Date, keyed to the view-model's window origin. Bars carry
|
|
447
|
+
// integer day offsets from t0; the scaler works on Dates, so we bridge here.
|
|
448
|
+
function dateAt(dayOffset: number): Date {
|
|
449
|
+
const t0 = props.vm?.meta.t0 ?? new Date();
|
|
450
|
+
return new Date(t0.getTime() + dayOffset * 86_400_000);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ThresholdLine's `domain` prop is typed as NumericDomain, but the scaler
|
|
454
|
+
// handles ms timestamps in a DateDomain identically. Cast at this boundary
|
|
455
|
+
// so the runtime works and TS stays quiet.
|
|
456
|
+
const xDomainAsNumeric = computed(() => xDomain.value as unknown as {
|
|
457
|
+
min: number;
|
|
458
|
+
max: number;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ── top-axis ticks ────────────────────────────────────────────────────────
|
|
462
|
+
// DDV's <Axis> hardcodes tick labels at the BOTTOM of the plot; for a Gantt
|
|
463
|
+
// we want them across the TOP. We roll our own here using the same date-fns
|
|
464
|
+
// unit progression DDV's solveTicksDateDomain uses, and target ~90px per
|
|
465
|
+
// label to keep them from crowding.
|
|
466
|
+
|
|
467
|
+
interface AxisTick {
|
|
468
|
+
date: Date;
|
|
469
|
+
label: string;
|
|
470
|
+
emphasize: boolean; // true for month/year starts — get a heavier gridline
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// A "MMM d" label is ~42px at 10px, so 90 was reserving more than double
|
|
474
|
+
// what a label needs and pushing the ladder up a rung too early — a 90-day
|
|
475
|
+
// window landed on months when weeks would have fitted comfortably.
|
|
476
|
+
const TARGET_LABEL_PX = 64;
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Boundaries are drawn one rung finer than labels, so week and day edges
|
|
480
|
+
* stay visible in a narrow window even when there's only room to LABEL
|
|
481
|
+
* months or weeks. Below this spacing the finer rung becomes a solid block
|
|
482
|
+
* of lines and stops being information, so it's dropped.
|
|
483
|
+
*/
|
|
484
|
+
const MINOR_GRID_MIN_PX = 7;
|
|
485
|
+
|
|
486
|
+
const axis = computed<{ major: AxisTick[]; minor: Date[] }>(() => {
|
|
487
|
+
if (!props.vm || layout.value.plotWidth <= 0)
|
|
488
|
+
return { major: [], minor: [] };
|
|
489
|
+
// The visible interval, at the visible density (bp-c6q).
|
|
490
|
+
const v = viewport.value;
|
|
491
|
+
const origin = props.vm.meta.t0.getTime();
|
|
492
|
+
const t0 = new Date(origin + v.startDay * MS_PER_DAY);
|
|
493
|
+
const t1 = new Date(origin + (v.startDay + v.spanDays) * MS_PER_DAY);
|
|
494
|
+
const pxPerDay = layout.value.plotWidth / Math.max(1e-9, v.spanDays);
|
|
495
|
+
|
|
496
|
+
const interval = { start: t0, end: t1 };
|
|
497
|
+
const inWindow = (d: Date) => d >= t0 && d <= t1;
|
|
498
|
+
|
|
499
|
+
// Pick the SMALLEST unit whose per-unit spacing >= TARGET_LABEL_PX. That
|
|
500
|
+
// gives the densest labels without overlap. `each*OfInterval` returns
|
|
501
|
+
// unit-boundaries (start of month, etc.), some of which predate t0 — we
|
|
502
|
+
// filter to what actually falls inside the window.
|
|
503
|
+
const units: Array<{
|
|
504
|
+
px: number;
|
|
505
|
+
/** Labels narrower than "MMM d" may sit closer than TARGET_LABEL_PX. */
|
|
506
|
+
minPx?: number;
|
|
507
|
+
each: (i: { start: Date; end: Date }) => Date[];
|
|
508
|
+
format: (d: Date) => string;
|
|
509
|
+
emphasize: (d: Date) => boolean;
|
|
510
|
+
}> = [
|
|
511
|
+
// Hours exist for the zoomed viewport (bp-c6q, bp-vsr): at 240px a day
|
|
512
|
+
// a six-hour tick is 60px apart, which a "06:00" label fits under.
|
|
513
|
+
{
|
|
514
|
+
px: pxPerDay / 24,
|
|
515
|
+
minPx: 40,
|
|
516
|
+
each: (i) => eachHourOfInterval(i),
|
|
517
|
+
format: (d) => formatDate(d, "HH:mm"),
|
|
518
|
+
emphasize: (d) => d.getHours() === 0,
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
px: pxPerDay / 4,
|
|
522
|
+
minPx: 40,
|
|
523
|
+
each: (i) => eachHourOfInterval(i).filter((d) => d.getHours() % 6 === 0),
|
|
524
|
+
format: (d) => (d.getHours() === 0 ? formatDate(d, "MMM d") : formatDate(d, "HH:mm")),
|
|
525
|
+
emphasize: (d) => d.getHours() === 0,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
px: pxPerDay,
|
|
529
|
+
each: eachDayOfInterval,
|
|
530
|
+
format: (d) => formatDate(d, "MMM d"),
|
|
531
|
+
emphasize: (d) => d.getDay() === 1,
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
px: pxPerDay * 7,
|
|
535
|
+
each: eachWeekOfInterval,
|
|
536
|
+
format: (d) => formatDate(d, "MMM d"),
|
|
537
|
+
emphasize: (d) => d.getDate() <= 7,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
px: pxPerDay * 30,
|
|
541
|
+
each: eachMonthOfInterval,
|
|
542
|
+
format: (d) => formatDate(d, "MMM yyyy"),
|
|
543
|
+
emphasize: (d) => d.getMonth() === 0,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
px: pxPerDay * 90,
|
|
547
|
+
each: eachQuarterOfInterval,
|
|
548
|
+
format: (d) => formatDate(d, "QQQ yyyy"),
|
|
549
|
+
emphasize: (d) => d.getMonth() === 0,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
px: pxPerDay * 365,
|
|
553
|
+
each: eachYearOfInterval,
|
|
554
|
+
format: (d) => formatDate(d, "yyyy"),
|
|
555
|
+
emphasize: () => true,
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
|
|
559
|
+
const chosenIdx = units.findIndex((u) => u.px >= (u.minPx ?? TARGET_LABEL_PX));
|
|
560
|
+
const chosen = units[chosenIdx === -1 ? 0 : chosenIdx];
|
|
561
|
+
|
|
562
|
+
// One rung finer than the labelled unit, when it still has room to read.
|
|
563
|
+
const finer = chosenIdx > 0 ? units[chosenIdx - 1] : null;
|
|
564
|
+
const minor =
|
|
565
|
+
finer && finer.px >= MINOR_GRID_MIN_PX
|
|
566
|
+
? finer.each(interval).filter(inWindow)
|
|
567
|
+
: [];
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
major: chosen
|
|
571
|
+
.each(interval)
|
|
572
|
+
.filter(inWindow)
|
|
573
|
+
.map((d) => ({
|
|
574
|
+
date: d,
|
|
575
|
+
label: chosen.format(d),
|
|
576
|
+
emphasize: chosen.emphasize(d),
|
|
577
|
+
})),
|
|
578
|
+
minor,
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const axisTicks = computed(() => axis.value.major);
|
|
583
|
+
const axisMinor = computed(() => axis.value.minor);
|
|
584
|
+
|
|
585
|
+
const todayIso = computed(() => {
|
|
586
|
+
if (!props.vm) return "";
|
|
587
|
+
const d = new Date(props.vm.meta.t0);
|
|
588
|
+
d.setDate(d.getDate() + props.vm.meta.todayDay);
|
|
589
|
+
return d.toLocaleDateString("en-US", {
|
|
590
|
+
month: "short",
|
|
591
|
+
day: "numeric",
|
|
592
|
+
year: "numeric",
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const barCssClass = (b: Bar): string => {
|
|
597
|
+
const cls = ["bar", `kind-${b.kind}`];
|
|
598
|
+
if (b.synthetic) cls.push("synthetic");
|
|
599
|
+
return cls.join(" ");
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Icon edge in user units. Lucide components render as nested <svg>
|
|
603
|
+
// elements here (see .gantt-icon), so this is their viewport size, and the
|
|
604
|
+
// label maths needs it to leave room.
|
|
605
|
+
const GANTT_ICON = 14;
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Horizontal room the TODAY label needs. Its text shares a baseline with the
|
|
609
|
+
* axis tick labels (y -8 vs -10) and is anchored the same way, so a tick
|
|
610
|
+
* landing near today's rule prints straight through it.
|
|
611
|
+
*/
|
|
612
|
+
const TODAY_LABEL_W = 56;
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Whether a tick's label would collide with the TODAY marker.
|
|
616
|
+
*
|
|
617
|
+
* Suppressing the tick rather than the marker loses nothing: the marker
|
|
618
|
+
* already prints the full date above itself, which is strictly more precise
|
|
619
|
+
* than the tick's month label.
|
|
620
|
+
*/
|
|
621
|
+
function tickCollidesWithToday(
|
|
622
|
+
tick: { date: Date },
|
|
623
|
+
scaler: { scale: (v: number) => number },
|
|
624
|
+
): boolean {
|
|
625
|
+
const meta = props.vm?.meta;
|
|
626
|
+
if (!meta) return false;
|
|
627
|
+
if (meta.todayDay < 0 || meta.todayDay > meta.totalDays) return false;
|
|
628
|
+
const tx = scaler.scale(tick.date.getTime());
|
|
629
|
+
const todayX = scaler.scale(dateAt(meta.todayDay).getTime());
|
|
630
|
+
return Math.abs(tx - todayX) < TODAY_LABEL_W;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Terminal icons sit at arrowhead scale, where a stroked glyph reads far
|
|
634
|
+
// weaker than the filled triangle a <marker> used to draw. Bigger, heavier
|
|
635
|
+
// and at full opacity to carry the same weight.
|
|
636
|
+
//
|
|
637
|
+
// Full-graph mode draws every edge at once, so its terminals shrink: at that
|
|
638
|
+
// density a full-weight arrow per edge buries the bars it points at. Size is
|
|
639
|
+
// per-role and passed as a prop, because a nested <svg> ignores CSS width.
|
|
640
|
+
const DEP_ICON = 18;
|
|
641
|
+
const DEP_ICON_NEUTRAL = 12;
|
|
642
|
+
|
|
643
|
+
const depIconSize = (role: keyof typeof ROLE_ICON) =>
|
|
644
|
+
role === "neutral" ? DEP_ICON_NEUTRAL : DEP_ICON;
|
|
645
|
+
|
|
646
|
+
// ── noop reserveSpace: Axis wants to negotiate its tick-label margins,
|
|
647
|
+
// but we've already carved fixed AXIS_TOP/AXIS_BOTTOM room. Absorb the
|
|
648
|
+
// updates without letting them mutate our fixed layout.
|
|
649
|
+
const reserveSpace = () => () => {};
|
|
650
|
+
|
|
651
|
+
// ── hover / click interactions (bp-6y1) ───────────────────────────────────
|
|
652
|
+
// Hover any bar → resolve the bead's detail via the store and float a
|
|
653
|
+
// tooltip pinned to the entry cursor position (NOT following the mouse, so
|
|
654
|
+
// the user can move onto the tooltip to click its links). Both the bar and
|
|
655
|
+
// the tooltip are hover targets; a short grace period on leave lets the
|
|
656
|
+
// cursor traverse the gap between them. The state machine and the panel's
|
|
657
|
+
// derived rows live in useBeadTooltip, shared with the drawer's subgraph
|
|
658
|
+
// (bp-67g.42), so the two hovers say the same things about a bead.
|
|
659
|
+
|
|
660
|
+
const {
|
|
661
|
+
hoveredBeadId,
|
|
662
|
+
props: tipProps,
|
|
663
|
+
enter: tipEnter,
|
|
664
|
+
cancelLeave,
|
|
665
|
+
scheduleLeave,
|
|
666
|
+
dismiss: tipDismiss,
|
|
667
|
+
toggleWatch: onToggleWatch,
|
|
668
|
+
} = useBeadTooltip();
|
|
669
|
+
|
|
670
|
+
// ── row hover ────────────────────────────────────────────────────────────
|
|
671
|
+
// One band across the whole row — label column and plot together — so a
|
|
672
|
+
// pointer over a bar can be lined up with the id and title on the left.
|
|
673
|
+
// Driven by transparent hit rects (label band + plot), and falls back to the
|
|
674
|
+
// tooltip's bead so the row stays lit while the tooltip is up.
|
|
675
|
+
const hoveredRowId = ref<string | null>(null);
|
|
676
|
+
// The open bead's row stays lit while the drawer is up: the drawer is the
|
|
677
|
+
// keyboard cursor (bp-67g.45), and this is its mark in the view. Hover wins
|
|
678
|
+
// while the pointer is on another row.
|
|
679
|
+
const litRowId = computed(() => hoveredRowId.value ?? hoveredBeadId.value ?? store.openBeadId);
|
|
680
|
+
|
|
681
|
+
// The bar's own rect is the band the panel stays off (bp-67g.41).
|
|
682
|
+
function onBarEnter(bar: Bar, e: MouseEvent) {
|
|
683
|
+
tipEnter(bar.beadId, e);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function onOpenDetails(id: string) {
|
|
687
|
+
store.openBead(id);
|
|
688
|
+
tipDismiss();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Right-click: the quick actions (bp-ocs). The hover tooltip goes first, or
|
|
692
|
+
// it sits over the menu.
|
|
693
|
+
function onBarMenu(bar: Bar, e: MouseEvent) {
|
|
694
|
+
tipDismiss();
|
|
695
|
+
store.openContextMenu(bar.beadId, e.clientX, e.clientY);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── copy-id flash ────────────────────────────────────────────────────────
|
|
699
|
+
// Clicking a row's id column copies the id to the clipboard and briefly
|
|
700
|
+
// swaps the display text to "copied ✓" before reverting. The flash state
|
|
701
|
+
// is keyed by bead id so simultaneous flashes across rows (rare, but the
|
|
702
|
+
// user could shift-click a batch later) don't stomp each other.
|
|
703
|
+
const copiedIds = ref<Set<string>>(new Set());
|
|
704
|
+
const copyTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
705
|
+
|
|
706
|
+
function copyRowId(id: string, e: MouseEvent) {
|
|
707
|
+
e.stopPropagation();
|
|
708
|
+
void navigator.clipboard.writeText(id);
|
|
709
|
+
copiedIds.value = new Set([...copiedIds.value, id]);
|
|
710
|
+
const prev = copyTimers.get(id);
|
|
711
|
+
if (prev) clearTimeout(prev);
|
|
712
|
+
copyTimers.set(
|
|
713
|
+
id,
|
|
714
|
+
setTimeout(() => {
|
|
715
|
+
const next = new Set(copiedIds.value);
|
|
716
|
+
next.delete(id);
|
|
717
|
+
copiedIds.value = next;
|
|
718
|
+
copyTimers.delete(id);
|
|
719
|
+
}, 900),
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// See BeadsGrid: an inferred match belongs in an assignee-filtered result
|
|
724
|
+
// but recedes, because it's the scheduler's guess and not recorded fact.
|
|
725
|
+
const assigneeFilterActive = computed(
|
|
726
|
+
() => (store.filters.assignees?.length ?? 0) > 0,
|
|
727
|
+
);
|
|
728
|
+
const isInferredMatch = (row: { syntheticAssignee: boolean }) =>
|
|
729
|
+
assigneeFilterActive.value && row.syntheticAssignee;
|
|
730
|
+
|
|
731
|
+
const watched = computed(() => store.watchedSet);
|
|
732
|
+
// Pins are a read-only reflection of the watch list; the drawer header owns
|
|
733
|
+
// adding and removing.
|
|
734
|
+
const isWatched = (id: string) => watched.value.has(id);
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The bead behind a group, when the group *is* a bead. Only epic grouping
|
|
738
|
+
* yields real bead ids; every other mode groups by a facet value (an
|
|
739
|
+
* assignee handle, a status) and placeholder buckets start with "__".
|
|
740
|
+
* Returns null when there's nothing to open, so the label stays inert.
|
|
741
|
+
*/
|
|
742
|
+
function groupBeadId(gid: string): string | null {
|
|
743
|
+
if (store.groupBy !== "epic") return null;
|
|
744
|
+
if (gid.startsWith("__")) return null;
|
|
745
|
+
return gid;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** An assignee lane's header is a handle: it opens that person (bp-67g.43). */
|
|
749
|
+
function groupPerson(gid: string): string | null {
|
|
750
|
+
if (store.groupBy !== "assignee") return null;
|
|
751
|
+
if (gid.startsWith("__")) return null;
|
|
752
|
+
return gid;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const isGroupOpenable = (gid: string) => !!groupBeadId(gid) || !!groupPerson(gid);
|
|
756
|
+
|
|
757
|
+
function openGroupBead(gid: string) {
|
|
758
|
+
const id = groupBeadId(gid);
|
|
759
|
+
if (id) return store.openBead(id);
|
|
760
|
+
const who = groupPerson(gid);
|
|
761
|
+
if (who) store.openPerson(who);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ── dim / highlight helpers ──────────────────────────────────────────────
|
|
765
|
+
const blockedIds = computed(() => store.blockedBeadIds);
|
|
766
|
+
const readyNowIds = computed(() => store.readyNowBeadIds);
|
|
767
|
+
const overCapAssignees = computed(() => store.overCapAssignees);
|
|
768
|
+
|
|
769
|
+
function isGroupOverCap(gid: string): boolean {
|
|
770
|
+
if (!store.highlights["over-cap"]) return false;
|
|
771
|
+
if (store.groupBy !== "assignee") return false;
|
|
772
|
+
return overCapAssignees.value.has(gid);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function barExtraClass(bar: Bar): string {
|
|
776
|
+
const cls: string[] = [];
|
|
777
|
+
if (blockedIds.value.has(bar.beadId)) cls.push("hl-blocked");
|
|
778
|
+
if (readyNowIds.value.has(bar.beadId)) cls.push("hl-ready-now");
|
|
779
|
+
return cls.join(" ");
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ── dependency arrows (bp-67g.1) ─────────────────────────────────────────
|
|
783
|
+
// Map every visible bead id → the pixel geometry of its bar (union of any
|
|
784
|
+
// split-bar segments the view-model emits). Recomputed with the view model.
|
|
785
|
+
|
|
786
|
+
interface BeadGeometry {
|
|
787
|
+
xStart: number;
|
|
788
|
+
xEnd: number;
|
|
789
|
+
yCenter: number;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// What-if ghosts (bp-q56): for each bead the what-if moved, the baseline's
|
|
793
|
+
// work span drawn dimmed under the live plan, with the delta beside it.
|
|
794
|
+
const rowIndexById = computed(() => new Map((props.vm?.rows ?? []).map((r) => [r.beadId, r.index] as const)));
|
|
795
|
+
const ghosts = computed(() => {
|
|
796
|
+
const d = store.whatIfDiff;
|
|
797
|
+
if (!d || !props.vm) return [];
|
|
798
|
+
const today = props.vm.meta.todayDay;
|
|
799
|
+
const total = props.vm.meta.totalDays;
|
|
800
|
+
const clampDay = (v: number) => Math.min(total, Math.max(0, today + v));
|
|
801
|
+
return d.moved
|
|
802
|
+
.filter((m) => rowIndexById.value.has(m.id))
|
|
803
|
+
.map((m) => ({
|
|
804
|
+
id: m.id,
|
|
805
|
+
rowIndex: rowIndexById.value.get(m.id)!,
|
|
806
|
+
startDay: clampDay(m.workBefore.start),
|
|
807
|
+
endDay: clampDay(m.workBefore.end),
|
|
808
|
+
afterStartDay: clampDay(m.workAfter.start),
|
|
809
|
+
label: describeDelta(m.deltaDays),
|
|
810
|
+
later: m.deltaDays > 0,
|
|
811
|
+
}));
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const beadGeometryById = computed<Map<string, BeadGeometry>>(() => {
|
|
815
|
+
const out = new Map<string, BeadGeometry>();
|
|
816
|
+
if (!props.vm) return out;
|
|
817
|
+
// xScaler isn't available in a pure computed (it's a slot arg), so we
|
|
818
|
+
// build the geometry in scaled-day space and convert on render below.
|
|
819
|
+
// For now, capture only Y and raw day extents; the arrow path picks the
|
|
820
|
+
// scaler up when it renders.
|
|
821
|
+
for (const bar of props.vm.bars) {
|
|
822
|
+
const existing = out.get(bar.beadId);
|
|
823
|
+
const yCenter =
|
|
824
|
+
rowYByIndex.value[bar.rowIndex] + BAR_H / 2 + 3;
|
|
825
|
+
if (!existing) {
|
|
826
|
+
out.set(bar.beadId, {
|
|
827
|
+
xStart: bar.startDay,
|
|
828
|
+
xEnd: bar.endDay,
|
|
829
|
+
yCenter,
|
|
830
|
+
});
|
|
831
|
+
} else {
|
|
832
|
+
existing.xStart = Math.min(existing.xStart, bar.startDay);
|
|
833
|
+
existing.xEnd = Math.max(existing.xEnd, bar.endDay);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// A "focal" bead is what an edge is drawn relative to for coloring:
|
|
840
|
+
// - outgoing (green) → focal bead BLOCKS this dependent
|
|
841
|
+
// - incoming (red) → this blocker is what's blocking the focal
|
|
842
|
+
// When no focal bead exists and "show all" is on, edges use a neutral hue.
|
|
843
|
+
type EdgeRole = "outgoing" | "incoming" | "neutral";
|
|
844
|
+
|
|
845
|
+
interface DepEdge {
|
|
846
|
+
fromId: string;
|
|
847
|
+
toId: string;
|
|
848
|
+
fromX: number;
|
|
849
|
+
fromY: number;
|
|
850
|
+
toX: number;
|
|
851
|
+
toY: number;
|
|
852
|
+
role: EdgeRole;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Which bead is the "focal" for arrow drawing? The hovered one, while the
|
|
856
|
+
// cursor is on it; otherwise none, and no arrows unless the user opted into
|
|
857
|
+
// "show all".
|
|
858
|
+
const focalBeadId = computed<string | null>(() => {
|
|
859
|
+
if (hoveredBeadId.value) return hoveredBeadId.value;
|
|
860
|
+
return null;
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
const dependencyEdges = computed<DepEdge[]>(() => {
|
|
864
|
+
if (!store.showDependencies) return [];
|
|
865
|
+
const beads = store.beads;
|
|
866
|
+
const geo = beadGeometryById.value;
|
|
867
|
+
const focal = focalBeadId.value;
|
|
868
|
+
const edges: DepEdge[] = [];
|
|
869
|
+
for (const b of beads) {
|
|
870
|
+
if (!geo.has(b.id)) continue;
|
|
871
|
+
for (const d of b.dependencies ?? []) {
|
|
872
|
+
if (d.type !== "blocks") continue;
|
|
873
|
+
const blockerGeo = geo.get(d.depends_on_id);
|
|
874
|
+
if (!blockerGeo) continue;
|
|
875
|
+
const dependentGeo = geo.get(b.id);
|
|
876
|
+
if (!dependentGeo) continue;
|
|
877
|
+
|
|
878
|
+
// Focal-only filter: full-graph arrows are unreadable at scale, so
|
|
879
|
+
// we default to "arrows only for the focal bead" and let the user
|
|
880
|
+
// opt into all edges via a separate toggle.
|
|
881
|
+
let role: EdgeRole = "neutral";
|
|
882
|
+
if (focal) {
|
|
883
|
+
if (d.depends_on_id === focal) role = "outgoing";
|
|
884
|
+
else if (b.id === focal) role = "incoming";
|
|
885
|
+
else continue;
|
|
886
|
+
} else if (!store.showAllDependencies) {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
edges.push({
|
|
891
|
+
fromId: d.depends_on_id,
|
|
892
|
+
toId: b.id,
|
|
893
|
+
fromX: blockerGeo.xEnd,
|
|
894
|
+
fromY: blockerGeo.yCenter,
|
|
895
|
+
toX: dependentGeo.xStart,
|
|
896
|
+
toY: dependentGeo.yCenter,
|
|
897
|
+
role,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return edges;
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Cubic-bezier arrow path from (x0,y0) to (x1,y1). The control points push
|
|
906
|
+
* the curve upward by ~row height so arrows arc above the bars they
|
|
907
|
+
* connect. If the destination sits to the LEFT of the source (dependent
|
|
908
|
+
* scheduled before blocker — should be rare), we still route the curve
|
|
909
|
+
* cleanly with a bigger arc.
|
|
910
|
+
*/
|
|
911
|
+
function arrowGeometry(
|
|
912
|
+
x0: number,
|
|
913
|
+
y0: number,
|
|
914
|
+
x1: number,
|
|
915
|
+
y1: number,
|
|
916
|
+
iconSize: number,
|
|
917
|
+
) {
|
|
918
|
+
const dx = x1 - x0;
|
|
919
|
+
const arcHeight = Math.max(ROW_H * 0.9, Math.min(80, Math.abs(dx) * 0.15));
|
|
920
|
+
const cy0 = y0 - arcHeight;
|
|
921
|
+
const cy1 = y1 - arcHeight;
|
|
922
|
+
const cx0 = x0 + (dx >= 0 ? Math.max(12, dx * 0.3) : -20);
|
|
923
|
+
const cx1 = x1 - (dx >= 0 ? Math.max(12, dx * 0.3) : -20);
|
|
924
|
+
// A cubic's tangent at t=1 is P3 - P2, so the terminal icon can be rotated
|
|
925
|
+
// to meet the curve instead of sitting at an arbitrary angle.
|
|
926
|
+
const rad = Math.atan2(y1 - cy1, x1 - cx1);
|
|
927
|
+
// Pull the glyph back along the tangent so it stops short of the bar
|
|
928
|
+
// instead of half-disappearing under it — the job <marker refX> used to do.
|
|
929
|
+
const pullback = iconSize * 0.45;
|
|
930
|
+
return {
|
|
931
|
+
d: `M ${x0} ${y0} C ${cx0} ${cy0}, ${cx1} ${cy1}, ${x1} ${y1}`,
|
|
932
|
+
tipX: x1 - Math.cos(rad) * pullback,
|
|
933
|
+
tipY: y1 - Math.sin(rad) * pullback,
|
|
934
|
+
angle: (rad * 180) / Math.PI,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Terminal icon per edge role. Markers can't do this: currentColor inside a
|
|
940
|
+
* <marker> resolves against the marker's own context in <defs>, not the
|
|
941
|
+
* referencing path, so a marker has to hardcode its fill (which is why this
|
|
942
|
+
* used to carry three near-identical marker defs). A nested <svg> placed at
|
|
943
|
+
* the tip inherits `color` from the edge's own class instead.
|
|
944
|
+
*/
|
|
945
|
+
const ROLE_ICON = {
|
|
946
|
+
// ArrowRight rather than a chevron: terminals get rotated onto the curve's
|
|
947
|
+
// tangent, and a bare chevron at an oblique angle reads as a checkmark.
|
|
948
|
+
// An arrow has a shaft, so its orientation survives any rotation.
|
|
949
|
+
outgoing: ArrowRight, // focal blocks these — flow continues downstream
|
|
950
|
+
// Semantic glyphs are reserved for the focal bead's own edges, where there
|
|
951
|
+
// are few of them and they're worth reading. Full-graph mode (neutral) stays
|
|
952
|
+
// plain: at that density a stop sign per edge is noise, and colour already
|
|
953
|
+
// carries the role.
|
|
954
|
+
incoming: Ban, // these block the focal — a stop, not an onward arrow
|
|
955
|
+
neutral: ArrowRight,
|
|
956
|
+
} as const;
|
|
957
|
+
</script>
|
|
958
|
+
|
|
959
|
+
<template>
|
|
960
|
+
<div
|
|
961
|
+
ref="wrapperEl"
|
|
962
|
+
class="beads-gantt"
|
|
963
|
+
:class="{ 'is-resizing-labels': isResizingLabelCol }"
|
|
964
|
+
@wheel="onPlotWheel"
|
|
965
|
+
:style="{
|
|
966
|
+
minHeight: contentHeight + 'px',
|
|
967
|
+
// DDV's inner <svg class='outer'> hard-caps at min-height 250px unless
|
|
968
|
+
// we override --minHeight here. --aspectRatio has to be 'auto' or DDV
|
|
969
|
+
// will resize the SVG to whatever ratio the wrapper started at
|
|
970
|
+
// (short) and never grow to fit the row count.
|
|
971
|
+
'--minHeight': contentHeight + 'px',
|
|
972
|
+
'--aspectRatio': 'auto',
|
|
973
|
+
}"
|
|
974
|
+
>
|
|
975
|
+
<DDV :layout="layout" :mouse-state="mouseState" class="beads-gantt-ddv">
|
|
976
|
+
<template #default>
|
|
977
|
+
<CartesianScaler
|
|
978
|
+
:x-source="xDomain"
|
|
979
|
+
:y-source="yDomain"
|
|
980
|
+
:x-target="xScreenDomain"
|
|
981
|
+
:y-target="yScreenDomain"
|
|
982
|
+
>
|
|
983
|
+
<template #default="{ xScaler }">
|
|
984
|
+
<!-- Clip titles to the title column so long strings can't run
|
|
985
|
+
into the priority chip on the right. Row texts sit inside
|
|
986
|
+
translated <g>s at varying Y, so the clip rect spans the
|
|
987
|
+
full vertical range — horizontal is the only constraint. -->
|
|
988
|
+
<defs>
|
|
989
|
+
<!-- Group labels get their own clip: they start further left
|
|
990
|
+
than row titles and must stop short of the progress chip
|
|
991
|
+
rather than the priority gutter. -->
|
|
992
|
+
<clipPath
|
|
993
|
+
id="beads-gantt-group-label-clip"
|
|
994
|
+
clipPathUnits="userSpaceOnUse"
|
|
995
|
+
>
|
|
996
|
+
<rect
|
|
997
|
+
:x="ID_COL_X"
|
|
998
|
+
:y="0"
|
|
999
|
+
:width="Math.max(0, layout.margins.left - PROGRESS_CHIP_W - 8 - ID_COL_X - PIN_COL_W)"
|
|
1000
|
+
:height="contentHeight"
|
|
1001
|
+
/>
|
|
1002
|
+
</clipPath>
|
|
1003
|
+
<clipPath id="beads-gantt-title-clip" clipPathUnits="userSpaceOnUse">
|
|
1004
|
+
<rect
|
|
1005
|
+
:x="TITLE_COL_X"
|
|
1006
|
+
:y="0"
|
|
1007
|
+
:width="Math.max(0, layout.margins.left - TITLE_COL_X - PRIORITY_COL_W - PIN_COL_W)"
|
|
1008
|
+
:height="contentHeight"
|
|
1009
|
+
/>
|
|
1010
|
+
</clipPath>
|
|
1011
|
+
<!-- The viewport (bp-c6q): every layer the x scaler positions
|
|
1012
|
+
is clipped to the plot column, so a bar, arrow or tick
|
|
1013
|
+
panned out of view cannot draw over the label column. In
|
|
1014
|
+
the plot groups' own coordinates, which start at the
|
|
1015
|
+
margins; the axis rows above the plot are included. -->
|
|
1016
|
+
<clipPath id="beads-gantt-plot-clip" clipPathUnits="userSpaceOnUse">
|
|
1017
|
+
<rect
|
|
1018
|
+
:x="0"
|
|
1019
|
+
:y="-AXIS_TOP"
|
|
1020
|
+
:width="Math.max(0, layout.plotWidth)"
|
|
1021
|
+
:height="Math.max(0, layout.plotHeight) + AXIS_TOP + AXIS_BOTTOM"
|
|
1022
|
+
/>
|
|
1023
|
+
</clipPath>
|
|
1024
|
+
<!-- Synthetic-assignee hatch (bp-67g.49). A luminance mask:
|
|
1025
|
+
the white stripes keep the bar and the gaps drop it, so
|
|
1026
|
+
the bar's own kind colour and opacity show through
|
|
1027
|
+
unchanged. The pattern tiles in user space, so stripes
|
|
1028
|
+
run continuously across neighbouring bars instead of
|
|
1029
|
+
restarting at each one's edge. The mask's content is in
|
|
1030
|
+
the referencing bar's coordinates — the plot's, since
|
|
1031
|
+
bars live in the translated plot group — and one rect
|
|
1032
|
+
the size of the plot covers every bar. -->
|
|
1033
|
+
<pattern
|
|
1034
|
+
id="beads-gantt-hatch"
|
|
1035
|
+
patternUnits="userSpaceOnUse"
|
|
1036
|
+
:width="HATCH_PITCH"
|
|
1037
|
+
:height="HATCH_PITCH"
|
|
1038
|
+
patternTransform="rotate(45)"
|
|
1039
|
+
>
|
|
1040
|
+
<rect :x="0" :y="0" :width="HATCH_STRIPE" :height="HATCH_PITCH" fill="#fff" />
|
|
1041
|
+
</pattern>
|
|
1042
|
+
<mask id="beads-gantt-synthetic-mask" maskContentUnits="userSpaceOnUse">
|
|
1043
|
+
<rect
|
|
1044
|
+
:x="-HATCH_PITCH"
|
|
1045
|
+
:y="-HATCH_PITCH"
|
|
1046
|
+
:width="layout.plotWidth + 2 * HATCH_PITCH"
|
|
1047
|
+
:height="layout.plotHeight + 2 * HATCH_PITCH"
|
|
1048
|
+
fill="url(#beads-gantt-hatch)"
|
|
1049
|
+
/>
|
|
1050
|
+
</mask>
|
|
1051
|
+
</defs>
|
|
1052
|
+
|
|
1053
|
+
<!-- Plot background -->
|
|
1054
|
+
<rect
|
|
1055
|
+
v-if="layout.plotWidth > 0 && layout.plotHeight > 0"
|
|
1056
|
+
class="plot-bg"
|
|
1057
|
+
:x="layout.margins.left"
|
|
1058
|
+
:y="layout.margins.top"
|
|
1059
|
+
:width="layout.plotWidth"
|
|
1060
|
+
:height="layout.plotHeight"
|
|
1061
|
+
/>
|
|
1062
|
+
|
|
1063
|
+
<!-- Top X-axis. Rendered manually (DDV's <Axis> hardcodes tick
|
|
1064
|
+
labels to the bottom of the plot). Ticks are auto-picked
|
|
1065
|
+
by the same date-fns unit progression DDV uses. -->
|
|
1066
|
+
<g
|
|
1067
|
+
v-if="xScaler && layout.plotWidth > 0"
|
|
1068
|
+
class="x-axis"
|
|
1069
|
+
clip-path="url(#beads-gantt-plot-clip)"
|
|
1070
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1071
|
+
>
|
|
1072
|
+
<g class="gridlines">
|
|
1073
|
+
<!-- Finer boundaries first, so a labelled major line always
|
|
1074
|
+
paints over its minor counterpart at the same x. -->
|
|
1075
|
+
<line
|
|
1076
|
+
v-for="(d, i) in axisMinor"
|
|
1077
|
+
:key="`minor-${i}`"
|
|
1078
|
+
class="minor"
|
|
1079
|
+
:x1="xScaler.scale(d.getTime())"
|
|
1080
|
+
:x2="xScaler.scale(d.getTime())"
|
|
1081
|
+
:y1="0"
|
|
1082
|
+
:y2="layout.plotHeight"
|
|
1083
|
+
/>
|
|
1084
|
+
<line
|
|
1085
|
+
v-for="(t, i) in axisTicks"
|
|
1086
|
+
:key="`grid-${i}`"
|
|
1087
|
+
:class="{ emphasize: t.emphasize }"
|
|
1088
|
+
:x1="xScaler.scale(t.date.getTime())"
|
|
1089
|
+
:x2="xScaler.scale(t.date.getTime())"
|
|
1090
|
+
:y1="0"
|
|
1091
|
+
:y2="layout.plotHeight"
|
|
1092
|
+
/>
|
|
1093
|
+
</g>
|
|
1094
|
+
<g class="tick-labels">
|
|
1095
|
+
<text
|
|
1096
|
+
v-for="(t, i) in axisTicks"
|
|
1097
|
+
v-show="!tickCollidesWithToday(t, xScaler)"
|
|
1098
|
+
:key="`tick-${i}`"
|
|
1099
|
+
:class="{ emphasize: t.emphasize }"
|
|
1100
|
+
:x="xScaler.scale(t.date.getTime()) + 4"
|
|
1101
|
+
:y="-8"
|
|
1102
|
+
>{{ t.label }}</text>
|
|
1103
|
+
</g>
|
|
1104
|
+
</g>
|
|
1105
|
+
|
|
1106
|
+
<!-- Group header strips. The count sits at the RIGHT edge of
|
|
1107
|
+
the plot so it never collides with the (potentially long)
|
|
1108
|
+
epic label in the left column. -->
|
|
1109
|
+
<g
|
|
1110
|
+
v-if="vm"
|
|
1111
|
+
class="left-column"
|
|
1112
|
+
:transform="`translate(0 ${layout.margins.top})`"
|
|
1113
|
+
>
|
|
1114
|
+
<g
|
|
1115
|
+
v-for="g in groupBands"
|
|
1116
|
+
:key="g.id"
|
|
1117
|
+
:class="{
|
|
1118
|
+
'group-band': true,
|
|
1119
|
+
'is-over-cap': isGroupOverCap(g.id),
|
|
1120
|
+
}"
|
|
1121
|
+
:transform="`translate(0 ${g.top})`"
|
|
1122
|
+
>
|
|
1123
|
+
<rect
|
|
1124
|
+
v-if="!isUngrouped"
|
|
1125
|
+
class="group-header"
|
|
1126
|
+
:x="0"
|
|
1127
|
+
:y="0"
|
|
1128
|
+
:width="layout.elementWidth"
|
|
1129
|
+
:height="g.height"
|
|
1130
|
+
/>
|
|
1131
|
+
<text
|
|
1132
|
+
v-if="!isUngrouped"
|
|
1133
|
+
class="group-label"
|
|
1134
|
+
:class="{ 'is-openable': isGroupOpenable(g.id) }"
|
|
1135
|
+
:x="ID_COL_X"
|
|
1136
|
+
:y="GROUP_H - 11"
|
|
1137
|
+
clip-path="url(#beads-gantt-group-label-clip)"
|
|
1138
|
+
@click.stop="openGroupBead(g.id)"
|
|
1139
|
+
>
|
|
1140
|
+
{{ g.label }}
|
|
1141
|
+
<title>{{ groupBeadId(g.id) ? `Open ${g.id}` : g.label }}</title>
|
|
1142
|
+
</text>
|
|
1143
|
+
<!-- Over-cap badge. The icon is a lucide component used as
|
|
1144
|
+
a NESTED <svg>: it renders its own <svg> root, so x/y
|
|
1145
|
+
(which fall through as attributes) place it, and it
|
|
1146
|
+
carries its own viewport rather than inheriting ours. -->
|
|
1147
|
+
<TriangleAlert
|
|
1148
|
+
v-if="!isUngrouped && isGroupOverCap(g.id)"
|
|
1149
|
+
class="gantt-icon over-cap-icon"
|
|
1150
|
+
:size="GANTT_ICON"
|
|
1151
|
+
:x="layout.margins.left + 8"
|
|
1152
|
+
:y="GROUP_H - 22"
|
|
1153
|
+
/>
|
|
1154
|
+
<Pin
|
|
1155
|
+
v-if="!isUngrouped && isWatched(g.id)"
|
|
1156
|
+
class="gantt-icon watch-pin"
|
|
1157
|
+
:size="WATCH_PIN"
|
|
1158
|
+
:x="layout.margins.left - PROGRESS_CHIP_W - PIN_COL_W - 3"
|
|
1159
|
+
:y="(GROUP_H - WATCH_PIN) / 2"
|
|
1160
|
+
/>
|
|
1161
|
+
<text
|
|
1162
|
+
v-if="!isUngrouped && isGroupOverCap(g.id)"
|
|
1163
|
+
class="over-cap-badge"
|
|
1164
|
+
:x="layout.margins.left + 8 + GANTT_ICON + 4"
|
|
1165
|
+
:y="GROUP_H - 11"
|
|
1166
|
+
>over cap</text>
|
|
1167
|
+
<!-- Progress chip: e.g. "3/17". Positioned to the right of
|
|
1168
|
+
the label, inside the label column. Filter-independent. -->
|
|
1169
|
+
<g
|
|
1170
|
+
v-if="!isUngrouped && g.progress.total > 0"
|
|
1171
|
+
class="group-progress"
|
|
1172
|
+
:transform="`translate(${layout.margins.left - PROGRESS_CHIP_W - 8} 0)`"
|
|
1173
|
+
>
|
|
1174
|
+
<rect
|
|
1175
|
+
class="progress-chip"
|
|
1176
|
+
:x="0"
|
|
1177
|
+
:y="4"
|
|
1178
|
+
:width="PROGRESS_CHIP_W"
|
|
1179
|
+
:height="12"
|
|
1180
|
+
rx="2"
|
|
1181
|
+
/>
|
|
1182
|
+
<text
|
|
1183
|
+
class="progress-chip-text"
|
|
1184
|
+
:x="PROGRESS_CHIP_W / 2"
|
|
1185
|
+
:y="14"
|
|
1186
|
+
text-anchor="middle"
|
|
1187
|
+
>{{ g.progress.closed }}/{{ g.progress.total }}</text>
|
|
1188
|
+
</g>
|
|
1189
|
+
<!-- Progress bar: slim rule under the label spanning the
|
|
1190
|
+
label column, closed-fraction filled. -->
|
|
1191
|
+
<g
|
|
1192
|
+
v-if="!isUngrouped && g.progress.total > 0"
|
|
1193
|
+
class="progress-bar"
|
|
1194
|
+
:transform="`translate(10 ${GROUP_H - PROGRESS_BAR_H - PROGRESS_BAR_MARGIN_Y})`"
|
|
1195
|
+
>
|
|
1196
|
+
<rect
|
|
1197
|
+
class="progress-bar-bg"
|
|
1198
|
+
:x="0"
|
|
1199
|
+
:y="0"
|
|
1200
|
+
:width="layout.margins.left - 20"
|
|
1201
|
+
:height="PROGRESS_BAR_H"
|
|
1202
|
+
rx="1.5"
|
|
1203
|
+
/>
|
|
1204
|
+
<rect
|
|
1205
|
+
class="progress-bar-fill"
|
|
1206
|
+
:x="0"
|
|
1207
|
+
:y="0"
|
|
1208
|
+
:width="(layout.margins.left - 20) * progressFraction(g.progress)"
|
|
1209
|
+
:height="PROGRESS_BAR_H"
|
|
1210
|
+
rx="1.5"
|
|
1211
|
+
/>
|
|
1212
|
+
</g>
|
|
1213
|
+
<!-- Row-count / expand pill on the right side of the plot
|
|
1214
|
+
area. When the group is truncated, this becomes a
|
|
1215
|
+
clickable "show N more" pill; when expanded (or fully
|
|
1216
|
+
visible) it's a passive "N beads" tag. -->
|
|
1217
|
+
<g
|
|
1218
|
+
v-if="g.truncatedCount > 0"
|
|
1219
|
+
class="group-expand-btn"
|
|
1220
|
+
:transform="`translate(${layout.margins.left + layout.plotWidth - 128} 4)`"
|
|
1221
|
+
@click.stop="store.toggleExpandGroup(g.id)"
|
|
1222
|
+
>
|
|
1223
|
+
<rect
|
|
1224
|
+
class="expand-pill"
|
|
1225
|
+
:x="0"
|
|
1226
|
+
:y="0"
|
|
1227
|
+
:width="120"
|
|
1228
|
+
:height="16"
|
|
1229
|
+
rx="8"
|
|
1230
|
+
/>
|
|
1231
|
+
<text
|
|
1232
|
+
class="expand-pill-text"
|
|
1233
|
+
:x="52"
|
|
1234
|
+
:y="12"
|
|
1235
|
+
text-anchor="middle"
|
|
1236
|
+
>show {{ g.truncatedCount }} more</text>
|
|
1237
|
+
<ChevronDown class="gantt-icon" :size="GANTT_ICON" :x="96" :y="1" />
|
|
1238
|
+
</g>
|
|
1239
|
+
<g
|
|
1240
|
+
v-else-if="store.expandedGroups[g.id]"
|
|
1241
|
+
class="group-expand-btn is-expanded"
|
|
1242
|
+
:transform="`translate(${layout.margins.left + layout.plotWidth - 128} 4)`"
|
|
1243
|
+
@click.stop="store.toggleExpandGroup(g.id)"
|
|
1244
|
+
>
|
|
1245
|
+
<rect
|
|
1246
|
+
class="expand-pill"
|
|
1247
|
+
:x="0"
|
|
1248
|
+
:y="0"
|
|
1249
|
+
:width="120"
|
|
1250
|
+
:height="16"
|
|
1251
|
+
rx="8"
|
|
1252
|
+
/>
|
|
1253
|
+
<text
|
|
1254
|
+
class="expand-pill-text"
|
|
1255
|
+
:x="52"
|
|
1256
|
+
:y="12"
|
|
1257
|
+
text-anchor="middle"
|
|
1258
|
+
>collapse</text>
|
|
1259
|
+
<ChevronUp class="gantt-icon" :size="GANTT_ICON" :x="96" :y="1" />
|
|
1260
|
+
</g>
|
|
1261
|
+
<text
|
|
1262
|
+
v-else-if="!isUngrouped"
|
|
1263
|
+
class="group-count"
|
|
1264
|
+
:x="layout.margins.left + layout.plotWidth - 8"
|
|
1265
|
+
:y="GROUP_H - 9"
|
|
1266
|
+
text-anchor="end"
|
|
1267
|
+
>
|
|
1268
|
+
{{ g.rowCount }} bead{{ g.rowCount === 1 ? "" : "s" }}<template v-if="g.syntheticCount > 0"> · {{ g.syntheticCount }} inferred</template>
|
|
1269
|
+
<title v-if="g.syntheticCount > 0">{{ inferredTitle(g) }}</title>
|
|
1270
|
+
</text>
|
|
1271
|
+
</g>
|
|
1272
|
+
</g>
|
|
1273
|
+
|
|
1274
|
+
<!-- Divider between label column and plot. The visible line is
|
|
1275
|
+
thin, but the hit-target rect around it is 8px wide so
|
|
1276
|
+
dragging feels forgiving. Persists to localStorage. -->
|
|
1277
|
+
<line
|
|
1278
|
+
v-if="layout.plotWidth > 0"
|
|
1279
|
+
class="label-divider"
|
|
1280
|
+
:class="{ 'is-resizing': isResizingLabelCol }"
|
|
1281
|
+
:x1="layout.margins.left"
|
|
1282
|
+
:y1="0"
|
|
1283
|
+
:x2="layout.margins.left"
|
|
1284
|
+
:y2="layout.margins.top + layout.plotHeight + AXIS_BOTTOM"
|
|
1285
|
+
/>
|
|
1286
|
+
<rect
|
|
1287
|
+
v-if="layout.plotWidth > 0"
|
|
1288
|
+
class="label-resize-handle"
|
|
1289
|
+
:x="layout.margins.left - 4"
|
|
1290
|
+
:y="0"
|
|
1291
|
+
:width="8"
|
|
1292
|
+
:height="layout.margins.top + layout.plotHeight + AXIS_BOTTOM"
|
|
1293
|
+
@mousedown="onLabelColResizeStart"
|
|
1294
|
+
/>
|
|
1295
|
+
|
|
1296
|
+
<!-- Row hover band: one translucent rect per row across the full
|
|
1297
|
+
element width, behind the text and the bars. Always present so
|
|
1298
|
+
the opacity transition can run both ways. -->
|
|
1299
|
+
<g
|
|
1300
|
+
v-if="vm"
|
|
1301
|
+
class="row-hover-layer"
|
|
1302
|
+
:transform="`translate(0 ${layout.margins.top})`"
|
|
1303
|
+
>
|
|
1304
|
+
<rect
|
|
1305
|
+
v-for="(row, i) in vm.rows"
|
|
1306
|
+
:key="`hover-${row.beadId}`"
|
|
1307
|
+
class="row-hover"
|
|
1308
|
+
:class="{ 'is-lit': litRowId === row.beadId }"
|
|
1309
|
+
:x="0"
|
|
1310
|
+
:y="rowYByIndex[i]"
|
|
1311
|
+
:width="layout.elementWidth"
|
|
1312
|
+
:height="ROW_H"
|
|
1313
|
+
/>
|
|
1314
|
+
</g>
|
|
1315
|
+
|
|
1316
|
+
<!-- Row labels (id + title + priority) -->
|
|
1317
|
+
<g
|
|
1318
|
+
v-if="vm"
|
|
1319
|
+
class="row-labels"
|
|
1320
|
+
:transform="`translate(0 ${layout.margins.top})`"
|
|
1321
|
+
>
|
|
1322
|
+
<g
|
|
1323
|
+
v-for="(row, i) in vm.rows"
|
|
1324
|
+
:key="row.beadId"
|
|
1325
|
+
:class="{
|
|
1326
|
+
'row-label-band': true,
|
|
1327
|
+
'is-inferred-match': isInferredMatch(row),
|
|
1328
|
+
'is-lit': litRowId === row.beadId,
|
|
1329
|
+
}"
|
|
1330
|
+
:transform="`translate(0 ${rowYByIndex[i]})`"
|
|
1331
|
+
@mouseenter="hoveredRowId = row.beadId"
|
|
1332
|
+
@mouseleave="hoveredRowId = null"
|
|
1333
|
+
>
|
|
1334
|
+
<!-- Hit target for the empty parts of the band. Stops 4px
|
|
1335
|
+
short of the label-column resize handle. -->
|
|
1336
|
+
<rect
|
|
1337
|
+
class="row-hit"
|
|
1338
|
+
:x="0"
|
|
1339
|
+
:y="0"
|
|
1340
|
+
:width="Math.max(0, layout.margins.left - 4)"
|
|
1341
|
+
:height="ROW_H"
|
|
1342
|
+
/>
|
|
1343
|
+
<!-- ID: click copies to clipboard with a brief "copied ✓"
|
|
1344
|
+
flash. Separate from the title so the two affordances
|
|
1345
|
+
don't collide. -->
|
|
1346
|
+
<text
|
|
1347
|
+
class="row-id"
|
|
1348
|
+
:class="{ 'is-copied': copiedIds.has(row.beadId) }"
|
|
1349
|
+
:x="ID_COL_X"
|
|
1350
|
+
:y="ROW_H - 6"
|
|
1351
|
+
@click="copyRowId(row.beadId, $event)"
|
|
1352
|
+
>{{ copiedIds.has(row.beadId) ? "copied ✓" : row.beadId }}</text>
|
|
1353
|
+
<!-- Title: click opens the detail drawer. Clipped to the
|
|
1354
|
+
title column so long strings can't collide with the
|
|
1355
|
+
priority chip on the right. -->
|
|
1356
|
+
<text
|
|
1357
|
+
class="row-title"
|
|
1358
|
+
:x="TITLE_COL_X"
|
|
1359
|
+
:y="ROW_H - 6"
|
|
1360
|
+
clip-path="url(#beads-gantt-title-clip)"
|
|
1361
|
+
@click="store.openBead(row.beadId)"
|
|
1362
|
+
>{{ row.title }}<title>{{ row.title }}</title></text>
|
|
1363
|
+
<Pin
|
|
1364
|
+
v-if="isWatched(row.beadId)"
|
|
1365
|
+
class="gantt-icon watch-pin"
|
|
1366
|
+
:size="WATCH_PIN"
|
|
1367
|
+
:x="layout.margins.left - PRIORITY_COL_W - PIN_COL_W + 5"
|
|
1368
|
+
:y="(ROW_H - WATCH_PIN) / 2"
|
|
1369
|
+
/>
|
|
1370
|
+
<text
|
|
1371
|
+
class="row-priority"
|
|
1372
|
+
:x="layout.margins.left - 8"
|
|
1373
|
+
:y="ROW_H - 6"
|
|
1374
|
+
text-anchor="end"
|
|
1375
|
+
>P{{ row.priority }}</text>
|
|
1376
|
+
</g>
|
|
1377
|
+
</g>
|
|
1378
|
+
|
|
1379
|
+
<!-- Row hit targets in the plot, behind the bars, so empty row
|
|
1380
|
+
space lights the row too. Start 4px in, clear of the resize
|
|
1381
|
+
handle. -->
|
|
1382
|
+
<g
|
|
1383
|
+
v-if="vm && layout.plotWidth > 4"
|
|
1384
|
+
class="row-hit-layer"
|
|
1385
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1386
|
+
>
|
|
1387
|
+
<rect
|
|
1388
|
+
v-for="(row, i) in vm.rows"
|
|
1389
|
+
:key="`hit-${row.beadId}`"
|
|
1390
|
+
class="row-hit"
|
|
1391
|
+
:x="4"
|
|
1392
|
+
:y="rowYByIndex[i]"
|
|
1393
|
+
:width="layout.plotWidth - 4"
|
|
1394
|
+
:height="ROW_H"
|
|
1395
|
+
@mouseenter="hoveredRowId = row.beadId"
|
|
1396
|
+
@mouseleave="hoveredRowId = null"
|
|
1397
|
+
/>
|
|
1398
|
+
</g>
|
|
1399
|
+
|
|
1400
|
+
<!-- Bars. Hover resolves the tooltip; a bar is not a link. -->
|
|
1401
|
+
<g
|
|
1402
|
+
v-if="vm && xScaler && layout.plotWidth > 0"
|
|
1403
|
+
class="bars"
|
|
1404
|
+
clip-path="url(#beads-gantt-plot-clip)"
|
|
1405
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1406
|
+
>
|
|
1407
|
+
<g
|
|
1408
|
+
v-for="(bar, i) in vm.bars"
|
|
1409
|
+
:key="`bar-${i}`"
|
|
1410
|
+
@mouseenter="onBarEnter(bar, $event)"
|
|
1411
|
+
@mouseleave="scheduleLeave"
|
|
1412
|
+
@contextmenu.prevent="onBarMenu(bar, $event)"
|
|
1413
|
+
>
|
|
1414
|
+
<!-- Wait segments (bp-6cg): dulled, hatched, cut short at
|
|
1415
|
+
the end that means something, with a dotted connector
|
|
1416
|
+
for what was cut. The work bars below stay to scale. -->
|
|
1417
|
+
<template v-if="isWaitKind(bar.kind)">
|
|
1418
|
+
<line
|
|
1419
|
+
v-if="waitGeo(bar, xScaler.scale).gap"
|
|
1420
|
+
class="wait-gap"
|
|
1421
|
+
:class="barCssClass(bar)"
|
|
1422
|
+
:x1="waitGeo(bar, xScaler.scale).gap!.x1"
|
|
1423
|
+
:x2="waitGeo(bar, xScaler.scale).gap!.x2"
|
|
1424
|
+
:y1="rowYByIndex[bar.rowIndex] + 3 + BAR_H / 2"
|
|
1425
|
+
:y2="rowYByIndex[bar.rowIndex] + 3 + BAR_H / 2"
|
|
1426
|
+
/>
|
|
1427
|
+
<rect
|
|
1428
|
+
:class="[barCssClass(bar), 'is-wait', { 'is-hovered': hoveredBeadId === bar.beadId }]"
|
|
1429
|
+
:x="waitGeo(bar, xScaler.scale).x"
|
|
1430
|
+
:y="rowYByIndex[bar.rowIndex] + 3 + WAIT_INSET"
|
|
1431
|
+
:width="waitGeo(bar, xScaler.scale).w"
|
|
1432
|
+
:height="BAR_H - 2 * WAIT_INSET"
|
|
1433
|
+
rx="2"
|
|
1434
|
+
/>
|
|
1435
|
+
</template>
|
|
1436
|
+
<rect
|
|
1437
|
+
v-else
|
|
1438
|
+
:class="[
|
|
1439
|
+
barCssClass(bar),
|
|
1440
|
+
barExtraClass(bar),
|
|
1441
|
+
{ 'is-hovered': hoveredBeadId === bar.beadId },
|
|
1442
|
+
]"
|
|
1443
|
+
:x="xScaler.scale(dateAt(bar.startDay))"
|
|
1444
|
+
:y="rowYByIndex[bar.rowIndex] + 3"
|
|
1445
|
+
:width="Math.max(MIN_BAR_PX, xScaler.scale(dateAt(bar.endDay)) - xScaler.scale(dateAt(bar.startDay)))"
|
|
1446
|
+
:height="BAR_H"
|
|
1447
|
+
rx="2"
|
|
1448
|
+
/>
|
|
1449
|
+
</g>
|
|
1450
|
+
<circle
|
|
1451
|
+
v-for="(bar, i) in vm.bars.filter((b) => b.shipMarker)"
|
|
1452
|
+
:key="`ship-${i}`"
|
|
1453
|
+
class="ship-marker"
|
|
1454
|
+
:cx="xScaler.scale(dateAt(bar.endDay))"
|
|
1455
|
+
:cy="rowYByIndex[bar.rowIndex] + BAR_H / 2 + 3"
|
|
1456
|
+
r="3"
|
|
1457
|
+
/>
|
|
1458
|
+
</g>
|
|
1459
|
+
|
|
1460
|
+
<!-- What-if ghosts (bp-q56): where the work WAS, dimmed, and the
|
|
1461
|
+
delta next to where it is now. -->
|
|
1462
|
+
<g
|
|
1463
|
+
v-if="xScaler && vm && ghosts.length"
|
|
1464
|
+
class="ghosts"
|
|
1465
|
+
clip-path="url(#beads-gantt-plot-clip)"
|
|
1466
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1467
|
+
>
|
|
1468
|
+
<template v-for="g in ghosts" :key="`ghost-${g.id}`">
|
|
1469
|
+
<rect
|
|
1470
|
+
class="ghost"
|
|
1471
|
+
:x="xScaler.scale(dateAt(g.startDay))"
|
|
1472
|
+
:y="rowYByIndex[g.rowIndex] + 3"
|
|
1473
|
+
:width="Math.max(MIN_BAR_PX, xScaler.scale(dateAt(g.endDay)) - xScaler.scale(dateAt(g.startDay)))"
|
|
1474
|
+
:height="BAR_H"
|
|
1475
|
+
rx="2"
|
|
1476
|
+
/>
|
|
1477
|
+
<text
|
|
1478
|
+
class="ghost-delta"
|
|
1479
|
+
:class="{ later: g.later, earlier: !g.later }"
|
|
1480
|
+
:x="xScaler.scale(dateAt(g.afterStartDay)) + 4"
|
|
1481
|
+
:y="rowYByIndex[g.rowIndex] + 3 - 2"
|
|
1482
|
+
>{{ g.label }}</text>
|
|
1483
|
+
</template>
|
|
1484
|
+
</g>
|
|
1485
|
+
|
|
1486
|
+
<!-- Dependency arrows (bp-67g.1). Rendered inside the plot's
|
|
1487
|
+
translate group so scaler pixels line up. Non-interactive
|
|
1488
|
+
so the underlying bars still receive hover/click. -->
|
|
1489
|
+
<g
|
|
1490
|
+
v-if="xScaler && vm && dependencyEdges.length"
|
|
1491
|
+
class="dep-arrows"
|
|
1492
|
+
clip-path="url(#beads-gantt-plot-clip)"
|
|
1493
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1494
|
+
>
|
|
1495
|
+
<g
|
|
1496
|
+
v-for="(e, i) in dependencyEdges"
|
|
1497
|
+
:key="`dep-${i}`"
|
|
1498
|
+
:class="[
|
|
1499
|
+
'dep-edge',
|
|
1500
|
+
`role-${e.role}`,
|
|
1501
|
+
]"
|
|
1502
|
+
>
|
|
1503
|
+
<template
|
|
1504
|
+
v-for="geo in [arrowGeometry(
|
|
1505
|
+
xScaler.scale(dateAt(e.fromX)),
|
|
1506
|
+
e.fromY,
|
|
1507
|
+
xScaler.scale(dateAt(e.toX)),
|
|
1508
|
+
e.toY,
|
|
1509
|
+
depIconSize(e.role),
|
|
1510
|
+
)]"
|
|
1511
|
+
>
|
|
1512
|
+
<path class="dep-arrow" :d="geo.d" />
|
|
1513
|
+
<!-- Terminal icon: a nested <svg> rotated onto the curve's
|
|
1514
|
+
end tangent. Inherits `color` from .dep-edge, which a
|
|
1515
|
+
<marker> could not do. -->
|
|
1516
|
+
<g :transform="`translate(${geo.tipX} ${geo.tipY}) rotate(${geo.angle})`">
|
|
1517
|
+
<component
|
|
1518
|
+
:is="ROLE_ICON[e.role]"
|
|
1519
|
+
class="dep-terminal-icon"
|
|
1520
|
+
:size="depIconSize(e.role)"
|
|
1521
|
+
:x="-DEP_ICON / 2"
|
|
1522
|
+
:y="-DEP_ICON / 2"
|
|
1523
|
+
/>
|
|
1524
|
+
</g>
|
|
1525
|
+
</template>
|
|
1526
|
+
</g>
|
|
1527
|
+
</g>
|
|
1528
|
+
|
|
1529
|
+
<!-- Today marker. ThresholdLine draws only the vertical rule
|
|
1530
|
+
(its `label` prop is inert — the primitive doesn't render
|
|
1531
|
+
text), so we position the TODAY label ourselves. Wrapped
|
|
1532
|
+
in the plot-area translate so the ThresholdLine's own
|
|
1533
|
+
`translate(scaler.scale(value) 0)` lands inside the plot. -->
|
|
1534
|
+
<g
|
|
1535
|
+
v-if="xScaler && vm && vm.meta.todayDay >= 0 && vm.meta.todayDay <= vm.meta.totalDays"
|
|
1536
|
+
class="today"
|
|
1537
|
+
clip-path="url(#beads-gantt-plot-clip)"
|
|
1538
|
+
:transform="`translate(${layout.margins.left} ${layout.margins.top})`"
|
|
1539
|
+
>
|
|
1540
|
+
<!-- ThresholdLine's `value` + `domain` props are typed as
|
|
1541
|
+
NumericDomain / number, but the scaler handles ms
|
|
1542
|
+
timestamps identically in a DateDomain. Pass epoch time
|
|
1543
|
+
+ the numeric-typed alias so runtime + TS both agree. -->
|
|
1544
|
+
<ThresholdLine
|
|
1545
|
+
:layout="{ ...layout, plotHeight: layout.plotHeight }"
|
|
1546
|
+
:value="dateAt(vm.meta.todayDay).getTime()"
|
|
1547
|
+
:scaler="xScaler"
|
|
1548
|
+
:domain="xDomainAsNumeric"
|
|
1549
|
+
color="var(--c-today)"
|
|
1550
|
+
is-x
|
|
1551
|
+
/>
|
|
1552
|
+
<!-- The date sits ON the tick row, taking the place of the
|
|
1553
|
+
tick this marker suppresses, so the row of dates across
|
|
1554
|
+
the top stays unbroken. TODAY floats above it. -->
|
|
1555
|
+
<text
|
|
1556
|
+
class="today-label"
|
|
1557
|
+
:x="xScaler.scale(dateAt(vm.meta.todayDay)) + 4"
|
|
1558
|
+
:y="-22"
|
|
1559
|
+
>TODAY</text>
|
|
1560
|
+
<text
|
|
1561
|
+
class="today-date"
|
|
1562
|
+
:x="xScaler.scale(dateAt(vm.meta.todayDay)) + 4"
|
|
1563
|
+
:y="-8"
|
|
1564
|
+
>{{ todayIso }}</text>
|
|
1565
|
+
</g>
|
|
1566
|
+
</template>
|
|
1567
|
+
</CartesianScaler>
|
|
1568
|
+
</template>
|
|
1569
|
+
</DDV>
|
|
1570
|
+
<!-- Horizontal viewport controls (bp-c6q). The strip over the axis is
|
|
1571
|
+
the pan and zoom handle — drag to pan, wheel to zoom — offset from
|
|
1572
|
+
the column divider so the label-width handle stays reachable. The
|
|
1573
|
+
scrollbar sits in the plot's bottom margin and is the discoverable
|
|
1574
|
+
way to scroll; both disappear when the window fits. -->
|
|
1575
|
+
<div
|
|
1576
|
+
v-if="hscroll.scrollable"
|
|
1577
|
+
class="axis-scrub"
|
|
1578
|
+
:style="{ left: layout.margins.left + 8 + 'px', width: Math.max(0, layout.plotWidth - 8) + 'px', height: AXIS_TOP + 'px' }"
|
|
1579
|
+
title="Drag to pan the timeline · wheel to zoom"
|
|
1580
|
+
@pointerdown="onScrubDown"
|
|
1581
|
+
@pointermove="onScrubMove"
|
|
1582
|
+
@pointerup="onScrubUp"
|
|
1583
|
+
@pointercancel="onScrubUp"
|
|
1584
|
+
@wheel="onScrubWheel"
|
|
1585
|
+
/>
|
|
1586
|
+
<div
|
|
1587
|
+
v-else
|
|
1588
|
+
class="axis-scrub is-fit"
|
|
1589
|
+
:style="{ left: layout.margins.left + 8 + 'px', width: Math.max(0, layout.plotWidth - 8) + 'px', height: AXIS_TOP + 'px' }"
|
|
1590
|
+
title="Wheel to zoom the timeline"
|
|
1591
|
+
@wheel="onScrubWheel"
|
|
1592
|
+
/>
|
|
1593
|
+
<div
|
|
1594
|
+
v-if="hscroll.scrollable"
|
|
1595
|
+
ref="hscrollEl"
|
|
1596
|
+
class="gantt-hscroll"
|
|
1597
|
+
:style="{ left: layout.margins.left + 'px', width: Math.max(0, layout.plotWidth) + 'px', height: AXIS_BOTTOM + 'px' }"
|
|
1598
|
+
aria-label="Scroll the timeline"
|
|
1599
|
+
@scroll="onHScroll"
|
|
1600
|
+
>
|
|
1601
|
+
<div :style="{ width: hscroll.contentWidth + 'px', height: '1px' }" />
|
|
1602
|
+
</div>
|
|
1603
|
+
<BeadTooltip
|
|
1604
|
+
v-bind="tipProps"
|
|
1605
|
+
@details="onOpenDetails"
|
|
1606
|
+
@watch="onToggleWatch"
|
|
1607
|
+
@tooltip-enter="cancelLeave"
|
|
1608
|
+
@tooltip-leave="scheduleLeave"
|
|
1609
|
+
/>
|
|
1610
|
+
</div>
|
|
1611
|
+
</template>
|
|
1612
|
+
|
|
1613
|
+
<style>
|
|
1614
|
+
/* Colours come from tokens.css. The static SVG emitter in src/cli.mjs still
|
|
1615
|
+
carries its own copy of this palette — it renders outside a browser, where
|
|
1616
|
+
custom properties and light-dark() aren't available. */
|
|
1617
|
+
.beads-gantt {
|
|
1618
|
+
|
|
1619
|
+
/* DDV theme overrides — its Axis + gridlines default to light-theme colors
|
|
1620
|
+
(grey/white) that are invisible on our dark background. */
|
|
1621
|
+
--axisTickFontColor: var(--c-dim);
|
|
1622
|
+
--axisLabelColor: var(--c-dim);
|
|
1623
|
+
--axisTickFontFamily: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
1624
|
+
--axisTickFontSize: 10px;
|
|
1625
|
+
--axisTickFontWeight: 400;
|
|
1626
|
+
--axisLabelFontFamily: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
1627
|
+
--boundaryStroke: var(--c-border-strong);
|
|
1628
|
+
--boundaryStrokeWidth: 1;
|
|
1629
|
+
--tickStroke: var(--c-border-strong);
|
|
1630
|
+
--tickStrokeWidth: 1;
|
|
1631
|
+
--subTickStroke: var(--c-border);
|
|
1632
|
+
--subTickStrokeWidth: 0.5;
|
|
1633
|
+
--gridStroke: var(--c-border);
|
|
1634
|
+
--gridStrokeWidth: 0.5;
|
|
1635
|
+
--subGridStroke: var(--c-border);
|
|
1636
|
+
--subGridStrokeWidth: 0.5;
|
|
1637
|
+
--gridStrokeDashArray: none;
|
|
1638
|
+
|
|
1639
|
+
background: var(--c-bg);
|
|
1640
|
+
border: 1px solid var(--c-border);
|
|
1641
|
+
border-radius: 4px;
|
|
1642
|
+
overflow: hidden;
|
|
1643
|
+
width: 100%;
|
|
1644
|
+
display: block;
|
|
1645
|
+
position: relative; /* the viewport controls are positioned over the svg */
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/* Pan/zoom handle over the axis rows (bp-c6q). Transparent: the axis draws
|
|
1649
|
+
underneath, this only catches the pointer. */
|
|
1650
|
+
.beads-gantt .axis-scrub {
|
|
1651
|
+
position: absolute;
|
|
1652
|
+
top: 0;
|
|
1653
|
+
cursor: grab;
|
|
1654
|
+
touch-action: none;
|
|
1655
|
+
z-index: 2;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
.beads-gantt .axis-scrub:active {
|
|
1659
|
+
cursor: grabbing;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
.beads-gantt .axis-scrub.is-fit {
|
|
1663
|
+
cursor: default;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/* The real scrollbar, in the plot's bottom margin. Thin, and in the
|
|
1667
|
+
panel's colours so it reads as part of the chart. */
|
|
1668
|
+
.beads-gantt .gantt-hscroll {
|
|
1669
|
+
position: absolute;
|
|
1670
|
+
bottom: 0;
|
|
1671
|
+
overflow-x: auto;
|
|
1672
|
+
overflow-y: hidden;
|
|
1673
|
+
scrollbar-width: thin;
|
|
1674
|
+
scrollbar-color: var(--c-border-strong) transparent;
|
|
1675
|
+
z-index: 2;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.beads-gantt-ddv {
|
|
1679
|
+
display: block;
|
|
1680
|
+
width: 100%;
|
|
1681
|
+
height: 100%;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
.beads-gantt .plot-bg {
|
|
1685
|
+
fill: var(--c-panel);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
.beads-gantt .group-header {
|
|
1689
|
+
fill: var(--c-border);
|
|
1690
|
+
fill-opacity: 0.4;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
.beads-gantt .group-label {
|
|
1694
|
+
fill: var(--c-epic);
|
|
1695
|
+
font-size: 12px;
|
|
1696
|
+
font-weight: 600;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
.beads-gantt .group-count {
|
|
1700
|
+
fill: var(--c-dim);
|
|
1701
|
+
font-size: 10px;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
.beads-gantt .group-expand-btn {
|
|
1705
|
+
cursor: pointer;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
.beads-gantt .expand-pill {
|
|
1709
|
+
fill: var(--c-epic);
|
|
1710
|
+
fill-opacity: 0.18;
|
|
1711
|
+
stroke: var(--c-epic);
|
|
1712
|
+
stroke-width: 0.75;
|
|
1713
|
+
stroke-opacity: 0.5;
|
|
1714
|
+
transition: fill-opacity 0.08s ease-out;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
.beads-gantt .group-expand-btn:hover .expand-pill {
|
|
1718
|
+
fill-opacity: 0.35;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
.beads-gantt .group-expand-btn.is-expanded .expand-pill {
|
|
1722
|
+
fill: var(--c-border);
|
|
1723
|
+
fill-opacity: 0.5;
|
|
1724
|
+
stroke: var(--c-dim);
|
|
1725
|
+
stroke-opacity: 0.4;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/* SVG text paints with `fill`; a lucide icon paints with stroke=currentColor,
|
|
1729
|
+
which resolves against `color`. Any rule that colours text next to an icon
|
|
1730
|
+
has to set both, or the icon renders in the inherited default and looks
|
|
1731
|
+
like a different control. */
|
|
1732
|
+
.beads-gantt .group-expand-btn {
|
|
1733
|
+
color: var(--c-epic);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
.beads-gantt .group-expand-btn.is-expanded {
|
|
1737
|
+
color: var(--c-dim);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.beads-gantt .expand-pill-text {
|
|
1741
|
+
fill: var(--c-epic);
|
|
1742
|
+
font-size: 10px;
|
|
1743
|
+
font-weight: 600;
|
|
1744
|
+
dominant-baseline: middle;
|
|
1745
|
+
pointer-events: none;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
.beads-gantt .group-expand-btn.is-expanded .expand-pill-text {
|
|
1749
|
+
fill: var(--c-dim);
|
|
1750
|
+
font-weight: 400;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
.beads-gantt .progress-chip {
|
|
1754
|
+
fill: var(--c-border);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
.beads-gantt .progress-chip-text {
|
|
1758
|
+
fill: var(--c-dim);
|
|
1759
|
+
font-family: ui-monospace, monospace;
|
|
1760
|
+
font-size: 9px;
|
|
1761
|
+
dominant-baseline: alphabetic;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
.beads-gantt .progress-bar-bg {
|
|
1765
|
+
fill: var(--c-border);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.beads-gantt .progress-bar-fill {
|
|
1769
|
+
fill: var(--c-shipped);
|
|
1770
|
+
fill-opacity: 0.8;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/* Row hover band: the eye's ruler from a bar back to its id and title.
|
|
1774
|
+
A lift, not a selection, and faster in than out so sweeping the pointer
|
|
1775
|
+
down the chart reads as one moving band rather than a flicker. */
|
|
1776
|
+
.beads-gantt .row-hover {
|
|
1777
|
+
fill: var(--c-accent-wash);
|
|
1778
|
+
opacity: 0;
|
|
1779
|
+
pointer-events: none;
|
|
1780
|
+
transition: opacity 0.16s ease-out;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
.beads-gantt .row-hover.is-lit {
|
|
1784
|
+
opacity: 1;
|
|
1785
|
+
transition: opacity 0.06s ease-out;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
.beads-gantt .row-hit {
|
|
1789
|
+
fill: transparent;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
.beads-gantt .row-id,
|
|
1793
|
+
.beads-gantt .row-title {
|
|
1794
|
+
cursor: pointer;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
.beads-gantt .row-id:hover,
|
|
1798
|
+
.beads-gantt .row-title:hover {
|
|
1799
|
+
fill: var(--c-epic);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
.beads-gantt .row-id {
|
|
1803
|
+
fill: var(--c-dim);
|
|
1804
|
+
font-family: ui-monospace, monospace;
|
|
1805
|
+
font-size: 10px;
|
|
1806
|
+
transition: fill 0.08s ease-out;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
.beads-gantt .row-id.is-copied {
|
|
1810
|
+
fill: var(--c-shipped);
|
|
1811
|
+
font-weight: 600;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
.beads-gantt .row-title {
|
|
1815
|
+
fill: var(--c-text);
|
|
1816
|
+
font-size: 11px;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
.beads-gantt .row-priority {
|
|
1820
|
+
fill: var(--c-dim);
|
|
1821
|
+
font-size: 9px;
|
|
1822
|
+
transition: fill 0.08s ease-out;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/* The lit row's dim text steps up one notch. Colour only: nothing moves. */
|
|
1826
|
+
.beads-gantt .row-label-band.is-lit .row-id:not(.is-copied),
|
|
1827
|
+
.beads-gantt .row-label-band.is-lit .row-priority {
|
|
1828
|
+
fill: var(--c-dim-bright);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
.beads-gantt .bar {
|
|
1832
|
+
stroke: none;
|
|
1833
|
+
fill-opacity: 0.9;
|
|
1834
|
+
cursor: pointer;
|
|
1835
|
+
transition: fill-opacity 0.08s ease-out;
|
|
1836
|
+
}
|
|
1837
|
+
.beads-gantt .bar.is-hovered {
|
|
1838
|
+
fill-opacity: 1;
|
|
1839
|
+
filter: brightness(1.2);
|
|
1840
|
+
}
|
|
1841
|
+
.beads-gantt .bar.kind-shipped {
|
|
1842
|
+
fill: var(--c-shipped);
|
|
1843
|
+
stroke: var(--c-shipped-stroke);
|
|
1844
|
+
stroke-width: 0.75;
|
|
1845
|
+
}
|
|
1846
|
+
/* 0.8, not 0.5. At 0.5 this washed into the panel and landed within 0.05
|
|
1847
|
+
OKLab of a futureBacklog bar (0.35 over the same panel) — two low-opacity
|
|
1848
|
+
fills over one background converge no matter how far apart their tokens
|
|
1849
|
+
are. A closed bead is a fact; the speculative projection is the faint
|
|
1850
|
+
one. Enforced by style-audit.test.mjs, which composites before comparing. */
|
|
1851
|
+
.beads-gantt .bar.kind-closedNoShip {
|
|
1852
|
+
fill: var(--c-closed-noship);
|
|
1853
|
+
fill-opacity: 0.8;
|
|
1854
|
+
}
|
|
1855
|
+
.beads-gantt .bar.kind-inProgressLive {
|
|
1856
|
+
fill: var(--c-in-progress);
|
|
1857
|
+
fill-opacity: 0.95;
|
|
1858
|
+
}
|
|
1859
|
+
.beads-gantt .bar.kind-future {
|
|
1860
|
+
fill: var(--c-future);
|
|
1861
|
+
fill-opacity: 0.75;
|
|
1862
|
+
}
|
|
1863
|
+
.beads-gantt .bar.kind-futureBacklog {
|
|
1864
|
+
fill: var(--c-backlog);
|
|
1865
|
+
fill-opacity: 0.35;
|
|
1866
|
+
}
|
|
1867
|
+
/* The wait clock (bp-6cg): the phase bar's own waiting and review colours,
|
|
1868
|
+
hatched like every projection that is not work, and faint. Their fills are
|
|
1869
|
+
deliberately NOT in the style audit's bar-kinds group: they are told apart
|
|
1870
|
+
from work by texture, height and cut, not by hue — a review wait sits in
|
|
1871
|
+
the in-progress hue family on purpose, as the drawer's phase bar does. */
|
|
1872
|
+
.beads-gantt .bar.kind-waitUnclaimed {
|
|
1873
|
+
fill: var(--c-phase-waiting);
|
|
1874
|
+
fill-opacity: 0.32;
|
|
1875
|
+
mask: url(#beads-gantt-synthetic-mask);
|
|
1876
|
+
}
|
|
1877
|
+
.beads-gantt .bar.kind-waitReview {
|
|
1878
|
+
fill: var(--c-phase-review);
|
|
1879
|
+
fill-opacity: 0.32;
|
|
1880
|
+
mask: url(#beads-gantt-synthetic-mask);
|
|
1881
|
+
}
|
|
1882
|
+
/* What-if ghosts (bp-q56): the plan as it was, under the plan as it would be. */
|
|
1883
|
+
.beads-gantt .ghost {
|
|
1884
|
+
fill: none;
|
|
1885
|
+
stroke: var(--c-dim);
|
|
1886
|
+
stroke-width: 1;
|
|
1887
|
+
stroke-dasharray: 3 2;
|
|
1888
|
+
pointer-events: none;
|
|
1889
|
+
}
|
|
1890
|
+
.beads-gantt .ghost-delta {
|
|
1891
|
+
font-size: 9px;
|
|
1892
|
+
font-weight: 600;
|
|
1893
|
+
pointer-events: none;
|
|
1894
|
+
}
|
|
1895
|
+
.beads-gantt .ghost-delta.later {
|
|
1896
|
+
fill: var(--c-danger);
|
|
1897
|
+
}
|
|
1898
|
+
.beads-gantt .ghost-delta.earlier {
|
|
1899
|
+
fill: var(--c-shipped);
|
|
1900
|
+
}
|
|
1901
|
+
.beads-gantt .wait-gap {
|
|
1902
|
+
stroke-width: 1;
|
|
1903
|
+
stroke-dasharray: 2 3;
|
|
1904
|
+
stroke-opacity: 0.45;
|
|
1905
|
+
pointer-events: none;
|
|
1906
|
+
}
|
|
1907
|
+
.beads-gantt .wait-gap.kind-waitUnclaimed {
|
|
1908
|
+
stroke: var(--c-phase-waiting);
|
|
1909
|
+
}
|
|
1910
|
+
.beads-gantt .wait-gap.kind-waitReview {
|
|
1911
|
+
stroke: var(--c-phase-review);
|
|
1912
|
+
}
|
|
1913
|
+
.beads-gantt .bar.kind-unassigned {
|
|
1914
|
+
fill: var(--c-future);
|
|
1915
|
+
fill-opacity: 0.35;
|
|
1916
|
+
stroke: oklch(from var(--c-future) calc(l - 0.35) c h);
|
|
1917
|
+
stroke-width: 0.75;
|
|
1918
|
+
}
|
|
1919
|
+
/* An inferred assignee is a texture, not a colour (bp-67g.49). The fill and
|
|
1920
|
+
opacity stay the bar's kind — a hatched future bar is the same violet at
|
|
1921
|
+
0.75 — and the mask cuts stripes out of it, so it reads as "this kind,
|
|
1922
|
+
not settled" and never as a fourth violet. A fourth violet is what the
|
|
1923
|
+
dashed stroke this replaces amounted to: --c-epic at 0.75px on a
|
|
1924
|
+
--c-future fill, twelve degrees of hue apart, on a 12px bar. Texture
|
|
1925
|
+
holds in every style and mode and under colour-vision deficiency, and
|
|
1926
|
+
adds nothing to the opacity ladder the audit has to police. Geometry is
|
|
1927
|
+
HATCH_PITCH / HATCH_STRIPE in the script and the <defs> in the template;
|
|
1928
|
+
style-audit.test.mjs checks the stripes stay legible at BAR_H and that
|
|
1929
|
+
this rule never grows a fill or fill-opacity the painted-kinds check
|
|
1930
|
+
cannot see. */
|
|
1931
|
+
.beads-gantt .bar.synthetic {
|
|
1932
|
+
mask: url(#beads-gantt-synthetic-mask);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
.beads-gantt .row-label-band.is-inferred-match {
|
|
1936
|
+
opacity: var(--o-inferred);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
.beads-gantt .watch-pin {
|
|
1940
|
+
color: var(--c-epic);
|
|
1941
|
+
pointer-events: none;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/* Only openable when the group is backed by a real bead — otherwise the
|
|
1945
|
+
label is a facet value and must not look like a link. */
|
|
1946
|
+
.beads-gantt .group-label.is-openable {
|
|
1947
|
+
cursor: pointer;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.beads-gantt .group-label.is-openable:hover {
|
|
1951
|
+
text-decoration: underline;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.beads-gantt .group-band.is-over-cap .group-label {
|
|
1955
|
+
fill: var(--c-today);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/* Lucide icons inside the chart arrive as NESTED <svg> roots, and a nested
|
|
1959
|
+
<svg> does not take its width/height from CSS — unlike a top-level one in
|
|
1960
|
+
HTML, it keeps its width/height attributes and CSS is ignored. So size is
|
|
1961
|
+
passed as :size at each call site (see GANTT_ICON / DEP_ICON)
|
|
1962
|
+
and only the properties that really do cascade live here: stroke-width is
|
|
1963
|
+
a genuine SVG presentation property, and stroke="currentColor" means each
|
|
1964
|
+
icon takes the `color` of the band it sits in. */
|
|
1965
|
+
.beads-gantt .gantt-icon {
|
|
1966
|
+
stroke-width: 2;
|
|
1967
|
+
pointer-events: none;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
.beads-gantt .over-cap-icon {
|
|
1971
|
+
color: var(--c-today);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
.beads-gantt .over-cap-badge {
|
|
1975
|
+
fill: var(--c-today);
|
|
1976
|
+
font-size: 10px;
|
|
1977
|
+
font-weight: 600;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
.beads-gantt .bar.hl-blocked {
|
|
1981
|
+
stroke: var(--c-today);
|
|
1982
|
+
stroke-width: 1.5;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
.beads-gantt .bar.hl-ready-now {
|
|
1986
|
+
stroke: var(--c-epic);
|
|
1987
|
+
stroke-width: 1.5;
|
|
1988
|
+
filter: drop-shadow(0 0 3px oklch(from var(--c-epic) l c h / 60%));
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
.beads-gantt .dep-arrow {
|
|
1992
|
+
fill: none;
|
|
1993
|
+
stroke-width: 1.25;
|
|
1994
|
+
pointer-events: none;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
.beads-gantt .dep-edge.role-outgoing {
|
|
1998
|
+
/* Focal bead BLOCKS these — green, "downstream." */
|
|
1999
|
+
color: var(--c-shipped);
|
|
2000
|
+
stroke: var(--c-shipped);
|
|
2001
|
+
stroke-opacity: 0.75;
|
|
2002
|
+
}
|
|
2003
|
+
.beads-gantt .dep-edge.role-incoming {
|
|
2004
|
+
/* These are what's blocking the focal bead — red, "upstream." */
|
|
2005
|
+
color: var(--c-today);
|
|
2006
|
+
stroke: var(--c-today);
|
|
2007
|
+
stroke-opacity: 0.75;
|
|
2008
|
+
}
|
|
2009
|
+
.beads-gantt .dep-edge.role-neutral {
|
|
2010
|
+
/* Full-graph mode — no focal, use dim violet so density stays readable. */
|
|
2011
|
+
color: var(--c-epic);
|
|
2012
|
+
stroke: var(--c-epic);
|
|
2013
|
+
stroke-opacity: 0.28;
|
|
2014
|
+
}
|
|
2015
|
+
.beads-gantt .dep-edge.role-neutral .dep-arrow {
|
|
2016
|
+
stroke-width: 1;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/* Full-graph mode draws every edge at once, so its terminals shrink and fade:
|
|
2020
|
+
at that density a full-weight arrow per edge buries the bars it points at.
|
|
2021
|
+
Focal edges are few, so they keep full weight. */
|
|
2022
|
+
.beads-gantt .dep-edge.role-neutral .dep-terminal-icon {
|
|
2023
|
+
stroke-width: 2.5;
|
|
2024
|
+
stroke-opacity: 0.6;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/* Terminal icon rides the curve's end tangent. stroke-opacity is inherited
|
|
2028
|
+
from the role above, so the icon fades with its edge. */
|
|
2029
|
+
.beads-gantt .dep-terminal-icon {
|
|
2030
|
+
stroke-width: 3;
|
|
2031
|
+
/* stroke-opacity inherits from the edge role, which is tuned for a hairline
|
|
2032
|
+
curve and leaves the glyph a smudge. Terminals carry the meaning, so they
|
|
2033
|
+
stay opaque. */
|
|
2034
|
+
stroke-opacity: 1;
|
|
2035
|
+
pointer-events: none;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
.beads-gantt .ship-marker {
|
|
2040
|
+
/* Decoration on top of the bar: let the pointer through to the bar so the
|
|
2041
|
+
hover (and its tooltip) still fires at the centre of a short shipped bar. */
|
|
2042
|
+
pointer-events: none;
|
|
2043
|
+
fill: var(--c-shipped);
|
|
2044
|
+
stroke: oklch(from var(--c-shipped) calc(l - 0.55) c h);
|
|
2045
|
+
stroke-width: 1;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
.beads-gantt .threshold line {
|
|
2049
|
+
stroke: var(--c-today);
|
|
2050
|
+
stroke-width: 1.5;
|
|
2051
|
+
stroke-dasharray: 3, 3;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
.beads-gantt .today-label {
|
|
2055
|
+
fill: var(--c-today);
|
|
2056
|
+
font-size: 10px;
|
|
2057
|
+
font-weight: 600;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
.beads-gantt .today-date {
|
|
2061
|
+
fill: var(--c-dim);
|
|
2062
|
+
font-size: 9px;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
.beads-gantt .label-divider {
|
|
2066
|
+
stroke: var(--c-border);
|
|
2067
|
+
stroke-width: 1;
|
|
2068
|
+
pointer-events: none;
|
|
2069
|
+
transition: stroke 0.08s ease-out, stroke-width 0.08s ease-out;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
.beads-gantt .label-divider.is-resizing {
|
|
2073
|
+
stroke: var(--c-epic);
|
|
2074
|
+
stroke-width: 2;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
.beads-gantt .label-resize-handle {
|
|
2078
|
+
fill: transparent;
|
|
2079
|
+
cursor: col-resize;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
.beads-gantt .label-resize-handle:hover {
|
|
2083
|
+
fill: var(--c-epic);
|
|
2084
|
+
fill-opacity: 0.25;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
.beads-gantt.is-resizing-labels {
|
|
2088
|
+
cursor: col-resize;
|
|
2089
|
+
user-select: none;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
.beads-gantt.is-resizing-labels .label-resize-handle {
|
|
2093
|
+
fill: var(--c-epic);
|
|
2094
|
+
fill-opacity: 0.35;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
.beads-gantt .x-axis .gridlines line {
|
|
2098
|
+
stroke: var(--c-border);
|
|
2099
|
+
stroke-width: 0.5;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/* Fainter than a labelled boundary: present enough to count weeks or days
|
|
2103
|
+
against, quiet enough not to compete with the bars. */
|
|
2104
|
+
.beads-gantt .x-axis .gridlines line.minor {
|
|
2105
|
+
stroke: var(--c-border);
|
|
2106
|
+
stroke-width: 0.5;
|
|
2107
|
+
stroke-opacity: 0.45;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
.beads-gantt .x-axis .gridlines line.emphasize {
|
|
2111
|
+
stroke: var(--c-border-strong);
|
|
2112
|
+
stroke-width: 1;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
.beads-gantt .x-axis .tick-labels text {
|
|
2116
|
+
fill: var(--c-dim);
|
|
2117
|
+
font-size: 10px;
|
|
2118
|
+
dominant-baseline: alphabetic;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
.beads-gantt .x-axis .tick-labels text.emphasize {
|
|
2122
|
+
fill: var(--c-text);
|
|
2123
|
+
font-weight: 600;
|
|
2124
|
+
}
|
|
2125
|
+
</style>
|