@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.
@@ -0,0 +1,90 @@
1
+ import type { KptRoadmapPositionMode, KptRoadmapZoom, KptTimeRange, KptTimelineOptions } from './types.ts';
2
+ /** Pojedyncza kolumna dolnego poziomu nagłówka i linii siatki. */
3
+ export interface KptTimelineCell {
4
+ /** Indeks w `cells` (0-based) — podstawa skali 'uniform'. */
5
+ index: number;
6
+ /** Klucz trackBy. */
7
+ key: string;
8
+ /** Początek jednostki (ms, lokalny). */
9
+ start: number;
10
+ /** Początek następnej jednostki (ms, wyłącznie). Uwzględnia doby 23/25 h. */
11
+ end: number;
12
+ /** Etykieta dolnego poziomu, np. '35', '24', '09:00'. */
13
+ label: string;
14
+ /** Podpis pod etykietą, np. '24.08–30.08' albo 'pon'. */
15
+ sublabel?: string;
16
+ /** Pełny opis do atrybutu `title`. */
17
+ title: string;
18
+ /** Lewa krawędź 0..1. */
19
+ ratioStart: number;
20
+ /** Prawa krawędź 0..1. */
21
+ ratioEnd: number;
22
+ isToday: boolean;
23
+ isWeekend: boolean;
24
+ /** Poza oknem godzin roboczych (tylko `workHours.mode === 'dim'`). */
25
+ isNonWorking: boolean;
26
+ isMonthBoundary: boolean;
27
+ isQuarterBoundary: boolean;
28
+ isYearBoundary: boolean;
29
+ /** Przed tą kolumną wycięto zakres (`workHours.mode === 'skip'`). */
30
+ hasGapBefore: boolean;
31
+ }
32
+ /** Grupa kolumn tworząca górny poziom nagłówka (np. miesiąc nad tygodniami). */
33
+ export interface KptTimelineBand {
34
+ key: string;
35
+ start: number;
36
+ end: number;
37
+ /** Indeks pierwszej i ostatniej obejmowanej kolumny (włącznie). */
38
+ fromIndex: number;
39
+ toIndex: number;
40
+ /** Liczba obejmowanych kolumn. */
41
+ span: number;
42
+ ratioStart: number;
43
+ ratioEnd: number;
44
+ /** Etykieta główna, np. 'sierpień'. */
45
+ label: string;
46
+ /** Etykieta pomocnicza, np. '2026'. */
47
+ sublabel?: string;
48
+ /** Pierwsze pasmo danego roku — nośnik czarnego znacznika roku. */
49
+ isYearStart: boolean;
50
+ /** Rok do znacznika; ustawiony tylko gdy `isYearStart`. */
51
+ yearBadge?: string;
52
+ }
53
+ /** Geometria paska na osi (wartości 0..1). */
54
+ export interface KptSpanGeom {
55
+ left: number;
56
+ width: number;
57
+ /** Item zaczyna się przed lewą krawędzią zakresu. */
58
+ clippedStart: boolean;
59
+ /** Item kończy się za prawą krawędzią zakresu. */
60
+ clippedEnd: boolean;
61
+ /** Czy jakakolwiek część mieści się w zakresie. */
62
+ visible: boolean;
63
+ }
64
+ /** Niemutowalny model osi czasu — wynik `buildTimeline`. */
65
+ export interface KptTimelineModel {
66
+ zoom: KptRoadmapZoom;
67
+ range: KptTimeRange;
68
+ positionMode: KptRoadmapPositionMode;
69
+ cells: readonly KptTimelineCell[];
70
+ bands: readonly KptTimelineBand[];
71
+ /** Czy górny poziom nagłówka istnieje. */
72
+ hasBands: boolean;
73
+ /** Pozycja „dziś" 0..1 albo null, gdy poza zakresem. */
74
+ todayRatio: number | null;
75
+ /** Czas → pozycja 0..1 (monotoniczna, clampowana do zakresu). */
76
+ toRatio(timestamp: number): number;
77
+ /** Pozycja 0..1 → czas (odwrotność `toRatio`; podstawa pod drag&drop). */
78
+ fromRatio(ratio: number): number;
79
+ /** Geometria przedziału wraz z informacją o przycięciu. */
80
+ span(startTs: number, endTs: number): KptSpanGeom;
81
+ }
82
+ /**
83
+ * Buduje model osi czasu: kolumny, pasma nagłówka i skalę.
84
+ * Wszystkie obliczenia w czasie LOKALNYM przeglądarki.
85
+ *
86
+ * @param range zakres osi w ms — zwykle wynik `resolveRange`
87
+ * @param zoom poziom przybliżenia
88
+ * @param opts locale, „dziś", weekendy, godziny robocze, tryb pozycjonowania
89
+ */
90
+ export declare function buildTimeline(range: KptTimeRange, zoom: KptRoadmapZoom, opts?: KptTimelineOptions): KptTimelineModel;
@@ -0,0 +1,375 @@
1
+ /*
2
+ * Model osi czasu: kolumny dolnego poziomu nagłówka, pasma górnego poziomu
3
+ * oraz skala czas ↔ pozycja.
4
+ *
5
+ * Skala domyślna ('uniform') odwzorowuje czas odcinkami: każda kolumna ma równą
6
+ * szerokość, a wewnątrz niej interpolujemy liniowo po ms. Dzięki temu doba po
7
+ * zmianie czasu (23/25 h) i luty nie są węższe od sąsiadów, a skala pozostaje
8
+ * ciągła i monotoniczna — zadanie może zaczynać się w środku dnia.
9
+ */
10
+ import { addHours, addUnits, floorToUnit, isSameDay, isoWeekNumber, isoWeekYear, quarterOf, toTimestamp, } from "./calendar.js";
11
+ import { clamp, linearScale } from "./scale.js";
12
+ /** Domyślna jednostka górnego poziomu nagłówka dla danego zoomu. */
13
+ const DEFAULT_BAND = {
14
+ hour: 'day',
15
+ day: 'week',
16
+ week: 'month',
17
+ month: 'quarter',
18
+ quarter: 'year',
19
+ year: 'none',
20
+ };
21
+ const DEFAULT_MAX_CELLS = 4000;
22
+ const DEFAULT_WEEKEND = [0, 6];
23
+ function resolveOptions(zoom, opts) {
24
+ const workHours = opts?.workHours;
25
+ return {
26
+ locale: opts?.locale,
27
+ todayTs: opts?.today !== undefined ? toTimestamp(opts.today) : Date.now(),
28
+ weekendDays: opts?.weekendDays ?? DEFAULT_WEEKEND,
29
+ // Okno godzin roboczych ma sens tylko przy kolumnach godzinowych.
30
+ workHours: zoom === 'hour' && workHours
31
+ ? {
32
+ from: workHours.from,
33
+ to: workHours.to,
34
+ mode: workHours.mode ?? 'skip',
35
+ skipWeekends: workHours.skipWeekends ?? true,
36
+ }
37
+ : null,
38
+ bandUnit: opts?.bandUnit ?? DEFAULT_BAND[zoom],
39
+ positionMode: opts?.positionMode ?? 'uniform',
40
+ weekPrefix: opts?.weekPrefix ?? 'W',
41
+ maxCells: opts?.maxCells ?? DEFAULT_MAX_CELLS,
42
+ };
43
+ }
44
+ function makeFormatters(locale) {
45
+ return {
46
+ dayNum: new Intl.DateTimeFormat(locale, { day: 'numeric' }),
47
+ weekdayShort: new Intl.DateTimeFormat(locale, { weekday: 'short' }),
48
+ dayMonth: new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' }),
49
+ monthLong: new Intl.DateTimeFormat(locale, { month: 'long' }),
50
+ monthShort: new Intl.DateTimeFormat(locale, { month: 'short' }),
51
+ dayLong: new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }),
52
+ hour: new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12: false }),
53
+ };
54
+ }
55
+ function isWeekendDay(date, o) {
56
+ return o.weekendDays.includes(date.getDay());
57
+ }
58
+ /** Czy dana godzina mieści się w oknie roboczym (i poza weekendem, gdy tak ustawiono)? */
59
+ function isWorkingSlot(date, o) {
60
+ const wh = o.workHours;
61
+ if (!wh)
62
+ return true;
63
+ if (wh.skipWeekends && isWeekendDay(date, o))
64
+ return false;
65
+ const hour = date.getHours();
66
+ return hour >= wh.from && hour < wh.to;
67
+ }
68
+ /**
69
+ * Kotwica przypisania kolumny do pasma.
70
+ * Dla tygodnia bierzemy czwartek — tak samo jak ISO wyznacza rok tygodnia,
71
+ * więc tydzień na przełomie miesiąca trafia do miesiąca swojego czwartku.
72
+ */
73
+ function bandAnchor(cellStart, zoom) {
74
+ if (zoom !== 'week')
75
+ return cellStart;
76
+ const anchor = new Date(cellStart);
77
+ anchor.setDate(anchor.getDate() + 3);
78
+ return anchor;
79
+ }
80
+ /**
81
+ * Wielka pierwsza litera. `Intl` zwraca nazwy zgodne z ortografią języka — po polsku,
82
+ * francusku czy czesku miesiące i dni tygodnia pisze się małą literą, po angielsku
83
+ * i niemiecku wielką. Na osi czasu i w podpowiedziach chcemy jednolitego wyglądu
84
+ * niezależnie od języka, więc podnosimy wyłącznie pierwszy znak; reszta zostaje bez
85
+ * zmian, żeby „wtorek, 15 września 2026" nie stało się „Wtorek, 15 Września 2026".
86
+ */
87
+ function capitalizeFirst(text) {
88
+ if (!text)
89
+ return text;
90
+ const first = text.charAt(0);
91
+ const upper = first.toLocaleUpperCase();
92
+ // `toLocaleUpperCase` bywa dłuższe niż źródło (np. niemieckie ß → SS) — wtedy
93
+ // zostawiamy oryginał, bo zamiana zmieniłaby długość i wygląd etykiety.
94
+ return upper.length === first.length ? upper + text.slice(1) : text;
95
+ }
96
+ function cellLabels(start, zoom, o, f) {
97
+ switch (zoom) {
98
+ case 'hour':
99
+ return {
100
+ label: f.hour.format(start),
101
+ title: capitalizeFirst(`${f.dayLong.format(start)}, ${f.hour.format(start)}`),
102
+ };
103
+ case 'day':
104
+ return {
105
+ label: f.dayNum.format(start),
106
+ sublabel: capitalizeFirst(f.weekdayShort.format(start)),
107
+ title: capitalizeFirst(f.dayLong.format(start)),
108
+ };
109
+ case 'week': {
110
+ // Podpis to prawdziwy zakres dat tygodnia: poniedziałek – niedziela.
111
+ const sunday = new Date(start);
112
+ sunday.setDate(sunday.getDate() + 6);
113
+ const range = `${f.dayMonth.format(start)}–${f.dayMonth.format(sunday)}`;
114
+ return {
115
+ label: `${o.weekPrefix}${isoWeekNumber(start)}`,
116
+ sublabel: range,
117
+ title: `${o.weekPrefix}${isoWeekNumber(start)} ${isoWeekYear(start)} · ${range}`,
118
+ };
119
+ }
120
+ case 'month':
121
+ return {
122
+ label: capitalizeFirst(f.monthShort.format(start)),
123
+ title: capitalizeFirst(`${f.monthLong.format(start)} ${start.getFullYear()}`),
124
+ };
125
+ case 'quarter':
126
+ return { label: `Q${quarterOf(start)}`, title: `Q${quarterOf(start)} ${start.getFullYear()}` };
127
+ case 'year':
128
+ return { label: String(start.getFullYear()), title: String(start.getFullYear()) };
129
+ }
130
+ }
131
+ function bandLabels(start, unit, f) {
132
+ switch (unit) {
133
+ case 'hour':
134
+ return { label: f.hour.format(start) };
135
+ case 'day':
136
+ return { label: capitalizeFirst(f.dayLong.format(start)) };
137
+ case 'week':
138
+ return { label: `W${isoWeekNumber(start)}`, sublabel: capitalizeFirst(f.monthLong.format(start)) };
139
+ case 'month':
140
+ return { label: capitalizeFirst(f.monthLong.format(start)), sublabel: String(start.getFullYear()) };
141
+ case 'quarter':
142
+ return { label: `Q${quarterOf(start)}`, sublabel: String(start.getFullYear()) };
143
+ case 'year':
144
+ return { label: String(start.getFullYear()) };
145
+ }
146
+ }
147
+ /**
148
+ * Rok kolumny użyty do wykrycia granicy roku.
149
+ * Przy zoomie tygodniowym musi to być rok tygodnia ISO — inaczej W53/2026
150
+ * (28.12.2026) dostałaby błędnie znacznik „2027".
151
+ */
152
+ function yearOfCell(date, zoom) {
153
+ return zoom === 'week' ? isoWeekYear(date) : date.getFullYear();
154
+ }
155
+ /**
156
+ * Początek następnej kolumny albo `null`, gdy kursor nie potrafi się już posunąć.
157
+ * DST wiosną przy zoomie godzinowym: 02:00 nie istnieje, więc `setHours`
158
+ * normalizuje na 03:00 i zwykły krok mógłby nie wyprzedzić `cursor` — wtedy
159
+ * przeskakujemy o dwie godziny.
160
+ */
161
+ function nextBoundary(cursor, zoom) {
162
+ const next = addUnits(cursor, zoom, 1);
163
+ if (next.getTime() > cursor.getTime())
164
+ return next;
165
+ const jumped = addHours(cursor, 2);
166
+ return jumped.getTime() > cursor.getTime() ? jumped : null;
167
+ }
168
+ /**
169
+ * Czy kolumna obejmuje „dziś"? Dla jednostek grubszych niż tydzień akceptujemy
170
+ * dodatkowo zgodność samej daty — `todayTs` bywa znormalizowane do północy.
171
+ */
172
+ function isTodayCell(cursor, next, zoom, o, today) {
173
+ const withinSpan = o.todayTs >= cursor.getTime() && o.todayTs < next.getTime();
174
+ if (zoom === 'hour' || zoom === 'day' || zoom === 'week')
175
+ return withinSpan;
176
+ return withinSpan || isSameDay(cursor, today);
177
+ }
178
+ /** Flagi wyglądu kolumny: tło weekendu/godzin niepracujących i granice okresów. */
179
+ function cellDecorations(cursor, zoom, o) {
180
+ const isDayGrid = zoom === 'day' || zoom === 'hour';
181
+ return {
182
+ isWeekend: isDayGrid && isWeekendDay(cursor, o),
183
+ isNonWorking: o.workHours?.mode === 'dim' && !isWorkingSlot(cursor, o),
184
+ isMonthBoundary: isDayGrid && cursor.getDate() === 1,
185
+ isQuarterBoundary: (zoom === 'month' || zoom === 'quarter') && cursor.getMonth() % 3 === 0,
186
+ };
187
+ }
188
+ function buildCells(range, zoom, o, f) {
189
+ const cells = [];
190
+ const today = new Date(o.todayTs);
191
+ // Startujemy od granicy jednostki — inaczej pierwsza kolumna byłaby ucięta.
192
+ let cursor = floorToUnit(new Date(range.start), zoom);
193
+ let pendingGap = false;
194
+ let previousYear = null;
195
+ let guard = 0;
196
+ while (cursor.getTime() < range.end && guard++ < o.maxCells) {
197
+ const next = nextBoundary(cursor, zoom);
198
+ if (next === null)
199
+ break;
200
+ if (o.workHours?.mode === 'skip' && !isWorkingSlot(cursor, o)) {
201
+ pendingGap = true;
202
+ cursor = next;
203
+ continue;
204
+ }
205
+ const year = yearOfCell(cursor, zoom);
206
+ const labels = cellLabels(cursor, zoom, o, f);
207
+ cells.push({
208
+ index: cells.length,
209
+ key: String(cursor.getTime()),
210
+ start: cursor.getTime(),
211
+ end: next.getTime(),
212
+ label: labels.label,
213
+ sublabel: labels.sublabel,
214
+ title: labels.title,
215
+ ratioStart: 0,
216
+ ratioEnd: 0,
217
+ isToday: isTodayCell(cursor, next, zoom, o, today),
218
+ ...cellDecorations(cursor, zoom, o),
219
+ isYearBoundary: previousYear !== null && year !== previousYear,
220
+ hasGapBefore: pendingGap,
221
+ });
222
+ pendingGap = false;
223
+ previousYear = year;
224
+ cursor = next;
225
+ }
226
+ return cells;
227
+ }
228
+ function applyRatios(cells, mode, range) {
229
+ const count = cells.length;
230
+ if (count === 0)
231
+ return;
232
+ if (mode === 'uniform') {
233
+ const step = 1 / count;
234
+ for (const cell of cells) {
235
+ cell.ratioStart = cell.index * step;
236
+ cell.ratioEnd = (cell.index + 1) * step;
237
+ }
238
+ return;
239
+ }
240
+ const scale = linearScale(range.start, range.end, 0, 1);
241
+ for (const cell of cells) {
242
+ cell.ratioStart = clamp(scale(cell.start), 0, 1);
243
+ cell.ratioEnd = clamp(scale(cell.end), 0, 1);
244
+ }
245
+ }
246
+ /** Indeks kolumny zawierającej `ts`, albo pierwszej kolumny po `ts`. */
247
+ function findCell(cells, ts) {
248
+ let lo = 0;
249
+ let hi = cells.length - 1;
250
+ while (lo < hi) {
251
+ const mid = (lo + hi) >> 1;
252
+ if (ts >= cells[mid].end)
253
+ lo = mid + 1;
254
+ else
255
+ hi = mid;
256
+ }
257
+ return lo;
258
+ }
259
+ function buildBands(cells, zoom, o, f) {
260
+ const unit = o.bandUnit;
261
+ if (unit === 'none' || cells.length === 0)
262
+ return [];
263
+ const bands = [];
264
+ let seenYear = null;
265
+ for (const cell of cells) {
266
+ const anchor = bandAnchor(new Date(cell.start), zoom);
267
+ const bandStart = floorToUnit(anchor, unit);
268
+ const key = String(bandStart.getTime());
269
+ const last = bands.at(-1);
270
+ if (last?.key === key) {
271
+ last.toIndex = cell.index;
272
+ last.span += 1;
273
+ last.ratioEnd = cell.ratioEnd;
274
+ last.end = cell.end;
275
+ continue;
276
+ }
277
+ const labels = bandLabels(bandStart, unit, f);
278
+ const year = unit === 'week' ? isoWeekYear(bandStart) : bandStart.getFullYear();
279
+ const isYearStart = seenYear === null ? bands.length === 0 : year !== seenYear;
280
+ seenYear = year;
281
+ bands.push({
282
+ key,
283
+ start: cell.start,
284
+ end: cell.end,
285
+ fromIndex: cell.index,
286
+ toIndex: cell.index,
287
+ span: 1,
288
+ ratioStart: cell.ratioStart,
289
+ ratioEnd: cell.ratioEnd,
290
+ label: labels.label,
291
+ sublabel: labels.sublabel,
292
+ isYearStart,
293
+ // Znacznik roku niesie tylko pierwsze pasmo danego roku.
294
+ yearBadge: isYearStart ? String(year) : undefined,
295
+ });
296
+ }
297
+ return bands;
298
+ }
299
+ /**
300
+ * Buduje model osi czasu: kolumny, pasma nagłówka i skalę.
301
+ * Wszystkie obliczenia w czasie LOKALNYM przeglądarki.
302
+ *
303
+ * @param range zakres osi w ms — zwykle wynik `resolveRange`
304
+ * @param zoom poziom przybliżenia
305
+ * @param opts locale, „dziś", weekendy, godziny robocze, tryb pozycjonowania
306
+ */
307
+ export function buildTimeline(range, zoom, opts) {
308
+ const o = resolveOptions(zoom, opts);
309
+ const f = makeFormatters(o.locale);
310
+ const cells = buildCells(range, zoom, o, f);
311
+ applyRatios(cells, o.positionMode, range);
312
+ const bands = buildBands(cells, zoom, o, f);
313
+ const count = cells.length;
314
+ const first = cells[0];
315
+ const last = cells[count - 1];
316
+ /** Czas → 0..1. Skala odcinkowa: indeks kolumny + interpolacja w jej wnętrzu. */
317
+ const toRatio = (ts) => {
318
+ if (count === 0)
319
+ return 0;
320
+ if (o.positionMode === 'linear') {
321
+ return clamp(linearScale(range.start, range.end, 0, 1)(ts), 0, 1);
322
+ }
323
+ if (ts <= first.start)
324
+ return 0;
325
+ if (ts >= last.end)
326
+ return 1;
327
+ const index = findCell(cells, ts);
328
+ const cell = cells[index];
329
+ // `ts` może wpaść w lukę wyciętą przez `workHours: 'skip'` — wtedy
330
+ // interpolacja wychodzi poza [0, 1] i clamp dociąga ją do krawędzi kolumny.
331
+ const inner = clamp(linearScale(cell.start, cell.end, 0, 1)(ts), 0, 1);
332
+ return (index + inner) / count;
333
+ };
334
+ const fromRatio = (ratio) => {
335
+ if (count === 0)
336
+ return range.start;
337
+ if (o.positionMode === 'linear') {
338
+ return range.start + clamp(ratio, 0, 1) * (range.end - range.start);
339
+ }
340
+ const scaled = clamp(ratio, 0, 1) * count;
341
+ const index = Math.min(count - 1, Math.floor(scaled));
342
+ const cell = cells[index];
343
+ // Poprawnie odwzorowuje także dobę 25-godzinną — długość bierzemy z kolumny.
344
+ return cell.start + (scaled - index) * (cell.end - cell.start);
345
+ };
346
+ const span = (startTs, endTs) => {
347
+ const lo = Math.min(startTs, endTs);
348
+ const hi = Math.max(startTs, endTs);
349
+ const axisStart = count > 0 ? first.start : range.start;
350
+ const axisEnd = count > 0 ? last.end : range.end;
351
+ const left = toRatio(lo);
352
+ const right = toRatio(hi);
353
+ return {
354
+ left,
355
+ width: Math.max(0, right - left),
356
+ clippedStart: lo < axisStart,
357
+ clippedEnd: hi > axisEnd,
358
+ // Kamień milowy ma zerową szerokość, więc sam dotyk krawędzi wystarczy.
359
+ visible: hi >= axisStart && lo <= axisEnd,
360
+ };
361
+ };
362
+ const todayInRange = count > 0 && o.todayTs >= first.start && o.todayTs <= last.end;
363
+ return {
364
+ zoom,
365
+ range,
366
+ positionMode: o.positionMode,
367
+ cells,
368
+ bands,
369
+ hasBands: bands.length > 0,
370
+ todayRatio: todayInRange ? toRatio(o.todayTs) : null,
371
+ toRatio,
372
+ fromRatio,
373
+ span,
374
+ };
375
+ }
@@ -0,0 +1,222 @@
1
+ /** Akceptowane formy daty na wejściu; rdzeń normalizuje je do epoch ms (czas LOKALNY). */
2
+ export type KptDateInput = Date | number | string;
3
+ /** Poziom przybliżenia osi czasu. */
4
+ export type KptRoadmapZoom = 'year' | 'quarter' | 'month' | 'week' | 'day' | 'hour';
5
+ /** Warianty kolorystyczne spójne z resztą biblioteki. */
6
+ export type KptRoadmapVariant = 'neutral' | 'primary' | 'success' | 'danger' | 'warning' | 'info';
7
+ /** Zamknięty przedział czasu w epoch ms. */
8
+ export interface KptTimeRange {
9
+ /** Początek włącznie (ms). */
10
+ start: number;
11
+ /** Koniec wyłącznie (ms). */
12
+ end: number;
13
+ }
14
+ /** Punkt w układzie px overlaya SVG. */
15
+ export interface KptRoadmapPoint {
16
+ x: number;
17
+ y: number;
18
+ }
19
+ /** Prostokąt w px overlaya SVG. */
20
+ export interface KptRoadmapRect {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+ /** Typ zależności wg konwencji zarządzania projektami. */
27
+ export type KptRoadmapLinkType = 'FS' | 'SS' | 'FF' | 'SF';
28
+ /** Krawędź grafu zależności między dwoma itemami. */
29
+ export interface KptRoadmapLink<TMeta = unknown> {
30
+ /** Stabilny klucz (trackBy); generowany z `from`/`to`/`type`, gdy brak. */
31
+ id?: string;
32
+ /** `id` itemu-poprzednika. */
33
+ from: string;
34
+ /** `id` itemu-następnika. */
35
+ to: string;
36
+ /** Domyślnie 'FS' (finish-to-start). */
37
+ type?: KptRoadmapLinkType;
38
+ /** Wyprzedzenie/opóźnienie w ms (ujemne = lead). Domyślnie 0. */
39
+ lag?: number;
40
+ variant?: KptRoadmapVariant;
41
+ color?: string;
42
+ meta?: TMeta;
43
+ }
44
+ /** Ograniczenia edycyjne — zarezerwowane pod drag&drop (v1 ich nie czyta). */
45
+ export interface KptRoadmapConstraints {
46
+ /** Item nie może zacząć się wcześniej niż tu. */
47
+ minStart?: KptDateInput;
48
+ /** Item nie może skończyć się później niż tu. */
49
+ maxEnd?: KptDateInput;
50
+ /** Minimalny czas trwania w ms. */
51
+ minDurationMs?: number;
52
+ /** Przyciąganie do jednostki podczas przeciągania. */
53
+ snap?: KptRoadmapZoom | 'none';
54
+ }
55
+ /** Pojedynczy pasek / kamień milowy roadmapy. */
56
+ export interface KptRoadmapItem<TMeta = unknown> {
57
+ /** Unikalny identyfikator (klucz trackBy i cel zależności). */
58
+ id: string;
59
+ /** Etykieta główna (kolumna sticky + wnętrze paska). */
60
+ label: string;
61
+ /** Etykieta pomocnicza (druga linia w kolumnie sticky). */
62
+ sublabel?: string;
63
+ /** Początek (czas lokalny). */
64
+ start: KptDateInput;
65
+ /** Koniec wyłącznie. Brak ⇒ kamień milowy (`end = start`). */
66
+ end?: KptDateInput;
67
+ /** Wymusza render jako romb, nawet gdy `end` podane. */
68
+ milestone?: boolean;
69
+ variant?: KptRoadmapVariant;
70
+ /** Dowolny kolor CSS — nadpisuje `variant`. */
71
+ color?: string;
72
+ /** Postęp 0..1; render jako wypełnienie wewnątrz paska. */
73
+ progress?: number;
74
+ /** Przynależność do grupy/swimlane. Brak ⇒ wiersz na poziomie 0. */
75
+ groupId?: string;
76
+ /** Zależności skrótowo: `string` = FS bez lagu. Scalane z `config.links`. */
77
+ dependsOn?: readonly (string | KptRoadmapLink)[];
78
+ /** Nazwa ikony (KptIconName) renderowana przed etykietą. */
79
+ icon?: string;
80
+ disabled?: boolean;
81
+ draggable?: boolean;
82
+ resizable?: boolean;
83
+ locked?: boolean;
84
+ constraints?: KptRoadmapConstraints;
85
+ /** Dane aplikacyjne przekazywane do szablonów i zdarzeń. */
86
+ meta?: TMeta;
87
+ }
88
+ /** Znormalizowany item — daty jako ms, wyliczone flagi. Wynik rdzenia. */
89
+ export interface KptRoadmapNormalizedItem<TMeta = unknown> {
90
+ readonly source: KptRoadmapItem<TMeta>;
91
+ readonly id: string;
92
+ readonly start: number;
93
+ readonly end: number;
94
+ readonly isMilestone: boolean;
95
+ readonly durationMs: number;
96
+ }
97
+ /** Sposób wyliczania paska zbiorczego grupy. */
98
+ export type KptRoadmapSummaryMode = 'auto' | 'none';
99
+ /** Grupa / swimlane. Może być zagnieżdżona przez `parentId`. */
100
+ export interface KptRoadmapGroup<TMeta = unknown> {
101
+ id: string;
102
+ label: string;
103
+ sublabel?: string;
104
+ /** Grupa nadrzędna — API gotowe na drzewo; `depth` steruje wcięciem. */
105
+ parentId?: string;
106
+ /** Stan początkowy zwinięcia (dalej sterowany sygnałem komponentu). */
107
+ collapsed?: boolean;
108
+ variant?: KptRoadmapVariant;
109
+ color?: string;
110
+ /** 'auto' (domyślnie) = pasek od min(start) do max(end) potomków. */
111
+ summary?: KptRoadmapSummaryMode;
112
+ meta?: TMeta;
113
+ }
114
+ /** Okno godzin roboczych dla zoomu 'hour'. */
115
+ export interface KptWorkHours {
116
+ /** Pierwsza godzina robocza włącznie, 0..23 (domyślnie 8). */
117
+ from: number;
118
+ /** Ostatnia godzina wyłącznie, 1..24 (domyślnie 18). */
119
+ to: number;
120
+ /**
121
+ * 'skip' — godziny poza oknem nie mają kolumn (oś się skraca) [domyślne],
122
+ * 'dim' — kolumny istnieją, dostają flagę `isNonWorking`.
123
+ */
124
+ mode?: 'skip' | 'dim';
125
+ /** Czy pomijać także całe dni weekendowe (domyślnie true przy mode='skip'). */
126
+ skipWeekends?: boolean;
127
+ }
128
+ /**
129
+ * Sposób odwzorowania czasu na pozycję.
130
+ * 'uniform' — każda kolumna ma równą szerokość, wewnątrz interpolacja liniowa po ms.
131
+ * Doba po zmianie czasu (23/25 h) i luty nie są węższe od sąsiadów.
132
+ * 'linear' — czysto liniowo (start..end → 0..1); kolumny wtedy nierówne.
133
+ */
134
+ export type KptRoadmapPositionMode = 'uniform' | 'linear';
135
+ export interface KptTimelineOptions {
136
+ /** BCP-47 dla `Intl`. Domyślnie locale z KptI18n. */
137
+ locale?: string;
138
+ /** „Dziś" — wstrzykiwalne dla testów i SSR. Domyślnie `new Date()`. */
139
+ today?: KptDateInput;
140
+ /** Dni weekendu wg `Date#getDay()`. Domyślnie `[0, 6]`. */
141
+ weekendDays?: readonly number[];
142
+ /** Godziny robocze — tylko przy `zoom: 'hour'`. */
143
+ workHours?: KptWorkHours;
144
+ /** Jednostka górnego poziomu nagłówka. Brak ⇒ domyślna dla zoomu. */
145
+ bandUnit?: KptRoadmapZoom | 'none';
146
+ /**
147
+ * Prefiks numeru tygodnia (domyślnie 'W').
148
+ * Rdzeń nie zna warstwy i18n — komponent podaje tu wartość z KptI18n.
149
+ */
150
+ weekPrefix?: string;
151
+ /** Domyślnie 'uniform'. */
152
+ positionMode?: KptRoadmapPositionMode;
153
+ /** Bezpiecznik przed eksplozją liczby kolumn. Domyślnie 4000. */
154
+ maxCells?: number;
155
+ }
156
+ export type KptRoadmapRowMode = 'row-per-item' | 'pack';
157
+ export interface KptRoadmapRowOptions {
158
+ /** 'row-per-item' (domyślnie) lub 'pack' (kilka itemów w jednym wierszu). */
159
+ mode?: KptRoadmapRowMode;
160
+ /** Wysokość wiersza itemu w px. Domyślnie 44. */
161
+ itemHeight?: number;
162
+ /** Wysokość wiersza nagłówka grupy w px. Domyślnie 38. */
163
+ groupHeight?: number;
164
+ /** Wysokość jednego pasa w trybie 'pack'. Domyślnie 28. */
165
+ laneHeight?: number;
166
+ /** Minimalny odstęp między itemami w tym samym pasie (ms). Domyślnie 0. */
167
+ packMinGapMs?: number;
168
+ /** Czy pokazywać wiersz nagłówka dla itemów bez `groupId`. Domyślnie false. */
169
+ showUngroupedHeader?: boolean;
170
+ /** Etykieta pseudo-grupy dla itemów bez `groupId`. */
171
+ ungroupedLabel?: string;
172
+ }
173
+ export interface KptRoadmapDependencyOptions {
174
+ /** Czy rysować overlay zależności. Domyślnie true. */
175
+ enabled?: boolean;
176
+ /** Długość wąsa wychodzącego z paska w px. Domyślnie 12. */
177
+ stub?: number;
178
+ /** Promień zaokrąglenia narożników łamanej w px. Domyślnie 6. */
179
+ cornerRadius?: number;
180
+ /** Rozmiar grotu strzałki w px. Domyślnie 6. */
181
+ arrowSize?: number;
182
+ /** Podświetlać zależności naruszone (następnik startuje za wcześnie). Domyślnie true. */
183
+ highlightViolations?: boolean;
184
+ }
185
+ /** Zarezerwowane pod v2 (drag&drop, resize, inline edit). W v1 ignorowane. */
186
+ export interface KptRoadmapInteractionOptions {
187
+ selectable?: boolean;
188
+ draggable?: boolean;
189
+ resizable?: boolean;
190
+ linkable?: boolean;
191
+ snap?: KptRoadmapZoom | 'none';
192
+ }
193
+ /** Pełna konfiguracja roadmapy — jeden obiekt wejściowy komponentu. */
194
+ export interface KptRoadmapConfig<TItem = unknown, TGroup = unknown> {
195
+ items: readonly KptRoadmapItem<TItem>[];
196
+ groups?: readonly KptRoadmapGroup<TGroup>[];
197
+ /** Zależności podane grafowo (alternatywa/uzupełnienie `item.dependsOn`). */
198
+ links?: readonly KptRoadmapLink[];
199
+ /** Domyślnie 'week'. */
200
+ zoom?: KptRoadmapZoom;
201
+ /** Zakres osi. Częściowy ⇒ brakujący kraniec z `autoRange`. Brak ⇒ cały `autoRange`. */
202
+ range?: Partial<KptTimeRange>;
203
+ locale?: string;
204
+ today?: KptDateInput;
205
+ timeline?: KptTimelineOptions;
206
+ rows?: KptRoadmapRowOptions;
207
+ dependencies?: KptRoadmapDependencyOptions;
208
+ interaction?: KptRoadmapInteractionOptions;
209
+ }
210
+ /** Rodzaj zmiany zgłaszanej przez interakcję (zdefiniowany w v1, emitowany od v2). */
211
+ export type KptRoadmapChangeKind = 'move' | 'resize-start' | 'resize-end' | 'link-add' | 'link-remove';
212
+ /** Ładunek zdarzenia edycji — stabilny kontrakt na przyszłość. */
213
+ export interface KptRoadmapChange<TMeta = unknown> {
214
+ kind: KptRoadmapChangeKind;
215
+ item: KptRoadmapItem<TMeta>;
216
+ /** Poprzednie wartości (ms). */
217
+ previous: KptTimeRange;
218
+ /** Nowe wartości (ms). */
219
+ next: KptTimeRange;
220
+ /** Dla 'link-add' / 'link-remove'. */
221
+ link?: KptRoadmapLink;
222
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Rdzeń roadmapy — czyste typy TypeScript (bez DOM i bez frameworka).
3
+ * Współdzielone przez warstwę Angular (Faza 1) i React (Faza 2).
4
+ *
5
+ * Kontrakt: wejście jest READONLY — rdzeń nigdy nie mutuje obiektów użytkownika.
6
+ * Edycja (drag&drop) odbywa się przez zdarzenia, nie przez mutację danych.
7
+ */
8
+ export {};