@joanroucoux/cairn-ui 0.3.0 → 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.
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, signal, input, inject, LOCALE_ID, booleanAttribute, computed, Component, output, ElementRef, DestroyRef, effect, contentChild, afterRenderEffect, forwardRef, Directive, contentChildren, model, viewChildren } from '@angular/core';
|
|
2
|
+
import { InjectionToken, signal, input, inject, LOCALE_ID, booleanAttribute, computed, Component, output, ElementRef, DestroyRef, effect, contentChild, afterRenderEffect, forwardRef, Directive, viewChild, afterNextRender, contentChildren, model, viewChildren } from '@angular/core';
|
|
3
|
+
import { pie, arc, curveMonotoneX, line } from 'd3-shape';
|
|
4
|
+
import { scaleLinear } from 'd3-scale';
|
|
3
5
|
|
|
4
6
|
const MINUS = '−';
|
|
5
7
|
const EM_DASH = '—';
|
|
@@ -594,6 +596,250 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
|
|
|
594
596
|
`, styles: ["dialog{transition:opacity var(--duration-base) var(--ease-sheet),transform var(--duration-base) var(--ease-sheet),overlay var(--duration-base) allow-discrete,display var(--duration-base) allow-discrete;opacity:1;transform:translateY(0)}dialog:not([open]){opacity:0;transform:translateY(100%);transition-duration:var(--duration-exit)}@starting-style{dialog[open]{opacity:0;transform:translateY(100%)}}@media(min-width:64rem){dialog{transform:scale(1)}dialog:not([open]){transform:scale(var(--enter-scale))}@starting-style{dialog[open]{transform:scale(var(--enter-scale))}}}dialog::backdrop{transition:opacity var(--duration-base) var(--ease-out);opacity:1}dialog:not([open])::backdrop{opacity:0;transition-duration:var(--duration-exit)}@starting-style{dialog[open]::backdrop{opacity:0}}\n"] }]
|
|
595
597
|
}], ctorParameters: () => [], propDecorators: { heading: [{ type: i0.Input, args: [{ isSignal: true, alias: "heading", required: true }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], open: [{ type: i0.Input, args: [{ isSignal: true, alias: "open", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
|
|
596
598
|
|
|
599
|
+
const OUTER_RADIUS = 93;
|
|
600
|
+
const INNER_RADIUS = 63;
|
|
601
|
+
const pieLayout = pie()
|
|
602
|
+
.value((slice) => slice.value)
|
|
603
|
+
.sort(null);
|
|
604
|
+
const arcGenerator = arc().innerRadius(INNER_RADIUS).outerRadius(OUTER_RADIUS);
|
|
605
|
+
function positionSlices(slices) {
|
|
606
|
+
return pieLayout(slices).map((arc) => ({ ...arc.data, path: arcGenerator(arc) }));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const MAX_RAMP = 6;
|
|
610
|
+
const OTHERS_ID = 'others';
|
|
611
|
+
function shareSlices(slices) {
|
|
612
|
+
const positive = slices.filter((slice) => slice.value > 0).sort((a, b) => b.value - a.value);
|
|
613
|
+
const total = positive.reduce((sum, slice) => sum + slice.value, 0);
|
|
614
|
+
return positive.map((slice) => ({ ...slice, share: slice.value / total }));
|
|
615
|
+
}
|
|
616
|
+
function rankSlices(slices, othersLabel) {
|
|
617
|
+
const shared = shareSlices(slices);
|
|
618
|
+
const toRanked = (slice, ramp, members = []) => ({
|
|
619
|
+
...slice,
|
|
620
|
+
ramp,
|
|
621
|
+
members,
|
|
622
|
+
});
|
|
623
|
+
if (shared.length <= MAX_RAMP) {
|
|
624
|
+
return shared.map((slice, index) => toRanked(slice, (index + 1)));
|
|
625
|
+
}
|
|
626
|
+
const kept = shared.slice(0, MAX_RAMP - 1);
|
|
627
|
+
const rest = shared.slice(MAX_RAMP - 1);
|
|
628
|
+
const othersValue = rest.reduce((sum, slice) => sum + slice.value, 0);
|
|
629
|
+
const othersShare = rest.reduce((sum, slice) => sum + slice.share, 0);
|
|
630
|
+
return [
|
|
631
|
+
...kept.map((slice, index) => toRanked(slice, (index + 1))),
|
|
632
|
+
toRanked({ id: OTHERS_ID, label: othersLabel, value: othersValue, share: othersShare }, MAX_RAMP, rest.map((slice) => slice.label)),
|
|
633
|
+
];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const identityValue$1 = (value) => `${value}`;
|
|
637
|
+
const identityShare = (share) => `${Math.round(share * 100)}%`;
|
|
638
|
+
const ACTIVE_SCALE = 1.08;
|
|
639
|
+
const ROW_CLASSES = 'flex w-full items-center gap-3 min-h-12 px-2 rounded-control text-left transition-colors duration-(--duration-fast) ease-out hover:bg-(--glow) focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--ring)';
|
|
640
|
+
const RAMP_FILL_CLASSES = {
|
|
641
|
+
1: 'fill-(--ramp-1)',
|
|
642
|
+
2: 'fill-(--ramp-2)',
|
|
643
|
+
3: 'fill-(--ramp-3)',
|
|
644
|
+
4: 'fill-(--ramp-4)',
|
|
645
|
+
5: 'fill-(--ramp-5)',
|
|
646
|
+
6: 'fill-(--ramp-6)',
|
|
647
|
+
};
|
|
648
|
+
const RAMP_DOT_CLASSES = {
|
|
649
|
+
1: 'bg-(--ramp-1)',
|
|
650
|
+
2: 'bg-(--ramp-2)',
|
|
651
|
+
3: 'bg-(--ramp-3)',
|
|
652
|
+
4: 'bg-(--ramp-4)',
|
|
653
|
+
5: 'bg-(--ramp-5)',
|
|
654
|
+
6: 'bg-(--ramp-6)',
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* A ring chart with a legend: slices rank from largest to smallest on a grey ramp, and hovering,
|
|
658
|
+
* focusing or activating a legend row highlights its slice and centres it in the ring.
|
|
659
|
+
*
|
|
660
|
+
* @example
|
|
661
|
+
* <ui-donut [slices]="byAssetClass()" label="Par classe d'actif" othersLabel="Autres" [valueFormat]="formatEur" [shareFormat]="formatShare" (sliceSelect)="goToHoldings($event)" />
|
|
662
|
+
*/
|
|
663
|
+
class UiDonut {
|
|
664
|
+
slices = input.required(/* @ts-ignore */
|
|
665
|
+
...(ngDevMode ? [{ debugName: "slices" }] : /* istanbul ignore next */ []));
|
|
666
|
+
label = input.required(/* @ts-ignore */
|
|
667
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
668
|
+
othersLabel = input.required(/* @ts-ignore */
|
|
669
|
+
...(ngDevMode ? [{ debugName: "othersLabel" }] : /* istanbul ignore next */ []));
|
|
670
|
+
valueFormat = input(identityValue$1, /* @ts-ignore */
|
|
671
|
+
...(ngDevMode ? [{ debugName: "valueFormat" }] : /* istanbul ignore next */ []));
|
|
672
|
+
shareFormat = input(identityShare, /* @ts-ignore */
|
|
673
|
+
...(ngDevMode ? [{ debugName: "shareFormat" }] : /* istanbul ignore next */ []));
|
|
674
|
+
sliceSelect = output();
|
|
675
|
+
activeId = signal(null, /* @ts-ignore */
|
|
676
|
+
...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
|
|
677
|
+
ranked = computed(() => rankSlices(this.slices(), this.othersLabel()), /* @ts-ignore */
|
|
678
|
+
...(ngDevMode ? [{ debugName: "ranked" }] : /* istanbul ignore next */ []));
|
|
679
|
+
positioned = computed(() => positionSlices(this.ranked()), /* @ts-ignore */
|
|
680
|
+
...(ngDevMode ? [{ debugName: "positioned" }] : /* istanbul ignore next */ []));
|
|
681
|
+
active = computed(() => {
|
|
682
|
+
const ranked = this.ranked();
|
|
683
|
+
const id = this.activeId();
|
|
684
|
+
return ranked.find((slice) => slice.id === id) ?? ranked[0] ?? null;
|
|
685
|
+
}, /* @ts-ignore */
|
|
686
|
+
...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
|
|
687
|
+
ariaLabel = computed(() => {
|
|
688
|
+
const detail = shareSlices(this.slices())
|
|
689
|
+
.map((slice) => `${slice.label} ${this.shareFormat()(slice.share)}`)
|
|
690
|
+
.join(', ');
|
|
691
|
+
return detail ? `${this.label()} : ${detail}` : this.label();
|
|
692
|
+
}, /* @ts-ignore */
|
|
693
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
694
|
+
pathClasses(slice) {
|
|
695
|
+
return `cursor-pointer stroke-(--card) ${RAMP_FILL_CLASSES[slice.ramp]}`;
|
|
696
|
+
}
|
|
697
|
+
dotClasses(slice) {
|
|
698
|
+
return `size-3 flex-none rounded-pill shadow-[inset_0_0_0_1px_var(--border)] ${RAMP_DOT_CLASSES[slice.ramp]}`;
|
|
699
|
+
}
|
|
700
|
+
rowClasses(slice) {
|
|
701
|
+
return `${ROW_CLASSES}${this.isActive(slice) ? ' bg-(--soft)' : ''}`;
|
|
702
|
+
}
|
|
703
|
+
subLabel(slice) {
|
|
704
|
+
return slice.members.length > 0 ? slice.members.join(', ') : (slice.sublabel ?? '');
|
|
705
|
+
}
|
|
706
|
+
arcTransform(slice) {
|
|
707
|
+
const scale = this.isActive(slice) ? ACTIVE_SCALE : 1;
|
|
708
|
+
return `translate(100px, 100px) scale(${scale})`;
|
|
709
|
+
}
|
|
710
|
+
isActive(slice) {
|
|
711
|
+
return this.active()?.id === slice.id;
|
|
712
|
+
}
|
|
713
|
+
highlight(id) {
|
|
714
|
+
this.activeId.set(id);
|
|
715
|
+
}
|
|
716
|
+
clearHighlight(id) {
|
|
717
|
+
if (this.activeId() === id) {
|
|
718
|
+
this.activeId.set(null);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
select(slice) {
|
|
722
|
+
this.activeId.set(slice.id);
|
|
723
|
+
this.sliceSelect.emit(slice.id);
|
|
724
|
+
}
|
|
725
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiDonut, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
726
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: UiDonut, isStandalone: true, selector: "ui-donut", inputs: { slices: { classPropertyName: "slices", publicName: "slices", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, othersLabel: { classPropertyName: "othersLabel", publicName: "othersLabel", isSignal: true, isRequired: true, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, shareFormat: { classPropertyName: "shareFormat", publicName: "shareFormat", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sliceSelect: "sliceSelect" }, host: { classAttribute: "flex flex-col items-center gap-6 sm:flex-row sm:items-center" }, ngImport: i0, template: `
|
|
727
|
+
<div class="relative size-[232px] flex-none sm:size-60">
|
|
728
|
+
<svg class="block h-full w-full" role="img" viewBox="0 0 200 200" [attr.aria-label]="ariaLabel()">
|
|
729
|
+
@for (slice of positioned(); track slice.id) {
|
|
730
|
+
<g class="transition-transform duration-(--duration-fast) ease-out" [style.transform]="arcTransform(slice)">
|
|
731
|
+
<path
|
|
732
|
+
stroke-width="1"
|
|
733
|
+
[attr.d]="slice.path"
|
|
734
|
+
[class]="pathClasses(slice)"
|
|
735
|
+
(click)="select(slice)"
|
|
736
|
+
(pointerenter)="highlight(slice.id)"
|
|
737
|
+
(pointerleave)="clearHighlight(slice.id)"
|
|
738
|
+
/>
|
|
739
|
+
</g>
|
|
740
|
+
}
|
|
741
|
+
</svg>
|
|
742
|
+
|
|
743
|
+
@if (active(); as slice) {
|
|
744
|
+
<div
|
|
745
|
+
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center"
|
|
746
|
+
data-testid="donut-centre"
|
|
747
|
+
>
|
|
748
|
+
<span class="text-caption max-w-full truncate text-(--muted-foreground)">{{ slice.label }}</span>
|
|
749
|
+
<span class="text-title font-semibold">{{ shareFormat()(slice.share) }}</span>
|
|
750
|
+
<span class="text-caption">{{ valueFormat()(slice.value) }}</span>
|
|
751
|
+
</div>
|
|
752
|
+
}
|
|
753
|
+
</div>
|
|
754
|
+
|
|
755
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
756
|
+
@for (slice of ranked(); track slice.id) {
|
|
757
|
+
<button
|
|
758
|
+
type="button"
|
|
759
|
+
[class]="rowClasses(slice)"
|
|
760
|
+
(blur)="clearHighlight(slice.id)"
|
|
761
|
+
(click)="select(slice)"
|
|
762
|
+
(focus)="highlight(slice.id)"
|
|
763
|
+
(pointerenter)="highlight(slice.id)"
|
|
764
|
+
(pointerleave)="clearHighlight(slice.id)"
|
|
765
|
+
>
|
|
766
|
+
<span [class]="dotClasses(slice)"></span>
|
|
767
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
768
|
+
<span class="text-body truncate font-medium">{{ slice.label }}</span>
|
|
769
|
+
<span class="text-caption truncate text-(--muted-foreground)">{{ subLabel(slice) }}</span>
|
|
770
|
+
</span>
|
|
771
|
+
<span class="flex flex-none flex-col items-end whitespace-nowrap">
|
|
772
|
+
<span class="text-body font-medium">{{ shareFormat()(slice.share) }}</span>
|
|
773
|
+
<span class="text-caption text-(--muted-foreground)">{{ valueFormat()(slice.value) }}</span>
|
|
774
|
+
</span>
|
|
775
|
+
</button>
|
|
776
|
+
}
|
|
777
|
+
</div>
|
|
778
|
+
`, isInline: true });
|
|
779
|
+
}
|
|
780
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiDonut, decorators: [{
|
|
781
|
+
type: Component,
|
|
782
|
+
args: [{
|
|
783
|
+
selector: 'ui-donut',
|
|
784
|
+
template: `
|
|
785
|
+
<div class="relative size-[232px] flex-none sm:size-60">
|
|
786
|
+
<svg class="block h-full w-full" role="img" viewBox="0 0 200 200" [attr.aria-label]="ariaLabel()">
|
|
787
|
+
@for (slice of positioned(); track slice.id) {
|
|
788
|
+
<g class="transition-transform duration-(--duration-fast) ease-out" [style.transform]="arcTransform(slice)">
|
|
789
|
+
<path
|
|
790
|
+
stroke-width="1"
|
|
791
|
+
[attr.d]="slice.path"
|
|
792
|
+
[class]="pathClasses(slice)"
|
|
793
|
+
(click)="select(slice)"
|
|
794
|
+
(pointerenter)="highlight(slice.id)"
|
|
795
|
+
(pointerleave)="clearHighlight(slice.id)"
|
|
796
|
+
/>
|
|
797
|
+
</g>
|
|
798
|
+
}
|
|
799
|
+
</svg>
|
|
800
|
+
|
|
801
|
+
@if (active(); as slice) {
|
|
802
|
+
<div
|
|
803
|
+
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center"
|
|
804
|
+
data-testid="donut-centre"
|
|
805
|
+
>
|
|
806
|
+
<span class="text-caption max-w-full truncate text-(--muted-foreground)">{{ slice.label }}</span>
|
|
807
|
+
<span class="text-title font-semibold">{{ shareFormat()(slice.share) }}</span>
|
|
808
|
+
<span class="text-caption">{{ valueFormat()(slice.value) }}</span>
|
|
809
|
+
</div>
|
|
810
|
+
}
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<div class="flex min-w-0 flex-1 flex-col">
|
|
814
|
+
@for (slice of ranked(); track slice.id) {
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
[class]="rowClasses(slice)"
|
|
818
|
+
(blur)="clearHighlight(slice.id)"
|
|
819
|
+
(click)="select(slice)"
|
|
820
|
+
(focus)="highlight(slice.id)"
|
|
821
|
+
(pointerenter)="highlight(slice.id)"
|
|
822
|
+
(pointerleave)="clearHighlight(slice.id)"
|
|
823
|
+
>
|
|
824
|
+
<span [class]="dotClasses(slice)"></span>
|
|
825
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
826
|
+
<span class="text-body truncate font-medium">{{ slice.label }}</span>
|
|
827
|
+
<span class="text-caption truncate text-(--muted-foreground)">{{ subLabel(slice) }}</span>
|
|
828
|
+
</span>
|
|
829
|
+
<span class="flex flex-none flex-col items-end whitespace-nowrap">
|
|
830
|
+
<span class="text-body font-medium">{{ shareFormat()(slice.share) }}</span>
|
|
831
|
+
<span class="text-caption text-(--muted-foreground)">{{ valueFormat()(slice.value) }}</span>
|
|
832
|
+
</span>
|
|
833
|
+
</button>
|
|
834
|
+
}
|
|
835
|
+
</div>
|
|
836
|
+
`,
|
|
837
|
+
host: {
|
|
838
|
+
class: 'flex flex-col items-center gap-6 sm:flex-row sm:items-center',
|
|
839
|
+
},
|
|
840
|
+
}]
|
|
841
|
+
}], propDecorators: { slices: [{ type: i0.Input, args: [{ isSignal: true, alias: "slices", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], othersLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "othersLabel", required: true }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], shareFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "shareFormat", required: false }] }], sliceSelect: [{ type: i0.Output, args: ["sliceSelect"] }] } });
|
|
842
|
+
|
|
597
843
|
const nextId$1 = (() => {
|
|
598
844
|
let count = 0;
|
|
599
845
|
return () => `ui-field-${++count}`;
|
|
@@ -801,6 +1047,665 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
|
|
|
801
1047
|
}]
|
|
802
1048
|
}], propDecorators: { errors: [{ type: i0.Input, args: [{ isSignal: true, alias: "errors", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }] } });
|
|
803
1049
|
|
|
1050
|
+
const axisTicks = (points, maxTicks = 5) => {
|
|
1051
|
+
if (points.length <= maxTicks) {
|
|
1052
|
+
return [...points];
|
|
1053
|
+
}
|
|
1054
|
+
const count = Math.max(3, Math.min(maxTicks, points.length));
|
|
1055
|
+
const step = (points.length - 1) / (count - 1);
|
|
1056
|
+
const indexes = new Set();
|
|
1057
|
+
for (let tick = 0; tick < count; tick++) {
|
|
1058
|
+
indexes.add(Math.round(tick * step));
|
|
1059
|
+
}
|
|
1060
|
+
return [...indexes].sort((a, b) => a - b).map((index) => points[index]);
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
1064
|
+
const nearestPointIndex = (points, x) => {
|
|
1065
|
+
let nearest = 0;
|
|
1066
|
+
let nearestDistance = Infinity;
|
|
1067
|
+
points.forEach((point, index) => {
|
|
1068
|
+
const distance = Math.abs(point.x - x);
|
|
1069
|
+
if (distance < nearestDistance) {
|
|
1070
|
+
nearestDistance = distance;
|
|
1071
|
+
nearest = index;
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
return nearest;
|
|
1075
|
+
};
|
|
1076
|
+
const navigateIndex = (key, current, last) => {
|
|
1077
|
+
switch (key) {
|
|
1078
|
+
case 'ArrowRight':
|
|
1079
|
+
return { index: clamp((current ?? -1) + 1, 0, last), preventDefault: true };
|
|
1080
|
+
case 'ArrowLeft':
|
|
1081
|
+
return { index: clamp((current ?? last + 1) - 1, 0, last), preventDefault: true };
|
|
1082
|
+
case 'Home':
|
|
1083
|
+
return { index: 0, preventDefault: true };
|
|
1084
|
+
case 'End':
|
|
1085
|
+
return { index: last, preventDefault: true };
|
|
1086
|
+
case 'Escape':
|
|
1087
|
+
return { index: null, preventDefault: false };
|
|
1088
|
+
default:
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const CURVE = curveMonotoneX;
|
|
1094
|
+
const buildGeometry = (points, width, height, padding = {}) => {
|
|
1095
|
+
if (points.length === 0) {
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
const { x: paddingX = 4, top: paddingTop = 4, bottom: paddingBottom = 4 } = padding;
|
|
1099
|
+
const plotTop = paddingTop;
|
|
1100
|
+
const plotBottom = height - paddingBottom;
|
|
1101
|
+
const times = points.map((point) => point.t);
|
|
1102
|
+
const values = points.map((point) => point.v);
|
|
1103
|
+
const minValue = Math.min(...values);
|
|
1104
|
+
const maxValue = Math.max(...values);
|
|
1105
|
+
const x = scaleLinear()
|
|
1106
|
+
.domain([Math.min(...times), Math.max(...times)])
|
|
1107
|
+
.range([paddingX, width - paddingX]);
|
|
1108
|
+
const y = minValue === maxValue
|
|
1109
|
+
? () => (plotTop + plotBottom) / 2
|
|
1110
|
+
: scaleLinear().domain([minValue, maxValue]).range([plotBottom, plotTop]);
|
|
1111
|
+
const toX = (point) => (points.length === 1 ? paddingX : x(point.t));
|
|
1112
|
+
const lineGenerator = line()
|
|
1113
|
+
.x(toX)
|
|
1114
|
+
.y((point) => y(point.v))
|
|
1115
|
+
.curve(CURVE);
|
|
1116
|
+
const first = points[0];
|
|
1117
|
+
const last = points.at(-1);
|
|
1118
|
+
return {
|
|
1119
|
+
line: lineGenerator(points),
|
|
1120
|
+
end: { x: toX(last), y: y(last.v) },
|
|
1121
|
+
start: { y: y(first.v) },
|
|
1122
|
+
points: points.map((point) => ({ ...point, x: toX(point), y: y(point.v) })),
|
|
1123
|
+
plotBottom,
|
|
1124
|
+
plotTop,
|
|
1125
|
+
};
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const LINE_GAP = 3;
|
|
1129
|
+
const COLLISION_MARGIN = 3;
|
|
1130
|
+
const EDGE_TOLERANCE = 3;
|
|
1131
|
+
const candidateSlots = (startY, box, label) => {
|
|
1132
|
+
const aboveTop = startY - LINE_GAP - label.height;
|
|
1133
|
+
const aboveBottom = startY - LINE_GAP;
|
|
1134
|
+
const belowTop = startY + LINE_GAP;
|
|
1135
|
+
const belowBottom = startY + LINE_GAP + label.height;
|
|
1136
|
+
const slots = [
|
|
1137
|
+
{ anchor: 'end', x: box.width, y: aboveBottom, top: aboveTop, bottom: aboveBottom },
|
|
1138
|
+
{ anchor: 'end', x: box.width, y: belowBottom, top: belowTop, bottom: belowBottom },
|
|
1139
|
+
{ anchor: 'start', x: 0, y: aboveBottom, top: aboveTop, bottom: aboveBottom },
|
|
1140
|
+
{ anchor: 'start', x: 0, y: belowBottom, top: belowTop, bottom: belowBottom },
|
|
1141
|
+
{ anchor: 'end', x: box.width, y: label.height, top: 0, bottom: label.height },
|
|
1142
|
+
{ anchor: 'start', x: 0, y: label.height, top: 0, bottom: label.height },
|
|
1143
|
+
{ anchor: 'end', x: box.width, y: box.height, top: box.height - label.height, bottom: box.height },
|
|
1144
|
+
{ anchor: 'start', x: 0, y: box.height, top: box.height - label.height, bottom: box.height },
|
|
1145
|
+
];
|
|
1146
|
+
return slots.filter((slot) => slot.top >= -EDGE_TOLERANCE && slot.bottom <= box.height + EDGE_TOLERANCE);
|
|
1147
|
+
};
|
|
1148
|
+
const hitsFor = (slot, label, points) => {
|
|
1149
|
+
const left = slot.anchor === 'end' ? slot.x - label.width : slot.x;
|
|
1150
|
+
const right = slot.anchor === 'end' ? slot.x : slot.x + label.width;
|
|
1151
|
+
const top = slot.top - COLLISION_MARGIN;
|
|
1152
|
+
const bottom = slot.bottom + COLLISION_MARGIN;
|
|
1153
|
+
return points.filter((point) => point.x >= left && point.x <= right && point.y >= top && point.y <= bottom).length;
|
|
1154
|
+
};
|
|
1155
|
+
const placeStartLabel = (points, startY, box, label) => {
|
|
1156
|
+
let best = null;
|
|
1157
|
+
for (const slot of candidateSlots(startY, box, label)) {
|
|
1158
|
+
const hits = hitsFor(slot, label, points);
|
|
1159
|
+
if (!best || hits < best.hits) {
|
|
1160
|
+
best = { slot, hits };
|
|
1161
|
+
}
|
|
1162
|
+
if (hits === 0) {
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const { anchor, x, y } = best.slot;
|
|
1167
|
+
return { anchor, x, y };
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
const TRANSITION_SAMPLES = 120;
|
|
1171
|
+
const TRANSITION_DURATION = 260;
|
|
1172
|
+
const easeOutQuint = (t) => 1 - (1 - t) ** 5;
|
|
1173
|
+
const prefersReducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1174
|
+
const monotoneTangents = (xs, ys) => {
|
|
1175
|
+
const n = xs.length;
|
|
1176
|
+
const slopes = [];
|
|
1177
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1178
|
+
const dx = xs[i + 1] - xs[i];
|
|
1179
|
+
slopes.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);
|
|
1180
|
+
}
|
|
1181
|
+
const tangents = new Array(n).fill(0);
|
|
1182
|
+
tangents[0] = slopes[0];
|
|
1183
|
+
tangents[n - 1] = slopes[n - 2];
|
|
1184
|
+
for (let i = 1; i < n - 1; i++) {
|
|
1185
|
+
const a = slopes[i - 1];
|
|
1186
|
+
const b = slopes[i];
|
|
1187
|
+
tangents[i] = a * b <= 0 ? 0 : (a + b) / 2;
|
|
1188
|
+
}
|
|
1189
|
+
for (let i = 0; i < n - 1; i++) {
|
|
1190
|
+
const slope = slopes[i];
|
|
1191
|
+
if (slope === 0) {
|
|
1192
|
+
tangents[i] = 0;
|
|
1193
|
+
tangents[i + 1] = 0;
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
const a = tangents[i] / slope;
|
|
1197
|
+
const b = tangents[i + 1] / slope;
|
|
1198
|
+
const s = a * a + b * b;
|
|
1199
|
+
if (s > 9) {
|
|
1200
|
+
const t = 3 / Math.sqrt(s);
|
|
1201
|
+
tangents[i] = t * a * slope;
|
|
1202
|
+
tangents[i + 1] = t * b * slope;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return tangents;
|
|
1206
|
+
};
|
|
1207
|
+
const sampleMonotone = (points, sampleXs) => {
|
|
1208
|
+
const xs = points.map((point) => point.x);
|
|
1209
|
+
const ys = points.map((point) => point.y);
|
|
1210
|
+
const tangents = monotoneTangents(xs, ys);
|
|
1211
|
+
let segment = 0;
|
|
1212
|
+
return sampleXs.map((x) => {
|
|
1213
|
+
while (segment < xs.length - 2 && x > xs[segment + 1]) {
|
|
1214
|
+
segment++;
|
|
1215
|
+
}
|
|
1216
|
+
const x0 = xs[segment];
|
|
1217
|
+
const x1 = xs[segment + 1];
|
|
1218
|
+
const dx = x1 - x0;
|
|
1219
|
+
const f = dx === 0 ? 0 : (x - x0) / dx;
|
|
1220
|
+
const f2 = f * f;
|
|
1221
|
+
const f3 = f2 * f;
|
|
1222
|
+
const y0 = ys[segment];
|
|
1223
|
+
const y1 = ys[segment + 1];
|
|
1224
|
+
const m0 = tangents[segment];
|
|
1225
|
+
const m1 = tangents[segment + 1];
|
|
1226
|
+
return (2 * f3 - 3 * f2 + 1) * y0 + (f3 - 2 * f2 + f) * m0 * dx + (-2 * f3 + 3 * f2) * y1 + (f3 - f2) * m1 * dx;
|
|
1227
|
+
});
|
|
1228
|
+
};
|
|
1229
|
+
const buildPolyline = (xs, ys) => xs.map((x, index) => `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${ys[index].toFixed(2)}`).join(' ');
|
|
1230
|
+
const interpolateLine = (from, to, progress) => {
|
|
1231
|
+
const left = to.points[0].x;
|
|
1232
|
+
const right = to.points.at(-1).x;
|
|
1233
|
+
const samples = Array.from({ length: TRANSITION_SAMPLES }, (_, index) => left + ((right - left) * index) / (TRANSITION_SAMPLES - 1));
|
|
1234
|
+
const fromYs = sampleMonotone(from.points, samples);
|
|
1235
|
+
const toYs = sampleMonotone(to.points, samples);
|
|
1236
|
+
return buildPolyline(samples, fromYs.map((fromY, index) => fromY + (toYs[index] - fromY) * progress));
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const DEFAULT_WIDTH = 640;
|
|
1240
|
+
const DEFAULT_HEIGHT = 240;
|
|
1241
|
+
const PLOT_PADDING = { x: 4, top: 10, bottom: 18 };
|
|
1242
|
+
const START_LABEL_HEIGHT = 17;
|
|
1243
|
+
const identityValue = (value) => `${value}`;
|
|
1244
|
+
const identityDelta = (delta) => `${delta > 0 ? '+' : ''}${delta}`;
|
|
1245
|
+
const identityTime = (time) => new Date(time).toISOString();
|
|
1246
|
+
/**
|
|
1247
|
+
* Value over time: a monotone curve with a dashed line at the starting value and a tooltip.
|
|
1248
|
+
*
|
|
1249
|
+
* @example
|
|
1250
|
+
* <ui-line-chart [points]="points" label="Net worth over one month" startLabel="Since" [valueFormat]="formatEur" />
|
|
1251
|
+
*/
|
|
1252
|
+
class UiLineChart {
|
|
1253
|
+
points = input.required(/* @ts-ignore */
|
|
1254
|
+
...(ngDevMode ? [{ debugName: "points" }] : /* istanbul ignore next */ []));
|
|
1255
|
+
label = input.required(/* @ts-ignore */
|
|
1256
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
1257
|
+
valueFormat = input(identityValue, /* @ts-ignore */
|
|
1258
|
+
...(ngDevMode ? [{ debugName: "valueFormat" }] : /* istanbul ignore next */ []));
|
|
1259
|
+
deltaFormat = input(identityDelta, /* @ts-ignore */
|
|
1260
|
+
...(ngDevMode ? [{ debugName: "deltaFormat" }] : /* istanbul ignore next */ []));
|
|
1261
|
+
timeFormat = input(identityTime, /* @ts-ignore */
|
|
1262
|
+
...(ngDevMode ? [{ debugName: "timeFormat" }] : /* istanbul ignore next */ []));
|
|
1263
|
+
axisFormat = input(identityTime, /* @ts-ignore */
|
|
1264
|
+
...(ngDevMode ? [{ debugName: "axisFormat" }] : /* istanbul ignore next */ []));
|
|
1265
|
+
startLabel = input('', /* @ts-ignore */
|
|
1266
|
+
...(ngDevMode ? [{ debugName: "startLabel" }] : /* istanbul ignore next */ []));
|
|
1267
|
+
timeColumnLabel = input('Time', /* @ts-ignore */
|
|
1268
|
+
...(ngDevMode ? [{ debugName: "timeColumnLabel" }] : /* istanbul ignore next */ []));
|
|
1269
|
+
valueColumnLabel = input('Value', /* @ts-ignore */
|
|
1270
|
+
...(ngDevMode ? [{ debugName: "valueColumnLabel" }] : /* istanbul ignore next */ []));
|
|
1271
|
+
#destroyRef = inject(DestroyRef);
|
|
1272
|
+
#frame = null;
|
|
1273
|
+
#hasRenderedOnce = false;
|
|
1274
|
+
#previousGeometry = null;
|
|
1275
|
+
svgRef = viewChild.required('svgRef', /* @ts-ignore */
|
|
1276
|
+
...(ngDevMode ? [{ debugName: "svgRef" }] : /* istanbul ignore next */ []));
|
|
1277
|
+
activeIndex = signal(null, /* @ts-ignore */
|
|
1278
|
+
...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
|
|
1279
|
+
animatedLine = signal(null, /* @ts-ignore */
|
|
1280
|
+
...(ngDevMode ? [{ debugName: "animatedLine" }] : /* istanbul ignore next */ []));
|
|
1281
|
+
measuredSize = signal(null, /* @ts-ignore */
|
|
1282
|
+
...(ngDevMode ? [{ debugName: "measuredSize" }] : /* istanbul ignore next */ []));
|
|
1283
|
+
effectiveWidth = computed(() => this.measuredSize()?.width ?? DEFAULT_WIDTH, /* @ts-ignore */
|
|
1284
|
+
...(ngDevMode ? [{ debugName: "effectiveWidth" }] : /* istanbul ignore next */ []));
|
|
1285
|
+
effectiveHeight = computed(() => this.measuredSize()?.height ?? DEFAULT_HEIGHT, /* @ts-ignore */
|
|
1286
|
+
...(ngDevMode ? [{ debugName: "effectiveHeight" }] : /* istanbul ignore next */ []));
|
|
1287
|
+
geometry = computed(() => buildGeometry(this.points(), this.effectiveWidth(), this.effectiveHeight(), PLOT_PADDING), /* @ts-ignore */
|
|
1288
|
+
...(ngDevMode ? [{ debugName: "geometry" }] : /* istanbul ignore next */ []));
|
|
1289
|
+
displayLine = computed(() => this.animatedLine() ?? this.geometry().line, /* @ts-ignore */
|
|
1290
|
+
...(ngDevMode ? [{ debugName: "displayLine" }] : /* istanbul ignore next */ []));
|
|
1291
|
+
ticks = computed(() => axisTicks(this.geometry().points), /* @ts-ignore */
|
|
1292
|
+
...(ngDevMode ? [{ debugName: "ticks" }] : /* istanbul ignore next */ []));
|
|
1293
|
+
active = computed(() => {
|
|
1294
|
+
const geometry = this.geometry();
|
|
1295
|
+
const index = this.activeIndex();
|
|
1296
|
+
return geometry && index !== null ? geometry.points[index] : null;
|
|
1297
|
+
}, /* @ts-ignore */
|
|
1298
|
+
...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
|
|
1299
|
+
startLabelInfo = computed(() => {
|
|
1300
|
+
const text = this.startLabel();
|
|
1301
|
+
const geometry = this.geometry();
|
|
1302
|
+
if (!text || !geometry) {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
const label = `${text} ${this.valueFormat()(geometry.points[0].v)}`;
|
|
1306
|
+
const size = { width: label.length * 0.56 * 12 + 8, height: START_LABEL_HEIGHT };
|
|
1307
|
+
const placement = placeStartLabel(geometry.points, geometry.start.y, { width: this.effectiveWidth(), height: this.effectiveHeight() }, size);
|
|
1308
|
+
return { label, placement };
|
|
1309
|
+
}, /* @ts-ignore */
|
|
1310
|
+
...(ngDevMode ? [{ debugName: "startLabelInfo" }] : /* istanbul ignore next */ []));
|
|
1311
|
+
#renderRef = afterNextRender(() => this.#observeSize());
|
|
1312
|
+
constructor() {
|
|
1313
|
+
effect(() => {
|
|
1314
|
+
const geometry = this.geometry();
|
|
1315
|
+
const previous = this.#previousGeometry;
|
|
1316
|
+
if (previous === geometry) {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
this.#previousGeometry = geometry;
|
|
1320
|
+
this.#cancelAnimation();
|
|
1321
|
+
if (!this.#hasRenderedOnce) {
|
|
1322
|
+
this.#hasRenderedOnce = true;
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
if (!previous || !geometry) {
|
|
1326
|
+
this.animatedLine.set(null);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (prefersReducedMotion()) {
|
|
1330
|
+
this.animatedLine.set(null);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
this.#animate(previous, geometry);
|
|
1334
|
+
});
|
|
1335
|
+
this.#destroyRef.onDestroy(() => this.#cancelAnimation());
|
|
1336
|
+
}
|
|
1337
|
+
#observeSize() {
|
|
1338
|
+
this.#destroyRef.onDestroy(() => this.#renderRef.destroy());
|
|
1339
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const element = this.svgRef().nativeElement;
|
|
1343
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
1344
|
+
if (entry) {
|
|
1345
|
+
const { width, height } = entry.contentRect;
|
|
1346
|
+
this.measuredSize.set({ width, height });
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
observer.observe(element);
|
|
1350
|
+
this.#destroyRef.onDestroy(() => observer.disconnect());
|
|
1351
|
+
}
|
|
1352
|
+
#cancelAnimation() {
|
|
1353
|
+
if (this.#frame !== null) {
|
|
1354
|
+
cancelAnimationFrame(this.#frame);
|
|
1355
|
+
this.#frame = null;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
#animate(from, to) {
|
|
1359
|
+
if (from.points.length < 2 || to.points.length < 2) {
|
|
1360
|
+
this.animatedLine.set(null);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const startTime = performance.now();
|
|
1364
|
+
const step = (now) => {
|
|
1365
|
+
const t = Math.min(1, (now - startTime) / TRANSITION_DURATION);
|
|
1366
|
+
this.animatedLine.set(interpolateLine(from, to, easeOutQuint(t)));
|
|
1367
|
+
if (t < 1) {
|
|
1368
|
+
this.#frame = requestAnimationFrame(step);
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
this.#frame = null;
|
|
1372
|
+
this.animatedLine.set(null);
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
this.#frame = requestAnimationFrame(step);
|
|
1376
|
+
}
|
|
1377
|
+
coreTick(index, count) {
|
|
1378
|
+
if (count <= 3) {
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
const middle = Math.floor((count - 1) / 2);
|
|
1382
|
+
return index === 0 || index === count - 1 || index === middle;
|
|
1383
|
+
}
|
|
1384
|
+
deltaFor(point) {
|
|
1385
|
+
return point.v - this.points()[0].v;
|
|
1386
|
+
}
|
|
1387
|
+
deltaClasses(point) {
|
|
1388
|
+
const delta = this.deltaFor(point);
|
|
1389
|
+
return delta > 0 ? 'text-(--positive)' : delta < 0 ? 'text-(--negative)' : 'text-(--muted-foreground)';
|
|
1390
|
+
}
|
|
1391
|
+
tooltipStyle(point) {
|
|
1392
|
+
return `left: ${(point.x / this.effectiveWidth()) * 100}%; top: ${(point.y / this.effectiveHeight()) * 100}%`;
|
|
1393
|
+
}
|
|
1394
|
+
onPointerMove(event) {
|
|
1395
|
+
const geometry = this.geometry();
|
|
1396
|
+
if (!geometry || geometry.points.length === 0) {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const rect = this.svgRef().nativeElement.getBoundingClientRect();
|
|
1400
|
+
const ratio = rect.width === 0 ? 1 : this.effectiveWidth() / rect.width;
|
|
1401
|
+
const x = (event.clientX - rect.left) * ratio;
|
|
1402
|
+
this.activeIndex.set(nearestPointIndex(geometry.points, x));
|
|
1403
|
+
}
|
|
1404
|
+
onPointerLeave() {
|
|
1405
|
+
this.activeIndex.set(null);
|
|
1406
|
+
}
|
|
1407
|
+
onKeydown(event) {
|
|
1408
|
+
const geometry = this.geometry();
|
|
1409
|
+
if (!geometry || geometry.points.length === 0) {
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const navigation = navigateIndex(event.key, this.activeIndex(), geometry.points.length - 1);
|
|
1413
|
+
if (!navigation) {
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (navigation.preventDefault) {
|
|
1417
|
+
event.preventDefault();
|
|
1418
|
+
}
|
|
1419
|
+
this.activeIndex.set(navigation.index);
|
|
1420
|
+
}
|
|
1421
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiLineChart, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1422
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: UiLineChart, isStandalone: true, selector: "ui-line-chart", inputs: { points: { classPropertyName: "points", publicName: "points", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, valueFormat: { classPropertyName: "valueFormat", publicName: "valueFormat", isSignal: true, isRequired: false, transformFunction: null }, deltaFormat: { classPropertyName: "deltaFormat", publicName: "deltaFormat", isSignal: true, isRequired: false, transformFunction: null }, timeFormat: { classPropertyName: "timeFormat", publicName: "timeFormat", isSignal: true, isRequired: false, transformFunction: null }, axisFormat: { classPropertyName: "axisFormat", publicName: "axisFormat", isSignal: true, isRequired: false, transformFunction: null }, startLabel: { classPropertyName: "startLabel", publicName: "startLabel", isSignal: true, isRequired: false, transformFunction: null }, timeColumnLabel: { classPropertyName: "timeColumnLabel", publicName: "timeColumnLabel", isSignal: true, isRequired: false, transformFunction: null }, valueColumnLabel: { classPropertyName: "valueColumnLabel", publicName: "valueColumnLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "block h-full" }, viewQueries: [{ propertyName: "svgRef", first: true, predicate: ["svgRef"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
1423
|
+
<div class="relative h-full">
|
|
1424
|
+
<svg
|
|
1425
|
+
#svgRef
|
|
1426
|
+
class="block h-full w-full touch-pan-y"
|
|
1427
|
+
role="img"
|
|
1428
|
+
tabindex="0"
|
|
1429
|
+
[attr.aria-label]="label()"
|
|
1430
|
+
[attr.height]="effectiveHeight()"
|
|
1431
|
+
[attr.viewBox]="'0 0 ' + effectiveWidth() + ' ' + effectiveHeight()"
|
|
1432
|
+
[attr.width]="effectiveWidth()"
|
|
1433
|
+
(keydown)="onKeydown($event)"
|
|
1434
|
+
(pointerleave)="onPointerLeave()"
|
|
1435
|
+
(pointermove)="onPointerMove($event)"
|
|
1436
|
+
>
|
|
1437
|
+
@if (geometry(); as geometry) {
|
|
1438
|
+
<path
|
|
1439
|
+
class="stroke-(--foreground)"
|
|
1440
|
+
data-chart-line
|
|
1441
|
+
fill="none"
|
|
1442
|
+
stroke-linecap="round"
|
|
1443
|
+
stroke-linejoin="round"
|
|
1444
|
+
stroke-width="2"
|
|
1445
|
+
[attr.d]="displayLine()"
|
|
1446
|
+
/>
|
|
1447
|
+
|
|
1448
|
+
<line
|
|
1449
|
+
class="stroke-(--border)"
|
|
1450
|
+
data-chart-start-line
|
|
1451
|
+
stroke-dasharray="4 4"
|
|
1452
|
+
stroke-width="1"
|
|
1453
|
+
x1="0"
|
|
1454
|
+
[attr.x2]="effectiveWidth()"
|
|
1455
|
+
[attr.y1]="geometry.start.y"
|
|
1456
|
+
[attr.y2]="geometry.start.y"
|
|
1457
|
+
/>
|
|
1458
|
+
|
|
1459
|
+
@if (startLabelInfo(); as info) {
|
|
1460
|
+
<text
|
|
1461
|
+
class="text-caption fill-(--subtle-foreground)"
|
|
1462
|
+
data-chart-start-label
|
|
1463
|
+
[attr.text-anchor]="info.placement.anchor"
|
|
1464
|
+
[attr.x]="info.placement.x"
|
|
1465
|
+
[attr.y]="info.placement.y"
|
|
1466
|
+
>
|
|
1467
|
+
{{ info.label }}
|
|
1468
|
+
</text>
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
<circle
|
|
1472
|
+
class="fill-(--foreground) stroke-(--card)"
|
|
1473
|
+
data-chart-end
|
|
1474
|
+
r="4"
|
|
1475
|
+
stroke-width="2"
|
|
1476
|
+
[attr.cx]="geometry.end.x"
|
|
1477
|
+
[attr.cy]="geometry.end.y"
|
|
1478
|
+
/>
|
|
1479
|
+
|
|
1480
|
+
@for (
|
|
1481
|
+
tick of ticks();
|
|
1482
|
+
track tick.t;
|
|
1483
|
+
let index = $index;
|
|
1484
|
+
let count = $count;
|
|
1485
|
+
let first = $first;
|
|
1486
|
+
let last = $last
|
|
1487
|
+
) {
|
|
1488
|
+
<text
|
|
1489
|
+
class="text-caption fill-(--subtle-foreground)"
|
|
1490
|
+
data-chart-axis-tick
|
|
1491
|
+
[attr.text-anchor]="first ? 'start' : last ? 'end' : 'middle'"
|
|
1492
|
+
[attr.x]="tick.x"
|
|
1493
|
+
[attr.y]="effectiveHeight() - 4"
|
|
1494
|
+
[class.max-sm:hidden]="!coreTick(index, count)"
|
|
1495
|
+
>
|
|
1496
|
+
{{ axisFormat()(tick.t) }}
|
|
1497
|
+
</text>
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
@if (active(); as point) {
|
|
1501
|
+
<line
|
|
1502
|
+
class="stroke-(--border)"
|
|
1503
|
+
data-chart-crosshair
|
|
1504
|
+
stroke-width="1"
|
|
1505
|
+
y1="0"
|
|
1506
|
+
[attr.x1]="point.x"
|
|
1507
|
+
[attr.x2]="point.x"
|
|
1508
|
+
[attr.y2]="effectiveHeight()"
|
|
1509
|
+
/>
|
|
1510
|
+
<circle
|
|
1511
|
+
class="fill-(--foreground) stroke-(--card)"
|
|
1512
|
+
data-chart-active
|
|
1513
|
+
r="4"
|
|
1514
|
+
stroke-width="2"
|
|
1515
|
+
[attr.cx]="point.x"
|
|
1516
|
+
[attr.cy]="point.y"
|
|
1517
|
+
/>
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
</svg>
|
|
1521
|
+
|
|
1522
|
+
@if (active(); as point) {
|
|
1523
|
+
<div
|
|
1524
|
+
class="rounded-container text-caption pointer-events-none absolute -translate-x-1/2 -translate-y-full bg-(--card) px-2.5 py-1.5 whitespace-nowrap shadow-[0_4px_16px_rgb(0_0_0/0.12)]"
|
|
1525
|
+
data-testid="chart-tooltip"
|
|
1526
|
+
role="status"
|
|
1527
|
+
[style]="tooltipStyle(point)"
|
|
1528
|
+
>
|
|
1529
|
+
<p class="font-medium text-(--foreground)">{{ valueFormat()(point.v) }}</p>
|
|
1530
|
+
<p data-chart-delta [class]="deltaClasses(point)">{{ deltaFormat()(deltaFor(point)) }}</p>
|
|
1531
|
+
<p class="text-(--subtle-foreground)">{{ timeFormat()(point.t) }}</p>
|
|
1532
|
+
</div>
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
@if (points().length > 0) {
|
|
1536
|
+
<table class="sr-only">
|
|
1537
|
+
<caption>
|
|
1538
|
+
{{
|
|
1539
|
+
label()
|
|
1540
|
+
}}
|
|
1541
|
+
</caption>
|
|
1542
|
+
<thead>
|
|
1543
|
+
<tr>
|
|
1544
|
+
<th scope="col">{{ timeColumnLabel() }}</th>
|
|
1545
|
+
<th scope="col">{{ valueColumnLabel() }}</th>
|
|
1546
|
+
</tr>
|
|
1547
|
+
</thead>
|
|
1548
|
+
<tbody>
|
|
1549
|
+
@for (point of points(); track point.t) {
|
|
1550
|
+
<tr>
|
|
1551
|
+
<td>{{ timeFormat()(point.t) }}</td>
|
|
1552
|
+
<td>{{ valueFormat()(point.v) }}</td>
|
|
1553
|
+
</tr>
|
|
1554
|
+
}
|
|
1555
|
+
</tbody>
|
|
1556
|
+
</table>
|
|
1557
|
+
}
|
|
1558
|
+
</div>
|
|
1559
|
+
`, isInline: true });
|
|
1560
|
+
}
|
|
1561
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: UiLineChart, decorators: [{
|
|
1562
|
+
type: Component,
|
|
1563
|
+
args: [{
|
|
1564
|
+
selector: 'ui-line-chart',
|
|
1565
|
+
template: `
|
|
1566
|
+
<div class="relative h-full">
|
|
1567
|
+
<svg
|
|
1568
|
+
#svgRef
|
|
1569
|
+
class="block h-full w-full touch-pan-y"
|
|
1570
|
+
role="img"
|
|
1571
|
+
tabindex="0"
|
|
1572
|
+
[attr.aria-label]="label()"
|
|
1573
|
+
[attr.height]="effectiveHeight()"
|
|
1574
|
+
[attr.viewBox]="'0 0 ' + effectiveWidth() + ' ' + effectiveHeight()"
|
|
1575
|
+
[attr.width]="effectiveWidth()"
|
|
1576
|
+
(keydown)="onKeydown($event)"
|
|
1577
|
+
(pointerleave)="onPointerLeave()"
|
|
1578
|
+
(pointermove)="onPointerMove($event)"
|
|
1579
|
+
>
|
|
1580
|
+
@if (geometry(); as geometry) {
|
|
1581
|
+
<path
|
|
1582
|
+
class="stroke-(--foreground)"
|
|
1583
|
+
data-chart-line
|
|
1584
|
+
fill="none"
|
|
1585
|
+
stroke-linecap="round"
|
|
1586
|
+
stroke-linejoin="round"
|
|
1587
|
+
stroke-width="2"
|
|
1588
|
+
[attr.d]="displayLine()"
|
|
1589
|
+
/>
|
|
1590
|
+
|
|
1591
|
+
<line
|
|
1592
|
+
class="stroke-(--border)"
|
|
1593
|
+
data-chart-start-line
|
|
1594
|
+
stroke-dasharray="4 4"
|
|
1595
|
+
stroke-width="1"
|
|
1596
|
+
x1="0"
|
|
1597
|
+
[attr.x2]="effectiveWidth()"
|
|
1598
|
+
[attr.y1]="geometry.start.y"
|
|
1599
|
+
[attr.y2]="geometry.start.y"
|
|
1600
|
+
/>
|
|
1601
|
+
|
|
1602
|
+
@if (startLabelInfo(); as info) {
|
|
1603
|
+
<text
|
|
1604
|
+
class="text-caption fill-(--subtle-foreground)"
|
|
1605
|
+
data-chart-start-label
|
|
1606
|
+
[attr.text-anchor]="info.placement.anchor"
|
|
1607
|
+
[attr.x]="info.placement.x"
|
|
1608
|
+
[attr.y]="info.placement.y"
|
|
1609
|
+
>
|
|
1610
|
+
{{ info.label }}
|
|
1611
|
+
</text>
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
<circle
|
|
1615
|
+
class="fill-(--foreground) stroke-(--card)"
|
|
1616
|
+
data-chart-end
|
|
1617
|
+
r="4"
|
|
1618
|
+
stroke-width="2"
|
|
1619
|
+
[attr.cx]="geometry.end.x"
|
|
1620
|
+
[attr.cy]="geometry.end.y"
|
|
1621
|
+
/>
|
|
1622
|
+
|
|
1623
|
+
@for (
|
|
1624
|
+
tick of ticks();
|
|
1625
|
+
track tick.t;
|
|
1626
|
+
let index = $index;
|
|
1627
|
+
let count = $count;
|
|
1628
|
+
let first = $first;
|
|
1629
|
+
let last = $last
|
|
1630
|
+
) {
|
|
1631
|
+
<text
|
|
1632
|
+
class="text-caption fill-(--subtle-foreground)"
|
|
1633
|
+
data-chart-axis-tick
|
|
1634
|
+
[attr.text-anchor]="first ? 'start' : last ? 'end' : 'middle'"
|
|
1635
|
+
[attr.x]="tick.x"
|
|
1636
|
+
[attr.y]="effectiveHeight() - 4"
|
|
1637
|
+
[class.max-sm:hidden]="!coreTick(index, count)"
|
|
1638
|
+
>
|
|
1639
|
+
{{ axisFormat()(tick.t) }}
|
|
1640
|
+
</text>
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
@if (active(); as point) {
|
|
1644
|
+
<line
|
|
1645
|
+
class="stroke-(--border)"
|
|
1646
|
+
data-chart-crosshair
|
|
1647
|
+
stroke-width="1"
|
|
1648
|
+
y1="0"
|
|
1649
|
+
[attr.x1]="point.x"
|
|
1650
|
+
[attr.x2]="point.x"
|
|
1651
|
+
[attr.y2]="effectiveHeight()"
|
|
1652
|
+
/>
|
|
1653
|
+
<circle
|
|
1654
|
+
class="fill-(--foreground) stroke-(--card)"
|
|
1655
|
+
data-chart-active
|
|
1656
|
+
r="4"
|
|
1657
|
+
stroke-width="2"
|
|
1658
|
+
[attr.cx]="point.x"
|
|
1659
|
+
[attr.cy]="point.y"
|
|
1660
|
+
/>
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
</svg>
|
|
1664
|
+
|
|
1665
|
+
@if (active(); as point) {
|
|
1666
|
+
<div
|
|
1667
|
+
class="rounded-container text-caption pointer-events-none absolute -translate-x-1/2 -translate-y-full bg-(--card) px-2.5 py-1.5 whitespace-nowrap shadow-[0_4px_16px_rgb(0_0_0/0.12)]"
|
|
1668
|
+
data-testid="chart-tooltip"
|
|
1669
|
+
role="status"
|
|
1670
|
+
[style]="tooltipStyle(point)"
|
|
1671
|
+
>
|
|
1672
|
+
<p class="font-medium text-(--foreground)">{{ valueFormat()(point.v) }}</p>
|
|
1673
|
+
<p data-chart-delta [class]="deltaClasses(point)">{{ deltaFormat()(deltaFor(point)) }}</p>
|
|
1674
|
+
<p class="text-(--subtle-foreground)">{{ timeFormat()(point.t) }}</p>
|
|
1675
|
+
</div>
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
@if (points().length > 0) {
|
|
1679
|
+
<table class="sr-only">
|
|
1680
|
+
<caption>
|
|
1681
|
+
{{
|
|
1682
|
+
label()
|
|
1683
|
+
}}
|
|
1684
|
+
</caption>
|
|
1685
|
+
<thead>
|
|
1686
|
+
<tr>
|
|
1687
|
+
<th scope="col">{{ timeColumnLabel() }}</th>
|
|
1688
|
+
<th scope="col">{{ valueColumnLabel() }}</th>
|
|
1689
|
+
</tr>
|
|
1690
|
+
</thead>
|
|
1691
|
+
<tbody>
|
|
1692
|
+
@for (point of points(); track point.t) {
|
|
1693
|
+
<tr>
|
|
1694
|
+
<td>{{ timeFormat()(point.t) }}</td>
|
|
1695
|
+
<td>{{ valueFormat()(point.v) }}</td>
|
|
1696
|
+
</tr>
|
|
1697
|
+
}
|
|
1698
|
+
</tbody>
|
|
1699
|
+
</table>
|
|
1700
|
+
}
|
|
1701
|
+
</div>
|
|
1702
|
+
`,
|
|
1703
|
+
host: {
|
|
1704
|
+
class: 'block h-full',
|
|
1705
|
+
},
|
|
1706
|
+
}]
|
|
1707
|
+
}], ctorParameters: () => [], propDecorators: { points: [{ type: i0.Input, args: [{ isSignal: true, alias: "points", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], valueFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueFormat", required: false }] }], deltaFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "deltaFormat", required: false }] }], timeFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeFormat", required: false }] }], axisFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "axisFormat", required: false }] }], startLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "startLabel", required: false }] }], timeColumnLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeColumnLabel", required: false }] }], valueColumnLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "valueColumnLabel", required: false }] }], svgRef: [{ type: i0.ViewChild, args: ['svgRef', { isSignal: true }] }] } });
|
|
1708
|
+
|
|
804
1709
|
const MENU_CLASSES = 'm-0 min-w-44 p-1 rounded-container bg-(--elevated) shadow-[0_8px_24px_rgb(0_0_0/0.16)] text-(--foreground) transition-[opacity,transform,overlay,display] transition-discrete duration-(--duration-fast) ease-out starting:open:opacity-0 starting:open:scale-(--enter-scale) opacity-0 scale-(--enter-scale) open:opacity-100 open:scale-100';
|
|
805
1710
|
const ITEM_CLASSES = 'flex w-full items-center gap-2 min-h-(--row-min) px-3 rounded-control text-label hover:bg-(--soft) focus-visible:bg-(--soft) outline-none';
|
|
806
1711
|
const nextId = (() => {
|
|
@@ -1619,5 +2524,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
|
|
|
1619
2524
|
* Generated bundle index. Do not edit.
|
|
1620
2525
|
*/
|
|
1621
2526
|
|
|
1622
|
-
export { AMOUNT_MASKINGS, AMOUNT_NUMERICS, ASYNC_STATES, AVATAR_SIZES, BADGE_VARIANTS, BUTTON_SIZES, BUTTON_VARIANTS, CARD_PADDINGS, CARD_VARIANTS, CELL_BREAKPOINTS, CONTROL_SIZES, DELTA_EMPHASES, DIALOG_WIDTHS, METER_TONES, STAT_SIZES, UI_AMOUNT_MASKED, UiAmount, UiAsync, UiAvatar, UiBadge, UiButton, UiCard, UiDelta, UiDialog, UiField, UiInput, UiMenu, UiMenuItem, UiMenuTrigger, UiMeter, UiNavItem, UiRow, UiSegmented, UiSelect, UiSkeleton, UiStat, UiSwitch, UiTab, UiTabBar, UiTable, UiTd, UiTextarea, UiTh, UiTr, formatAmount };
|
|
2527
|
+
export { AMOUNT_MASKINGS, AMOUNT_NUMERICS, ASYNC_STATES, AVATAR_SIZES, BADGE_VARIANTS, BUTTON_SIZES, BUTTON_VARIANTS, CARD_PADDINGS, CARD_VARIANTS, CELL_BREAKPOINTS, CONTROL_SIZES, DELTA_EMPHASES, DIALOG_WIDTHS, METER_TONES, STAT_SIZES, UI_AMOUNT_MASKED, UiAmount, UiAsync, UiAvatar, UiBadge, UiButton, UiCard, UiDelta, UiDialog, UiDonut, UiField, UiInput, UiLineChart, UiMenu, UiMenuItem, UiMenuTrigger, UiMeter, UiNavItem, UiRow, UiSegmented, UiSelect, UiSkeleton, UiStat, UiSwitch, UiTab, UiTabBar, UiTable, UiTd, UiTextarea, UiTh, UiTr, formatAmount };
|
|
1623
2528
|
//# sourceMappingURL=joanroucoux-cairn-ui.mjs.map
|