@kntnt/engagement-metrics 1.0.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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/element.d.ts +49 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/iife.d.ts +6 -0
- package/dist/iife.d.ts.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +435 -0
- package/dist/interval-set.d.ts +22 -0
- package/dist/interval-set.d.ts.map +1 -0
- package/dist/kntnt-engagement-metrics.min.js +1 -0
- package/dist/measurer.d.ts +47 -0
- package/dist/measurer.d.ts.map +1 -0
- package/dist/timer.d.ts +45 -0
- package/dist/timer.d.ts.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kntnt Sweden AB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @kntnt/engagement-metrics
|
|
2
|
+
|
|
3
|
+
Lightweight client-side library that measures how deeply users engage with text content on web pages — distinguishing *reading* (pausing on visible content) from *scanning* (scrolling through). Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @kntnt/engagement-metrics
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### ESM (bundler)
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createMeasurer } from '@kntnt/engagement-metrics'
|
|
17
|
+
|
|
18
|
+
const measurer = createMeasurer({
|
|
19
|
+
selector: 'p',
|
|
20
|
+
readingSpeed: 1380,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
measurer.addListener({
|
|
24
|
+
update(metrics) {
|
|
25
|
+
console.log(`Reading: ${(metrics.readingRatio * 100).toFixed(0)}%`)
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
measurer.start()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### IIFE (script tag)
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<script src="https://unpkg.com/@kntnt/engagement-metrics/dist/kntnt-engagement-metrics.min.js"></script>
|
|
36
|
+
<script>
|
|
37
|
+
KntntEngagementMetrics.measurer = KntntEngagementMetrics.start({ selector: 'article p' })
|
|
38
|
+
</script>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Add-ons
|
|
42
|
+
|
|
43
|
+
Use the core with one or more analytics add-ons:
|
|
44
|
+
|
|
45
|
+
- [`@kntnt/engagement-metrics-matomo`](https://www.npmjs.com/package/@kntnt/engagement-metrics-matomo) — Matomo Analytics
|
|
46
|
+
- [`@kntnt/engagement-metrics-gtag`](https://www.npmjs.com/package/@kntnt/engagement-metrics-gtag) — Google Analytics 4
|
|
47
|
+
- [`@kntnt/engagement-metrics-overlay`](https://www.npmjs.com/package/@kntnt/engagement-metrics-overlay) — Visual overlay for debugging and demos
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
|
|
51
|
+
See the [main repository](https://github.com/Kntnt/kntnt-engagement-metrics) for full documentation, configuration options, and the algorithm specification.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
[MIT](https://github.com/Kntnt/kntnt-engagement-metrics/blob/main/LICENSE) — Copyright (c) 2026 Kntnt Sweden AB
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Timer } from './timer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tracks a single content element's visibility and reading state.
|
|
4
|
+
*/
|
|
5
|
+
export declare class TrackedElement {
|
|
6
|
+
#private;
|
|
7
|
+
readonly node: Element;
|
|
8
|
+
readonly timer: Timer;
|
|
9
|
+
readonly charCount: number;
|
|
10
|
+
/**
|
|
11
|
+
* @param node - The DOM element to track.
|
|
12
|
+
* @param readingSpeed - Reading speed in characters per minute.
|
|
13
|
+
*/
|
|
14
|
+
constructor(node: Element, readingSpeed: number);
|
|
15
|
+
/** Current visibility ratio (0–1) as reported by IntersectionObserver. */
|
|
16
|
+
get visibilityRatio(): number;
|
|
17
|
+
/** Whether this element has ever been visible in the viewport. */
|
|
18
|
+
get hasBeenSeen(): boolean;
|
|
19
|
+
/** Whether the reading timer for this element has completed. */
|
|
20
|
+
get isFullyRead(): boolean;
|
|
21
|
+
/** Reading progress for this element (0–1). */
|
|
22
|
+
get readingProgress(): number;
|
|
23
|
+
/** Cumulative fraction (0–1) of this element that has ever been visible. */
|
|
24
|
+
get seenRatio(): number;
|
|
25
|
+
/** The merged seen intervals, for diagnostic/overlay use. */
|
|
26
|
+
get seenIntervals(): ReadonlyArray<readonly [number, number]>;
|
|
27
|
+
/** Start of the currently visible interval (0 = element top, 1 = element bottom). */
|
|
28
|
+
get visibleStart(): number;
|
|
29
|
+
/** End of the currently visible interval (0 = element top, 1 = element bottom). */
|
|
30
|
+
get visibleEnd(): number;
|
|
31
|
+
/**
|
|
32
|
+
* Precomputed targetRatio based on the visible interval at the time of the last
|
|
33
|
+
* IO callback. Snapshots timer progress so the ceiling doesn't slide as the
|
|
34
|
+
* timer advances between callbacks.
|
|
35
|
+
*/
|
|
36
|
+
get computedTargetRatio(): number;
|
|
37
|
+
/**
|
|
38
|
+
* Record a visible interval for this element.
|
|
39
|
+
* @internal Used by Measurer — add-ons and external code must not call this.
|
|
40
|
+
*/
|
|
41
|
+
addSeenInterval(start: number, end: number): void;
|
|
42
|
+
/**
|
|
43
|
+
* Update visibility state from an IntersectionObserver entry.
|
|
44
|
+
* Computes the visible interval, accumulates it into seen intervals, and
|
|
45
|
+
* snapshots the targetRatio based on current timer progress.
|
|
46
|
+
*/
|
|
47
|
+
updateVisibility(entry: IntersectionObserverEntry): void;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=element.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"element.d.ts","sourceRoot":"","sources":["../src/element.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAElC;;GAEG;AACH,qBAAa,cAAc;;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAS1B;;;OAGG;gBACS,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM;IAO/C,0EAA0E;IAC1E,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED,kEAAkE;IAClE,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,gEAAgE;IAChE,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,+CAA+C;IAC/C,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED,4EAA4E;IAC5E,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,6DAA6D;IAC7D,IAAI,aAAa,IAAI,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAE5D;IAED,qFAAqF;IACrF,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,mFAAmF;IACnF,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED;;;;OAIG;IACH,IAAI,mBAAmB,IAAI,MAAM,CAEhC;IAED;;;OAGG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAIjD;;;;OAIG;IACH,gBAAgB,CAAC,KAAK,EAAE,yBAAyB,GAAG,IAAI;CA8BzD"}
|
package/dist/iife.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iife.d.ts","sourceRoot":"","sources":["../src/iife.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kntnt/engagement-metrics
|
|
3
|
+
*
|
|
4
|
+
* Lightweight library that measures how deeply users engage with
|
|
5
|
+
* content on web pages — reading vs. scanning, time spent, and
|
|
6
|
+
* completion rate.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import { Measurer } from './measurer.js';
|
|
11
|
+
import type { MeasurerConfig } from './types.js';
|
|
12
|
+
export { TrackedElement } from './element.js';
|
|
13
|
+
export { IntervalSet } from './interval-set.js';
|
|
14
|
+
export { Measurer } from './measurer.js';
|
|
15
|
+
export type { EngagementMetrics, MeasurerConfig, MetricsListener } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Convenience factory function that creates a new Measurer instance.
|
|
18
|
+
*
|
|
19
|
+
* @param config - Partial configuration (defaults are applied for missing values).
|
|
20
|
+
* @returns A new Measurer instance, ready to be started.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { createMeasurer } from '@kntnt/engagement-metrics'
|
|
25
|
+
*
|
|
26
|
+
* const measurer = createMeasurer({ selector: 'article p' })
|
|
27
|
+
* measurer.start()
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function createMeasurer(config?: Partial<MeasurerConfig>): Measurer;
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAEhD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEpF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,QAAQ,CAEzE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// src/interval-set.ts
|
|
2
|
+
class IntervalSet {
|
|
3
|
+
#intervals = [];
|
|
4
|
+
add(start, end) {
|
|
5
|
+
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
6
|
+
return;
|
|
7
|
+
const s = Math.max(0, Math.min(1, start));
|
|
8
|
+
const e = Math.max(0, Math.min(1, end));
|
|
9
|
+
if (s >= e)
|
|
10
|
+
return;
|
|
11
|
+
if (this.coverage >= 1)
|
|
12
|
+
return;
|
|
13
|
+
this.#intervals.push([s, e]);
|
|
14
|
+
this.#merge();
|
|
15
|
+
}
|
|
16
|
+
get coverage() {
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const [s, e] of this.#intervals) {
|
|
19
|
+
total += e - s;
|
|
20
|
+
}
|
|
21
|
+
return total;
|
|
22
|
+
}
|
|
23
|
+
get size() {
|
|
24
|
+
return this.#intervals.length;
|
|
25
|
+
}
|
|
26
|
+
get intervals() {
|
|
27
|
+
return this.#intervals.map(([s, e]) => [s, e]);
|
|
28
|
+
}
|
|
29
|
+
clear() {
|
|
30
|
+
this.#intervals.length = 0;
|
|
31
|
+
}
|
|
32
|
+
#merge() {
|
|
33
|
+
const iv = this.#intervals;
|
|
34
|
+
iv.sort((a, b) => a[0] - b[0]);
|
|
35
|
+
let write = 0;
|
|
36
|
+
for (let read = 1;read < iv.length; read++) {
|
|
37
|
+
const cur = iv[write];
|
|
38
|
+
const next = iv[read];
|
|
39
|
+
if (next[0] <= cur[1]) {
|
|
40
|
+
cur[1] = Math.max(cur[1], next[1]);
|
|
41
|
+
} else {
|
|
42
|
+
write++;
|
|
43
|
+
iv[write] = next;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
iv.length = write + 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/timer.ts
|
|
51
|
+
class Timer {
|
|
52
|
+
#initialDuration;
|
|
53
|
+
#remaining;
|
|
54
|
+
#targetRatio;
|
|
55
|
+
constructor(durationSeconds) {
|
|
56
|
+
this.#initialDuration = Math.max(0, durationSeconds);
|
|
57
|
+
this.#remaining = this.#initialDuration;
|
|
58
|
+
this.#targetRatio = 1;
|
|
59
|
+
}
|
|
60
|
+
get initialDuration() {
|
|
61
|
+
return this.#initialDuration;
|
|
62
|
+
}
|
|
63
|
+
get remaining() {
|
|
64
|
+
return this.#remaining;
|
|
65
|
+
}
|
|
66
|
+
get isComplete() {
|
|
67
|
+
return this.#remaining <= 0;
|
|
68
|
+
}
|
|
69
|
+
get progress() {
|
|
70
|
+
if (this.#initialDuration === 0)
|
|
71
|
+
return 1;
|
|
72
|
+
return 1 - this.#remaining / this.#initialDuration;
|
|
73
|
+
}
|
|
74
|
+
get targetRatio() {
|
|
75
|
+
return this.#targetRatio;
|
|
76
|
+
}
|
|
77
|
+
set targetRatio(value) {
|
|
78
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
79
|
+
return;
|
|
80
|
+
this.#targetRatio = value;
|
|
81
|
+
}
|
|
82
|
+
get isAtTarget() {
|
|
83
|
+
if (this.#initialDuration === 0)
|
|
84
|
+
return true;
|
|
85
|
+
return this.progress >= this.#targetRatio;
|
|
86
|
+
}
|
|
87
|
+
advance(seconds) {
|
|
88
|
+
const floor = this.#initialDuration * (1 - this.#targetRatio);
|
|
89
|
+
this.#remaining = Math.max(floor, this.#remaining - seconds);
|
|
90
|
+
}
|
|
91
|
+
recalibrate(newDurationSeconds) {
|
|
92
|
+
const currentProgress = this.progress;
|
|
93
|
+
this.#initialDuration = Math.max(0, newDurationSeconds);
|
|
94
|
+
this.#remaining = this.#initialDuration * (1 - currentProgress);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/element.ts
|
|
99
|
+
class TrackedElement {
|
|
100
|
+
node;
|
|
101
|
+
timer;
|
|
102
|
+
charCount;
|
|
103
|
+
#visibilityRatio = 0;
|
|
104
|
+
#hasBeenSeen = false;
|
|
105
|
+
#seenIntervals = new IntervalSet;
|
|
106
|
+
#visibleStart = 0;
|
|
107
|
+
#visibleEnd = 0;
|
|
108
|
+
#computedTargetRatio = 0;
|
|
109
|
+
constructor(node, readingSpeed) {
|
|
110
|
+
this.node = node;
|
|
111
|
+
this.charCount = (node.textContent ?? "").length;
|
|
112
|
+
const durationSeconds = this.charCount > 0 ? this.charCount / readingSpeed * 60 : 0;
|
|
113
|
+
this.timer = new Timer(durationSeconds);
|
|
114
|
+
}
|
|
115
|
+
get visibilityRatio() {
|
|
116
|
+
return this.#visibilityRatio;
|
|
117
|
+
}
|
|
118
|
+
get hasBeenSeen() {
|
|
119
|
+
return this.#hasBeenSeen;
|
|
120
|
+
}
|
|
121
|
+
get isFullyRead() {
|
|
122
|
+
return this.timer.isComplete;
|
|
123
|
+
}
|
|
124
|
+
get readingProgress() {
|
|
125
|
+
return this.timer.progress;
|
|
126
|
+
}
|
|
127
|
+
get seenRatio() {
|
|
128
|
+
return this.#seenIntervals.coverage;
|
|
129
|
+
}
|
|
130
|
+
get seenIntervals() {
|
|
131
|
+
return this.#seenIntervals.intervals;
|
|
132
|
+
}
|
|
133
|
+
get visibleStart() {
|
|
134
|
+
return this.#visibleStart;
|
|
135
|
+
}
|
|
136
|
+
get visibleEnd() {
|
|
137
|
+
return this.#visibleEnd;
|
|
138
|
+
}
|
|
139
|
+
get computedTargetRatio() {
|
|
140
|
+
return this.#computedTargetRatio;
|
|
141
|
+
}
|
|
142
|
+
addSeenInterval(start, end) {
|
|
143
|
+
this.#seenIntervals.add(start, end);
|
|
144
|
+
}
|
|
145
|
+
updateVisibility(entry) {
|
|
146
|
+
this.#visibilityRatio = entry.intersectionRatio;
|
|
147
|
+
if (entry.isIntersecting && !this.#hasBeenSeen) {
|
|
148
|
+
this.#hasBeenSeen = true;
|
|
149
|
+
}
|
|
150
|
+
const rect = entry.boundingClientRect;
|
|
151
|
+
const rootBounds = entry.rootBounds;
|
|
152
|
+
if (rootBounds && rect.height > 0) {
|
|
153
|
+
const start = Math.max(0, Math.min(1, -rect.top / rect.height));
|
|
154
|
+
const end = Math.max(0, Math.min(1, (rootBounds.height - rect.top) / rect.height));
|
|
155
|
+
if (start < end) {
|
|
156
|
+
this.#visibleStart = start;
|
|
157
|
+
this.#visibleEnd = end;
|
|
158
|
+
this.#seenIntervals.add(start, end);
|
|
159
|
+
const progress = this.timer.progress;
|
|
160
|
+
const newReadable = Math.max(0, end - Math.max(start, progress));
|
|
161
|
+
this.#computedTargetRatio = Math.min(1, progress + newReadable);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
this.#visibleStart = 0;
|
|
166
|
+
this.#visibleEnd = 0;
|
|
167
|
+
this.#computedTargetRatio = this.timer.progress;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/measurer.ts
|
|
172
|
+
var DEFAULTS = {
|
|
173
|
+
selector: "p",
|
|
174
|
+
exclude: "",
|
|
175
|
+
readingSpeed: 1380,
|
|
176
|
+
tickInterval: 200,
|
|
177
|
+
observerThresholds: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
|
|
178
|
+
scrollSpeedThreshold: 200,
|
|
179
|
+
scrollCooldown: 50
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
class Measurer {
|
|
183
|
+
#config;
|
|
184
|
+
#listeners = new Set;
|
|
185
|
+
#elements = [];
|
|
186
|
+
#elementMap = new Map;
|
|
187
|
+
#observer = null;
|
|
188
|
+
#animationFrameId = null;
|
|
189
|
+
#lastTickTime = 0;
|
|
190
|
+
#isScrolling = false;
|
|
191
|
+
#isPageVisible = true;
|
|
192
|
+
#isActive = false;
|
|
193
|
+
#scrollTimeoutId = null;
|
|
194
|
+
#lastScrollY = 0;
|
|
195
|
+
#lastScrollTime = 0;
|
|
196
|
+
#maxScanningDepth = 0;
|
|
197
|
+
constructor(config = {}) {
|
|
198
|
+
this.#config = { ...DEFAULTS, ...config };
|
|
199
|
+
}
|
|
200
|
+
get isActive() {
|
|
201
|
+
return this.#isActive;
|
|
202
|
+
}
|
|
203
|
+
addListener(listener) {
|
|
204
|
+
this.#listeners.add(listener);
|
|
205
|
+
}
|
|
206
|
+
removeListener(listener) {
|
|
207
|
+
this.#listeners.delete(listener);
|
|
208
|
+
}
|
|
209
|
+
start() {
|
|
210
|
+
if (this.#isActive)
|
|
211
|
+
return;
|
|
212
|
+
this.#elements.length = 0;
|
|
213
|
+
this.#elementMap.clear();
|
|
214
|
+
this.#maxScanningDepth = 0;
|
|
215
|
+
const nodes = this.#discoverNodes();
|
|
216
|
+
if (!nodes)
|
|
217
|
+
return;
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
const tracked = new TrackedElement(node, this.#config.readingSpeed);
|
|
220
|
+
this.#elements.push(tracked);
|
|
221
|
+
this.#elementMap.set(node, tracked);
|
|
222
|
+
}
|
|
223
|
+
if (this.#elements.length === 0) {
|
|
224
|
+
console.warn("[kntnt-engagement-metrics] No content elements found for selector:", this.#config.selector);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.#observer = new IntersectionObserver((entries) => this.#handleIntersection(entries), {
|
|
228
|
+
threshold: this.#config.observerThresholds
|
|
229
|
+
});
|
|
230
|
+
for (const element of this.#elements) {
|
|
231
|
+
this.#observer.observe(element.node);
|
|
232
|
+
}
|
|
233
|
+
window.addEventListener("scroll", this.#handleScroll, { passive: true });
|
|
234
|
+
document.addEventListener("visibilitychange", this.#handleVisibilityChange);
|
|
235
|
+
this.#isActive = true;
|
|
236
|
+
this.#lastTickTime = performance.now();
|
|
237
|
+
this.#animationFrameId = requestAnimationFrame((t) => this.#tick(t));
|
|
238
|
+
}
|
|
239
|
+
stop() {
|
|
240
|
+
if (!this.#isActive)
|
|
241
|
+
return;
|
|
242
|
+
this.#isActive = false;
|
|
243
|
+
if (this.#animationFrameId !== null) {
|
|
244
|
+
cancelAnimationFrame(this.#animationFrameId);
|
|
245
|
+
this.#animationFrameId = null;
|
|
246
|
+
}
|
|
247
|
+
this.#observer?.disconnect();
|
|
248
|
+
this.#observer = null;
|
|
249
|
+
window.removeEventListener("scroll", this.#handleScroll);
|
|
250
|
+
document.removeEventListener("visibilitychange", this.#handleVisibilityChange);
|
|
251
|
+
if (this.#scrollTimeoutId !== null) {
|
|
252
|
+
clearTimeout(this.#scrollTimeoutId);
|
|
253
|
+
this.#scrollTimeoutId = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
getMetrics() {
|
|
257
|
+
return this.#computeMetrics();
|
|
258
|
+
}
|
|
259
|
+
getElements() {
|
|
260
|
+
return this.#elements;
|
|
261
|
+
}
|
|
262
|
+
setReadingSpeed(charsPerMinute) {
|
|
263
|
+
if (!Number.isFinite(charsPerMinute) || charsPerMinute <= 0) {
|
|
264
|
+
console.warn("[kntnt-engagement-metrics] Invalid reading speed:", charsPerMinute);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const element of this.#elements) {
|
|
268
|
+
const newDuration = element.charCount > 0 ? element.charCount / charsPerMinute * 60 : 0;
|
|
269
|
+
element.timer.recalibrate(newDuration);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
setScrollCooldown(ms) {
|
|
273
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
274
|
+
console.warn("[kntnt-engagement-metrics] Invalid scroll cooldown:", ms);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.#config = { ...this.#config, scrollCooldown: ms };
|
|
278
|
+
}
|
|
279
|
+
setScrollSpeedThreshold(pxPerSec) {
|
|
280
|
+
if (!Number.isFinite(pxPerSec) || pxPerSec <= 0) {
|
|
281
|
+
console.warn("[kntnt-engagement-metrics] Invalid scroll speed threshold:", pxPerSec);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.#config = { ...this.#config, scrollSpeedThreshold: pxPerSec };
|
|
285
|
+
}
|
|
286
|
+
#discoverNodes() {
|
|
287
|
+
let nodes;
|
|
288
|
+
try {
|
|
289
|
+
nodes = document.querySelectorAll(this.#config.selector);
|
|
290
|
+
} catch {
|
|
291
|
+
console.warn("[kntnt-engagement-metrics] Invalid selector:", this.#config.selector);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const { exclude } = this.#config;
|
|
295
|
+
const result = [];
|
|
296
|
+
for (const node of nodes) {
|
|
297
|
+
if (exclude && this.#isExcluded(node, exclude))
|
|
298
|
+
continue;
|
|
299
|
+
if (!("offsetHeight" in node) || node.offsetHeight === 0)
|
|
300
|
+
continue;
|
|
301
|
+
if ((node.textContent ?? "").length === 0)
|
|
302
|
+
continue;
|
|
303
|
+
result.push(node);
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
#isExcluded(node, exclude) {
|
|
308
|
+
try {
|
|
309
|
+
return node.closest(exclude) !== null;
|
|
310
|
+
} catch {
|
|
311
|
+
console.warn("[kntnt-engagement-metrics] Invalid exclude selector:", exclude);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
#handleIntersection(entries) {
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
const element = this.#elementMap.get(entry.target);
|
|
318
|
+
if (!element)
|
|
319
|
+
continue;
|
|
320
|
+
const wasSeen = element.hasBeenSeen;
|
|
321
|
+
element.updateVisibility(entry);
|
|
322
|
+
if (!wasSeen && element.hasBeenSeen && element.node.isConnected) {
|
|
323
|
+
const rect = element.node.getBoundingClientRect();
|
|
324
|
+
const elementBottom = rect.bottom + window.scrollY;
|
|
325
|
+
if (elementBottom > this.#maxScanningDepth) {
|
|
326
|
+
this.#maxScanningDepth = elementBottom;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
#handleScroll = () => {
|
|
332
|
+
const now = performance.now();
|
|
333
|
+
const scrollY = window.scrollY;
|
|
334
|
+
const timeDelta = now - this.#lastScrollTime;
|
|
335
|
+
const distance = Math.abs(scrollY - this.#lastScrollY);
|
|
336
|
+
if (timeDelta > 0) {
|
|
337
|
+
const speed = distance / timeDelta * 1000;
|
|
338
|
+
if (speed > this.#config.scrollSpeedThreshold) {
|
|
339
|
+
this.#isScrolling = true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
this.#lastScrollY = scrollY;
|
|
343
|
+
this.#lastScrollTime = now;
|
|
344
|
+
if (this.#scrollTimeoutId !== null) {
|
|
345
|
+
clearTimeout(this.#scrollTimeoutId);
|
|
346
|
+
}
|
|
347
|
+
this.#scrollTimeoutId = setTimeout(() => {
|
|
348
|
+
this.#isScrolling = false;
|
|
349
|
+
this.#scrollTimeoutId = null;
|
|
350
|
+
}, this.#config.scrollCooldown);
|
|
351
|
+
};
|
|
352
|
+
#handleVisibilityChange = () => {
|
|
353
|
+
this.#isPageVisible = document.visibilityState === "visible";
|
|
354
|
+
};
|
|
355
|
+
#tick(timestamp) {
|
|
356
|
+
if (!this.#isActive)
|
|
357
|
+
return;
|
|
358
|
+
if (timestamp - this.#lastTickTime >= this.#config.tickInterval) {
|
|
359
|
+
this.#advanceTimers();
|
|
360
|
+
const metrics = this.#notifyListeners();
|
|
361
|
+
this.#lastTickTime = timestamp;
|
|
362
|
+
if (metrics.readingRatio >= 1 && metrics.scanningRatio >= 1) {
|
|
363
|
+
this.stop();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.#animationFrameId = requestAnimationFrame((t) => this.#tick(t));
|
|
368
|
+
}
|
|
369
|
+
#advanceTimers() {
|
|
370
|
+
if (!this.#isPageVisible || this.#isScrolling)
|
|
371
|
+
return;
|
|
372
|
+
const elapsed = this.#config.tickInterval / 1000;
|
|
373
|
+
for (const element of this.#elements) {
|
|
374
|
+
if (element.isFullyRead)
|
|
375
|
+
continue;
|
|
376
|
+
if (element.visibilityRatio === 0)
|
|
377
|
+
continue;
|
|
378
|
+
element.timer.targetRatio = element.computedTargetRatio;
|
|
379
|
+
if (element.timer.isAtTarget)
|
|
380
|
+
continue;
|
|
381
|
+
element.timer.advance(elapsed);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
#notifyListeners() {
|
|
386
|
+
const metrics = this.#computeMetrics();
|
|
387
|
+
for (const listener of this.#listeners) {
|
|
388
|
+
try {
|
|
389
|
+
listener.update(metrics);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
console.warn("[kntnt-engagement-metrics] Listener error:", e);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return metrics;
|
|
395
|
+
}
|
|
396
|
+
#computeMetrics() {
|
|
397
|
+
let readingTime = 0;
|
|
398
|
+
let contentTime = 0;
|
|
399
|
+
let readingLength = 0;
|
|
400
|
+
let contentLength = 0;
|
|
401
|
+
for (const element of this.#elements) {
|
|
402
|
+
const timerProgress = element.readingProgress;
|
|
403
|
+
readingTime += element.timer.initialDuration * timerProgress;
|
|
404
|
+
contentTime += element.timer.initialDuration;
|
|
405
|
+
readingLength += element.charCount * timerProgress;
|
|
406
|
+
contentLength += element.charCount;
|
|
407
|
+
}
|
|
408
|
+
const scanningDepth = this.#maxScanningDepth;
|
|
409
|
+
const contentDepth = document.documentElement.scrollHeight - window.innerHeight;
|
|
410
|
+
const readingRatio = contentLength > 0 ? readingLength / contentLength : 0;
|
|
411
|
+
const scanningRatio = contentDepth > 0 ? Math.min(1, scanningDepth / contentDepth) : 0;
|
|
412
|
+
return {
|
|
413
|
+
readingTime,
|
|
414
|
+
contentTime,
|
|
415
|
+
readingLength,
|
|
416
|
+
contentLength,
|
|
417
|
+
scanningDepth,
|
|
418
|
+
contentDepth,
|
|
419
|
+
readingRatio,
|
|
420
|
+
scanningRatio,
|
|
421
|
+
isActive: this.#isActive
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/index.ts
|
|
427
|
+
function createMeasurer(config) {
|
|
428
|
+
return new Measurer(config);
|
|
429
|
+
}
|
|
430
|
+
export {
|
|
431
|
+
createMeasurer,
|
|
432
|
+
TrackedElement,
|
|
433
|
+
Measurer,
|
|
434
|
+
IntervalSet
|
|
435
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accumulates [start, end] intervals in the range 0–1 and merges overlapping
|
|
3
|
+
* or adjacent ones. Used to track which fraction of a content element has
|
|
4
|
+
* ever been visible in the viewport.
|
|
5
|
+
*/
|
|
6
|
+
export declare class IntervalSet {
|
|
7
|
+
#private;
|
|
8
|
+
/**
|
|
9
|
+
* Add a visible interval. Values are clamped to [0, 1].
|
|
10
|
+
* Degenerate, NaN, and Infinity inputs are silently ignored.
|
|
11
|
+
*/
|
|
12
|
+
add(start: number, end: number): void;
|
|
13
|
+
/** Total covered fraction (0–1). */
|
|
14
|
+
get coverage(): number;
|
|
15
|
+
/** Number of disjoint intervals currently stored. */
|
|
16
|
+
get size(): number;
|
|
17
|
+
/** The merged intervals, for diagnostic/overlay use. */
|
|
18
|
+
get intervals(): ReadonlyArray<readonly [number, number]>;
|
|
19
|
+
/** Reset to empty. */
|
|
20
|
+
clear(): void;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=interval-set.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interval-set.d.ts","sourceRoot":"","sources":["../src/interval-set.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,WAAW;;IAGtB;;;OAGG;IACH,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAkBrC,oCAAoC;IACpC,IAAI,QAAQ,IAAI,MAAM,CAMrB;IAED,qDAAqD;IACrD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,wDAAwD;IACxD,IAAI,SAAS,IAAI,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAExD;IAED,sBAAsB;IACtB,KAAK,IAAI,IAAI;CAwBd"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var k="1.0.0";class X{#K=[];add(K,q){if(!Number.isFinite(K)||!Number.isFinite(q))return;let z=Math.max(0,Math.min(1,K)),G=Math.max(0,Math.min(1,q));if(z>=G)return;if(this.coverage>=1)return;this.#K.push([z,G]),this.#z()}get coverage(){let K=0;for(let[q,z]of this.#K)K+=z-q;return K}get size(){return this.#K.length}get intervals(){return this.#K.map(([K,q])=>[K,q])}clear(){this.#K.length=0}#z(){let K=this.#K;K.sort((z,G)=>z[0]-G[0]);let q=0;for(let z=1;z<K.length;z++){let G=K[q],H=K[z];if(H[0]<=G[1])G[1]=Math.max(G[1],H[1]);else q++,K[q]=H}K.length=q+1}}class Z{#K;#z;#q;constructor(K){this.#K=Math.max(0,K),this.#z=this.#K,this.#q=1}get initialDuration(){return this.#K}get remaining(){return this.#z}get isComplete(){return this.#z<=0}get progress(){if(this.#K===0)return 1;return 1-this.#z/this.#K}get targetRatio(){return this.#q}set targetRatio(K){if(!Number.isFinite(K)||K<0||K>1)return;this.#q=K}get isAtTarget(){if(this.#K===0)return!0;return this.progress>=this.#q}advance(K){let q=this.#K*(1-this.#q);this.#z=Math.max(q,this.#z-K)}recalibrate(K){let q=this.progress;this.#K=Math.max(0,K),this.#z=this.#K*(1-q)}}class _{node;timer;charCount;#K=0;#z=!1;#q=new X;#J=0;#H=0;#G=0;constructor(K,q){this.node=K,this.charCount=(K.textContent??"").length;let z=this.charCount>0?this.charCount/q*60:0;this.timer=new Z(z)}get visibilityRatio(){return this.#K}get hasBeenSeen(){return this.#z}get isFullyRead(){return this.timer.isComplete}get readingProgress(){return this.timer.progress}get seenRatio(){return this.#q.coverage}get seenIntervals(){return this.#q.intervals}get visibleStart(){return this.#J}get visibleEnd(){return this.#H}get computedTargetRatio(){return this.#G}addSeenInterval(K,q){this.#q.add(K,q)}updateVisibility(K){if(this.#K=K.intersectionRatio,K.isIntersecting&&!this.#z)this.#z=!0;let{boundingClientRect:q,rootBounds:z}=K;if(z&&q.height>0){let G=Math.max(0,Math.min(1,-q.top/q.height)),H=Math.max(0,Math.min(1,(z.height-q.top)/q.height));if(G<H){this.#J=G,this.#H=H,this.#q.add(G,H);let J=this.timer.progress,W=Math.max(0,H-Math.max(G,J));this.#G=Math.min(1,J+W);return}}this.#J=0,this.#H=0,this.#G=this.timer.progress}}var E={selector:"p",exclude:"",readingSpeed:1380,tickInterval:200,observerThresholds:[0,0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1],scrollSpeedThreshold:200,scrollCooldown:50};class ${#K;#z=new Set;#q=[];#J=new Map;#H=null;#G=null;#Z=0;#_=!1;#$=!0;#Q=!1;#W=null;#j=0;#k=0;#X=0;constructor(K={}){this.#K={...E,...K}}get isActive(){return this.#Q}addListener(K){this.#z.add(K)}removeListener(K){this.#z.delete(K)}start(){if(this.#Q)return;this.#q.length=0,this.#J.clear(),this.#X=0;let K=this.#N();if(!K)return;for(let q of K){let z=new _(q,this.#K.readingSpeed);this.#q.push(z),this.#J.set(q,z)}if(this.#q.length===0){console.warn("[kntnt-engagement-metrics] No content elements found for selector:",this.#K.selector);return}this.#H=new IntersectionObserver((q)=>this.#Y(q),{threshold:this.#K.observerThresholds});for(let q of this.#q)this.#H.observe(q.node);window.addEventListener("scroll",this.#C,{passive:!0}),document.addEventListener("visibilitychange",this.#O),this.#Q=!0,this.#Z=performance.now(),this.#G=requestAnimationFrame((q)=>this.#U(q))}stop(){if(!this.#Q)return;if(this.#Q=!1,this.#G!==null)cancelAnimationFrame(this.#G),this.#G=null;if(this.#H?.disconnect(),this.#H=null,window.removeEventListener("scroll",this.#C),document.removeEventListener("visibilitychange",this.#O),this.#W!==null)clearTimeout(this.#W),this.#W=null}getMetrics(){return this.#E()}getElements(){return this.#q}setReadingSpeed(K){if(!Number.isFinite(K)||K<=0){console.warn("[kntnt-engagement-metrics] Invalid reading speed:",K);return}for(let q of this.#q){let z=q.charCount>0?q.charCount/K*60:0;q.timer.recalibrate(z)}}setScrollCooldown(K){if(!Number.isFinite(K)||K<0){console.warn("[kntnt-engagement-metrics] Invalid scroll cooldown:",K);return}this.#K={...this.#K,scrollCooldown:K}}setScrollSpeedThreshold(K){if(!Number.isFinite(K)||K<=0){console.warn("[kntnt-engagement-metrics] Invalid scroll speed threshold:",K);return}this.#K={...this.#K,scrollSpeedThreshold:K}}#N(){let K;try{K=document.querySelectorAll(this.#K.selector)}catch{return console.warn("[kntnt-engagement-metrics] Invalid selector:",this.#K.selector),null}let{exclude:q}=this.#K,z=[];for(let G of K){if(q&&this.#y(G,q))continue;if(!("offsetHeight"in G)||G.offsetHeight===0)continue;if((G.textContent??"").length===0)continue;z.push(G)}return z}#y(K,q){try{return K.closest(q)!==null}catch{return console.warn("[kntnt-engagement-metrics] Invalid exclude selector:",q),!0}}#Y(K){for(let q of K){let z=this.#J.get(q.target);if(!z)continue;let G=z.hasBeenSeen;if(z.updateVisibility(q),!G&&z.hasBeenSeen&&z.node.isConnected){let J=z.node.getBoundingClientRect().bottom+window.scrollY;if(J>this.#X)this.#X=J}}}#C=()=>{let K=performance.now(),q=window.scrollY,z=K-this.#k,G=Math.abs(q-this.#j);if(z>0){if(G/z*1000>this.#K.scrollSpeedThreshold)this.#_=!0}if(this.#j=q,this.#k=K,this.#W!==null)clearTimeout(this.#W);this.#W=setTimeout(()=>{this.#_=!1,this.#W=null},this.#K.scrollCooldown)};#O=()=>{this.#$=document.visibilityState==="visible"};#U(K){if(!this.#Q)return;if(K-this.#Z>=this.#K.tickInterval){this.#V();let q=this.#B();if(this.#Z=K,q.readingRatio>=1&&q.scanningRatio>=1){this.stop();return}}this.#G=requestAnimationFrame((q)=>this.#U(q))}#V(){if(!this.#$||this.#_)return;let K=this.#K.tickInterval/1000;for(let q of this.#q){if(q.isFullyRead)continue;if(q.visibilityRatio===0)continue;if(q.timer.targetRatio=q.computedTargetRatio,q.timer.isAtTarget)continue;q.timer.advance(K);return}}#B(){let K=this.#E();for(let q of this.#z)try{q.update(K)}catch(z){console.warn("[kntnt-engagement-metrics] Listener error:",z)}return K}#E(){let K=0,q=0,z=0,G=0;for(let Q of this.#q){let j=Q.readingProgress;K+=Q.timer.initialDuration*j,q+=Q.timer.initialDuration,z+=Q.charCount*j,G+=Q.charCount}let H=this.#X,J=document.documentElement.scrollHeight-window.innerHeight,W=G>0?z/G:0,O=J>0?Math.min(1,H/J):0;return{readingTime:K,contentTime:q,readingLength:z,contentLength:G,scanningDepth:H,contentDepth:J,readingRatio:W,scanningRatio:O,isActive:this.#Q}}}function C(K){return new $(K)}function N(K){let q=C(K);if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>q.start());else q.start();return q}var y=window.KntntEngagementMetrics;if(y)console.warn("[kntnt-engagement-metrics] Already loaded, skipping duplicate initialization");else{let K={createMeasurer:C,start:N,version:k,measurer:null};window.KntntEngagementMetrics=K}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TrackedElement } from './element.js';
|
|
2
|
+
import type { EngagementMetrics, MeasurerConfig, MetricsListener } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Central orchestrator for engagement measurement.
|
|
5
|
+
*
|
|
6
|
+
* Discovers content elements in the DOM, tracks their visibility via
|
|
7
|
+
* IntersectionObserver, and runs a measurement tick loop that estimates
|
|
8
|
+
* reading progress. Notifies registered listeners on each tick.
|
|
9
|
+
*/
|
|
10
|
+
export declare class Measurer {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(config?: Partial<MeasurerConfig>);
|
|
13
|
+
/** True if measurement is currently running. */
|
|
14
|
+
get isActive(): boolean;
|
|
15
|
+
/** Register a metrics listener. */
|
|
16
|
+
addListener(listener: MetricsListener): void;
|
|
17
|
+
/** Remove a previously registered listener. */
|
|
18
|
+
removeListener(listener: MetricsListener): void;
|
|
19
|
+
/** Start measuring. Call after the DOM is ready. */
|
|
20
|
+
start(): void;
|
|
21
|
+
/** Stop measuring and clean up all observers and listeners. */
|
|
22
|
+
stop(): void;
|
|
23
|
+
/** Get the current metrics snapshot. */
|
|
24
|
+
getMetrics(): EngagementMetrics;
|
|
25
|
+
/** Expose tracked elements for visualization or diagnostic purposes. */
|
|
26
|
+
getElements(): ReadonlyArray<TrackedElement>;
|
|
27
|
+
/**
|
|
28
|
+
* Change the reading speed and recalibrate all element timers.
|
|
29
|
+
* Preserves each element's current reading progress.
|
|
30
|
+
*
|
|
31
|
+
* @param charsPerMinute - New reading speed in characters per minute. Must be a positive number.
|
|
32
|
+
*/
|
|
33
|
+
setReadingSpeed(charsPerMinute: number): void;
|
|
34
|
+
/**
|
|
35
|
+
* Change the scroll cooldown duration at runtime.
|
|
36
|
+
*
|
|
37
|
+
* @param ms - Cooldown in milliseconds. Must be a non-negative finite number.
|
|
38
|
+
*/
|
|
39
|
+
setScrollCooldown(ms: number): void;
|
|
40
|
+
/**
|
|
41
|
+
* Change the scroll speed threshold at runtime.
|
|
42
|
+
*
|
|
43
|
+
* @param pxPerSec - Minimum scroll speed in pixels per second. Must be a positive finite number.
|
|
44
|
+
*/
|
|
45
|
+
setScrollSpeedThreshold(pxPerSec: number): void;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=measurer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measurer.d.ts","sourceRoot":"","sources":["../src/measurer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAYpF;;;;;;GAMG;AACH,qBAAa,QAAQ;;gBAiBP,MAAM,GAAE,OAAO,CAAC,cAAc,CAAM;IAIhD,gDAAgD;IAChD,IAAI,QAAQ,IAAI,OAAO,CAEtB;IAED,mCAAmC;IACnC,WAAW,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAI5C,+CAA+C;IAC/C,cAAc,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAI/C,oDAAoD;IACpD,KAAK,IAAI,IAAI;IA+Cb,+DAA+D;IAC/D,IAAI,IAAI,IAAI;IAqBZ,wCAAwC;IACxC,UAAU,IAAI,iBAAiB;IAI/B,wEAAwE;IACxE,WAAW,IAAI,aAAa,CAAC,cAAc,CAAC;IAI5C;;;;;OAKG;IACH,eAAe,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI;IAY7C;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAQnC;;;;OAIG;IACH,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;CA0KhD"}
|
package/dist/timer.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A countdown timer that represents the estimated reading time for a content element.
|
|
3
|
+
* Supports a target-ratio cap: advance() will not push progress beyond the
|
|
4
|
+
* configured targetRatio, modelling "read up to the visible boundary" semantics.
|
|
5
|
+
*/
|
|
6
|
+
export declare class Timer {
|
|
7
|
+
#private;
|
|
8
|
+
/**
|
|
9
|
+
* @param durationSeconds - Estimated reading time in seconds.
|
|
10
|
+
*/
|
|
11
|
+
constructor(durationSeconds: number);
|
|
12
|
+
/** The original estimated reading time in seconds. */
|
|
13
|
+
get initialDuration(): number;
|
|
14
|
+
/** Remaining time in seconds. */
|
|
15
|
+
get remaining(): number;
|
|
16
|
+
/** Whether the timer has reached zero. */
|
|
17
|
+
get isComplete(): boolean;
|
|
18
|
+
/** Reading progress as a ratio (0–1). */
|
|
19
|
+
get progress(): number;
|
|
20
|
+
/**
|
|
21
|
+
* Maximum progress (0–1) that advance() is allowed to reach.
|
|
22
|
+
* Set by the measurer to match the element's visibility ratio.
|
|
23
|
+
*
|
|
24
|
+
* @internal Used by Measurer — add-ons and external code must not set this.
|
|
25
|
+
*/
|
|
26
|
+
get targetRatio(): number;
|
|
27
|
+
/** @internal */
|
|
28
|
+
set targetRatio(value: number);
|
|
29
|
+
/** Whether progress has reached or exceeded the current targetRatio. */
|
|
30
|
+
get isAtTarget(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Advance the timer by the given number of seconds.
|
|
33
|
+
* The timer will not go below zero, and remaining will not drop
|
|
34
|
+
* below `initialDuration * (1 - targetRatio)`.
|
|
35
|
+
*/
|
|
36
|
+
advance(seconds: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* Recalibrate the timer with a new duration, preserving current progress.
|
|
39
|
+
* If progress is 60% and new duration is 10s, remaining becomes 4s.
|
|
40
|
+
*
|
|
41
|
+
* @param newDurationSeconds - New estimated reading time in seconds.
|
|
42
|
+
*/
|
|
43
|
+
recalibrate(newDurationSeconds: number): void;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=timer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timer.d.ts","sourceRoot":"","sources":["../src/timer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,KAAK;;IAKhB;;OAEG;gBACS,eAAe,EAAE,MAAM;IAMnC,sDAAsD;IACtD,IAAI,eAAe,IAAI,MAAM,CAE5B;IAED,iCAAiC;IACjC,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,0CAA0C;IAC1C,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,yCAAyC;IACzC,IAAI,QAAQ,IAAI,MAAM,CAGrB;IAED;;;;;OAKG;IACH,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,gBAAgB;IAChB,IAAI,WAAW,CAAC,KAAK,EAAE,MAAM,EAG5B;IAED,wEAAwE;IACxE,IAAI,UAAU,IAAI,OAAO,CAGxB;IAED;;;;OAIG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAK9B;;;;;OAKG;IACH,WAAW,CAAC,kBAAkB,EAAE,MAAM,GAAG,IAAI;CAK9C"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the engagement metrics measurer.
|
|
3
|
+
* All fields are optional — defaults are applied for missing values.
|
|
4
|
+
*/
|
|
5
|
+
export interface MeasurerConfig {
|
|
6
|
+
/** CSS selector for content elements. Default: `'p'` */
|
|
7
|
+
readonly selector: string;
|
|
8
|
+
/** CSS selector for elements to exclude. Any element matched by `selector` is excluded if it matches `exclude` itself or has an ancestor matching `exclude`. Default: `''` (no exclusions) */
|
|
9
|
+
readonly exclude: string;
|
|
10
|
+
/** Average reading speed in characters per minute. Default: `1380` */
|
|
11
|
+
readonly readingSpeed: number;
|
|
12
|
+
/** Milliseconds between measurement ticks. Default: `200` */
|
|
13
|
+
readonly tickInterval: number;
|
|
14
|
+
/** IntersectionObserver threshold steps (array of ratios 0–1). Default: `[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` */
|
|
15
|
+
readonly observerThresholds: number[];
|
|
16
|
+
/** Minimum scroll speed (px/sec) to count as active scrolling. Default: `200` */
|
|
17
|
+
readonly scrollSpeedThreshold: number;
|
|
18
|
+
/** Milliseconds after last scroll event before reading resumes. Default: `50` */
|
|
19
|
+
readonly scrollCooldown: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Snapshot of engagement metrics at a point in time.
|
|
23
|
+
*/
|
|
24
|
+
export interface EngagementMetrics {
|
|
25
|
+
/** Estimated seconds of actual reading so far. */
|
|
26
|
+
readonly readingTime: number;
|
|
27
|
+
/** Total estimated reading time for all content (seconds). */
|
|
28
|
+
readonly contentTime: number;
|
|
29
|
+
/** Estimated characters read so far. */
|
|
30
|
+
readonly readingLength: number;
|
|
31
|
+
/** Total characters across all content elements. */
|
|
32
|
+
readonly contentLength: number;
|
|
33
|
+
/** Maximum vertical scroll position reached (pixels). */
|
|
34
|
+
readonly scanningDepth: number;
|
|
35
|
+
/** Total scrollable content height (pixels). */
|
|
36
|
+
readonly contentDepth: number;
|
|
37
|
+
/** `readingLength / contentLength` (0–1). */
|
|
38
|
+
readonly readingRatio: number;
|
|
39
|
+
/** `scanningDepth / contentDepth` (0–1, capped at 1). */
|
|
40
|
+
readonly scanningRatio: number;
|
|
41
|
+
/** True if measurement is still running. */
|
|
42
|
+
readonly isActive: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Interface for consumers of engagement metrics.
|
|
46
|
+
* Implement this to receive metric updates on each measurement tick.
|
|
47
|
+
*/
|
|
48
|
+
export interface MetricsListener {
|
|
49
|
+
/** Called on each measurement tick with a fresh metrics snapshot. */
|
|
50
|
+
update(metrics: EngagementMetrics): void;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IAEzB,8LAA8L;IAC9L,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IAExB,sEAAsE;IACtE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAE7B,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAE7B,mIAAmI;IACnI,QAAQ,CAAC,kBAAkB,EAAE,MAAM,EAAE,CAAA;IAErC,iFAAiF;IACjF,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IAErC,iFAAiF;IACjF,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;CAChC;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAE5B,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAE5B,wCAAwC;IACxC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAE9B,oDAAoD;IACpD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAE9B,yDAAyD;IACzD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAE9B,gDAAgD;IAChD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAE7B,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAE7B,yDAAyD;IACzD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAE9B,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC3B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,MAAM,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAA;CACzC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kntnt/engagement-metrics",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight client-side library that measures how deeply users engage with content.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Thomas Barregren",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Kntnt/kntnt-engagement-metrics.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"module": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./iife": {
|
|
22
|
+
"default": "./dist/kntnt-engagement-metrics.min.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc && bun build src/index.ts --outdir dist --target browser --format esm && bun build src/iife.ts --outfile dist/kntnt-engagement-metrics.min.js --target browser --minify",
|
|
32
|
+
"typecheck": "tsc",
|
|
33
|
+
"lint": "biome check src/",
|
|
34
|
+
"test": "bun test"
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"keywords": [
|
|
38
|
+
"engagement",
|
|
39
|
+
"metrics",
|
|
40
|
+
"analytics",
|
|
41
|
+
"reading-time",
|
|
42
|
+
"scroll-depth",
|
|
43
|
+
"content-analytics"
|
|
44
|
+
]
|
|
45
|
+
}
|