@bigplay/tree-view-core 0.3.1 → 0.5.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/dist/types.d.ts CHANGED
@@ -4,8 +4,15 @@
4
4
  */
5
5
  /** Куда растёт дерево: корень сверху / снизу / слева / справа. */
6
6
  export type Direction = 'top-to-bottom' | 'bottom-to-top' | 'left-to-right' | 'right-to-left';
7
- /** Форма соединительной линии между родителем и ребёнком. */
8
- export type LinkStyle = 'straight' | 'elbow' | 'curve';
7
+ /**
8
+ * Форма соединительной линии между родителем и ребёнком.
9
+ *
10
+ * `'bracket'` — как в турнирной сетке: дети одного родителя сходятся в одну
11
+ * вертикаль, и уже от её середины линия идёт к родителю. Отличается от
12
+ * `'elbow'` только там, где родитель стоит не по центру своих детей —
13
+ * например при `levelLayout: 'stack'`.
14
+ */
15
+ export type LinkStyle = 'straight' | 'elbow' | 'curve' | 'bracket';
9
16
  /**
10
17
  * Размер карточки: одно число на все узлы или функция от узла.
11
18
  *
@@ -13,6 +20,28 @@ export type LinkStyle = 'straight' | 'elbow' | 'curve';
13
20
  * у финала одна строка, а у остальных матчей две.
14
21
  */
15
22
  export type NodeSize<T> = number | ((node: TreeNode<T>) => number);
23
+ /**
24
+ * Расстояние между соседями: одно число или функция от узла и того,
25
+ * сколько всего узлов на его уровне.
26
+ *
27
+ * Счётчик нужен, когда отступ зависит от плотности: в раунде на восемь матчей
28
+ * их стоит развести подальше, а в полуфинале — свести поближе.
29
+ */
30
+ export type GapSize<T> = number | ((node: TreeNode<T>, levelCount: number) => number);
31
+ /**
32
+ * Как расставлять узлы поперёк уровня:
33
+ * - `'tree'` (по умолчанию) — родитель по центру своих детей, как в турнирной сетке;
34
+ * - `'stack'` — каждый уровень укладывается сам по себе, узел за узлом от начала.
35
+ * Нужен, когда уровни показываются по одному и их высота важнее, чем то,
36
+ * насколько ровно родитель сидит между детьми.
37
+ */
38
+ export type LevelLayout = 'tree' | 'stack';
39
+ /**
40
+ * Куда прижимать уровень в раскладке `'stack'`:
41
+ * - `'start'` (по умолчанию) — к началу полосы, все уровни начинаются с одной черты;
42
+ * - `'center'` — по центру самого населённого уровня.
43
+ */
44
+ export type LevelAlign = 'start' | 'center';
16
45
  /** Настройки раскладки. Все размеры — в пикселях. */
17
46
  export interface TreeViewOptions<T = any> {
18
47
  /** Ширина карточки узла. */
@@ -22,11 +51,34 @@ export interface TreeViewOptions<T = any> {
22
51
  /** Пустое место между уровнями (между родителем и детьми). */
23
52
  levelGap: number;
24
53
  /** Пустое место между соседними узлами одного уровня. */
25
- siblingGap: number;
54
+ siblingGap: GapSize<T>;
26
55
  /** Направление роста дерева. */
27
56
  direction: Direction;
28
57
  /** Форма линий. */
29
58
  linkStyle: LinkStyle;
59
+ /** Как расставлять узлы поперёк уровня. По умолчанию — `'tree'`. */
60
+ levelLayout?: LevelLayout;
61
+ /** Куда прижимать уровень при `levelLayout: 'stack'`. По умолчанию — `'start'`. */
62
+ levelAlign?: LevelAlign;
63
+ /**
64
+ * Пустое место между соседями с РАЗНЫМИ родителями — только в `'stack'`.
65
+ * Не задан — берётся `siblingGap`, и уровень идёт ровным шагом.
66
+ *
67
+ * Нужен, чтобы читалась структура: в турнирной сетке два матча сходятся
68
+ * в один следующий, и такую пару видно, только если внутри неё теснее,
69
+ * чем между парами.
70
+ */
71
+ groupGap?: GapSize<T>;
72
+ /**
73
+ * На каком расстоянии от карточки ребёнка проходит вертикальная часть
74
+ * «ступеньки» (`linkStyle: 'elbow'`). Не задан — ровно посередине между
75
+ * уровнями.
76
+ *
77
+ * Нужен, когда промежуток между уровнями большой: колено уезжает далеко
78
+ * вправо, и вместо скобки, объединяющей пару, у каждой карточки получается
79
+ * свой длинный хвост.
80
+ */
81
+ elbowOffset?: number;
30
82
  }
31
83
  /**
32
84
  * Узел дерева после нормализации входных данных.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigplay/tree-view-core",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Раскладка дерева: чистые функции без привязки к фреймворку",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/styles.css CHANGED
@@ -19,12 +19,13 @@
19
19
  width: 100%;
20
20
  height: 100%;
21
21
  overflow: hidden;
22
- /* Без этого мобильный браузер сам скроллит страницу вместо перетаскивания. */
23
- touch-action: none;
24
22
  }
25
23
 
26
24
  .tree-view--pannable {
27
25
  cursor: grab;
26
+ /* Без этого мобильный браузер сам скроллит страницу вместо перетаскивания.
27
+ Когда таскать нельзя — не мешаем: страницу и полосу раундов листают пальцем. */
28
+ touch-action: none;
28
29
  }
29
30
 
30
31
  /* size="content": размер задаётся инлайном, ничего не обрезаем. */
@@ -115,6 +116,9 @@
115
116
  --tv-bracket-my-bg: #00146c;
116
117
  --tv-bracket-my-color: #ffffff;
117
118
  --tv-bracket-radius: 6px;
119
+ --tv-bracket-shadow:
120
+ 0 0 0 1px rgb(15 23 42 / 8%),
121
+ 0 1px 3px rgb(15 23 42 / 10%);
118
122
 
119
123
  position: relative;
120
124
  box-sizing: border-box;
@@ -128,8 +132,21 @@
128
132
  font-size: 12px;
129
133
  }
130
134
 
131
- .tv-bracket--my-team {
135
+ /* Матч — один блок с собственным контуром: два соседних матча иначе
136
+ читаются как один список из четырёх команд. Обрезка по скруглению живёт
137
+ здесь, а не на карточке, — подсказку и кнопку сворачивания обрезать нельзя. */
138
+ .tv-bracket__rows {
139
+ box-sizing: border-box;
140
+ display: flex;
141
+ flex: 1 1 auto;
142
+ flex-direction: column;
143
+ min-height: 0;
144
+ overflow: hidden;
132
145
  border-radius: var(--tv-bracket-radius);
146
+ box-shadow: var(--tv-bracket-shadow);
147
+ }
148
+
149
+ .tv-bracket--my-team .tv-bracket__rows {
133
150
  outline: 2px solid var(--tv-bracket-my-bg);
134
151
  }
135
152
 
@@ -187,6 +204,43 @@
187
204
  background: var(--tv-bracket-loser-bg);
188
205
  }
189
206
 
207
+ /* Кнопка «свернуть предыдущие матчи»: висит на краю карточки, с той стороны,
208
+ откуда приходят линии от детей. Кружок нарочно маленький — карточка узкая. */
209
+ .tv-bracket__toggle {
210
+ position: absolute;
211
+ top: 50%;
212
+ left: -11px;
213
+ z-index: 1;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ width: 22px;
218
+ height: 22px;
219
+ padding: 0;
220
+ font: inherit;
221
+ font-size: 13px;
222
+ font-weight: 700;
223
+ line-height: 1;
224
+ color: var(--tv-bracket-winner-bg);
225
+ cursor: pointer;
226
+ background: #ffffff;
227
+ border: 1px solid #d1d5db;
228
+ border-radius: 50%;
229
+ box-shadow: 0 1px 3px rgb(15 23 42 / 18%);
230
+ transform: translateY(-50%);
231
+ }
232
+
233
+ .tv-bracket__toggle:hover {
234
+ border-color: var(--tv-bracket-winner-bg);
235
+ }
236
+
237
+ /* Свёрнутый матч виден сразу: кнопка залита, а не пустая. */
238
+ .tv-bracket__toggle--collapsed {
239
+ color: var(--tv-bracket-winner-color);
240
+ background: var(--tv-bracket-winner-bg);
241
+ border-color: var(--tv-bracket-winner-bg);
242
+ }
243
+
190
244
  .tv-bracket__name {
191
245
  max-width: 120px;
192
246
  overflow: hidden;
@@ -232,6 +286,14 @@
232
286
  --tv-rounds-color: #9ca3af;
233
287
  --tv-rounds-border: 2px solid #e5e7eb;
234
288
  --tv-rounds-gap: 20px;
289
+ --tv-rounds-fade: 220ms;
290
+ /* Как матч складывается, уходя в предыдущий раунд. */
291
+ --tv-rounds-collapse-scale: 0.86;
292
+ --tv-rounds-collapse-ease: cubic-bezier(0.4, 0, 0.2, 1);
293
+ /* В свайпере линия — единственная подсказка, что матч идёт дальше,
294
+ поэтому она заметнее, чем на широком экране. */
295
+ --tv-rounds-link-color: #94a3b8;
296
+ --tv-rounds-link-width: 3px;
235
297
 
236
298
  display: flex;
237
299
  flex-direction: column;
@@ -264,3 +326,95 @@
264
326
  border: none;
265
327
  border-right: var(--tv-rounds-border);
266
328
  }
329
+
330
+ /* ── Свайпер: узкий экран ───────────────────────────────────────
331
+ * Раунд занимает всю ширину блока, а сама полоса становится
332
+ * прокручиваемой: шапка и сетка едут вместе, листаются пальцем и
333
+ * прилипают к границе раунда. Сетка при этом одна — линии между
334
+ * матчами никуда не деваются.
335
+ */
336
+
337
+ /* В свайпере матчи пары стоят вплотную, и подсказка над карточкой налезала бы
338
+ на соседнюю. Свою команду там и так видно по рамке. */
339
+ .tv-rounds--swipe .tv-bracket__hint {
340
+ display: none;
341
+ }
342
+
343
+ /* Линии в свайпере: единственное, что показывает, куда матч идёт дальше. */
344
+ .tv-rounds--swipe .tree-view {
345
+ --tv-link-color: var(--tv-rounds-link-color);
346
+ --tv-link-width: var(--tv-rounds-link-width);
347
+ }
348
+
349
+ .tv-rounds--swipe {
350
+ width: 100%;
351
+ min-width: 0;
352
+ overflow-x: auto;
353
+ /* Свайп внутри полосы не должен утягивать за собой страницу. */
354
+ overscroll-behavior-x: contain;
355
+ scroll-snap-type: x mandatory;
356
+ scrollbar-width: none;
357
+ }
358
+
359
+ .tv-rounds--swipe::-webkit-scrollbar {
360
+ display: none;
361
+ }
362
+
363
+ .tv-rounds--swipe .tv-rounds__item {
364
+ scroll-snap-align: start;
365
+ /* Резкий свайп не должен перепрыгивать через раунд: останавливаемся на каждом. */
366
+ scroll-snap-stop: always;
367
+ }
368
+
369
+ /* Пройденный раунд: убран с глаз, но не из раскладки — иначе прокрутка
370
+ и точки прилипания поехали бы прямо под пальцем.
371
+
372
+ Уходит не просто гаснув, а складываясь в сторону следующего раунда: матч
373
+ ужимается к своему правому краю — туда, откуда уходит линия к продолжению, —
374
+ и растворяется. При возврате назад разворачивается обратно.
375
+
376
+ Видимость снимается только после того, как карточка ужалась, — поэтому
377
+ у неё отдельная задержка. Селектор нарочно такой же длины, как у правила
378
+ ниже: иначе переход из него перебил бы этот и матч исчезал бы рывком. */
379
+ .tv-rounds--swipe [data-depth] {
380
+ transition:
381
+ opacity var(--tv-rounds-fade, 220ms) ease,
382
+ transform var(--tv-rounds-fade, 220ms) var(--tv-rounds-collapse-ease);
383
+ }
384
+
385
+ .tv-rounds--swipe [data-depth].tv-hidden {
386
+ visibility: hidden;
387
+ opacity: 0;
388
+ transition:
389
+ opacity var(--tv-rounds-fade, 220ms) ease,
390
+ transform var(--tv-rounds-fade, 220ms) var(--tv-rounds-collapse-ease),
391
+ visibility 0s linear var(--tv-rounds-fade, 220ms);
392
+ }
393
+
394
+ /* Складывается сам матч — ужимается к правому краю, туда, где продолжение.
395
+ Линии просто гаснут: путь в SVG не масштабируешь так, чтобы он остался ровным.
396
+
397
+ Только сжатие, без сдвига: сдвинутая карточка вылезает за край и увеличивает
398
+ область прокрутки, а `scroll-snap: mandatory` тут же пересчитывает прилипание —
399
+ получается бесконечный круг «сдвинули → переснапили → сдвинули». */
400
+ .tv-rounds--swipe .tree-view__node.tv-hidden {
401
+ transform: scale(var(--tv-rounds-collapse-scale));
402
+ transform-origin: right center;
403
+ }
404
+
405
+ /* Вне свайпера прятать нечего анимировать — убираем сразу. */
406
+ .tv-hidden {
407
+ visibility: hidden;
408
+ }
409
+
410
+ /* Кому анимации мешают — тому сразу и без переходов. */
411
+ @media (prefers-reduced-motion: reduce) {
412
+ .tv-rounds--swipe [data-depth],
413
+ .tv-rounds--swipe [data-depth].tv-hidden {
414
+ transition: none;
415
+ }
416
+
417
+ .tv-rounds--swipe .tree-view__node.tv-hidden {
418
+ transform: none;
419
+ }
420
+ }