summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoel Klinghed <the_jk@spawned.biz>2024-12-18 00:49:41 +0100
committerJoel Klinghed <the_jk@spawned.biz>2024-12-18 00:49:41 +0100
commit8eed49067b82c7ec017ace069427185a2e135e41 (patch)
tree122550d0c07538cb4c8a10d9baece4648cbf3008 /src
parent3b198d300fd634326dcab2557c8afb18adeba144 (diff)
Add display and fix many issues
Diffstat (limited to 'src')
-rw-r--r--src/clicks.ts70
-rw-r--r--src/clicks_display.ts137
-rw-r--r--src/display.html61
-rw-r--r--src/server.py117
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()
+