diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-12-18 00:49:41 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-12-18 00:49:41 +0100 |
| commit | 8eed49067b82c7ec017ace069427185a2e135e41 (patch) | |
| tree | 122550d0c07538cb4c8a10d9baece4648cbf3008 /src | |
| parent | 3b198d300fd634326dcab2557c8afb18adeba144 (diff) | |
Add display and fix many issues
Diffstat (limited to 'src')
| -rw-r--r-- | src/clicks.ts | 70 | ||||
| -rw-r--r-- | src/clicks_display.ts | 137 | ||||
| -rw-r--r-- | src/display.html | 61 | ||||
| -rw-r--r-- | src/server.py | 117 |
4 files changed, 364 insertions, 21 deletions
diff --git a/src/clicks.ts b/src/clicks.ts index 9eb8bcd..35471f5 100644 --- a/src/clicks.ts +++ b/src/clicks.ts @@ -1,10 +1,15 @@ +const CLICKS_WSURL: string | undefined = (window as any).clicksWsUrl + class Session { private wss: WebSocketStream private queue: string[] = [] - private writer?: WritableStreamDefaultWriter + private writer: WritableStreamDefaultWriter | undefined + private url: string | undefined + private header: string | undefined - constructor() { - this.wss = new WebSocketStream("ws://127.0.0.1:8001/") + constructor(url: string, header: string) { + this.wss = new WebSocketStream(url) + this.header = header } async connect() { @@ -16,6 +21,22 @@ class Session { } reportEvent(event: string) { + // Could do this with NavigateEvent instead, + // but then we would report all navigations, + // even if there are no clicks, which is unnecessary. + if (window.location.href !== this.url) { + if (this.header !== undefined) { + this.pushEvent(this.header) + this.header = undefined + } + this.url = window.location.href + this.pushEvent(`u|${this.url}`) + } + + this.pushEvent(event) + } + + private pushEvent(event: string) { if (this.writer === undefined) { this.queue.push(event) } else { @@ -24,29 +45,36 @@ class Session { } } -function startSession(data: string): Session { - const session = new Session() +function startSession(data: string): Session | undefined { + if (CLICKS_WSURL === undefined) return undefined + const session = new Session(CLICKS_WSURL, data) void session.connect() - session.reportEvent(data) return session } document.addEventListener("DOMContentLoaded", () => { - const ua = navigator.userAgentData?.brands?.map((brand) => `${brand.brand}:${brand.version}`).join(',') + const ua = navigator.userAgentData?.brands + ?.map((brand) => `${brand.brand}:${brand.version}`) + .join(",") const session = startSession( - `${document.documentElement.clientWidth}x${document.documentElement.clientHeight}:${ua}` - ) - document.documentElement.addEventListener( - "click", - (e: MouseEvent) => { - const target = - e.target instanceof HTMLElement ? e.target.tagName : "" - const x = e.pageX / document.documentElement.clientWidth - const y = e.pageY / document.documentElement.clientHeight - session.reportEvent(`${x}x${y}:${target}`) - }, - { - capture: true, - } + `${document.documentElement.clientWidth}|${document.documentElement.clientHeight}|${ua}` ) + if (session !== undefined) { + document.documentElement.addEventListener( + "click", + (e: MouseEvent) => { + const target = + e.target instanceof HTMLElement ? e.target.tagName : "" + const x = e.pageX / document.documentElement.clientWidth + // clientWidth is NOT a typo, using width as scale, + // to not have to know height of rendered document when + // showing. + const y = e.pageY / document.documentElement.clientWidth + session.reportEvent(`${x}|${y}|${target}`) + }, + { + capture: true, + } + ) + } }) diff --git a/src/clicks_display.ts b/src/clicks_display.ts new file mode 100644 index 0000000..ff54705 --- /dev/null +++ b/src/clicks_display.ts @@ -0,0 +1,137 @@ +import h337 from "heatmap.js" + +namespace Clicks { + export type ClickEvent = { + x: number + y: number + tag: string + } + + export type UrlEvent = { + url: string + } + + export type Event = ClickEvent | UrlEvent + + export interface Format { + format: string + version: string + width: number + height: number + brands: string + events: Event[] + } +} + +class PageData { + readonly url: string + readonly data: h337.HeatmapData<h337.DataPoint> + + constructor(url: string, data: h337.DataPoint[]) { + this.url = url + let maxValue = 0 + for (const d of data) { + if (d.value > maxValue) maxValue = d.value + } + this.data = { + data: data, + min: 0, + max: maxValue, + } + } +} + +class Display { + private status: HTMLElement + private frame: HTMLIFrameElement + private heatmap: HTMLElement + private data: PageData[] | undefined + private map: h337.Heatmap<"value", "x", "y"> + + constructor() { + this.status = document.getElementById("status")!! + this.frame = document.getElementById("frame")!! as HTMLIFrameElement + this.heatmap = document.getElementById("heatmap")!! + this.map = h337.create({ + container: this.heatmap + }) + } + + loadFile(file: File) { + this.setStatus("Loading...") + const reader = new FileReader() + reader.addEventListener('load', () => { + const data = JSON.parse(reader.result as string) as Clicks.Format + if (data.format !== "clicks" || data.version !== "1.0") { + this.setStatus("Unknown format") + return + } + this.data = [] + const scaleX = this.frame.clientWidth + const scaleY = data.width + let url: string | undefined + let clicks: h337.DataPoint[] = [] + let map = new Map<string, number>() + for (const event of data.events) { + if ("url" in event) { + if (url !== undefined) { + console.log(clicks) + this.data.push(new PageData(url, clicks)) + } + url = event.url + clicks = [] + map.clear() + } else { + const ce = event as Clicks.ClickEvent + const x = Math.round(ce.x * scaleX) + const y = Math.round(ce.y * scaleY) + const pos = `${x}x${y}` + const index = map.get(pos) + if (index === undefined) { + map.set(pos, clicks!!.length) + clicks.push({ x: x, y: y, value: 1}) + } else { + clicks[index].value++ + } + } + } + if (url !== undefined) { + console.log(clicks) + this.data.push(new PageData(url, clicks)) + } + + this.setStatus("Loaded") + if (this.data.length > 0) + this.show(this.data[0]) + }) + reader.readAsText(file) + } + + private setStatus(newStatus: string) { + this.status.innerText = newStatus + } + + private show(data: PageData) { + this.frame.src = data.url + this.heatmap.style.transform = 'translateY(0px)' + this.frame.addEventListener('load', () => { + this.frame.contentDocument?.addEventListener('scroll', () => { + const y = this.frame.contentDocument!!.documentElement.scrollTop + this.heatmap.style.transform = `translateY(${-y}px)` + }) + }) + this.map.setData(data.data) + } +} + +document.addEventListener('DOMContentLoaded', () => { + const file = document.getElementById('file') + const display = new Display() + file?.addEventListener('change', (event: Event) => { + const files = (event.target as HTMLInputElement | null)!!.files + if (files === null) return + if (files.length > 0) { + display.loadFile(files[0]) + } + }) +})
\ No newline at end of file diff --git a/src/display.html b/src/display.html new file mode 100644 index 0000000..64df6b7 --- /dev/null +++ b/src/display.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <title>Clicks Display</title> + <style type="text/css"> + html, + body { + height: 100%; + margin: 0; + } + + #container { + display: flex; + flex-flow: column; + height: 100%; + } + + #banner { + flex: 0 1 auto; + border-bottom: 1px solid black; + padding: 0.3em; + } + + #status { + margin: 1em; + } + + #frame_container { + flex: 1 1 auto; + } + + #frame { + width: 100%; + height: 100%; + border: 0px; + } + + #heatmap { + position: relative; + top: -100%; + left: 0px; + width: 100%; + height: 100%; + pointer-events: none; + } + </style> + <script type="text/javascript" src="../build/clicks_display.min.js"></script> + </head> + <body> + <div id="container"> + <div id="banner"> + <span id="status">No file loaded</span> + <input type="file" name="file" id="file" accept=".json, application/json"> + </div> + <div id="frame_container"> + <iframe id="frame"></iframe> + <div id="heatmap"></div> + </div> + </div> + </body> +</html> diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..0ab0e89 --- /dev/null +++ b/src/server.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python + +import argparse +import asyncio +import json +import sys + +from datetime import datetime, timezone +from pathlib import Path +from websockets.asyncio.server import serve +from websockets.exceptions import ConnectionClosedOK + +class Context(): + def __init__(self, output): + self.output = output + output.mkdir(parents = True, exist_ok = True) + + def next(self): + ts = datetime.now(timezone.utc).timestamp() + return self.output / f"session-{ts}.json" + + +def quote(instr): + return json.dumps(instr) + +def parse_header(message): + parts = message.split('|') + if len(parts) < 3: + return None + return { + 'width': float(parts[0]), + 'height': float(parts[1]), + 'brands': '|'.join(parts[2:]), + } + +def parse_event(message): + parts = message.split('|') + if parts[0] == 'u' and len(parts) > 1: + return { + 'type': 'url', + 'url': '|'.join(parts[1:]), + } + if len(parts) < 3: + return None + return { + 'type': 'click', + 'x': float(parts[0]), + 'y': float(parts[1]), + 'tag': parts[2], + } + +async def handler(context, websocket): + filename = context.next() + with open(filename, "w", encoding="utf-8") as session_file: + try: + message = await websocket.recv() + except ConnectionClosedOK: + filename.unlink() + return + + header = parse_header(message) + if not header: + print(f"Bad header: {message}", file=sys.stderr) + filename.unlink() + return + + print(f'''{{ + "format":"clicks", + "version":"1.0", + "width": {header['width']}, + "height": {header['height']}, + "brands": {quote(header['brands'])}, + "events": [''', + file=session_file) + + comma = '' + + while True: + try: + message = await websocket.recv() + event = parse_event(message) + if event: + if event['type'] == 'click': + print(f'''{comma}{{ +"x": {event['x']}, "y": {event['y']}, "tag": {quote(event['tag'])} +}}''', + file=session_file) + else: + print(f'''{comma}{{"url": {quote(event['url'])}}}''', + file=session_file) + + if not comma: + comma = ',' + except ConnectionClosedOK: + break + + print(']}', file=session_file) + + +async def server(output): + context = Context(output) + async with serve(lambda x: handler(context, x), "", 8001): + await asyncio.get_running_loop().create_future() + +def main(): + parser = argparse.ArgumentParser( + description='Example server') + + parser.add_argument('output', type=Path, help='output directory') + + args = parser.parse_args() + + asyncio.run(server(args.output)) + +if __name__ == "__main__": + main() + |
