openreplay/frontend/app/player/web/addons/TargetMarker.ts
Delirium 960da9f037
Tracker 14.x.x changes (#2240)
* feat tracker: add document titles to tabs

* feat: titles for tabs

* feat tracker: send initial title, parse titles better

* feat ui: tab name styles

* feat tracker: update changelogs

* fix tracker: fix tests

* fix tracker: fix failing tests, add some coverage

* fix tracker: fix failing tests, add some coverage

* Heatmaps  (#2264)

* feat ui: start heatmaps ui and tracker update

* fix ui: drop clickmap from session

* fix ui: refactor heatmap painter

* fix ui: store click coords as int percent

* feat(backend): insert normalized x and y to PG

* feat(backend): insert normalized x and y to CH

* feat(connector): added missing import

* feat(backend): fixed different uint type issue

* fix tracker: use max scrollable size for doc

* fix gen files

* fix ui: fix random crash, remove demo data generator

* fix ui: rm some dead code

---------

Co-authored-by: Alexander <zavorotynskiy@pm.me>

* fix tracker: add heatmap changelog to tracker CHANGELOG.md

* fix tracker: fix peerjs version to 1.5.4 (was 1.5.1)

* fix document height calculation

* Crossdomain tracking (#2277)

* feat tracker: crossdomain tracking (start commit)

* catch crossdomain messages, add nodeid placeholder

* click scroll

* frame placeholder number -> dynamic

* click rewriter, fix scroll prop

* some docs

* some docs

* fix options merging

* CHANGELOG.md update

* checking that crossdomain will not fire automatically

* simplify func declaration

* update test data

* change clickmap document height calculation to scrollheight (which should be true height)

---------

Co-authored-by: Alexander <zavorotynskiy@pm.me>
2024-06-24 13:49:26 +02:00

206 lines
6.3 KiB
TypeScript

import type { Store } from '../../common/types';
import type Screen from '../Screen/Screen';
import type { Point } from '../Screen/types';
import { clickmapStyles } from './clickmapStyles';
import heatmapRenderer from './simpleHeatmap';
function getOffset(el: Element, innerWindow: Window) {
const rect = el.getBoundingClientRect();
return {
fixedLeft: rect.left + innerWindow.scrollX,
fixedTop: rect.top + innerWindow.scrollY,
rect,
};
}
interface BoundingRect {
top: number;
left: number;
width: number;
height: number;
}
export interface MarkedTarget {
boundingRect: BoundingRect;
el: Element;
selector: string;
count: number;
index: number;
active?: boolean;
percent: number;
}
export interface State {
markedTargets: MarkedTarget[] | null;
activeTargetIndex: number;
}
export default class TargetMarker {
private clickMapOverlay: HTMLCanvasElement | null = null;
static INITIAL_STATE: State = {
markedTargets: null,
activeTargetIndex: 0,
};
constructor(
private readonly screen: Screen,
private readonly store: Store<State>
) {}
updateMarkedTargets() {
const { markedTargets } = this.store.get();
if (markedTargets) {
this.store.update({
markedTargets: markedTargets.map((mt: any) => ({
...mt,
boundingRect: this.calculateRelativeBoundingRect(mt.el),
})),
});
}
if (heatmapRenderer.checkReady()) {
heatmapRenderer.resize().draw();
}
}
private calculateRelativeBoundingRect(el: Element): BoundingRect {
const parentEl = this.screen.getParentElement();
if (!parentEl) return { top: 0, left: 0, width: 0, height: 0 }; //TODO: can be initialized(?) on mounted screen only
const { top, left, width, height } = el.getBoundingClientRect();
const s = this.screen.getScale();
const screenRect = this.screen.overlay.getBoundingClientRect(); //this.screen.getBoundingClientRect() (now private)
const parentRect = parentEl.getBoundingClientRect();
return {
top: top * s + screenRect.top - parentRect.top,
left: left * s + screenRect.left - parentRect.left,
width: width * s,
height: height * s,
};
}
setActiveTarget(index: number) {
const window = this.screen.window;
const markedTargets: MarkedTarget[] | null = this.store.get().markedTargets;
const target = markedTargets && markedTargets[index];
if (target && window) {
const { fixedTop, rect } = getOffset(target.el, window);
const scrollToY = fixedTop - window.innerHeight / 1.5;
if (rect.top < 0 || rect.top > window.innerHeight) {
// behavior hack TODO: fix it somehow when they will decide to remove it from browser api
// @ts-ignore
window.scrollTo({ top: scrollToY, behavior: 'instant' });
setTimeout(() => {
if (!markedTargets) {
return;
}
this.store.update({
markedTargets: markedTargets.map((t) =>
t === target
? {
...target,
boundingRect: this.calculateRelativeBoundingRect(target.el),
}
: t
),
});
}, 0);
}
}
this.store.update({ activeTargetIndex: index });
}
private actualScroll: Point | null = null;
markTargets(selections: { selector: string; count: number }[] | null) {
if (selections) {
const totalCount = selections.reduce((a, b) => {
return a + b.count;
}, 0);
const markedTargets: MarkedTarget[] = [];
let index = 0;
selections.forEach((s) => {
const el = this.screen.getElementBySelector(s.selector);
if (!el) return;
markedTargets.push({
...s,
el,
index: index++,
percent: Math.round((s.count * 100) / totalCount),
boundingRect: this.calculateRelativeBoundingRect(el),
count: s.count,
});
});
this.actualScroll = this.screen.getCurrentScroll();
this.store.update({ markedTargets });
} else {
if (this.actualScroll) {
this.screen.window?.scrollTo(this.actualScroll.x, this.actualScroll.y);
this.actualScroll = null;
}
this.store.update({ markedTargets: null });
}
}
injectTargets(clicks: { normalizedX: number; normalizedY: number }[] | null) {
if (clicks && this.screen.document) {
this.clickMapOverlay?.remove();
const overlay = document.createElement('canvas');
const iframeSize = this.screen.iframeStylesRef;
const scrollHeight = this.screen.document?.documentElement.scrollHeight || 0;
const scrollWidth = this.screen.document?.documentElement.scrollWidth || 0;
const scaleRatio = this.screen.getScale();
Object.assign(
overlay.style,
clickmapStyles.overlayStyle({
height: iframeSize.height,
width: iframeSize.width,
scale: scaleRatio,
})
);
this.clickMapOverlay = overlay;
this.screen.getParentElement()?.appendChild(overlay);
const pointMap: Record<string, { times: number; data: number[], original: any }> = {};
const ovWidth = parseInt(iframeSize.width);
const ovHeight = parseInt(iframeSize.height);
overlay.width = ovWidth;
overlay.height = ovHeight;
let maxIntensity = 0;
clicks.forEach((point) => {
const key = `${point.normalizedY}-${point.normalizedX}`;
if (pointMap[key]) {
const times = pointMap[key].times + 1;
maxIntensity = Math.max(maxIntensity, times);
pointMap[key].times = times;
} else {
const clickData = [
(point.normalizedX / 100) * scrollWidth,
(point.normalizedY / 100) * scrollHeight,
];
pointMap[key] = { times: 1, data: clickData, original: point };
}
});
const heatmapData: number[][] = [];
for (const key in pointMap) {
const { data, times } = pointMap[key];
heatmapData.push([...data, times]);
}
heatmapRenderer
.setCanvas(overlay)
.setData(heatmapData)
.setRadius(15, 10)
.setMax(maxIntensity)
.resize()
.draw();
} else {
this.store.update({ markedTargets: null });
this.clickMapOverlay?.remove();
this.clickMapOverlay = null;
}
}
}