@vespera-ui/angular 0.4.0 → 0.7.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,239 @@
1
+ import { Component, Input } from '@angular/core';
2
+
3
+ type Pt = [number, number];
4
+ function smoothPath(pts: Pt[]): string {
5
+ if (pts.length < 2) return '';
6
+ let d = `M ${pts[0]![0]} ${pts[0]![1]}`;
7
+ for (let i = 0; i < pts.length - 1; i++) {
8
+ const [x0, y0] = pts[i]!;
9
+ const [x1, y1] = pts[i + 1]!;
10
+ const cx = (x0 + x1) / 2;
11
+ d += ` C ${cx} ${y0} ${cx} ${y1} ${x1} ${y1}`;
12
+ }
13
+ return d;
14
+ }
15
+ function niceNum(n: number): string {
16
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
17
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1) + 'k';
18
+ return String(n);
19
+ }
20
+ let acUid = 0;
21
+
22
+ @Component({
23
+ selector: 'vsp-area-chart',
24
+ template: `<svg [attr.width]="width" [attr.height]="height" style="display: block">
25
+ <defs>
26
+ <linearGradient [id]="gid" x1="0" x2="0" y1="0" y2="1">
27
+ <stop offset="0" [attr.stop-color]="color" stop-opacity="0.22" />
28
+ <stop offset="1" [attr.stop-color]="color" stop-opacity="0" />
29
+ </linearGradient>
30
+ </defs>
31
+ @for (t of ticks; track $index) {
32
+ <line
33
+ [attr.x1]="padL"
34
+ [attr.x2]="width - padR"
35
+ [attr.y1]="t.y"
36
+ [attr.y2]="t.y"
37
+ stroke="var(--grid-line)"
38
+ stroke-width="1"
39
+ />
40
+ <text
41
+ [attr.x]="padL - 8"
42
+ [attr.y]="t.y + 3.5"
43
+ text-anchor="end"
44
+ font-size="10"
45
+ fill="var(--text-faint)"
46
+ font-family="var(--font-mono)"
47
+ >
48
+ {{ t.label }}
49
+ </text>
50
+ }
51
+ @if (labels) {
52
+ @for (lb of labels; track $index; let i = $index) {
53
+ @if (showLabel(i)) {
54
+ <text
55
+ [attr.x]="sx(i, labels.length)"
56
+ [attr.y]="height - 8"
57
+ text-anchor="middle"
58
+ font-size="10"
59
+ fill="var(--text-faint)"
60
+ font-family="var(--font-mono)"
61
+ >
62
+ {{ lb }}
63
+ </text>
64
+ }
65
+ }
66
+ }
67
+ @for (ln of lineEls; track $index) {
68
+ @if (ln.area) {
69
+ <path [attr.d]="ln.area" [attr.fill]="'url(#' + gid + ')'" />
70
+ }
71
+ <path
72
+ [attr.d]="ln.d"
73
+ fill="none"
74
+ [attr.stroke]="ln.stroke"
75
+ stroke-width="2.4"
76
+ stroke-linecap="round"
77
+ [attr.stroke-dasharray]="ln.dash"
78
+ [style.opacity]="ln.opacity"
79
+ />
80
+ }
81
+ </svg>`,
82
+ })
83
+ export class VspAreaChart {
84
+ @Input() series: number[][] = [];
85
+ @Input() labels?: string[];
86
+ @Input() width = 760;
87
+ @Input() height = 260;
88
+ @Input() color = 'var(--accent)';
89
+ @Input() color2 = 'var(--accent-2)';
90
+ @Input() dual = false;
91
+ gid = 'ac' + ++acUid;
92
+ padL = 38;
93
+ padB = 26;
94
+ padT = 12;
95
+ padR = 8;
96
+ get innerW(): number {
97
+ return Math.max(10, this.width - this.padL - this.padR);
98
+ }
99
+ get innerH(): number {
100
+ return this.height - this.padB - this.padT;
101
+ }
102
+ get s0(): number[] {
103
+ return this.series[0] ?? [];
104
+ }
105
+ get s1(): number[] | undefined {
106
+ return this.series[1];
107
+ }
108
+ get max(): number {
109
+ return Math.max(...(this.dual && this.s1 ? [...this.s0, ...this.s1] : this.s0), 0) * 1.12;
110
+ }
111
+ get rng(): number {
112
+ return this.max || 1;
113
+ }
114
+ sx(i: number, len: number): number {
115
+ return this.padL + (i / Math.max(1, len - 1)) * this.innerW;
116
+ }
117
+ sy(v: number): number {
118
+ return this.padT + this.innerH - (v / this.rng) * this.innerH;
119
+ }
120
+ showLabel(i: number): boolean {
121
+ return !!this.labels && i % Math.ceil(this.labels.length / 7) === 0;
122
+ }
123
+ get ticks(): { y: number; label: string }[] {
124
+ return Array.from({ length: 5 }, (_, i) => ({
125
+ y: this.sy((this.max / 4) * i),
126
+ label: niceNum(Math.round((this.max / 4) * i)),
127
+ }));
128
+ }
129
+ get lineEls(): {
130
+ d: string;
131
+ area: string | null;
132
+ stroke: string;
133
+ dash: string | null;
134
+ opacity: number;
135
+ }[] {
136
+ const sets = this.dual && this.s1 ? [this.s0, this.s1] : [this.s0];
137
+ return sets.map((arr, li) => {
138
+ const pts: Pt[] = arr.map((v, i) => [this.sx(i, arr.length), this.sy(v)]);
139
+ const last = pts[pts.length - 1];
140
+ const first = pts[0];
141
+ return {
142
+ d: smoothPath(pts),
143
+ area:
144
+ li === 0 && first && last
145
+ ? `${smoothPath(pts)} L ${last[0]} ${this.padT + this.innerH} L ${first[0]} ${this.padT + this.innerH} Z`
146
+ : null,
147
+ stroke: li === 0 ? this.color : this.color2,
148
+ dash: li === 1 ? '5 5' : null,
149
+ opacity: li === 1 ? 0.7 : 1,
150
+ };
151
+ });
152
+ }
153
+ }
154
+
155
+ @Component({
156
+ selector: 'vsp-bar-chart',
157
+ template: `<svg [attr.width]="width" [attr.height]="height" style="display: block">
158
+ @for (g of grid; track $index) {
159
+ <line
160
+ [attr.x1]="padL"
161
+ [attr.x2]="width - 8"
162
+ [attr.y1]="g.y"
163
+ [attr.y2]="g.y"
164
+ stroke="var(--grid-line)"
165
+ />
166
+ <text
167
+ [attr.x]="padL - 8"
168
+ [attr.y]="g.y + 3.5"
169
+ text-anchor="end"
170
+ font-size="10"
171
+ fill="var(--text-faint)"
172
+ font-family="var(--font-mono)"
173
+ >
174
+ {{ g.label }}
175
+ </text>
176
+ }
177
+ @for (b of bars; track $index) {
178
+ <rect
179
+ [attr.x]="b.x"
180
+ [attr.y]="b.y"
181
+ [attr.width]="b.w"
182
+ [attr.height]="b.h"
183
+ rx="4"
184
+ [attr.fill]="barFill"
185
+ />
186
+ @if (b.label != null) {
187
+ <text
188
+ [attr.x]="b.x + b.w / 2"
189
+ [attr.y]="height - 8"
190
+ text-anchor="middle"
191
+ font-size="10"
192
+ fill="var(--text-faint)"
193
+ font-family="var(--font-mono)"
194
+ >
195
+ {{ b.label }}
196
+ </text>
197
+ }
198
+ }
199
+ </svg>`,
200
+ })
201
+ export class VspBarChart {
202
+ @Input() data: number[] = [];
203
+ @Input() labels?: string[];
204
+ @Input() width = 620;
205
+ @Input() height = 240;
206
+ @Input() color = 'var(--accent)';
207
+ padL = 34;
208
+ padB = 26;
209
+ padT = 10;
210
+ get innerW(): number {
211
+ return Math.max(10, this.width - this.padL - 8);
212
+ }
213
+ get innerH(): number {
214
+ return this.height - this.padB - this.padT;
215
+ }
216
+ get max(): number {
217
+ return Math.max(...this.data, 0) * 1.15 || 1;
218
+ }
219
+ get bw(): number {
220
+ return this.innerW / (this.data.length || 1);
221
+ }
222
+ get barFill(): string {
223
+ return `color-mix(in oklab, ${this.color} 78%, transparent)`;
224
+ }
225
+ get grid(): { y: number; label: string }[] {
226
+ return [0, 0.5, 1].map((f) => ({
227
+ y: this.padT + this.innerH - f * this.innerH,
228
+ label: niceNum(Math.round(this.max * f)),
229
+ }));
230
+ }
231
+ get bars(): { x: number; y: number; w: number; h: number; label: string | undefined }[] {
232
+ return this.data.map((v, i) => {
233
+ const bh = (v / this.max) * this.innerH;
234
+ const x = this.padL + i * this.bw + this.bw * 0.18;
235
+ const bwi = this.bw * 0.64;
236
+ return { x, y: this.padT + this.innerH - bh, w: bwi, h: bh, label: this.labels?.[i] };
237
+ });
238
+ }
239
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ Component,
3
+ ElementRef,
4
+ EventEmitter,
5
+ Input,
6
+ Output,
7
+ QueryList,
8
+ ViewChildren,
9
+ } from '@angular/core';
10
+
11
+ @Component({
12
+ selector: 'vsp-otp-input',
13
+ template: `<div class="ui-otp">
14
+ @for (i of indices; track i) {
15
+ <input
16
+ #otp
17
+ inputmode="numeric"
18
+ maxlength="1"
19
+ [value]="value[i] ?? ''"
20
+ (input)="set(i, otp.value)"
21
+ (keydown)="onKey(i, $event)"
22
+ />
23
+ }
24
+ </div>`,
25
+ })
26
+ export class VspOTPInput {
27
+ @Input() length = 6;
28
+ @Input() value = '';
29
+ @Output() valueChange = new EventEmitter<string>();
30
+ @ViewChildren('otp') inputs!: QueryList<ElementRef<HTMLInputElement>>;
31
+
32
+ get indices(): number[] {
33
+ return Array.from({ length: this.length }, (_, i) => i);
34
+ }
35
+ set(i: number, raw: string): void {
36
+ const clean = raw.replace(/\D/g, '');
37
+ const chars = Array.from({ length: this.length }, (_, k) => this.value[k] ?? '');
38
+ chars[i] = clean.slice(-1);
39
+ this.value = chars.join('');
40
+ this.valueChange.emit(this.value);
41
+ if (clean && i < this.length - 1) this.inputs.get(i + 1)?.nativeElement.focus();
42
+ }
43
+ onKey(i: number, e: KeyboardEvent): void {
44
+ if (e.key === 'Backspace' && !this.value[i] && i > 0)
45
+ this.inputs.get(i - 1)?.nativeElement.focus();
46
+ }
47
+ }
package/src/public-api.ts CHANGED
@@ -13,3 +13,6 @@ export * from './data.component';
13
13
  export * from './structure.component';
14
14
  export * from './charts.component';
15
15
  export * from './extras.component';
16
+ export * from './tree.component';
17
+ export * from './otp.component';
18
+ export * from './area-bar.component';
@@ -0,0 +1,114 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { Component, Input, OnInit } from '@angular/core';
3
+
4
+ export interface TreeNodeData {
5
+ id?: string;
6
+ label: string;
7
+ badge?: string;
8
+ children?: TreeNodeData[];
9
+ }
10
+
11
+ @Component({
12
+ selector: 'vsp-tree',
13
+ imports: [NgTemplateOutlet],
14
+ template: `<div class="ui-tree">
15
+ @for (n of data; track $index) {
16
+ <ng-container *ngTemplateOutlet="nodeTpl; context: { $implicit: n }" />
17
+ }
18
+ </div>
19
+
20
+ <ng-template #nodeTpl let-n>
21
+ <div>
22
+ <div
23
+ [class]="rowCls(n)"
24
+ role="button"
25
+ tabindex="0"
26
+ (click)="activate(n)"
27
+ (keydown.enter)="activate(n)"
28
+ >
29
+ @if (hasKids(n)) {
30
+ <svg
31
+ class="tw-chev"
32
+ viewBox="0 0 24 24"
33
+ width="16"
34
+ height="16"
35
+ fill="none"
36
+ stroke="currentColor"
37
+ stroke-width="2"
38
+ stroke-linecap="round"
39
+ stroke-linejoin="round"
40
+ >
41
+ <path d="M9 18l6-6-6-6" />
42
+ </svg>
43
+ } @else {
44
+ <span style="width: 16px; flex-shrink: 0"></span>
45
+ }
46
+ <svg
47
+ class="tw-icon"
48
+ viewBox="0 0 24 24"
49
+ width="16"
50
+ height="16"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ >
57
+ @if (hasKids(n)) {
58
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
59
+ } @else {
60
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6" />
61
+ }
62
+ </svg>
63
+ <span
64
+ style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
65
+ >{{ n.label }}</span
66
+ >
67
+ @if (n.badge != null) {
68
+ <span class="mono" style="font-size: 11px; color: var(--text-faint)">{{
69
+ n.badge
70
+ }}</span>
71
+ }
72
+ </div>
73
+ @if (hasKids(n) && expanded.has(id(n))) {
74
+ <div class="ui-tree-children">
75
+ @for (c of n.children; track $index) {
76
+ <ng-container *ngTemplateOutlet="nodeTpl; context: { $implicit: c }" />
77
+ }
78
+ </div>
79
+ }
80
+ </div>
81
+ </ng-template>`,
82
+ })
83
+ export class VspTree implements OnInit {
84
+ @Input() data: TreeNodeData[] = [];
85
+ @Input() defaultExpanded: string[] = [];
86
+ expanded = new Set<string>();
87
+ selected: string | null = null;
88
+
89
+ ngOnInit(): void {
90
+ this.expanded = new Set(this.defaultExpanded);
91
+ }
92
+ id(n: TreeNodeData): string {
93
+ return n.id ?? n.label;
94
+ }
95
+ hasKids(n: TreeNodeData): boolean {
96
+ return (n.children ?? []).length > 0;
97
+ }
98
+ rowCls(n: TreeNodeData): string {
99
+ const i = this.id(n);
100
+ return (
101
+ 'ui-tree-row' + (this.expanded.has(i) ? ' open' : '') + (this.selected === i ? ' sel' : '')
102
+ );
103
+ }
104
+ activate(n: TreeNodeData): void {
105
+ const i = this.id(n);
106
+ if (this.hasKids(n)) {
107
+ const next = new Set(this.expanded);
108
+ if (next.has(i)) next.delete(i);
109
+ else next.add(i);
110
+ this.expanded = next;
111
+ }
112
+ this.selected = i;
113
+ }
114
+ }