@huloglobal/vendure-plugin-visitor-analytics 0.9.1 → 0.10.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/package.json +2 -2
- package/ui/components/visitors.component.ts +291 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huloglobal/vendure-plugin-visitor-analytics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Full-funnel visitor analytics for Vendure storefronts — pageviews, time-on-page, session journey, exit pages, funnel drop-off, and per-visitor profile drawer with parsed user-agent + MaxMind GeoLite2 enrichment. Auto-issues visitor + session cookies on first request; logs guest and signed-in events against the same visitor id so the journey survives login.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"author": "Wayne Garrison <wayne@garrison.me.uk>",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@huloglobal/vendure-licence-sdk": "^0.4.0",
|
|
41
41
|
"@nestjs/common": "^10.0.0",
|
|
42
|
-
"@vendure/core": "^3.
|
|
42
|
+
"@vendure/core": "^3.7.1",
|
|
43
43
|
"typeorm": "^0.3.0",
|
|
44
44
|
"typescript": "^5.4.0"
|
|
45
45
|
},
|
|
@@ -179,10 +179,75 @@ interface VisitorProfile {
|
|
|
179
179
|
</div>
|
|
180
180
|
</vdr-page-block>
|
|
181
181
|
|
|
182
|
+
<!-- ── Daily activity chart — line chart over the summary's daily
|
|
183
|
+
series. Series toggles are multi-select (identity colors are
|
|
184
|
+
fixed per series and never repaint on toggle); state persists
|
|
185
|
+
in localStorage alongside the section collapse prefs. -->
|
|
182
186
|
<vdr-page-block>
|
|
183
187
|
<div class="card">
|
|
184
188
|
<div class="card-block">
|
|
185
|
-
<
|
|
189
|
+
<button type="button" class="collapse-head" (click)="toggleSection('chart')" [attr.aria-expanded]="sectionOpen['chart']">
|
|
190
|
+
<h3 class="card-title">Daily activity <span class="muted">(last {{ days }} days)</span></h3>
|
|
191
|
+
<clr-icon shape="angle" [attr.dir]="sectionOpen['chart'] ? 'down' : 'right'"></clr-icon>
|
|
192
|
+
</button>
|
|
193
|
+
<ng-container *ngIf="sectionOpen['chart']">
|
|
194
|
+
<div class="series-toggles" role="group" aria-label="Chart series">
|
|
195
|
+
<button *ngFor="let sd of seriesDefs" type="button"
|
|
196
|
+
class="series-pill" [class.on]="seriesOn[sd.key]"
|
|
197
|
+
[attr.aria-pressed]="seriesOn[sd.key]"
|
|
198
|
+
(click)="toggleSeries(sd.key)">
|
|
199
|
+
<span class="series-chip" [style.background]="seriesOn[sd.key] ? sd.color : 'transparent'" [style.borderColor]="sd.color"></span>
|
|
200
|
+
{{ sd.label }}
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
<div *ngIf="!chart" class="muted pad">No data in this range.</div>
|
|
204
|
+
<div class="chart-wrap" *ngIf="chart">
|
|
205
|
+
<svg #dailySvg class="daily-chart" viewBox="0 0 800 240" preserveAspectRatio="none" role="img"
|
|
206
|
+
[attr.aria-label]="'Daily activity chart, ' + days + ' days'"
|
|
207
|
+
(mousemove)="onChartMove($event, dailySvg)" (mouseleave)="onChartLeave()">
|
|
208
|
+
<!-- recessive gridlines + y labels -->
|
|
209
|
+
<g *ngFor="let t of chart.ticks">
|
|
210
|
+
<line [attr.x1]="chart.L" [attr.x2]="chart.R" [attr.y1]="t.y" [attr.y2]="t.y" class="gridline"/>
|
|
211
|
+
<text [attr.x]="chart.L - 6" [attr.y]="t.y + 3" class="axis-label" text-anchor="end">{{ t.label }}</text>
|
|
212
|
+
</g>
|
|
213
|
+
<!-- x labels -->
|
|
214
|
+
<text *ngFor="let xl of chart.xlabels" [attr.x]="xl.x" y="236" class="axis-label" text-anchor="middle">{{ xl.label }}</text>
|
|
215
|
+
<!-- series lines: 2px, color fixed per series -->
|
|
216
|
+
<polyline *ngFor="let ln of chart.lines" [attr.points]="ln.points"
|
|
217
|
+
fill="none" [attr.stroke]="ln.color" stroke-width="2"
|
|
218
|
+
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
|
|
219
|
+
<!-- hover crosshair + markers -->
|
|
220
|
+
<g *ngIf="hoverIdx !== null && chart.xs[hoverIdx] !== undefined">
|
|
221
|
+
<line [attr.x1]="chart.xs[hoverIdx]" [attr.x2]="chart.xs[hoverIdx]" y1="10" y2="216" class="crosshair"/>
|
|
222
|
+
<circle *ngFor="let ln of chart.lines" [attr.cx]="chart.xs[hoverIdx]" [attr.cy]="ln.ys[hoverIdx]"
|
|
223
|
+
r="4" [attr.fill]="ln.color" class="hover-dot"/>
|
|
224
|
+
</g>
|
|
225
|
+
</svg>
|
|
226
|
+
<!-- tooltip: text tokens carry the values; colored chips carry identity -->
|
|
227
|
+
<div class="chart-tip" *ngIf="hoverIdx !== null && chart.days[hoverIdx]"
|
|
228
|
+
[style.left.%]="(chart.xs[hoverIdx] / 800) * 100"
|
|
229
|
+
[class.flip]="chart.xs[hoverIdx] > 560">
|
|
230
|
+
<div class="tip-date">{{ chart.days[hoverIdx].label }}</div>
|
|
231
|
+
<div class="tip-row" *ngFor="let ln of chart.lines">
|
|
232
|
+
<span class="series-chip" [style.background]="ln.color"></span>
|
|
233
|
+
<span class="tip-name">{{ ln.label }}</span>
|
|
234
|
+
<span class="tip-val">{{ chart.days[hoverIdx].values[ln.key] | number }}</span>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</ng-container>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</vdr-page-block>
|
|
242
|
+
|
|
243
|
+
<vdr-page-block>
|
|
244
|
+
<div class="card">
|
|
245
|
+
<div class="card-block">
|
|
246
|
+
<button type="button" class="collapse-head" (click)="toggleSection('funnel')" [attr.aria-expanded]="sectionOpen['funnel']">
|
|
247
|
+
<h3 class="card-title">Funnel <span class="muted">(last {{ days }} days)</span></h3>
|
|
248
|
+
<clr-icon shape="angle" [attr.dir]="sectionOpen['funnel'] ? 'down' : 'right'"></clr-icon>
|
|
249
|
+
</button>
|
|
250
|
+
<ng-container *ngIf="sectionOpen['funnel']">
|
|
186
251
|
<div *ngIf="funnel.length === 0" class="muted pad">No data yet.</div>
|
|
187
252
|
<div class="funnel" *ngIf="funnel.length > 0">
|
|
188
253
|
<div class="funnel-row" *ngFor="let s of funnel">
|
|
@@ -196,6 +261,7 @@ interface VisitorProfile {
|
|
|
196
261
|
</div>
|
|
197
262
|
</div>
|
|
198
263
|
</div>
|
|
264
|
+
</ng-container>
|
|
199
265
|
</div>
|
|
200
266
|
</div>
|
|
201
267
|
</vdr-page-block>
|
|
@@ -204,10 +270,14 @@ interface VisitorProfile {
|
|
|
204
270
|
<div class="two-col">
|
|
205
271
|
<div class="card">
|
|
206
272
|
<div class="card-block">
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
273
|
+
<button type="button" class="collapse-head" (click)="toggleSection('top')" [attr.aria-expanded]="sectionOpen['top']">
|
|
274
|
+
<h3 class="card-title">
|
|
275
|
+
Top pages
|
|
276
|
+
<span class="muted">{{ topTotal | number }} total</span>
|
|
277
|
+
</h3>
|
|
278
|
+
<clr-icon shape="angle" [attr.dir]="sectionOpen['top'] ? 'down' : 'right'"></clr-icon>
|
|
279
|
+
</button>
|
|
280
|
+
<ng-container *ngIf="sectionOpen['top']">
|
|
211
281
|
<div *ngIf="topPages.length === 0" class="muted pad">No data.</div>
|
|
212
282
|
<table class="table table-compact" *ngIf="topPages.length > 0">
|
|
213
283
|
<thead>
|
|
@@ -230,15 +300,20 @@ interface VisitorProfile {
|
|
|
230
300
|
<span class="muted">{{ topSkip + 1 }}–{{ topSkip + topPages.length }} of {{ topTotal }}</span>
|
|
231
301
|
<button class="btn btn-sm" (click)="topNext()" [disabled]="topSkip + topTake >= topTotal">Next ›</button>
|
|
232
302
|
</div>
|
|
303
|
+
</ng-container>
|
|
233
304
|
</div>
|
|
234
305
|
</div>
|
|
235
306
|
|
|
236
307
|
<div class="card">
|
|
237
308
|
<div class="card-block">
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
309
|
+
<button type="button" class="collapse-head" (click)="toggleSection('exit')" [attr.aria-expanded]="sectionOpen['exit']">
|
|
310
|
+
<h3 class="card-title">
|
|
311
|
+
Exit pages
|
|
312
|
+
<span class="muted">{{ exitTotal | number }} total</span>
|
|
313
|
+
</h3>
|
|
314
|
+
<clr-icon shape="angle" [attr.dir]="sectionOpen['exit'] ? 'down' : 'right'"></clr-icon>
|
|
315
|
+
</button>
|
|
316
|
+
<ng-container *ngIf="sectionOpen['exit']">
|
|
242
317
|
<div *ngIf="exitPages.length === 0" class="muted pad">No data.</div>
|
|
243
318
|
<table class="table table-compact" *ngIf="exitPages.length > 0">
|
|
244
319
|
<thead>
|
|
@@ -259,6 +334,7 @@ interface VisitorProfile {
|
|
|
259
334
|
<span class="muted">{{ exitSkip + 1 }}–{{ exitSkip + exitPages.length }} of {{ exitTotal }}</span>
|
|
260
335
|
<button class="btn btn-sm" (click)="exitNext()" [disabled]="exitSkip + exitTake >= exitTotal">Next ›</button>
|
|
261
336
|
</div>
|
|
337
|
+
</ng-container>
|
|
262
338
|
</div>
|
|
263
339
|
</div>
|
|
264
340
|
</div>
|
|
@@ -267,10 +343,14 @@ interface VisitorProfile {
|
|
|
267
343
|
<vdr-page-block>
|
|
268
344
|
<div class="card">
|
|
269
345
|
<div class="card-block">
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
346
|
+
<button type="button" class="collapse-head" (click)="toggleSection('visitors')" [attr.aria-expanded]="sectionOpen['visitors']">
|
|
347
|
+
<h3 class="card-title">
|
|
348
|
+
Visitors
|
|
349
|
+
<span class="muted">{{ recentTotal | number }} total · click a row for the full profile</span>
|
|
350
|
+
</h3>
|
|
351
|
+
<clr-icon shape="angle" [attr.dir]="sectionOpen['visitors'] ? 'down' : 'right'"></clr-icon>
|
|
352
|
+
</button>
|
|
353
|
+
<ng-container *ngIf="sectionOpen['visitors']">
|
|
274
354
|
<div *ngIf="recent.length === 0" class="muted pad">No visitors in this range.</div>
|
|
275
355
|
<table class="table table-compact" *ngIf="recent.length > 0">
|
|
276
356
|
<thead>
|
|
@@ -319,6 +399,7 @@ interface VisitorProfile {
|
|
|
319
399
|
<span class="muted">{{ recentSkip + 1 }}–{{ recentSkip + recent.length }} of {{ recentTotal }}</span>
|
|
320
400
|
<button class="btn btn-sm" (click)="recentNext()" [disabled]="recentSkip + recentTake >= recentTotal">Next ›</button>
|
|
321
401
|
</div>
|
|
402
|
+
</ng-container>
|
|
322
403
|
</div>
|
|
323
404
|
</div>
|
|
324
405
|
</vdr-page-block>
|
|
@@ -453,6 +534,72 @@ interface VisitorProfile {
|
|
|
453
534
|
styles: [`
|
|
454
535
|
:host { color: var(--color-text-100, inherit); display: block; }
|
|
455
536
|
|
|
537
|
+
/* ── Chart series colors — categorical identity, fixed order.
|
|
538
|
+
Validated (scripts/validate_palette.js): light set passes all
|
|
539
|
+
six checks on the light admin surface; dark steps pass on
|
|
540
|
+
#1f2937. Colors follow the SERIES, never its toggle rank. */
|
|
541
|
+
:host {
|
|
542
|
+
--hulo-s1: #2a78d6; /* unique visitors — blue */
|
|
543
|
+
--hulo-s2: #eb6834; /* sessions — orange */
|
|
544
|
+
--hulo-s3: #1baf7a; /* page views — aqua */
|
|
545
|
+
--hulo-s4: #eda100; /* events — yellow */
|
|
546
|
+
}
|
|
547
|
+
:host-context([data-theme='dark']),
|
|
548
|
+
:host-context(.theme-dark) {
|
|
549
|
+
--hulo-s1: #3987e5;
|
|
550
|
+
--hulo-s2: #d95926;
|
|
551
|
+
--hulo-s3: #199e70;
|
|
552
|
+
--hulo-s4: #c98500;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ── Collapsible section headers ─────────────────────────── */
|
|
556
|
+
.collapse-head {
|
|
557
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
558
|
+
width: 100%; background: none; border: 0; padding: 0; cursor: pointer;
|
|
559
|
+
text-align: left; color: inherit;
|
|
560
|
+
}
|
|
561
|
+
.collapse-head .card-title { margin-bottom: 0; }
|
|
562
|
+
.collapse-head clr-icon { color: var(--color-component-color-300, #64748b); }
|
|
563
|
+
.collapse-head:hover clr-icon { color: var(--color-primary-500, #f59e0b); }
|
|
564
|
+
.collapse-head[aria-expanded='true'] .card-title { margin-bottom: 12px; }
|
|
565
|
+
|
|
566
|
+
/* ── Daily activity chart ────────────────────────────────── */
|
|
567
|
+
.series-toggles { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
|
568
|
+
.series-pill {
|
|
569
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
570
|
+
padding: 5px 12px; min-height: 30px; border-radius: 999px;
|
|
571
|
+
border: 1px solid var(--color-component-border-200, #d1d5db);
|
|
572
|
+
background: var(--color-component-bg-100, #fff);
|
|
573
|
+
color: var(--color-component-color-200, #475569);
|
|
574
|
+
font-size: 12px; font-weight: 600; cursor: pointer;
|
|
575
|
+
transition: border-color 0.15s ease, color 0.15s ease;
|
|
576
|
+
}
|
|
577
|
+
.series-pill.on { color: var(--color-text-100, #0f172a); border-color: var(--color-component-border-300, #94a3b8); }
|
|
578
|
+
.series-pill:hover { border-color: var(--color-primary-500, #f59e0b); }
|
|
579
|
+
.series-chip {
|
|
580
|
+
width: 10px; height: 10px; border-radius: 3px; display: inline-block;
|
|
581
|
+
border: 1.5px solid transparent; flex: 0 0 auto;
|
|
582
|
+
}
|
|
583
|
+
.chart-wrap { position: relative; }
|
|
584
|
+
.daily-chart { width: 100%; height: 240px; display: block; }
|
|
585
|
+
.gridline { stroke: var(--color-component-border-100, #e2e8f0); stroke-width: 1; }
|
|
586
|
+
.axis-label { fill: var(--color-component-color-300, #64748b); font-size: 10px; }
|
|
587
|
+
.crosshair { stroke: var(--color-component-color-300, #94a3b8); stroke-width: 1; stroke-dasharray: 3 3; }
|
|
588
|
+
.hover-dot { stroke: var(--color-component-bg-100, #fff); stroke-width: 2; }
|
|
589
|
+
.chart-tip {
|
|
590
|
+
position: absolute; top: 8px; transform: translateX(10px);
|
|
591
|
+
background: var(--color-component-bg-100, #fff);
|
|
592
|
+
border: 1px solid var(--color-component-border-200, #d1d5db);
|
|
593
|
+
border-radius: 8px; padding: 8px 10px; pointer-events: none;
|
|
594
|
+
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12); z-index: 5;
|
|
595
|
+
min-width: 150px;
|
|
596
|
+
}
|
|
597
|
+
.chart-tip.flip { transform: translateX(calc(-100% - 10px)); }
|
|
598
|
+
.tip-date { font-size: 11px; font-weight: 700; color: var(--color-text-100, #0f172a); margin-bottom: 4px; }
|
|
599
|
+
.tip-row { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 1px 0; }
|
|
600
|
+
.tip-name { color: var(--color-component-color-200, #475569); flex: 1; }
|
|
601
|
+
.tip-val { font-weight: 700; color: var(--color-text-100, #0f172a); font-variant-numeric: tabular-nums; }
|
|
602
|
+
|
|
456
603
|
/* ── HULO shared hero + help pattern ─────────────────────── */
|
|
457
604
|
.hulo-hero {
|
|
458
605
|
display: flex; align-items: center; gap: 18px;
|
|
@@ -664,6 +811,134 @@ export class VisitorsComponent implements OnInit, OnDestroy {
|
|
|
664
811
|
channelId: number | null = null;
|
|
665
812
|
channels: Array<{ id: number; code: string }> = [];
|
|
666
813
|
|
|
814
|
+
// ── Daily-activity chart ────────────────────────────────────────
|
|
815
|
+
// Series colors are FIXED per series (identity), validated for
|
|
816
|
+
// CVD separation + lightness on both admin surfaces — see the
|
|
817
|
+
// :host CSS vars. Toggling a series never repaints the others.
|
|
818
|
+
daily: Array<{ day: string; visitors: number; sessions: number; events: number; pageviews: number }> = [];
|
|
819
|
+
seriesDefs = [
|
|
820
|
+
{ key: 'visitors', label: 'Unique visitors', color: 'var(--hulo-s1)' },
|
|
821
|
+
{ key: 'sessions', label: 'Sessions', color: 'var(--hulo-s2)' },
|
|
822
|
+
{ key: 'pageviews', label: 'Page views', color: 'var(--hulo-s3)' },
|
|
823
|
+
{ key: 'events', label: 'Events', color: 'var(--hulo-s4)' },
|
|
824
|
+
];
|
|
825
|
+
seriesOn: Record<string, boolean> = { visitors: true, sessions: true, pageviews: false, events: false };
|
|
826
|
+
chart: {
|
|
827
|
+
L: number; R: number;
|
|
828
|
+
ticks: Array<{ y: number; label: string }>;
|
|
829
|
+
xlabels: Array<{ x: number; label: string }>;
|
|
830
|
+
lines: Array<{ key: string; label: string; color: string; points: string; ys: number[] }>;
|
|
831
|
+
xs: number[];
|
|
832
|
+
days: Array<{ label: string; values: Record<string, number> }>;
|
|
833
|
+
} | null = null;
|
|
834
|
+
hoverIdx: number | null = null;
|
|
835
|
+
|
|
836
|
+
// ── Collapsible sections (persisted) ────────────────────────────
|
|
837
|
+
sectionOpen: Record<string, boolean> = { chart: true, funnel: true, top: true, exit: true, visitors: true };
|
|
838
|
+
private readonly prefsKey = 'hulo-visitor-journey-prefs';
|
|
839
|
+
|
|
840
|
+
private restorePrefs(): void {
|
|
841
|
+
try {
|
|
842
|
+
const p = JSON.parse(localStorage.getItem(this.prefsKey) || '{}');
|
|
843
|
+
if (p.series && typeof p.series === 'object') Object.assign(this.seriesOn, p.series);
|
|
844
|
+
if (p.sections && typeof p.sections === 'object') Object.assign(this.sectionOpen, p.sections);
|
|
845
|
+
// Never restore into a state with zero visible series.
|
|
846
|
+
if (!this.seriesDefs.some(sd => this.seriesOn[sd.key])) this.seriesOn['visitors'] = true;
|
|
847
|
+
} catch { /* corrupted prefs — fall back to defaults */ }
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private savePrefs(): void {
|
|
851
|
+
try {
|
|
852
|
+
localStorage.setItem(this.prefsKey, JSON.stringify({ series: this.seriesOn, sections: this.sectionOpen }));
|
|
853
|
+
} catch { /* storage full/blocked — non-fatal */ }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
toggleSection(key: string): void {
|
|
857
|
+
this.sectionOpen[key] = !this.sectionOpen[key];
|
|
858
|
+
this.savePrefs();
|
|
859
|
+
this.cdr.markForCheck();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
toggleSeries(key: string): void {
|
|
863
|
+
// Keep at least one series visible.
|
|
864
|
+
const activeCount = this.seriesDefs.filter(sd => this.seriesOn[sd.key]).length;
|
|
865
|
+
if (this.seriesOn[key] && activeCount <= 1) return;
|
|
866
|
+
this.seriesOn[key] = !this.seriesOn[key];
|
|
867
|
+
this.savePrefs();
|
|
868
|
+
this.rebuildChart();
|
|
869
|
+
this.cdr.markForCheck();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Nice ceiling for the y-axis: 1/2/5 × 10^n at or above v. */
|
|
873
|
+
private niceCeil(v: number): number {
|
|
874
|
+
if (v <= 0) return 1;
|
|
875
|
+
const mag = Math.pow(10, Math.floor(Math.log10(v)));
|
|
876
|
+
for (const m of [1, 2, 5, 10]) {
|
|
877
|
+
if (m * mag >= v) return m * mag;
|
|
878
|
+
}
|
|
879
|
+
return 10 * mag;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private rebuildChart(): void {
|
|
883
|
+
const days = this.daily || [];
|
|
884
|
+
const active = this.seriesDefs.filter(sd => this.seriesOn[sd.key]);
|
|
885
|
+
if (days.length === 0 || active.length === 0) { this.chart = null; this.hoverIdx = null; return; }
|
|
886
|
+
const L = 44, R = 788, T = 10, B = 216;
|
|
887
|
+
const maxVal = Math.max(1, ...days.map(d => Math.max(...active.map(sd => Number((d as any)[sd.key]) || 0))));
|
|
888
|
+
const yMax = this.niceCeil(maxVal);
|
|
889
|
+
const x = (i: number) => (days.length === 1 ? (L + R) / 2 : L + (i * (R - L)) / (days.length - 1));
|
|
890
|
+
const y = (v: number) => B - (v / yMax) * (B - T);
|
|
891
|
+
const fmtDay = (raw: string) => {
|
|
892
|
+
const d = new Date(raw);
|
|
893
|
+
return isNaN(d.getTime()) ? String(raw).slice(5) : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
894
|
+
};
|
|
895
|
+
const xs = days.map((_, i) => Math.round(x(i) * 10) / 10);
|
|
896
|
+
const lines = active.map(sd => {
|
|
897
|
+
const ys = days.map(d => Math.round(y(Number((d as any)[sd.key]) || 0) * 10) / 10);
|
|
898
|
+
return {
|
|
899
|
+
key: sd.key, label: sd.label, color: sd.color, ys,
|
|
900
|
+
points: xs.map((px, i) => px + ',' + ys[i]).join(' '),
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
const ticks = [0, 0.5, 1].map(f => ({
|
|
904
|
+
y: Math.round(y(yMax * f) * 10) / 10,
|
|
905
|
+
label: String(Math.round(yMax * f)),
|
|
906
|
+
}));
|
|
907
|
+
const labelEvery = Math.max(1, Math.ceil(days.length / 6));
|
|
908
|
+
const xlabels = days
|
|
909
|
+
.map((d, i) => ({ i, d }))
|
|
910
|
+
.filter(({ i }) => i % labelEvery === 0 || i === days.length - 1)
|
|
911
|
+
.map(({ i, d }) => ({ x: xs[i], label: fmtDay(d.day) }));
|
|
912
|
+
this.chart = {
|
|
913
|
+
L, R, ticks, xlabels, lines, xs,
|
|
914
|
+
days: days.map(d => ({
|
|
915
|
+
label: fmtDay(d.day),
|
|
916
|
+
values: {
|
|
917
|
+
visitors: Number(d.visitors) || 0,
|
|
918
|
+
sessions: Number(d.sessions) || 0,
|
|
919
|
+
pageviews: Number(d.pageviews) || 0,
|
|
920
|
+
events: Number(d.events) || 0,
|
|
921
|
+
},
|
|
922
|
+
})),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
onChartMove(ev: MouseEvent, svg: SVGSVGElement): void {
|
|
927
|
+
if (!this.chart || this.chart.xs.length === 0) return;
|
|
928
|
+
const rect = svg.getBoundingClientRect();
|
|
929
|
+
const vx = ((ev.clientX - rect.left) / rect.width) * 800;
|
|
930
|
+
let best = 0, bestDist = Infinity;
|
|
931
|
+
for (let i = 0; i < this.chart.xs.length; i++) {
|
|
932
|
+
const d = Math.abs(this.chart.xs[i] - vx);
|
|
933
|
+
if (d < bestDist) { bestDist = d; best = i; }
|
|
934
|
+
}
|
|
935
|
+
if (this.hoverIdx !== best) { this.hoverIdx = best; this.cdr.markForCheck(); }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
onChartLeave(): void {
|
|
939
|
+
if (this.hoverIdx !== null) { this.hoverIdx = null; this.cdr.markForCheck(); }
|
|
940
|
+
}
|
|
941
|
+
|
|
667
942
|
summary = { visitors: 0, sessions: 0, pageviews: 0, avgTimeMs: 0 };
|
|
668
943
|
funnel: FunnelStage[] = [];
|
|
669
944
|
|
|
@@ -708,6 +983,7 @@ export class VisitorsComponent implements OnInit, OnDestroy {
|
|
|
708
983
|
) {}
|
|
709
984
|
|
|
710
985
|
ngOnInit() {
|
|
986
|
+
this.restorePrefs();
|
|
711
987
|
this.loadChannels();
|
|
712
988
|
this.loadAll();
|
|
713
989
|
this.connectLive();
|
|
@@ -825,6 +1101,8 @@ export class VisitorsComponent implements OnInit, OnDestroy {
|
|
|
825
1101
|
this.fetchRecent(),
|
|
826
1102
|
]).then(([summaryRes, funnelRes]) => {
|
|
827
1103
|
this.summary = summaryRes?.totals || this.summary;
|
|
1104
|
+
this.daily = Array.isArray(summaryRes?.daily) ? summaryRes.daily : [];
|
|
1105
|
+
this.rebuildChart();
|
|
828
1106
|
this.funnel = funnelRes?.stages || [];
|
|
829
1107
|
this.loading = false;
|
|
830
1108
|
this.cdr.markForCheck();
|