poe-svelte-ui-lib 1.31.0 → 1.31.2

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.
@@ -37,7 +37,30 @@
37
37
 
38
38
  const currentLevel = $derived<IPeriodChartLevel | undefined>(levels[currentLevelIndex])
39
39
  const values = $derived(currentLevel?.data ?? [])
40
- const maxValue = $derived(values.length ? Math.max(...values, 0) * 1.1 || 1 : 1)
40
+
41
+ /* Шкала Y: шаг 1/2/2.5/5·10ⁿ, ~4 деления, верх округляется вверх до шага, низ всегда 0 */
42
+ const AXIS_DIVISIONS = 4
43
+ const axis = $derived.by(() => {
44
+ const max = values.reduce((m, v) => (Number.isFinite(v) && v > m ? v : m), 0)
45
+ if (max <= 0) return { step: 0.25, top: 1, ticks: [1, 0.75, 0.5, 0.25, 0] }
46
+ const raw = max / AXIS_DIVISIONS
47
+ const mag = 10 ** Math.floor(Math.log10(raw))
48
+ const step = ([1, 2, 2.5, 5, 10].find((m) => m * mag >= raw * (1 - 1e-9)) ?? 10) * mag
49
+ const count = Math.max(1, Math.ceil(max / step - 1e-9))
50
+ return { step, top: step * count, ticks: Array.from({ length: count + 1 }, (_, i) => step * (count - i)) }
51
+ })
52
+
53
+ /* Подпись деления: k/M для больших значений, число знаков после запятой — по величине шага */
54
+ const formatTick = (v: number) => {
55
+ const [div, suffix] = axis.top >= 1e6 ? [1e6, "M"] : axis.top >= 1e4 ? [1e3, "k"] : [1, ""]
56
+ const step = axis.step / div
57
+ let decimals = 0
58
+ while (decimals < 10 && Math.abs(Math.round(step * 10 ** decimals) - step * 10 ** decimals) > 1e-6) decimals++
59
+ return (v / div).toFixed(decimals) + suffix
60
+ }
61
+
62
+ /* Значение во всплывающей подсказке: 4 значащие цифры, без потери малых величин вроде 0.0734 */
63
+ const formatValue = (v: number) => (Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(4))))
41
64
 
42
65
  const selectLevel = (index: number) => {
43
66
  if (index === currentLevelIndex) return
@@ -46,7 +69,7 @@
46
69
  }
47
70
 
48
71
  const barLabel = (i: number) => currentLevel?.labels?.[i] ?? String(i + 1)
49
- const barHeightPercent = (value: number) => Math.max((value / maxValue) * 100, value > 0 ? 2 : 0)
72
+ const barHeightPercent = (value: number) => Math.max((value / axis.top) * 100, value > 0 ? 2 : 0)
50
73
 
51
74
  /* Подписи по оси прореживаются: при 31 значении все они не читаются */
52
75
  const labelStep = $derived(Math.ceil(values.length / 12) || 1)
@@ -57,61 +80,85 @@
57
80
  <h5 class={twMerge("w-full text-[13px] font-semibold text-(--muted-color)", label.class)}>{label.name}</h5>
58
81
  {/if}
59
82
 
60
- <!-- Сегментированный переключатель уровней -->
61
- <div class="flex w-full items-center gap-0.5 rounded-[10px] bg-(--container-color) p-[3px]">
62
- {#each levels as level, index (level.name)}
63
- <button
64
- type="button"
65
- class={twMerge(
66
- "flex-1 cursor-pointer rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150",
67
- index === currentLevelIndex
68
- ? "bg-(--back-color) font-semibold text-(--font-color) shadow-(--elevation-1)"
69
- : "font-medium text-(--muted-color) hover:text-(--font-color)",
70
- )}
71
- onclick={() => selectLevel(index)}
72
- >
73
- {level.name}
74
- </button>
75
- {/each}
76
- </div>
83
+ <!-- Сегментированный переключатель уровней: при одном уровне переключать нечего -->
84
+ {#if levels.length > 1}
85
+ <div class="flex w-full items-center gap-0.5 rounded-[10px] bg-(--container-color) p-[3px]">
86
+ {#each levels as level, index (level.name)}
87
+ <button
88
+ type="button"
89
+ class={twMerge(
90
+ "flex-1 cursor-pointer rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150",
91
+ index === currentLevelIndex
92
+ ? "bg-(--back-color) font-semibold text-(--font-color) shadow-(--elevation-1)"
93
+ : "font-medium text-(--muted-color) hover:text-(--font-color)",
94
+ )}
95
+ onclick={() => selectLevel(index)}
96
+ >
97
+ {level.name}
98
+ </button>
99
+ {/each}
100
+ </div>
101
+ {/if}
77
102
 
78
103
  <!-- Столбчатый график -->
79
104
  <div class="relative flex w-full flex-col gap-1.5 rounded-[12px] border border-(--hairline-color) bg-(--back-color) p-3.5">
80
105
  {#if values.length === 0}
81
106
  <div class="flex h-40 w-full items-center justify-center text-[13px] text-(--faint-color)">Нет данных</div>
82
107
  {:else}
83
- <div class="flex h-40 w-full items-end gap-1">
84
- {#each values as value, i}
85
- <div
86
- class="relative flex h-full flex-1 flex-col items-center justify-end"
87
- role="img"
88
- aria-label={`${barLabel(i)}: ${value}${unit}`}
89
- onmouseenter={() => (hoveredIndex = i)}
90
- onmouseleave={() => (hoveredIndex = null)}
91
- >
92
- {#if hoveredIndex === i}
93
- <span
94
- class="pointer-events-none absolute -top-1 z-10 -translate-y-full rounded-lg border border-(--hairline-color) bg-(--back-color) px-2 py-1 text-[12px] font-semibold tabular-nums whitespace-nowrap shadow-(--elevation-2)"
108
+ <div class="flex w-full gap-2">
109
+ <!-- Шкала Y: подписи нулевой высоты, центр каждой ровно на своей линии сетки -->
110
+ <div class="flex h-40 shrink-0 flex-col justify-between">
111
+ {#each axis.ticks as tick}
112
+ <div class="flex h-0 items-center justify-end text-[10px] font-medium text-(--faint-color) tabular-nums">
113
+ {formatTick(tick)}
114
+ </div>
115
+ {/each}
116
+ </div>
117
+
118
+ <div class="flex min-w-0 flex-1 flex-col gap-1.5">
119
+ <div class="relative flex h-40 w-full items-end gap-1">
120
+ {#each axis.ticks as tick}
121
+ <div
122
+ class="pointer-events-none absolute inset-x-0 border-t {tick === 0
123
+ ? 'border-(--hairline-color)'
124
+ : 'border-dashed border-(--hairline-color)/60'}"
125
+ style="bottom: {(tick / axis.top) * 100}%"
126
+ ></div>
127
+ {/each}
128
+
129
+ {#each values as value, i}
130
+ <div
131
+ class="relative flex h-full flex-1 flex-col items-center justify-end"
132
+ role="img"
133
+ aria-label={`${barLabel(i)}: ${value}${unit}`}
134
+ onmouseenter={() => (hoveredIndex = i)}
135
+ onmouseleave={() => (hoveredIndex = null)}
95
136
  >
96
- {barLabel(i)}: {Math.round(value)}{unit}
97
- </span>
98
- {/if}
99
- <div
100
- class="w-full rounded-t-[3px] transition-[height,background-color] duration-150 {hoveredIndex === i
101
- ? 'bg-(--accent-color)'
102
- : 'bg-(--accent-color)/35'}"
103
- style="height: {barHeightPercent(value)}%"
104
- ></div>
137
+ {#if hoveredIndex === i}
138
+ <span
139
+ class="pointer-events-none absolute -top-1 z-10 -translate-y-full rounded-lg border border-(--hairline-color) bg-(--back-color) px-2 py-1 text-[12px] font-semibold tabular-nums whitespace-nowrap shadow-(--elevation-2)"
140
+ >
141
+ {barLabel(i)}: {formatValue(value)}{unit}
142
+ </span>
143
+ {/if}
144
+ <div
145
+ class="w-full rounded-t-[3px] transition-[height,background-color] duration-150 {hoveredIndex === i
146
+ ? 'bg-(--accent-color)'
147
+ : 'bg-(--accent-color)/35'}"
148
+ style="height: {barHeightPercent(value)}%"
149
+ ></div>
150
+ </div>
151
+ {/each}
105
152
  </div>
106
- {/each}
107
- </div>
108
153
 
109
- <div class="flex w-full gap-1">
110
- {#each values as _, i}
111
- <div class="flex-1 truncate text-center text-[10px] font-bold text-(--faint-color)">
112
- {i % labelStep === 0 ? barLabel(i) : ""}
154
+ <div class="flex w-full gap-1">
155
+ {#each values as _, i}
156
+ <div class="flex-1 truncate text-center text-[10px] font-bold text-(--faint-color)">
157
+ {i % labelStep === 0 ? barLabel(i) : ""}
158
+ </div>
159
+ {/each}
113
160
  </div>
114
- {/each}
161
+ </div>
115
162
  </div>
116
163
  {/if}
117
164
  </div>
@@ -13,14 +13,14 @@
13
13
  | Название | Тип | По умолчанию | Описание |
14
14
  | ------------------- | --------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
15
  | `id` | `string` | `crypto.randomUUID()` | Уникальный идентификатор компонента |
16
- | `wrapperClass` | `string` | `""` | CSS-классы обёртки — через `bg-*` задаёт цвет переключателя и столбиков (по умолчанию нейтрально, без декоративной заливки) |
16
+ | `wrapperClass` | `string` | `""` | CSS-классы обёртки — через `bg-*` задаёт цвет переключателя и столбиков (по умолчанию нейтрально, без декоративной заливки) |
17
17
  | `label` | `{ name?: string; class?: string }` | `{ name: "", class: "" }` | Заголовок над графиком |
18
- | `levels` | `IPeriodChartLevel[]` | 4 демо-уровня со случайными данными | Уровни детализации, порядок = порядок вкладок переключателя; открывается всегда на первом (индекс 0) |
18
+ | `levels` | `IPeriodChartLevel[]` | 4 демо-уровня со случайными данными | Уровни детализации, порядок = порядок вкладок переключателя; открывается всегда на первом (индекс 0); при одном уровне переключатель не показывается |
19
19
  | `levels[].name` | `string` | `-` | Подпись вкладки, напр. `"Час"` |
20
20
  | `levels[].variable` | `string` | `undefined` | Имя переменной устройства, откуда берутся `data` этого уровня, напр. `"PWR.EHour"` — используется конструктором для подписки, сам компонент по нему ничего не запрашивает |
21
21
  | `levels[].data` | `number[]` | `-` | Значения — длина массива = число столбиков, ничем не ограничена |
22
22
  | `levels[].labels` | `string[]` | `undefined` | Подписи столбиков по X; если не заданы — используется индекс+1 |
23
- | `unit` | `string` | `""` | Единицы измерения, добавляются к значению в `aria-label`, напр. `"Вт·ч"` |
23
+ | `unit` | `string` | `""` | Единицы измерения, добавляются к значению в подсказке и `aria-label`, напр. `" Вт·ч"` |
24
24
  | `onLevelChange` | `(index: number, level: IPeriodChartLevel) => void` | `() => {}` | Вызывается при переключении вкладки — только уведомление, без фетча внутри |
25
25
 
26
26
  ## События
@@ -72,7 +72,9 @@
72
72
 
73
73
  - `currentLevelIndex` (`$state`) — индекс активной вкладки, всегда инициализируется `0` (первый уровень)
74
74
  - `currentLevel`/`values` (`$derived`) — текущий уровень и его массив значений
75
- - `maxValue` (`$derived`) — максимум текущего массива с отступом ×1.1 для автомасштаба высоты столбиков
75
+ - `axis` (`$derived`) — шкала Y по максимуму текущего массива: шаг из ряда 1 / 2 / 2.5 / 5 × 10ⁿ
76
+ (~4 деления), верх шкалы = максимум, округлённый вверх до шага, низ всегда 0; высота столбиков
77
+ считается от верха шкалы
76
78
 
77
79
  ### Сторы и зависимости
78
80
 
@@ -92,23 +94,24 @@
92
94
  ### Адаптивность
93
95
 
94
96
  - Столбики растягиваются на всю ширину контейнера (`flex-1` на каждый), высота — в процентах от
95
- зоны столбиков фиксированной высоты (`h-40`); значение (округлено до целого, `Math.round`)
96
- выводится прямо внутри столбика, по центру — видно всегда, без наведения. Для маленьких значений
97
- столбик слишком низкий, чтобы число влезло внутрь (`overflow-hidden` его обрежет) — если высота
98
- столбика меньше `MIN_LABEL_PX` (18px из `BAR_ZONE_PX`=160px = `h-40`), число рисуется НАД
99
- столбиком вместо "внутри". Подписи (`labels[i]`/индекс+1) выводятся отдельной строкой под этой
100
- зоной, тоже без наведения
101
- - Никакого тултипа — компонент не реагирует на hover вообще
102
- - Полностью на CSS-переменных темы (`--back-color`, `--border-color`, `--shadow-color`,
103
- `--bg-color` через `wrapperClass`) — тёмная/светлая тема не требует отдельной настройки
97
+ верха шкалы в зоне фиксированной высоты (`h-40`); ненулевое значение всегда видно минимум на 2 %
98
+ - Шкала Y слева: подписи делений + горизонтальная сетка за столбиками (пунктир, базовая линия 0 —
99
+ сплошная). Шкала пересчитывается автоматически при смене уровня или данных: `0…100`,
100
+ `0…0.08` с шагом `0.02`, `0…3M`. Число знаков после запятой — по величине шага, для больших
101
+ значений — суффиксы `k` (верх ≥ 10 000) и `M` (≥ 1 000 000)
102
+ - Точное значение — в подсказке при наведении на столбик: 4 значащие цифры + `unit`
103
+ (`910: 0.0734 мкВт/см²`), малые величины не теряются
104
+ - Подписи по X (`labels[i]`/индекс+1) — отдельной строкой под столбиками, при большом числе
105
+ столбиков прореживаются (не больше ~12 подписей)
106
+ - Полностью на CSS-переменных темы (`--back-color`, `--accent-color`, `--hairline-color`,
107
+ `--faint-color`) — тёмная/светлая тема не требует отдельной настройки
104
108
 
105
109
  ### Ограничения
106
110
 
107
111
  - Компонент презентационный — не хранит историю, не опрашивает устройство сам; при смене
108
112
  `levels` извне (например после ответа на `onLevelChange`) график перерисовывается автоматически
109
- - Значение внутри столбика округляется до целого только для отображения (`Math.round`) — исходные
110
- дробные данные в `levels[].data` не изменяются; `unit` в самом столбике не показывается (мало
111
- места), только в `aria-label` для доступности
113
+ - Отрицательные значения не отображаются — шкала всегда начинается с 0
114
+ - Округление только для отображения — исходные данные в `levels[].data` не изменяются
112
115
 
113
116
  ### Производительность
114
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-svelte-ui-lib",
3
- "version": "1.31.0",
3
+ "version": "1.31.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {