@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 konce.pt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://ui.konce.pt/images/koncept_ui_lib.png" alt="Koncept UI — Angular component library" width="100%" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# @konce-pt/roadmap
|
|
6
|
+
|
|
7
|
+
Rdzeń roadmapy typu Gantt [Koncept UI](https://gitlab.com/konce-pt/koncept-ui): arytmetyka
|
|
8
|
+
kalendarzowa (ISO 8601), model osi czasu, układ wierszy z pakowaniem pasów i geometria zależności.
|
|
9
|
+
Czysty TypeScript, **zero zależności runtime**, bez wiązania z frameworkiem.
|
|
10
|
+
|
|
11
|
+
Zwykle nie instalujesz go samodzielnie: używają go `@konce-pt/angular/roadmap`
|
|
12
|
+
i `@konce-pt/react/roadmap`, i oba re-eksportują jego API. Sięgnij po niego wprost, gdy liczysz
|
|
13
|
+
harmonogram po stronie serwera albo budujesz własny widok na tej samej geometrii.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i @konce-pt/roadmap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { buildTimeline, autoRange, isoWeekNumber } from '@konce-pt/roadmap';
|
|
21
|
+
|
|
22
|
+
// Numeracja ISO: tydzień 1 zawiera pierwszy czwartek roku.
|
|
23
|
+
isoWeekNumber(new Date(2027, 0, 1)); // 53
|
|
24
|
+
|
|
25
|
+
// Oś wyliczona z danych i wyrównana do pełnych jednostek.
|
|
26
|
+
const range = autoRange(items, { zoom: 'week' });
|
|
27
|
+
|
|
28
|
+
// Kolumny równej szerokości mimo zmiany czasu; span() daje pozycję paska jako ułamek osi.
|
|
29
|
+
const timeline = buildTimeline(range, 'week', { weekPrefix: 'W' });
|
|
30
|
+
timeline.span(start, end); // { left, width, visible, clippedStart, clippedEnd }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
- **Kalendarz** — `isoWeekNumber`, `isoWeekYear`, `isoWeekday`, `isoWeeksInYear`, `startOfIsoWeek`,
|
|
36
|
+
`startOfDay`, `addDays`, `addHours`, `addMonths`, `addUnits`, `floorToUnit`, `ceilToUnit`,
|
|
37
|
+
`countUnits`, `diffDays`, `daysInMonth`, `isSameDay`, `toTimestamp`
|
|
38
|
+
- **Oś czasu** — `buildTimeline`
|
|
39
|
+
- **Wiersze** — `normalizeItems`, `buildRows`, `packLanes`, `sliceRows`, `spanOf`, `rollupProgress`
|
|
40
|
+
- **Zależności** — `resolveLinks`, `linkGeometry`, `roundedPolyline`, `arrowHead`
|
|
41
|
+
- **Zakres** — `autoRange`, `resolveRange`
|
|
42
|
+
- **Typy** — `KptRoadmapConfig` i wszystko, do czego się odwołuje
|
|
43
|
+
|
|
44
|
+
Pełny opis: [`llms.txt`](./llms.txt) (EN) i [`llms-pl.txt`](./llms-pl.txt) (PL).
|
|
45
|
+
|
|
46
|
+
## Testy
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm --filter @konce-pt/roadmap exec node --import ../../scripts/test-node-setup.mjs --test "src/**/*.test.ts"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Licencja
|
|
53
|
+
|
|
54
|
+
MIT © [konce.pt](https://konce.pt)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { KptRoadmapConfig, KptRoadmapItem, KptRoadmapNormalizedItem, KptRoadmapZoom, KptTimeRange } from './types.ts';
|
|
2
|
+
export interface KptAutoRangeOptions {
|
|
3
|
+
zoom: KptRoadmapZoom;
|
|
4
|
+
/** Margines po obu stronach w jednostkach zoomu. */
|
|
5
|
+
padUnits?: number;
|
|
6
|
+
/** Minimalna szerokość osi w jednostkach zoomu. */
|
|
7
|
+
minUnits?: number;
|
|
8
|
+
/** Czy zakres ma zawsze obejmować „dziś". Domyślnie true. */
|
|
9
|
+
includeToday?: boolean;
|
|
10
|
+
today?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Zakres osi wyliczony z danych: min(start)…max(end) powiększony o margines
|
|
14
|
+
* i wyrównany do granic jednostki zoomu.
|
|
15
|
+
* Dla pustej listy zwraca okno wokół „dziś" o szerokości `minUnits`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function autoRange(items: readonly KptRoadmapItem[], opts: KptAutoRangeOptions): KptTimeRange;
|
|
18
|
+
/**
|
|
19
|
+
* Ostateczny zakres osi. `config.range` ma priorytet, a brakujący kraniec
|
|
20
|
+
* uzupełniamy z `autoRange`. Wynik zawsze leży na granicach jednostki zoomu.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveRange(config: Pick<KptRoadmapConfig, 'range' | 'zoom' | 'today' | 'timeline'>, items: readonly (KptRoadmapItem | KptRoadmapNormalizedItem)[]): KptTimeRange;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Wyznaczanie zakresu osi czasu z danych.
|
|
3
|
+
*
|
|
4
|
+
* Krańce zawsze wyrównujemy na zewnątrz do granicy jednostki zoomu, żeby
|
|
5
|
+
* pierwsza i ostatnia kolumna nie były ucięte w połowie.
|
|
6
|
+
*/
|
|
7
|
+
import { addUnits, ceilToUnit, countUnits, floorToUnit, toTimestamp } from "./calendar.js";
|
|
8
|
+
import { extent } from "./scale.js";
|
|
9
|
+
/** Margines po obu stronach danych, w jednostkach zoomu. */
|
|
10
|
+
const DEFAULT_PAD = {
|
|
11
|
+
hour: 2,
|
|
12
|
+
day: 2,
|
|
13
|
+
week: 1,
|
|
14
|
+
month: 1,
|
|
15
|
+
quarter: 1,
|
|
16
|
+
year: 0,
|
|
17
|
+
};
|
|
18
|
+
/** Minimalna szerokość osi, w jednostkach zoomu — żeby jeden item nie dał jednej kolumny. */
|
|
19
|
+
const DEFAULT_MIN = {
|
|
20
|
+
hour: 12,
|
|
21
|
+
day: 14,
|
|
22
|
+
week: 8,
|
|
23
|
+
month: 6,
|
|
24
|
+
quarter: 4,
|
|
25
|
+
year: 3,
|
|
26
|
+
};
|
|
27
|
+
/** Zbiera skrajne wartości czasu z itemów, pomijając wpisy z niepoprawną datą. */
|
|
28
|
+
function collectBounds(items) {
|
|
29
|
+
const bounds = [];
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
const start = toTimestamp(item.start);
|
|
32
|
+
if (!Number.isFinite(start))
|
|
33
|
+
continue;
|
|
34
|
+
const end = item.end !== undefined ? toTimestamp(item.end) : start;
|
|
35
|
+
bounds.push(start, Number.isFinite(end) ? end : start);
|
|
36
|
+
}
|
|
37
|
+
return bounds;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Zakres osi wyliczony z danych: min(start)…max(end) powiększony o margines
|
|
41
|
+
* i wyrównany do granic jednostki zoomu.
|
|
42
|
+
* Dla pustej listy zwraca okno wokół „dziś" o szerokości `minUnits`.
|
|
43
|
+
*/
|
|
44
|
+
export function autoRange(items, opts) {
|
|
45
|
+
const { zoom } = opts;
|
|
46
|
+
const pad = opts.padUnits ?? DEFAULT_PAD[zoom];
|
|
47
|
+
const minUnits = opts.minUnits ?? DEFAULT_MIN[zoom];
|
|
48
|
+
const todayTs = opts.today ?? Date.now();
|
|
49
|
+
const bounds = collectBounds(items);
|
|
50
|
+
if ((opts.includeToday ?? true) && bounds.length > 0)
|
|
51
|
+
bounds.push(todayTs);
|
|
52
|
+
const [min, max] = bounds.length > 0 ? extent(bounds) : [todayTs, todayTs];
|
|
53
|
+
// Margines dodajemy po polach daty (DST-safe), nie przez arytmetykę na ms.
|
|
54
|
+
let start = floorToUnit(addUnits(new Date(min), zoom, -pad), zoom);
|
|
55
|
+
let end = ceilToUnit(addUnits(new Date(max), zoom, pad), zoom);
|
|
56
|
+
// Dociągnięcie do minimalnej szerokości — symetrycznie, zaczynając od prawej.
|
|
57
|
+
let units = countUnits(start, end, zoom);
|
|
58
|
+
while (units < minUnits) {
|
|
59
|
+
end = addUnits(end, zoom, 1);
|
|
60
|
+
units++;
|
|
61
|
+
if (units < minUnits) {
|
|
62
|
+
start = addUnits(start, zoom, -1);
|
|
63
|
+
units++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { start: start.getTime(), end: end.getTime() };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ostateczny zakres osi. `config.range` ma priorytet, a brakujący kraniec
|
|
70
|
+
* uzupełniamy z `autoRange`. Wynik zawsze leży na granicach jednostki zoomu.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveRange(config, items) {
|
|
73
|
+
const zoom = config.zoom ?? 'week';
|
|
74
|
+
const todaySource = config.today ?? config.timeline?.today;
|
|
75
|
+
const today = todaySource !== undefined ? toTimestamp(todaySource) : Date.now();
|
|
76
|
+
// `KptRoadmapNormalizedItem` niesie oryginał w `source` — sprowadzamy oba kształty
|
|
77
|
+
// do postaci akceptowanej przez `autoRange`.
|
|
78
|
+
const raw = items.map((item) => ('source' in item ? item.source : item));
|
|
79
|
+
const auto = autoRange(raw, { zoom, today });
|
|
80
|
+
const startTs = config.range?.start ?? auto.start;
|
|
81
|
+
const endTs = config.range?.end ?? auto.end;
|
|
82
|
+
const start = floorToUnit(new Date(startTs), zoom);
|
|
83
|
+
let end = ceilToUnit(new Date(endTs), zoom);
|
|
84
|
+
// Zakres musi obejmować co najmniej jedną kolumnę.
|
|
85
|
+
if (end.getTime() <= start.getTime())
|
|
86
|
+
end = addUnits(start, zoom, 1);
|
|
87
|
+
return { start: start.getTime(), end: end.getTime() };
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { KptDateInput, KptRoadmapZoom } from './types.ts';
|
|
2
|
+
/** Normalizuje dowolne wejście daty do epoch ms. Zwraca NaN dla wartości nieparsowalnych. */
|
|
3
|
+
export declare function toTimestamp(value: KptDateInput): number;
|
|
4
|
+
/**
|
|
5
|
+
* Początek lokalnej doby.
|
|
6
|
+
*
|
|
7
|
+
* Guard: w strefach, gdzie przejście DST wypada o północy (np. America/Santiago),
|
|
8
|
+
* `setHours(0, …)` potrafi wylądować w dobie poprzedniej — wtedy korygujemy
|
|
9
|
+
* o jedną dobę w przód.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startOfDay(date: Date): Date;
|
|
12
|
+
/** Dodaje `n` dób kalendarzowych, zachowując godzinę ścienną (DST-safe). */
|
|
13
|
+
export declare function addDays(date: Date, n: number): Date;
|
|
14
|
+
/** Dodaje `n` godzin ściennych. Godzina nieistniejąca (wiosna) normalizuje się w przód. */
|
|
15
|
+
export declare function addHours(date: Date, n: number): Date;
|
|
16
|
+
/** Liczba dni w miesiącu (`month` 0-indeksowany). */
|
|
17
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
18
|
+
/** Dodaje `n` miesięcy z przycięciem dnia (31 stycznia + 1 miesiąc = 28/29 lutego). */
|
|
19
|
+
export declare function addMonths(date: Date, n: number): Date;
|
|
20
|
+
/**
|
|
21
|
+
* Różnica w pełnych dobach kalendarzowych.
|
|
22
|
+
* `Math.round` jest konieczny: doba DST ma 23 h (0.958) albo 25 h (1.042),
|
|
23
|
+
* więc `Math.floor` gubiłby jeden dzień dwa razy w roku.
|
|
24
|
+
*/
|
|
25
|
+
export declare function diffDays(a: Date, b: Date): number;
|
|
26
|
+
/** Czy obie daty wypadają w tej samej lokalnej dobie? */
|
|
27
|
+
export declare function isSameDay(a: Date, b: Date): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Dzień tygodnia wg ISO 8601: 1 = poniedziałek … 7 = niedziela.
|
|
30
|
+
* `Date#getDay()` zwraca 0 dla niedzieli, stąd przesunięcie.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isoWeekday(date: Date): number;
|
|
33
|
+
/** Poniedziałek tygodnia ISO zawierającego `date` (początek doby). */
|
|
34
|
+
export declare function startOfIsoWeek(date: Date): Date;
|
|
35
|
+
/**
|
|
36
|
+
* Rok tygodnia ISO 8601 — bywa różny od `getFullYear()` na przełomie roku.
|
|
37
|
+
* Przykłady: 2025-12-29 → 2026 (tydzień 1), 2027-01-03 → 2026 (tydzień 53).
|
|
38
|
+
*/
|
|
39
|
+
export declare function isoWeekYear(date: Date): number;
|
|
40
|
+
/**
|
|
41
|
+
* Numer tygodnia ISO 8601 (1..53).
|
|
42
|
+
* Tydzień zaczyna się w poniedziałek, a tydzień 1 to ten, który zawiera pierwszy
|
|
43
|
+
* czwartek roku (równoważnie: zawiera 4 stycznia).
|
|
44
|
+
*
|
|
45
|
+
* `Math.round` jest tu krytyczny: przy DST różnica dwóch północy to 7 dni ±1 h
|
|
46
|
+
* (6.994 / 7.006), więc `Math.floor` zaniżałby numer tygodnia przez pół roku.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isoWeekNumber(date: Date): number;
|
|
49
|
+
/** Liczba tygodni ISO w danym roku tygodniowym (52 albo 53). */
|
|
50
|
+
export declare function isoWeeksInYear(weekYear: number): number;
|
|
51
|
+
/** Początek jednostki zoomu zawierającej `date` (w dół). */
|
|
52
|
+
export declare function floorToUnit(date: Date, zoom: KptRoadmapZoom): Date;
|
|
53
|
+
/** Dodaje `n` jednostek zoomu (DST-safe — po polach daty, nie po ms). */
|
|
54
|
+
export declare function addUnits(date: Date, zoom: KptRoadmapZoom, n: number): Date;
|
|
55
|
+
/** Początek następnej jednostki zoomu; bez zmian, gdy `date` już leży na granicy. */
|
|
56
|
+
export declare function ceilToUnit(date: Date, zoom: KptRoadmapZoom): Date;
|
|
57
|
+
/**
|
|
58
|
+
* Liczba pełnych jednostek zoomu między dwiema granicami (DST-safe).
|
|
59
|
+
* Dla 'hour' liczymy wprost po ms — doba DST po prostu ma 23 albo 25 godzin
|
|
60
|
+
* i tyle właśnie powinno powstać kolumn.
|
|
61
|
+
*/
|
|
62
|
+
export declare function countUnits(start: Date, end: Date, zoom: KptRoadmapZoom): number;
|
|
63
|
+
/** Numer kwartału (1..4) dla danej daty. */
|
|
64
|
+
export declare function quarterOf(date: Date): number;
|
package/dist/calendar.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Arytmetyka kalendarzowa w czasie LOKALNYM, odporna na zmianę czasu (DST).
|
|
3
|
+
*
|
|
4
|
+
* Roadmapa jest narzędziem planistycznym — użytkownik myśli zegarem ściennym,
|
|
5
|
+
* więc wszystkie obliczenia idą po lokalnych polach daty.
|
|
6
|
+
*
|
|
7
|
+
* ZASADA: nigdy nie dodajemy milisekund. `t + 86_400_000` na 2026-03-29 00:00 CET
|
|
8
|
+
* daje 2026-03-30 01:00 (dryf kumulatywny), a na 2026-10-25 daje tę samą datę
|
|
9
|
+
* dwa razy. Zamiast tego iterujemy po polach `Date` (`setDate`, `setHours`),
|
|
10
|
+
* bo one normalizują wynik do lokalnego zegara ściennego.
|
|
11
|
+
*
|
|
12
|
+
* Wszystkie różnice dni/tygodni liczymy przez `Math.round`, nigdy `floor`/`ceil`:
|
|
13
|
+
* doba DST to 0.958 albo 1.042 doby, a `round` te odchyłki pochłania.
|
|
14
|
+
*/
|
|
15
|
+
const MS_DAY = 86_400_000;
|
|
16
|
+
const MS_WEEK = 604_800_000;
|
|
17
|
+
const MS_HOUR = 3_600_000;
|
|
18
|
+
/** Normalizuje dowolne wejście daty do epoch ms. Zwraca NaN dla wartości nieparsowalnych. */
|
|
19
|
+
export function toTimestamp(value) {
|
|
20
|
+
if (typeof value === 'number')
|
|
21
|
+
return value;
|
|
22
|
+
if (value instanceof Date)
|
|
23
|
+
return value.getTime();
|
|
24
|
+
return new Date(value).getTime();
|
|
25
|
+
}
|
|
26
|
+
/** Kopia obiektu Date — nigdy nie mutujemy argumentów. */
|
|
27
|
+
function clone(date) {
|
|
28
|
+
return new Date(date);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Początek lokalnej doby.
|
|
32
|
+
*
|
|
33
|
+
* Guard: w strefach, gdzie przejście DST wypada o północy (np. America/Santiago),
|
|
34
|
+
* `setHours(0, …)` potrafi wylądować w dobie poprzedniej — wtedy korygujemy
|
|
35
|
+
* o jedną dobę w przód.
|
|
36
|
+
*/
|
|
37
|
+
export function startOfDay(date) {
|
|
38
|
+
const result = clone(date);
|
|
39
|
+
result.setHours(0, 0, 0, 0);
|
|
40
|
+
if (result.getDate() !== date.getDate() || result.getMonth() !== date.getMonth()) {
|
|
41
|
+
result.setTime(date.getTime());
|
|
42
|
+
result.setHours(0, 0, 0, 0);
|
|
43
|
+
if (result.getTime() < date.getTime() - MS_DAY)
|
|
44
|
+
result.setDate(result.getDate() + 1);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/** Dodaje `n` dób kalendarzowych, zachowując godzinę ścienną (DST-safe). */
|
|
49
|
+
export function addDays(date, n) {
|
|
50
|
+
const result = clone(date);
|
|
51
|
+
result.setDate(result.getDate() + n);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/** Dodaje `n` godzin ściennych. Godzina nieistniejąca (wiosna) normalizuje się w przód. */
|
|
55
|
+
export function addHours(date, n) {
|
|
56
|
+
const result = clone(date);
|
|
57
|
+
result.setHours(result.getHours() + n);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/** Liczba dni w miesiącu (`month` 0-indeksowany). */
|
|
61
|
+
export function daysInMonth(year, month) {
|
|
62
|
+
return new Date(year, month + 1, 0).getDate();
|
|
63
|
+
}
|
|
64
|
+
/** Dodaje `n` miesięcy z przycięciem dnia (31 stycznia + 1 miesiąc = 28/29 lutego). */
|
|
65
|
+
export function addMonths(date, n) {
|
|
66
|
+
const result = clone(date);
|
|
67
|
+
const day = result.getDate();
|
|
68
|
+
result.setDate(1);
|
|
69
|
+
result.setMonth(result.getMonth() + n);
|
|
70
|
+
result.setDate(Math.min(day, daysInMonth(result.getFullYear(), result.getMonth())));
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Różnica w pełnych dobach kalendarzowych.
|
|
75
|
+
* `Math.round` jest konieczny: doba DST ma 23 h (0.958) albo 25 h (1.042),
|
|
76
|
+
* więc `Math.floor` gubiłby jeden dzień dwa razy w roku.
|
|
77
|
+
*/
|
|
78
|
+
export function diffDays(a, b) {
|
|
79
|
+
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_DAY);
|
|
80
|
+
}
|
|
81
|
+
/** Czy obie daty wypadają w tej samej lokalnej dobie? */
|
|
82
|
+
export function isSameDay(a, b) {
|
|
83
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
84
|
+
}
|
|
85
|
+
// ──────────────────────────────────────────────────────────────────── ISO 8601
|
|
86
|
+
/**
|
|
87
|
+
* Dzień tygodnia wg ISO 8601: 1 = poniedziałek … 7 = niedziela.
|
|
88
|
+
* `Date#getDay()` zwraca 0 dla niedzieli, stąd przesunięcie.
|
|
89
|
+
*/
|
|
90
|
+
export function isoWeekday(date) {
|
|
91
|
+
return ((date.getDay() + 6) % 7) + 1;
|
|
92
|
+
}
|
|
93
|
+
/** Poniedziałek tygodnia ISO zawierającego `date` (początek doby). */
|
|
94
|
+
export function startOfIsoWeek(date) {
|
|
95
|
+
const day = startOfDay(date);
|
|
96
|
+
return startOfDay(addDays(day, -(isoWeekday(day) - 1)));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Czwartek tygodnia ISO zawierającego `date`.
|
|
100
|
+
*
|
|
101
|
+
* Czwartek jest kotwicą ISO: rok czwartka to rok tygodnia, a odległość między
|
|
102
|
+
* czwartkami dwóch tygodni to zawsze dokładnie N tygodni.
|
|
103
|
+
*/
|
|
104
|
+
function isoThursday(date) {
|
|
105
|
+
const day = startOfDay(date);
|
|
106
|
+
return startOfDay(addDays(day, 4 - isoWeekday(day)));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Rok tygodnia ISO 8601 — bywa różny od `getFullYear()` na przełomie roku.
|
|
110
|
+
* Przykłady: 2025-12-29 → 2026 (tydzień 1), 2027-01-03 → 2026 (tydzień 53).
|
|
111
|
+
*/
|
|
112
|
+
export function isoWeekYear(date) {
|
|
113
|
+
return isoThursday(date).getFullYear();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Numer tygodnia ISO 8601 (1..53).
|
|
117
|
+
* Tydzień zaczyna się w poniedziałek, a tydzień 1 to ten, który zawiera pierwszy
|
|
118
|
+
* czwartek roku (równoważnie: zawiera 4 stycznia).
|
|
119
|
+
*
|
|
120
|
+
* `Math.round` jest tu krytyczny: przy DST różnica dwóch północy to 7 dni ±1 h
|
|
121
|
+
* (6.994 / 7.006), więc `Math.floor` zaniżałby numer tygodnia przez pół roku.
|
|
122
|
+
*/
|
|
123
|
+
export function isoWeekNumber(date) {
|
|
124
|
+
const thursday = isoThursday(date);
|
|
125
|
+
const jan4 = new Date(thursday.getFullYear(), 0, 4);
|
|
126
|
+
const firstThursday = isoThursday(jan4);
|
|
127
|
+
return 1 + Math.round((thursday.getTime() - firstThursday.getTime()) / MS_WEEK);
|
|
128
|
+
}
|
|
129
|
+
/** Liczba tygodni ISO w danym roku tygodniowym (52 albo 53). */
|
|
130
|
+
export function isoWeeksInYear(weekYear) {
|
|
131
|
+
// 28 grudnia zawsze leży w ostatnim tygodniu ISO danego roku.
|
|
132
|
+
return isoWeekNumber(new Date(weekYear, 11, 28));
|
|
133
|
+
}
|
|
134
|
+
// ─────────────────────────────────────────────────── zaokrąglanie do jednostki
|
|
135
|
+
/** Początek jednostki zoomu zawierającej `date` (w dół). */
|
|
136
|
+
export function floorToUnit(date, zoom) {
|
|
137
|
+
const result = clone(date);
|
|
138
|
+
switch (zoom) {
|
|
139
|
+
case 'hour':
|
|
140
|
+
result.setMinutes(0, 0, 0);
|
|
141
|
+
return result;
|
|
142
|
+
case 'day':
|
|
143
|
+
return startOfDay(result);
|
|
144
|
+
case 'week':
|
|
145
|
+
return startOfIsoWeek(result);
|
|
146
|
+
case 'month':
|
|
147
|
+
result.setDate(1);
|
|
148
|
+
return startOfDay(result);
|
|
149
|
+
case 'quarter':
|
|
150
|
+
result.setMonth(Math.floor(result.getMonth() / 3) * 3, 1);
|
|
151
|
+
return startOfDay(result);
|
|
152
|
+
case 'year':
|
|
153
|
+
result.setMonth(0, 1);
|
|
154
|
+
return startOfDay(result);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Dodaje `n` jednostek zoomu (DST-safe — po polach daty, nie po ms). */
|
|
158
|
+
export function addUnits(date, zoom, n) {
|
|
159
|
+
switch (zoom) {
|
|
160
|
+
case 'hour':
|
|
161
|
+
return addHours(date, n);
|
|
162
|
+
case 'day':
|
|
163
|
+
return addDays(date, n);
|
|
164
|
+
case 'week':
|
|
165
|
+
return addDays(date, n * 7);
|
|
166
|
+
case 'month':
|
|
167
|
+
return addMonths(date, n);
|
|
168
|
+
case 'quarter':
|
|
169
|
+
return addMonths(date, n * 3);
|
|
170
|
+
case 'year':
|
|
171
|
+
return addMonths(date, n * 12);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Początek następnej jednostki zoomu; bez zmian, gdy `date` już leży na granicy. */
|
|
175
|
+
export function ceilToUnit(date, zoom) {
|
|
176
|
+
const floored = floorToUnit(date, zoom);
|
|
177
|
+
if (floored.getTime() === date.getTime())
|
|
178
|
+
return floored;
|
|
179
|
+
return addUnits(floored, zoom, 1);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Liczba pełnych jednostek zoomu między dwiema granicami (DST-safe).
|
|
183
|
+
* Dla 'hour' liczymy wprost po ms — doba DST po prostu ma 23 albo 25 godzin
|
|
184
|
+
* i tyle właśnie powinno powstać kolumn.
|
|
185
|
+
*/
|
|
186
|
+
export function countUnits(start, end, zoom) {
|
|
187
|
+
switch (zoom) {
|
|
188
|
+
case 'hour':
|
|
189
|
+
return Math.round((end.getTime() - start.getTime()) / MS_HOUR);
|
|
190
|
+
case 'day':
|
|
191
|
+
return diffDays(start, end);
|
|
192
|
+
case 'week':
|
|
193
|
+
return Math.round(diffDays(start, end) / 7);
|
|
194
|
+
case 'month':
|
|
195
|
+
return (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth());
|
|
196
|
+
case 'quarter':
|
|
197
|
+
return Math.round(((end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth())) / 3);
|
|
198
|
+
case 'year':
|
|
199
|
+
return end.getFullYear() - start.getFullYear();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Numer kwartału (1..4) dla danej daty. */
|
|
203
|
+
export function quarterOf(date) {
|
|
204
|
+
return Math.floor(date.getMonth() / 3) + 1;
|
|
205
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* Rdzeń roadmapy (pure TS) — barrel eksportów. */
|
|
2
|
+
export * from "./types.js";
|
|
3
|
+
export * from "./scale.js";
|
|
4
|
+
export * from "./calendar.js";
|
|
5
|
+
export * from "./timeline.js";
|
|
6
|
+
export * from "./layout.js";
|
|
7
|
+
export * from "./links.js";
|
|
8
|
+
export * from "./auto-range.js";
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { KptRoadmapGroup, KptRoadmapItem, KptRoadmapNormalizedItem, KptRoadmapRowOptions, KptTimeRange } from './types.ts';
|
|
2
|
+
export type KptRoadmapRowKind = 'group' | 'item';
|
|
3
|
+
/** Item osadzony w konkretnym wierszu i pasie. */
|
|
4
|
+
export interface KptRoadmapPlacedItem<TMeta = unknown> extends KptRoadmapNormalizedItem<TMeta> {
|
|
5
|
+
/** Indeks pasa w obrębie wiersza (0 przy trybie 'row-per-item'). */
|
|
6
|
+
lane: number;
|
|
7
|
+
rowKey: string;
|
|
8
|
+
}
|
|
9
|
+
/** Wyliczony agregat grupy. */
|
|
10
|
+
export interface KptRoadmapGroupState {
|
|
11
|
+
collapsed: boolean;
|
|
12
|
+
/** Liczba itemów należących do grupy. */
|
|
13
|
+
itemCount: number;
|
|
14
|
+
/** Zakres zbiorczy (null, gdy grupa pusta). */
|
|
15
|
+
span: KptTimeRange | null;
|
|
16
|
+
/** Postęp ważony czasem trwania, 0..1 (null, gdy żaden item go nie podaje). */
|
|
17
|
+
progress: number | null;
|
|
18
|
+
}
|
|
19
|
+
/** Wiersz renderowalny — jednostka wirtualizacji. */
|
|
20
|
+
export interface KptRoadmapRow<TItem = unknown, TGroup = unknown> {
|
|
21
|
+
key: string;
|
|
22
|
+
kind: KptRoadmapRowKind;
|
|
23
|
+
/** Indeks w spłaszczonej liście widocznych wierszy. */
|
|
24
|
+
index: number;
|
|
25
|
+
/** Poziom zagnieżdżenia — wcięcie w kolumnie etykiet. */
|
|
26
|
+
depth: number;
|
|
27
|
+
/** Offset od góry w px (skumulowany). */
|
|
28
|
+
top: number;
|
|
29
|
+
height: number;
|
|
30
|
+
/** Liczba pasów (>1 tylko w trybie 'pack'). */
|
|
31
|
+
laneCount: number;
|
|
32
|
+
/** Wypełnione dla `kind === 'group'`. */
|
|
33
|
+
group?: KptRoadmapGroup<TGroup>;
|
|
34
|
+
groupState?: KptRoadmapGroupState;
|
|
35
|
+
/** Wypełnione dla `kind === 'item'`. */
|
|
36
|
+
items: readonly KptRoadmapPlacedItem<TItem>[];
|
|
37
|
+
}
|
|
38
|
+
/** Wpis odrzucony przy normalizacji — do ostrzeżeń w trybie deweloperskim. */
|
|
39
|
+
export interface KptRoadmapInvalidItem {
|
|
40
|
+
id: string;
|
|
41
|
+
reason: string;
|
|
42
|
+
}
|
|
43
|
+
export interface KptRoadmapLayout<TItem = unknown, TGroup = unknown> {
|
|
44
|
+
rows: readonly KptRoadmapRow<TItem, TGroup>[];
|
|
45
|
+
/** Łączna wysokość w px. */
|
|
46
|
+
totalHeight: number;
|
|
47
|
+
/** Wszystkie znormalizowane itemy po `id` — także te w zwiniętych grupach. */
|
|
48
|
+
byId: ReadonlyMap<string, KptRoadmapNormalizedItem<TItem>>;
|
|
49
|
+
/** Mapa `itemId → rowKey` dla itemów widocznych. */
|
|
50
|
+
rowOfItem: ReadonlyMap<string, string>;
|
|
51
|
+
/** Itemy odrzucone (zła data, duplikat `id`). */
|
|
52
|
+
invalid: readonly KptRoadmapInvalidItem[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Normalizuje itemy: daty na ms, brak `end` oznacza kamień milowy.
|
|
56
|
+
* Odrzuca wpisy z nieparsowalną datą oraz duplikaty `id`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function normalizeItems<T>(items: readonly KptRoadmapItem<T>[]): {
|
|
59
|
+
valid: KptRoadmapNormalizedItem<T>[];
|
|
60
|
+
invalid: KptRoadmapInvalidItem[];
|
|
61
|
+
};
|
|
62
|
+
/** Zakres zbiorczy listy itemów: min(start)…max(end). */
|
|
63
|
+
export declare function spanOf(items: readonly KptRoadmapNormalizedItem[]): KptTimeRange | null;
|
|
64
|
+
/**
|
|
65
|
+
* Postęp grupy ważony czasem trwania.
|
|
66
|
+
* Kamienie milowe mają zerową długość, więc liczymy je z wagą 1.
|
|
67
|
+
*/
|
|
68
|
+
export declare function rollupProgress(items: readonly KptRoadmapNormalizedItem[]): number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Zachłanne przydzielanie pasów (interval partitioning).
|
|
71
|
+
* Itemy sortujemy po czasie startu i wkładamy do pierwszego pasa, w którym
|
|
72
|
+
* poprzednie zadanie zdążyło się skończyć. Zwraca liczbę użytych pasów.
|
|
73
|
+
*/
|
|
74
|
+
export declare function packLanes(items: KptRoadmapPlacedItem[], minGapMs?: number): number;
|
|
75
|
+
/**
|
|
76
|
+
* Spłaszcza grupy i itemy do listy wierszy renderowalnych.
|
|
77
|
+
*
|
|
78
|
+
* Kolejność: grupy w kolejności podanej przez użytkownika, wewnątrz itemy
|
|
79
|
+
* posortowane po czasie startu, na końcu itemy bez `groupId`. Zwinięta grupa
|
|
80
|
+
* nie emituje wierszy potomnych, ale jej zakres zbiorczy nadal obejmuje dzieci.
|
|
81
|
+
*
|
|
82
|
+
* @param collapsed zbiór `id` grup zwiniętych (stan trzymany w sygnale komponentu)
|
|
83
|
+
*/
|
|
84
|
+
export declare function buildRows<TItem, TGroup>(items: readonly KptRoadmapItem<TItem>[], groups: readonly KptRoadmapGroup<TGroup>[] | undefined, collapsed: ReadonlySet<string>, opts?: KptRoadmapRowOptions): KptRoadmapLayout<TItem, TGroup>;
|
|
85
|
+
/**
|
|
86
|
+
* Wycinek wierszy widocznych w oknie przewijania.
|
|
87
|
+
* Szuka binarnie po skumulowanym `top`, więc działa także dla wierszy
|
|
88
|
+
* o różnej wysokości (tryb 'pack').
|
|
89
|
+
*/
|
|
90
|
+
export declare function sliceRows<TItem, TGroup>(layout: KptRoadmapLayout<TItem, TGroup>, scrollTop: number, viewportHeight: number, buffer?: number): {
|
|
91
|
+
rows: readonly KptRoadmapRow<TItem, TGroup>[];
|
|
92
|
+
startIndex: number;
|
|
93
|
+
endIndex: number;
|
|
94
|
+
};
|