@konce-pt/roadmap 0.8.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/auto-range.d.ts +22 -0
- package/dist/auto-range.js +88 -0
- package/dist/calendar.d.ts +64 -0
- package/dist/calendar.js +205 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/layout.d.ts +94 -0
- package/dist/layout.js +306 -0
- package/dist/links.d.ts +45 -0
- package/dist/links.js +249 -0
- package/dist/scale.d.ts +11 -0
- package/dist/scale.js +42 -0
- package/dist/timeline.d.ts +90 -0
- package/dist/timeline.js +375 -0
- package/dist/types.d.ts +222 -0
- package/dist/types.js +8 -0
- package/package.json +45 -0
package/dist/layout.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Układ wierszy: normalizacja itemów, spłaszczenie grup do listy wierszy
|
|
3
|
+
* renderowalnych, przydział pasów przy nakładaniu się zadań i wirtualizacja.
|
|
4
|
+
*
|
|
5
|
+
* Offset pionowy (`top`) liczymy tutaj, dzięki czemu warstwa widoku pozycjonuje
|
|
6
|
+
* wiersze absolutnie, a wirtualizacja sprowadza się do wycięcia podlisty —
|
|
7
|
+
* bez elementów dystansowych i z obsługą wierszy o różnej wysokości.
|
|
8
|
+
*/
|
|
9
|
+
import { toTimestamp } from "./calendar.js";
|
|
10
|
+
const DEFAULT_ITEM_HEIGHT = 44;
|
|
11
|
+
const DEFAULT_GROUP_HEIGHT = 38;
|
|
12
|
+
const DEFAULT_LANE_HEIGHT = 28;
|
|
13
|
+
/**
|
|
14
|
+
* Normalizuje itemy: daty na ms, brak `end` oznacza kamień milowy.
|
|
15
|
+
* Odrzuca wpisy z nieparsowalną datą oraz duplikaty `id`.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeItems(items) {
|
|
18
|
+
const valid = [];
|
|
19
|
+
const invalid = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
if (!item.id) {
|
|
23
|
+
invalid.push({ id: String(item.label ?? '?'), reason: 'brak id' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (seen.has(item.id)) {
|
|
27
|
+
invalid.push({ id: item.id, reason: 'zduplikowane id' });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const start = toTimestamp(item.start);
|
|
31
|
+
if (!Number.isFinite(start)) {
|
|
32
|
+
invalid.push({ id: item.id, reason: 'niepoprawna data początkowa' });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const rawEnd = item.end !== undefined ? toTimestamp(item.end) : start;
|
|
36
|
+
if (!Number.isFinite(rawEnd)) {
|
|
37
|
+
invalid.push({ id: item.id, reason: 'niepoprawna data końcowa' });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Odwrócony zakres prostujemy zamiast odrzucać — dane bywają brudne.
|
|
41
|
+
const from = Math.min(start, rawEnd);
|
|
42
|
+
const to = Math.max(start, rawEnd);
|
|
43
|
+
const isMilestone = item.milestone === true || item.end === undefined || from === to;
|
|
44
|
+
seen.add(item.id);
|
|
45
|
+
valid.push({
|
|
46
|
+
source: item,
|
|
47
|
+
id: item.id,
|
|
48
|
+
start: from,
|
|
49
|
+
end: isMilestone ? from : to,
|
|
50
|
+
isMilestone,
|
|
51
|
+
durationMs: isMilestone ? 0 : to - from,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { valid, invalid };
|
|
55
|
+
}
|
|
56
|
+
/** Zakres zbiorczy listy itemów: min(start)…max(end). */
|
|
57
|
+
export function spanOf(items) {
|
|
58
|
+
if (items.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
let start = items[0].start;
|
|
61
|
+
let end = items[0].end;
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
if (item.start < start)
|
|
64
|
+
start = item.start;
|
|
65
|
+
if (item.end > end)
|
|
66
|
+
end = item.end;
|
|
67
|
+
}
|
|
68
|
+
return { start, end };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Postęp grupy ważony czasem trwania.
|
|
72
|
+
* Kamienie milowe mają zerową długość, więc liczymy je z wagą 1.
|
|
73
|
+
*/
|
|
74
|
+
export function rollupProgress(items) {
|
|
75
|
+
let weightSum = 0;
|
|
76
|
+
let progressSum = 0;
|
|
77
|
+
let any = false;
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
const progress = item.source.progress;
|
|
80
|
+
if (progress === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
any = true;
|
|
83
|
+
const weight = item.isMilestone ? 1 : Math.max(1, item.durationMs);
|
|
84
|
+
weightSum += weight;
|
|
85
|
+
progressSum += weight * Math.min(1, Math.max(0, progress));
|
|
86
|
+
}
|
|
87
|
+
return any && weightSum > 0 ? progressSum / weightSum : null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Zachłanne przydzielanie pasów (interval partitioning).
|
|
91
|
+
* Itemy sortujemy po czasie startu i wkładamy do pierwszego pasa, w którym
|
|
92
|
+
* poprzednie zadanie zdążyło się skończyć. Zwraca liczbę użytych pasów.
|
|
93
|
+
*/
|
|
94
|
+
export function packLanes(items, minGapMs = 0) {
|
|
95
|
+
const sorted = [...items].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
96
|
+
/** Ostatni zajęty koniec dla każdego pasa. */
|
|
97
|
+
const laneEnd = [];
|
|
98
|
+
for (const item of sorted) {
|
|
99
|
+
const to = Math.max(item.end, item.start);
|
|
100
|
+
let lane = laneEnd.findIndex((end) => end + minGapMs <= item.start);
|
|
101
|
+
if (lane === -1) {
|
|
102
|
+
lane = laneEnd.length;
|
|
103
|
+
laneEnd.push(to);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
laneEnd[lane] = to;
|
|
107
|
+
}
|
|
108
|
+
item.lane = lane;
|
|
109
|
+
}
|
|
110
|
+
return Math.max(1, laneEnd.length);
|
|
111
|
+
}
|
|
112
|
+
/** Głębokość grupy w drzewie `parentId` (z zabezpieczeniem przed cyklem). */
|
|
113
|
+
function groupDepth(group, byId) {
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let current = group.parentId;
|
|
116
|
+
const guard = new Set([group.id]);
|
|
117
|
+
while (current && !guard.has(current)) {
|
|
118
|
+
guard.add(current);
|
|
119
|
+
depth++;
|
|
120
|
+
current = byId.get(current)?.parentId;
|
|
121
|
+
}
|
|
122
|
+
return depth;
|
|
123
|
+
}
|
|
124
|
+
/** Czy któryś z przodków grupy jest zwinięty? */
|
|
125
|
+
function hasCollapsedAncestor(group, byId, collapsed) {
|
|
126
|
+
let current = group.parentId;
|
|
127
|
+
const guard = new Set([group.id]);
|
|
128
|
+
while (current && !guard.has(current)) {
|
|
129
|
+
if (collapsed.has(current))
|
|
130
|
+
return true;
|
|
131
|
+
guard.add(current);
|
|
132
|
+
current = byId.get(current)?.parentId;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Spłaszcza grupy i itemy do listy wierszy renderowalnych.
|
|
138
|
+
*
|
|
139
|
+
* Kolejność: grupy w kolejności podanej przez użytkownika, wewnątrz itemy
|
|
140
|
+
* posortowane po czasie startu, na końcu itemy bez `groupId`. Zwinięta grupa
|
|
141
|
+
* nie emituje wierszy potomnych, ale jej zakres zbiorczy nadal obejmuje dzieci.
|
|
142
|
+
*
|
|
143
|
+
* @param collapsed zbiór `id` grup zwiniętych (stan trzymany w sygnale komponentu)
|
|
144
|
+
*/
|
|
145
|
+
export function buildRows(items, groups, collapsed, opts) {
|
|
146
|
+
const mode = opts?.mode ?? 'row-per-item';
|
|
147
|
+
const itemHeight = opts?.itemHeight ?? DEFAULT_ITEM_HEIGHT;
|
|
148
|
+
const groupHeight = opts?.groupHeight ?? DEFAULT_GROUP_HEIGHT;
|
|
149
|
+
const laneHeight = opts?.laneHeight ?? DEFAULT_LANE_HEIGHT;
|
|
150
|
+
const minGap = opts?.packMinGapMs ?? 0;
|
|
151
|
+
const { valid, invalid } = normalizeItems(items);
|
|
152
|
+
const byId = new Map(valid.map((item) => [item.id, item]));
|
|
153
|
+
const rowOfItem = new Map();
|
|
154
|
+
const rows = [];
|
|
155
|
+
const groupList = groups ?? [];
|
|
156
|
+
const groupById = new Map(groupList.map((group) => [group.id, group]));
|
|
157
|
+
// Itemy w kubełkach wg grupy; nieznane `groupId` traktujemy jak brak grupy.
|
|
158
|
+
const buckets = new Map();
|
|
159
|
+
const ungrouped = [];
|
|
160
|
+
for (const item of valid) {
|
|
161
|
+
const groupId = item.source.groupId;
|
|
162
|
+
if (groupId && groupById.has(groupId)) {
|
|
163
|
+
const bucket = buckets.get(groupId);
|
|
164
|
+
if (bucket)
|
|
165
|
+
bucket.push(item);
|
|
166
|
+
else
|
|
167
|
+
buckets.set(groupId, [item]);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
ungrouped.push(item);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const bucket of buckets.values())
|
|
174
|
+
bucket.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
175
|
+
ungrouped.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
176
|
+
let top = 0;
|
|
177
|
+
/** Dokłada wiersze itemów — jeden na item albo jeden spakowany. */
|
|
178
|
+
const pushItemRows = (bucket, depth, keyPrefix) => {
|
|
179
|
+
if (bucket.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
if (mode === 'pack') {
|
|
182
|
+
const rowKey = `${keyPrefix}:pack`;
|
|
183
|
+
const placed = bucket.map((item) => ({ ...item, lane: 0, rowKey }));
|
|
184
|
+
const laneCount = packLanes(placed, minGap);
|
|
185
|
+
const height = Math.max(itemHeight, laneCount * laneHeight + 8);
|
|
186
|
+
for (const item of placed)
|
|
187
|
+
rowOfItem.set(item.id, rowKey);
|
|
188
|
+
rows.push({
|
|
189
|
+
key: rowKey,
|
|
190
|
+
kind: 'item',
|
|
191
|
+
index: rows.length,
|
|
192
|
+
depth,
|
|
193
|
+
top,
|
|
194
|
+
height,
|
|
195
|
+
laneCount,
|
|
196
|
+
items: placed,
|
|
197
|
+
});
|
|
198
|
+
top += height;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
for (const item of bucket) {
|
|
202
|
+
const rowKey = `item:${item.id}`;
|
|
203
|
+
rowOfItem.set(item.id, rowKey);
|
|
204
|
+
rows.push({
|
|
205
|
+
key: rowKey,
|
|
206
|
+
kind: 'item',
|
|
207
|
+
index: rows.length,
|
|
208
|
+
depth,
|
|
209
|
+
top,
|
|
210
|
+
height: itemHeight,
|
|
211
|
+
laneCount: 1,
|
|
212
|
+
items: [{ ...item, lane: 0, rowKey }],
|
|
213
|
+
});
|
|
214
|
+
top += itemHeight;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
for (const group of groupList) {
|
|
218
|
+
// Grupa pod zwiniętym przodkiem w ogóle się nie renderuje.
|
|
219
|
+
if (hasCollapsedAncestor(group, groupById, collapsed))
|
|
220
|
+
continue;
|
|
221
|
+
const bucket = buckets.get(group.id) ?? [];
|
|
222
|
+
const depth = groupDepth(group, groupById);
|
|
223
|
+
const isCollapsed = collapsed.has(group.id);
|
|
224
|
+
const rowKey = `group:${group.id}`;
|
|
225
|
+
rows.push({
|
|
226
|
+
key: rowKey,
|
|
227
|
+
kind: 'group',
|
|
228
|
+
index: rows.length,
|
|
229
|
+
depth,
|
|
230
|
+
top,
|
|
231
|
+
height: groupHeight,
|
|
232
|
+
laneCount: 1,
|
|
233
|
+
group,
|
|
234
|
+
groupState: {
|
|
235
|
+
collapsed: isCollapsed,
|
|
236
|
+
itemCount: bucket.length,
|
|
237
|
+
// Pasek zbiorczy obejmuje dzieci również wtedy, gdy grupa jest zwinięta.
|
|
238
|
+
span: group.summary === 'none' ? null : spanOf(bucket),
|
|
239
|
+
progress: rollupProgress(bucket),
|
|
240
|
+
},
|
|
241
|
+
items: [],
|
|
242
|
+
});
|
|
243
|
+
top += groupHeight;
|
|
244
|
+
if (!isCollapsed)
|
|
245
|
+
pushItemRows(bucket, depth + 1, `group:${group.id}`);
|
|
246
|
+
}
|
|
247
|
+
if (ungrouped.length > 0) {
|
|
248
|
+
if (opts?.showUngroupedHeader) {
|
|
249
|
+
const rowKey = 'group:__ungrouped';
|
|
250
|
+
const isCollapsed = collapsed.has('__ungrouped');
|
|
251
|
+
rows.push({
|
|
252
|
+
key: rowKey,
|
|
253
|
+
kind: 'group',
|
|
254
|
+
index: rows.length,
|
|
255
|
+
depth: 0,
|
|
256
|
+
top,
|
|
257
|
+
height: groupHeight,
|
|
258
|
+
laneCount: 1,
|
|
259
|
+
group: { id: '__ungrouped', label: opts.ungroupedLabel ?? '' },
|
|
260
|
+
groupState: {
|
|
261
|
+
collapsed: isCollapsed,
|
|
262
|
+
itemCount: ungrouped.length,
|
|
263
|
+
span: spanOf(ungrouped),
|
|
264
|
+
progress: rollupProgress(ungrouped),
|
|
265
|
+
},
|
|
266
|
+
items: [],
|
|
267
|
+
});
|
|
268
|
+
top += groupHeight;
|
|
269
|
+
if (!isCollapsed)
|
|
270
|
+
pushItemRows(ungrouped, 1, 'ungrouped');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
pushItemRows(ungrouped, 0, 'ungrouped');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { rows, totalHeight: top, byId, rowOfItem, invalid };
|
|
277
|
+
}
|
|
278
|
+
/** Indeks pierwszego wiersza sięgającego pozycji `y` (binary search po `top`). */
|
|
279
|
+
function firstRowAt(rows, y) {
|
|
280
|
+
let lo = 0;
|
|
281
|
+
let hi = rows.length - 1;
|
|
282
|
+
while (lo < hi) {
|
|
283
|
+
const mid = (lo + hi) >> 1;
|
|
284
|
+
if (rows[mid].top + rows[mid].height <= y)
|
|
285
|
+
lo = mid + 1;
|
|
286
|
+
else
|
|
287
|
+
hi = mid;
|
|
288
|
+
}
|
|
289
|
+
return lo;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Wycinek wierszy widocznych w oknie przewijania.
|
|
293
|
+
* Szuka binarnie po skumulowanym `top`, więc działa także dla wierszy
|
|
294
|
+
* o różnej wysokości (tryb 'pack').
|
|
295
|
+
*/
|
|
296
|
+
export function sliceRows(layout, scrollTop, viewportHeight, buffer = 4) {
|
|
297
|
+
const all = layout.rows;
|
|
298
|
+
if (all.length === 0 || viewportHeight <= 0) {
|
|
299
|
+
return { rows: all, startIndex: 0, endIndex: all.length };
|
|
300
|
+
}
|
|
301
|
+
const first = firstRowAt(all, Math.max(0, scrollTop));
|
|
302
|
+
const last = firstRowAt(all, Math.max(0, scrollTop + viewportHeight));
|
|
303
|
+
const startIndex = Math.max(0, first - buffer);
|
|
304
|
+
const endIndex = Math.min(all.length, last + buffer + 1);
|
|
305
|
+
return { rows: all.slice(startIndex, endIndex), startIndex, endIndex };
|
|
306
|
+
}
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { KptRoadmapDependencyOptions, KptRoadmapLink, KptRoadmapLinkType, KptRoadmapNormalizedItem, KptRoadmapPoint, KptRoadmapRect, KptRoadmapVariant } from './types.ts';
|
|
2
|
+
/** Pasek w układzie px overlaya wraz z czasem — czas służy do detekcji naruszeń. */
|
|
3
|
+
export interface KptLinkEndpoint {
|
|
4
|
+
id: string;
|
|
5
|
+
rect: KptRoadmapRect;
|
|
6
|
+
startTs: number;
|
|
7
|
+
endTs: number;
|
|
8
|
+
}
|
|
9
|
+
export interface KptLinkGeom {
|
|
10
|
+
key: string;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
type: KptRoadmapLinkType;
|
|
14
|
+
/** Ścieżka `d` łamanej z zaokrąglonymi narożnikami. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Ścieżka grotu strzałki (trójkąt domknięty przez `Z`). */
|
|
17
|
+
arrow: string;
|
|
18
|
+
variant?: KptRoadmapVariant;
|
|
19
|
+
color?: string;
|
|
20
|
+
/** Następnik startuje wcześniej, niż pozwala zależność (z uwzględnieniem `lag`). */
|
|
21
|
+
isViolated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Scala zależności z `item.dependsOn` (skrót oznacza typ 'FS' bez lagu)
|
|
25
|
+
* z listą `config.links`, odrzuca krawędzie wiszące i wykrywa cykle.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveLinks(items: readonly KptRoadmapNormalizedItem[], links: readonly KptRoadmapLink[] | undefined): {
|
|
28
|
+
links: KptRoadmapLink[];
|
|
29
|
+
cycles: string[][];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Łamana ortogonalna z zaokrąglonymi narożnikami.
|
|
33
|
+
* Narożnik rysujemy krzywą kwadratową — dla kąta prostego wygląda jak łuk,
|
|
34
|
+
* a ścieżka jest o połowę krótsza. Promień przycinamy do połowy krótszego
|
|
35
|
+
* z sąsiednich odcinków, żeby ciasne zakręty nie tworzyły pętli.
|
|
36
|
+
*/
|
|
37
|
+
export declare function roundedPolyline(points: readonly KptRoadmapPoint[], radius: number): string;
|
|
38
|
+
/** Grot strzałki w punkcie `point`, skierowany zgodnie z `direction`. */
|
|
39
|
+
export declare function arrowHead(point: KptRoadmapPoint, direction: 'left' | 'right', size: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* Geometria pojedynczej zależności.
|
|
42
|
+
* Zwraca `null`, gdy któryś koniec nie jest widoczny (zwinięta grupa albo
|
|
43
|
+
* wiersz poza oknem wirtualizacji).
|
|
44
|
+
*/
|
|
45
|
+
export declare function linkGeometry(link: KptRoadmapLink, source: KptLinkEndpoint | undefined, target: KptLinkEndpoint | undefined, opts?: KptRoadmapDependencyOptions): KptLinkGeom | null;
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Geometria zależności między paskami — warstwa SVG nad torami.
|
|
3
|
+
*
|
|
4
|
+
* Overlay pracuje w pikselach, nie w procentach: zaokrąglenie narożnika wymaga
|
|
5
|
+
* tych samych jednostek na obu osiach, inaczej łuki byłyby zniekształcone.
|
|
6
|
+
*/
|
|
7
|
+
import { svgNum } from "./scale.js";
|
|
8
|
+
const DEFAULT_STUB = 12;
|
|
9
|
+
const DEFAULT_CORNER = 6;
|
|
10
|
+
const DEFAULT_ARROW = 6;
|
|
11
|
+
/** Stabilny klucz krawędzi — pozwala pominąć duplikaty i służy jako trackBy. */
|
|
12
|
+
function linkKey(link) {
|
|
13
|
+
return link.id ?? `${link.from}→${link.to}:${link.type ?? 'FS'}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Scala zależności z `item.dependsOn` (skrót oznacza typ 'FS' bez lagu)
|
|
17
|
+
* z listą `config.links`, odrzuca krawędzie wiszące i wykrywa cykle.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveLinks(items, links) {
|
|
20
|
+
const known = new Set(items.map((item) => item.id));
|
|
21
|
+
const merged = new Map();
|
|
22
|
+
const add = (link) => {
|
|
23
|
+
if (!known.has(link.from) || !known.has(link.to) || link.from === link.to)
|
|
24
|
+
return;
|
|
25
|
+
const key = linkKey(link);
|
|
26
|
+
if (!merged.has(key))
|
|
27
|
+
merged.set(key, link);
|
|
28
|
+
};
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
for (const dependency of item.source.dependsOn ?? []) {
|
|
31
|
+
if (typeof dependency === 'string')
|
|
32
|
+
add({ from: dependency, to: item.id, type: 'FS' });
|
|
33
|
+
else
|
|
34
|
+
add(dependency);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const link of links ?? [])
|
|
38
|
+
add(link);
|
|
39
|
+
return { links: [...merged.values()], cycles: findCycles([...merged.values()]) };
|
|
40
|
+
}
|
|
41
|
+
/** Wykrywa cykle w grafie zależności (DFS z kolorowaniem wierzchołków). */
|
|
42
|
+
function findCycles(links) {
|
|
43
|
+
const adjacency = new Map();
|
|
44
|
+
for (const link of links) {
|
|
45
|
+
const list = adjacency.get(link.from);
|
|
46
|
+
if (list)
|
|
47
|
+
list.push(link.to);
|
|
48
|
+
else
|
|
49
|
+
adjacency.set(link.from, [link.to]);
|
|
50
|
+
}
|
|
51
|
+
const cycles = [];
|
|
52
|
+
const state = new Map();
|
|
53
|
+
const stack = [];
|
|
54
|
+
const visit = (node) => {
|
|
55
|
+
const current = state.get(node);
|
|
56
|
+
if (current === 'done')
|
|
57
|
+
return;
|
|
58
|
+
if (current === 'visiting') {
|
|
59
|
+
const from = stack.lastIndexOf(node);
|
|
60
|
+
if (from !== -1)
|
|
61
|
+
cycles.push(stack.slice(from));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
state.set(node, 'visiting');
|
|
65
|
+
stack.push(node);
|
|
66
|
+
for (const next of adjacency.get(node) ?? [])
|
|
67
|
+
visit(next);
|
|
68
|
+
stack.pop();
|
|
69
|
+
state.set(node, 'done');
|
|
70
|
+
};
|
|
71
|
+
for (const node of adjacency.keys())
|
|
72
|
+
visit(node);
|
|
73
|
+
return cycles;
|
|
74
|
+
}
|
|
75
|
+
/** Czy zależność jest naruszona? Liczone w ms, nie w pikselach. */
|
|
76
|
+
function violates(link, source, target) {
|
|
77
|
+
const lag = link.lag ?? 0;
|
|
78
|
+
switch (link.type ?? 'FS') {
|
|
79
|
+
case 'FS':
|
|
80
|
+
return target.startTs < source.endTs + lag;
|
|
81
|
+
case 'SS':
|
|
82
|
+
return target.startTs < source.startTs + lag;
|
|
83
|
+
case 'FF':
|
|
84
|
+
return target.endTs < source.endTs + lag;
|
|
85
|
+
case 'SF':
|
|
86
|
+
return target.endTs < source.startTs + lag;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Usuwa punkty pokrywające się i leżące na jednej prostej z sąsiadami. */
|
|
90
|
+
function dedupeCollinear(points) {
|
|
91
|
+
const result = [];
|
|
92
|
+
for (const point of points) {
|
|
93
|
+
const last = result.at(-1);
|
|
94
|
+
if (last && Math.abs(last.x - point.x) < 1e-6 && Math.abs(last.y - point.y) < 1e-6)
|
|
95
|
+
continue;
|
|
96
|
+
result.push(point);
|
|
97
|
+
}
|
|
98
|
+
for (let i = result.length - 2; i >= 1; i--) {
|
|
99
|
+
const previous = result[i - 1];
|
|
100
|
+
const current = result[i];
|
|
101
|
+
const next = result[i + 1];
|
|
102
|
+
const collinearX = Math.abs(previous.x - current.x) < 1e-6 && Math.abs(current.x - next.x) < 1e-6;
|
|
103
|
+
const collinearY = Math.abs(previous.y - current.y) < 1e-6 && Math.abs(current.y - next.y) < 1e-6;
|
|
104
|
+
if (collinearX || collinearY)
|
|
105
|
+
result.splice(i, 1);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Łamana ortogonalna z zaokrąglonymi narożnikami.
|
|
111
|
+
* Narożnik rysujemy krzywą kwadratową — dla kąta prostego wygląda jak łuk,
|
|
112
|
+
* a ścieżka jest o połowę krótsza. Promień przycinamy do połowy krótszego
|
|
113
|
+
* z sąsiednich odcinków, żeby ciasne zakręty nie tworzyły pętli.
|
|
114
|
+
*/
|
|
115
|
+
export function roundedPolyline(points, radius) {
|
|
116
|
+
const path = dedupeCollinear(points);
|
|
117
|
+
if (path.length < 2)
|
|
118
|
+
return '';
|
|
119
|
+
const parts = [`M ${svgNum(path[0].x)} ${svgNum(path[0].y)}`];
|
|
120
|
+
for (let i = 1; i < path.length - 1; i++) {
|
|
121
|
+
const previous = path[i - 1];
|
|
122
|
+
const current = path[i];
|
|
123
|
+
const next = path[i + 1];
|
|
124
|
+
const lengthIn = Math.hypot(current.x - previous.x, current.y - previous.y);
|
|
125
|
+
const lengthOut = Math.hypot(next.x - current.x, next.y - current.y);
|
|
126
|
+
const r = Math.max(0, Math.min(radius, lengthIn / 2, lengthOut / 2));
|
|
127
|
+
if (r === 0 || lengthIn === 0 || lengthOut === 0) {
|
|
128
|
+
parts.push(`L ${svgNum(current.x)} ${svgNum(current.y)}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const enter = {
|
|
132
|
+
x: current.x + ((previous.x - current.x) / lengthIn) * r,
|
|
133
|
+
y: current.y + ((previous.y - current.y) / lengthIn) * r,
|
|
134
|
+
};
|
|
135
|
+
const leave = {
|
|
136
|
+
x: current.x + ((next.x - current.x) / lengthOut) * r,
|
|
137
|
+
y: current.y + ((next.y - current.y) / lengthOut) * r,
|
|
138
|
+
};
|
|
139
|
+
parts.push(`L ${svgNum(enter.x)} ${svgNum(enter.y)}`, `Q ${svgNum(current.x)} ${svgNum(current.y)} ${svgNum(leave.x)} ${svgNum(leave.y)}`);
|
|
140
|
+
}
|
|
141
|
+
const last = path.at(-1);
|
|
142
|
+
parts.push(`L ${svgNum(last.x)} ${svgNum(last.y)}`);
|
|
143
|
+
return parts.join(' ');
|
|
144
|
+
}
|
|
145
|
+
/** Grot strzałki w punkcie `point`, skierowany zgodnie z `direction`. */
|
|
146
|
+
export function arrowHead(point, direction, size) {
|
|
147
|
+
const sign = direction === 'right' ? 1 : -1;
|
|
148
|
+
const baseX = point.x - sign * size;
|
|
149
|
+
return [
|
|
150
|
+
`M ${svgNum(point.x)} ${svgNum(point.y)}`,
|
|
151
|
+
`L ${svgNum(baseX)} ${svgNum(point.y - size / 2)}`,
|
|
152
|
+
`L ${svgNum(baseX)} ${svgNum(point.y + size / 2)}`,
|
|
153
|
+
'Z',
|
|
154
|
+
].join(' ');
|
|
155
|
+
}
|
|
156
|
+
/** Kotwica wyjścia/wejścia zależnie od typu: -1 = lewa krawędź, 1 = prawa. */
|
|
157
|
+
function anchors(type) {
|
|
158
|
+
switch (type) {
|
|
159
|
+
case 'FS':
|
|
160
|
+
return { out: 1, in: -1 };
|
|
161
|
+
case 'SS':
|
|
162
|
+
return { out: -1, in: -1 };
|
|
163
|
+
case 'FF':
|
|
164
|
+
return { out: 1, in: 1 };
|
|
165
|
+
case 'SF':
|
|
166
|
+
return { out: -1, in: 1 };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function edgePoint(rect, side) {
|
|
170
|
+
return { x: side === 1 ? rect.x + rect.width : rect.x, y: rect.y + rect.height / 2 };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Trasa łamanej między dwoma kotwicami.
|
|
174
|
+
*
|
|
175
|
+
* Konwencja kierunków: `outDir` i `inDir` wskazują stronę paska (-1 = lewa
|
|
176
|
+
* krawędź, +1 = prawa), więc odsunięcie „na zewnątrz" to zawsze `x + dir * stub`.
|
|
177
|
+
*
|
|
178
|
+
* Gdy cel leży za końcem źródła, wystarczy prosty schodek: pion wypada między
|
|
179
|
+
* paskami. W przeciwnym razie trzeba je obejść poziomym objazdem prowadzonym
|
|
180
|
+
* w połowie odstępu między wierszami.
|
|
181
|
+
*/
|
|
182
|
+
function route(out, outDir, anchor, endPoint, inDir, stub, detourGap) {
|
|
183
|
+
const afterOut = out.x + outDir * stub;
|
|
184
|
+
const beforeIn = endPoint.x + inDir * stub;
|
|
185
|
+
if (outDir === inDir) {
|
|
186
|
+
// SS / FF — obie kotwice po tej samej stronie pasków. Wspólny pion musi ominąć
|
|
187
|
+
// oba paski, więc bierzemy punkt najdalej wysunięty w kierunku wyjścia;
|
|
188
|
+
// inaczej trasa schowałaby się pod paskiem, z którego wychodzi.
|
|
189
|
+
const middle = outDir === -1 ? Math.min(afterOut, beforeIn) : Math.max(afterOut, beforeIn);
|
|
190
|
+
return [out, { x: middle, y: out.y }, { x: middle, y: endPoint.y }, endPoint];
|
|
191
|
+
}
|
|
192
|
+
// FS / SF — kotwice po przeciwnych stronach. Prosty schodek ma sens tylko wtedy,
|
|
193
|
+
// gdy między paskami zostaje miejsce na czytelny pion. Zapas liczymy względem
|
|
194
|
+
// rzeczywistej krawędzi celu (`anchor`), a nie skróconego o grot końca trzonu.
|
|
195
|
+
// Warunek `room >= 0` jest równoważny „odstęp >= 2 * stub", więc próg zawijasa
|
|
196
|
+
// wynika wprost z geometrii i nie wymaga osobnej stałej.
|
|
197
|
+
const room = (anchor.x + inDir * stub - afterOut) * (inDir === -1 ? 1 : -1);
|
|
198
|
+
if (room >= 0) {
|
|
199
|
+
const lo = Math.min(afterOut, beforeIn);
|
|
200
|
+
const hi = Math.max(afterOut, beforeIn);
|
|
201
|
+
const middle = Math.min(hi, Math.max(lo, (out.x + endPoint.x) / 2));
|
|
202
|
+
return [out, { x: middle, y: out.y }, { x: middle, y: endPoint.y }, endPoint];
|
|
203
|
+
}
|
|
204
|
+
// Paski stykają się albo cel cofa się względem źródła — prowadzimy trasę wokół,
|
|
205
|
+
// przez odstęp między wierszami.
|
|
206
|
+
const detourY = Math.abs(endPoint.y - out.y) < 1e-6 ? out.y + detourGap : (out.y + endPoint.y) / 2;
|
|
207
|
+
return [
|
|
208
|
+
out,
|
|
209
|
+
{ x: afterOut, y: out.y },
|
|
210
|
+
{ x: afterOut, y: detourY },
|
|
211
|
+
{ x: beforeIn, y: detourY },
|
|
212
|
+
{ x: beforeIn, y: endPoint.y },
|
|
213
|
+
endPoint,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Geometria pojedynczej zależności.
|
|
218
|
+
* Zwraca `null`, gdy któryś koniec nie jest widoczny (zwinięta grupa albo
|
|
219
|
+
* wiersz poza oknem wirtualizacji).
|
|
220
|
+
*/
|
|
221
|
+
export function linkGeometry(link, source, target, opts) {
|
|
222
|
+
if (!source || !target)
|
|
223
|
+
return null;
|
|
224
|
+
const type = link.type ?? 'FS';
|
|
225
|
+
const stub = opts?.stub ?? DEFAULT_STUB;
|
|
226
|
+
const corner = opts?.cornerRadius ?? DEFAULT_CORNER;
|
|
227
|
+
const arrowSize = opts?.arrowSize ?? DEFAULT_ARROW;
|
|
228
|
+
const side = anchors(type);
|
|
229
|
+
const from = edgePoint(source.rect, side.out);
|
|
230
|
+
const to = edgePoint(target.rect, side.in);
|
|
231
|
+
// Grot dosuwamy do krawędzi paska, więc trzon kończy się tuż przed nim —
|
|
232
|
+
// czyli o długość grotu na zewnątrz celu.
|
|
233
|
+
const tip = to;
|
|
234
|
+
const shaftEnd = { x: to.x + side.in * arrowSize, y: to.y };
|
|
235
|
+
const detourGap = Math.max(source.rect.height, target.rect.height);
|
|
236
|
+
const points = route(from, side.out, to, shaftEnd, side.in, stub, detourGap);
|
|
237
|
+
const highlight = opts?.highlightViolations ?? true;
|
|
238
|
+
return {
|
|
239
|
+
key: linkKey(link),
|
|
240
|
+
from: link.from,
|
|
241
|
+
to: link.to,
|
|
242
|
+
type,
|
|
243
|
+
path: roundedPolyline(points, corner),
|
|
244
|
+
arrow: arrowHead(tip, side.in === -1 ? 'right' : 'left', arrowSize),
|
|
245
|
+
variant: link.variant,
|
|
246
|
+
color: link.color,
|
|
247
|
+
isViolated: highlight ? violates(link, source, target) : false,
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/scale.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Zakres [min, max] tablicy liczb (0..0 dla pustej). */
|
|
2
|
+
export declare function extent(values: readonly number[]): [number, number];
|
|
3
|
+
/** Interpolacja liniowa domeny → zakresu (np. czas → udział 0..1). */
|
|
4
|
+
export declare function linearScale(domainMin: number, domainMax: number, rangeMin: number, rangeMax: number): (value: number) => number;
|
|
5
|
+
/** Ogranicza wartość do przedziału [min, max]. */
|
|
6
|
+
export declare function clamp(value: number, min: number, max: number): number;
|
|
7
|
+
/**
|
|
8
|
+
* Skraca liczbę na potrzeby atrybutu `d` ścieżki SVG.
|
|
9
|
+
* Odpowiednik prywatnego `n()` z `charts/src/core/bar.ts` — trzyma ścieżki krótkie.
|
|
10
|
+
*/
|
|
11
|
+
export declare function svgNum(value: number): string;
|
package/dist/scale.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Skalowanie liniowe i pomocniki liczbowe.
|
|
3
|
+
*
|
|
4
|
+
* `linearScale` i `extent` są celowo zduplikowane z `charts/src/core/scale.ts`
|
|
5
|
+
* (ten sam kontrakt): import między subpathami tworzyłby twardą zależność
|
|
6
|
+
* `roadmap → charts` dla kilkunastu linii kodu.
|
|
7
|
+
*/
|
|
8
|
+
/** Zakres [min, max] tablicy liczb (0..0 dla pustej). */
|
|
9
|
+
export function extent(values) {
|
|
10
|
+
if (values.length === 0)
|
|
11
|
+
return [0, 0];
|
|
12
|
+
let min = values[0];
|
|
13
|
+
let max = values[0];
|
|
14
|
+
for (const v of values) {
|
|
15
|
+
if (v < min)
|
|
16
|
+
min = v;
|
|
17
|
+
if (v > max)
|
|
18
|
+
max = v;
|
|
19
|
+
}
|
|
20
|
+
return [min, max];
|
|
21
|
+
}
|
|
22
|
+
/** Interpolacja liniowa domeny → zakresu (np. czas → udział 0..1). */
|
|
23
|
+
export function linearScale(domainMin, domainMax, rangeMin, rangeMax) {
|
|
24
|
+
const dSpan = domainMax - domainMin || 1;
|
|
25
|
+
const rSpan = rangeMax - rangeMin;
|
|
26
|
+
return (value) => rangeMin + ((value - domainMin) / dSpan) * rSpan;
|
|
27
|
+
}
|
|
28
|
+
/** Ogranicza wartość do przedziału [min, max]. */
|
|
29
|
+
export function clamp(value, min, max) {
|
|
30
|
+
if (value < min)
|
|
31
|
+
return min;
|
|
32
|
+
if (value > max)
|
|
33
|
+
return max;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Skraca liczbę na potrzeby atrybutu `d` ścieżki SVG.
|
|
38
|
+
* Odpowiednik prywatnego `n()` z `charts/src/core/bar.ts` — trzyma ścieżki krótkie.
|
|
39
|
+
*/
|
|
40
|
+
export function svgNum(value) {
|
|
41
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(3);
|
|
42
|
+
}
|