@uptimizr/db 0.1.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/AGENTS.md +109 -0
- package/LICENSE +201 -0
- package/README.md +89 -0
- package/dist/cli/createProject.d.ts +3 -0
- package/dist/cli/createProject.d.ts.map +1 -0
- package/dist/cli/createProject.js +41 -0
- package/dist/cli/createProject.js.map +1 -0
- package/dist/cli/migrate.d.ts +3 -0
- package/dist/cli/migrate.d.ts.map +1 -0
- package/dist/cli/migrate.js +21 -0
- package/dist/cli/migrate.js.map +1 -0
- package/dist/cli/seed.d.ts +2 -0
- package/dist/cli/seed.d.ts.map +1 -0
- package/dist/cli/seed.js +175 -0
- package/dist/cli/seed.js.map +1 -0
- package/dist/duckdb/client.d.ts +42 -0
- package/dist/duckdb/client.d.ts.map +1 -0
- package/dist/duckdb/client.js +93 -0
- package/dist/duckdb/client.js.map +1 -0
- package/dist/duckdb/events.d.ts +44 -0
- package/dist/duckdb/events.d.ts.map +1 -0
- package/dist/duckdb/events.js +225 -0
- package/dist/duckdb/events.js.map +1 -0
- package/dist/duckdb/migrations.d.ts +20 -0
- package/dist/duckdb/migrations.d.ts.map +1 -0
- package/dist/duckdb/migrations.js +323 -0
- package/dist/duckdb/migrations.js.map +1 -0
- package/dist/duckdb/projects.d.ts +23 -0
- package/dist/duckdb/projects.d.ts.map +1 -0
- package/dist/duckdb/projects.js +55 -0
- package/dist/duckdb/projects.js.map +1 -0
- package/dist/duckdb/queries.d.ts +10 -0
- package/dist/duckdb/queries.d.ts.map +1 -0
- package/dist/duckdb/queries.js +10 -0
- package/dist/duckdb/queries.js.map +1 -0
- package/dist/duckdb/sceneRegistry.d.ts +15 -0
- package/dist/duckdb/sceneRegistry.d.ts.map +1 -0
- package/dist/duckdb/sceneRegistry.js +98 -0
- package/dist/duckdb/sceneRegistry.js.map +1 -0
- package/dist/env.d.ts +25 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +55 -0
- package/dist/env.js.map +1 -0
- package/dist/events.d.ts +107 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +153 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata.d.ts +84 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +23 -0
- package/dist/metadata.js.map +1 -0
- package/dist/parity/cases.d.ts +31 -0
- package/dist/parity/cases.d.ts.map +1 -0
- package/dist/parity/cases.js +524 -0
- package/dist/parity/cases.js.map +1 -0
- package/dist/parity/compare.d.ts +47 -0
- package/dist/parity/compare.d.ts.map +1 -0
- package/dist/parity/compare.js +90 -0
- package/dist/parity/compare.js.map +1 -0
- package/dist/parity/fixtures.d.ts +45 -0
- package/dist/parity/fixtures.d.ts.map +1 -0
- package/dist/parity/fixtures.js +157 -0
- package/dist/parity/fixtures.js.map +1 -0
- package/dist/parity/index.d.ts +12 -0
- package/dist/parity/index.d.ts.map +1 -0
- package/dist/parity/index.js +12 -0
- package/dist/parity/index.js.map +1 -0
- package/dist/query/aggregations.d.ts +393 -0
- package/dist/query/aggregations.d.ts.map +1 -0
- package/dist/query/aggregations.js +1412 -0
- package/dist/query/aggregations.js.map +1 -0
- package/dist/query/dialect.d.ts +100 -0
- package/dist/query/dialect.d.ts.map +1 -0
- package/dist/query/dialect.js +88 -0
- package/dist/query/dialect.js.map +1 -0
- package/dist/query/duckdbDialect.d.ts +27 -0
- package/dist/query/duckdbDialect.d.ts.map +1 -0
- package/dist/query/duckdbDialect.js +83 -0
- package/dist/query/duckdbDialect.js.map +1 -0
- package/dist/query/index.d.ts +22 -0
- package/dist/query/index.d.ts.map +1 -0
- package/dist/query/index.js +30 -0
- package/dist/query/index.js.map +1 -0
- package/dist/query/types.d.ts +388 -0
- package/dist/query/types.d.ts.map +1 -0
- package/dist/query/types.js +11 -0
- package/dist/query/types.js.map +1 -0
- package/llms.txt +21 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialect-agnostic analytics aggregations (ADR 0020).
|
|
3
|
+
*
|
|
4
|
+
* Each builder describes an aggregation once and renders it through a
|
|
5
|
+
* {@link Dialect}, returning a {@link QuerySpec}. ClickHouse-specific syntax is
|
|
6
|
+
* confined to the dialect; the query *logic* lives here and is shared by every
|
|
7
|
+
* engine (DuckDB for OSS, single-tenant ClickHouse for the scale tier).
|
|
8
|
+
*
|
|
9
|
+
* Invariant: no multi-tenant concepts here. Filtering is by `project_id` and the
|
|
10
|
+
* optional scene/source/session dimensions only.
|
|
11
|
+
*/
|
|
12
|
+
import { ParamBag, cameraModeClause, dayRangeClause, rangeClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
|
|
13
|
+
/** List sessions for a project with event counts and time bounds. */
|
|
14
|
+
export function buildListSessions(projectId, opts, d) {
|
|
15
|
+
const bag = new ParamBag(d);
|
|
16
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
17
|
+
const range = rangeClause(bag, opts);
|
|
18
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
19
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 100);
|
|
20
|
+
return {
|
|
21
|
+
query: `
|
|
22
|
+
SELECT
|
|
23
|
+
session_id,
|
|
24
|
+
${d.anyValue("visitor_id")} AS visitor_id,
|
|
25
|
+
count() AS events,
|
|
26
|
+
min(ts) AS started_at,
|
|
27
|
+
max(ts) AS ended_at
|
|
28
|
+
FROM events
|
|
29
|
+
WHERE project_id = ${pid}${range}${cameraMode}
|
|
30
|
+
GROUP BY session_id
|
|
31
|
+
ORDER BY started_at DESC
|
|
32
|
+
LIMIT ${limit}
|
|
33
|
+
`,
|
|
34
|
+
query_params: bag.values,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 2D pointer heatmap: bin normalized screen positions into a `bins x bins` grid.
|
|
39
|
+
* Covers `pointer_move` and `pointer_click`.
|
|
40
|
+
*/
|
|
41
|
+
export function buildPointerHeatmap(projectId, opts, d) {
|
|
42
|
+
const bag = new ParamBag(d);
|
|
43
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
44
|
+
const bins = bag.add("bins", "u32", opts.bins ?? 50);
|
|
45
|
+
const range = rangeClause(bag, opts);
|
|
46
|
+
const scene = sceneClause(bag, opts);
|
|
47
|
+
const source = sourceClause(bag, opts);
|
|
48
|
+
const session = sessionClause(bag, opts);
|
|
49
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
50
|
+
return {
|
|
51
|
+
query: `
|
|
52
|
+
SELECT
|
|
53
|
+
floor(screen[1] * ${bins}) AS gx,
|
|
54
|
+
floor(screen[2] * ${bins}) AS gy,
|
|
55
|
+
count() AS count
|
|
56
|
+
FROM events
|
|
57
|
+
WHERE project_id = ${pid}
|
|
58
|
+
AND event_type IN ('pointer_move', 'pointer_click')
|
|
59
|
+
AND length(screen) = 2${range}${scene}${source}${session}${cameraMode}
|
|
60
|
+
GROUP BY gx, gy
|
|
61
|
+
ORDER BY count DESC
|
|
62
|
+
`,
|
|
63
|
+
query_params: bag.values,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* World-space (3D) pointer heatmap: voxel-bin the raycast hit points of pointer
|
|
68
|
+
* events into a uniform grid of `cellSize`-sized cubes. Results are capped to the
|
|
69
|
+
* busiest `limit` voxels.
|
|
70
|
+
*/
|
|
71
|
+
export function buildWorldHeatmap(projectId, opts, d) {
|
|
72
|
+
const bag = new ParamBag(d);
|
|
73
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
74
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
|
|
75
|
+
const range = rangeClause(bag, opts);
|
|
76
|
+
const scene = sceneClause(bag, opts);
|
|
77
|
+
const source = sourceClause(bag, opts);
|
|
78
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
79
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 1000);
|
|
80
|
+
return {
|
|
81
|
+
query: `
|
|
82
|
+
SELECT
|
|
83
|
+
floor(hit_point[1] / ${cellSize}) AS vx,
|
|
84
|
+
floor(hit_point[2] / ${cellSize}) AS vy,
|
|
85
|
+
floor(hit_point[3] / ${cellSize}) AS vz,
|
|
86
|
+
count() AS count
|
|
87
|
+
FROM events
|
|
88
|
+
WHERE project_id = ${pid}
|
|
89
|
+
AND event_type IN ('pointer_move', 'pointer_click')
|
|
90
|
+
AND length(hit_point) = 3${range}${scene}${source}${cameraMode}
|
|
91
|
+
GROUP BY vx, vy, vz
|
|
92
|
+
ORDER BY count DESC
|
|
93
|
+
LIMIT ${limit}
|
|
94
|
+
`,
|
|
95
|
+
query_params: bag.values,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* World-space (3D) gaze heatmap (ADR 0030): voxel-bin the camera-pose gaze
|
|
100
|
+
* surface hits (`camera_sample.hitPoint`) into the same uniform grid as the
|
|
101
|
+
* pointer world heatmap. This is the "what did people actually look at" map — it
|
|
102
|
+
* lands on real geometry and serves orbit/viewer, first-person, and XR scenes
|
|
103
|
+
* alike. Distinct from the click-driven world heatmap (looked-at vs clicked) and
|
|
104
|
+
* from the abstract direction sphere (surface vs angle). Results are capped to
|
|
105
|
+
* the busiest `limit` voxels; an optional `session` scopes it to one visit
|
|
106
|
+
* (ADR 0010 §1a). Gaze has no pointer input-source, so there is no `source` filter.
|
|
107
|
+
*/
|
|
108
|
+
export function buildGazeHeatmap(projectId, opts, d) {
|
|
109
|
+
const bag = new ParamBag(d);
|
|
110
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
111
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
|
|
112
|
+
const range = rangeClause(bag, opts);
|
|
113
|
+
const scene = sceneClause(bag, opts);
|
|
114
|
+
const session = sessionClause(bag, opts);
|
|
115
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
116
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 1000);
|
|
117
|
+
return {
|
|
118
|
+
query: `
|
|
119
|
+
SELECT
|
|
120
|
+
floor(hit_point[1] / ${cellSize}) AS vx,
|
|
121
|
+
floor(hit_point[2] / ${cellSize}) AS vy,
|
|
122
|
+
floor(hit_point[3] / ${cellSize}) AS vz,
|
|
123
|
+
count() AS count
|
|
124
|
+
FROM events
|
|
125
|
+
WHERE project_id = ${pid}
|
|
126
|
+
AND event_type = 'camera_sample'
|
|
127
|
+
AND length(hit_point) = 3${range}${scene}${session}${cameraMode}
|
|
128
|
+
GROUP BY vx, vy, vz
|
|
129
|
+
ORDER BY count DESC
|
|
130
|
+
LIMIT ${limit}
|
|
131
|
+
`,
|
|
132
|
+
query_params: bag.values,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* View-direction heatmap: bin camera forward vectors by spherical angles.
|
|
137
|
+
* `azimuth = atan2(z, x)`, `elevation = asin(y / |v|)`, each bucketed into `bins`.
|
|
138
|
+
*/
|
|
139
|
+
export function buildCameraDirectionHeatmap(projectId, opts, d) {
|
|
140
|
+
const bag = new ParamBag(d);
|
|
141
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
142
|
+
const bins = bag.add("bins", "u32", opts.bins ?? 36);
|
|
143
|
+
const range = rangeClause(bag, opts);
|
|
144
|
+
const scene = sceneClause(bag, opts);
|
|
145
|
+
const session = sessionClause(bag, opts);
|
|
146
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
147
|
+
return {
|
|
148
|
+
query: `
|
|
149
|
+
SELECT
|
|
150
|
+
floor((atan2(direction[3], direction[1]) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
|
|
151
|
+
floor((asin(direction[2] / greatest(${d.vectorNorm("direction")}, 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
|
|
152
|
+
count() AS count
|
|
153
|
+
FROM events
|
|
154
|
+
WHERE project_id = ${pid}
|
|
155
|
+
AND event_type = 'camera_sample'
|
|
156
|
+
AND length(direction) = 3${range}${scene}${session}${cameraMode}
|
|
157
|
+
GROUP BY azimuth_bin, elevation_bin
|
|
158
|
+
ORDER BY count DESC
|
|
159
|
+
`,
|
|
160
|
+
query_params: bag.values,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Top-down "floor plan" camera-position heatmap (ADR 0026): bin `camera_sample`
|
|
165
|
+
* world positions onto the X/Z ground plane in `cellSize`-sized cells, tracking the
|
|
166
|
+
* mean height per cell. For first-person (`cameraType: "free"`) sessions this is
|
|
167
|
+
* the "where do visitors walk / dwell" map; capped to the busiest `limit` cells.
|
|
168
|
+
*/
|
|
169
|
+
export function buildCameraPositionHeatmap(projectId, opts, d) {
|
|
170
|
+
const bag = new ParamBag(d);
|
|
171
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
172
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
|
|
173
|
+
const range = rangeClause(bag, opts);
|
|
174
|
+
const scene = sceneClause(bag, opts);
|
|
175
|
+
const session = sessionClause(bag, opts);
|
|
176
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
177
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 2000);
|
|
178
|
+
return {
|
|
179
|
+
query: `
|
|
180
|
+
SELECT
|
|
181
|
+
floor(position[1] / ${cellSize}) AS gx,
|
|
182
|
+
floor(position[3] / ${cellSize}) AS gz,
|
|
183
|
+
avg(position[2]) AS avg_y,
|
|
184
|
+
count() AS count
|
|
185
|
+
FROM events
|
|
186
|
+
WHERE project_id = ${pid}
|
|
187
|
+
AND event_type = 'camera_sample'
|
|
188
|
+
AND length(position) = 3${range}${scene}${session}${cameraMode}
|
|
189
|
+
GROUP BY gx, gz
|
|
190
|
+
ORDER BY count DESC
|
|
191
|
+
LIMIT ${limit}
|
|
192
|
+
`,
|
|
193
|
+
query_params: bag.values,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A single session's walked path (ADR 0026): the ordered `camera_sample` world
|
|
198
|
+
* positions for one session, oldest first. Drives the dashboard's trajectory view
|
|
199
|
+
* (a poly-line over the floor plan). Capped to `limit` points.
|
|
200
|
+
*/
|
|
201
|
+
export function buildSessionTrajectory(projectId, opts, d) {
|
|
202
|
+
const bag = new ParamBag(d);
|
|
203
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
204
|
+
const session = bag.add("session", "string", opts.session);
|
|
205
|
+
const range = rangeClause(bag, opts);
|
|
206
|
+
const scene = sceneClause(bag, opts);
|
|
207
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 5000);
|
|
208
|
+
return {
|
|
209
|
+
query: `
|
|
210
|
+
SELECT
|
|
211
|
+
${d.epochMs("ts")} AS ts,
|
|
212
|
+
position[1] AS x,
|
|
213
|
+
position[2] AS y,
|
|
214
|
+
position[3] AS z
|
|
215
|
+
FROM events
|
|
216
|
+
WHERE project_id = ${pid}
|
|
217
|
+
AND session_id = ${session}
|
|
218
|
+
AND event_type = 'camera_sample'
|
|
219
|
+
AND length(position) = 3${range}${scene}
|
|
220
|
+
ORDER BY ts ASC
|
|
221
|
+
LIMIT ${limit}
|
|
222
|
+
`,
|
|
223
|
+
query_params: bag.values,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Click ↔ gaze correlation: aggregate each `pointer_click` into a ray from an
|
|
228
|
+
* origin voxel to a hit voxel, where the voxel size matches the world heatmap so
|
|
229
|
+
* origins and hits share the same grid.
|
|
230
|
+
*
|
|
231
|
+
* Origin selection (ADR 0011 — source-agnostic): pose-enabled sources (XR
|
|
232
|
+
* controllers, hands, gaze) carry their own world-space pointing ray, so when a
|
|
233
|
+
* click has a `ray.origin` we use it verbatim — the controller/hand/gaze is the
|
|
234
|
+
* true pointing origin, not the headset/camera. Flat pointers (mouse, touch,
|
|
235
|
+
* stylus) have no native ray, so they fall back to the nearest preceding
|
|
236
|
+
* `camera_sample` in the same session (the historical view-gated behavior). The
|
|
237
|
+
* camera join is therefore a LEFT join so pose clicks survive even in sessions
|
|
238
|
+
* that never emit a `camera_sample`; rows with neither a ray nor a camera origin
|
|
239
|
+
* are dropped.
|
|
240
|
+
*/
|
|
241
|
+
export function buildClickGazeRay(projectId, opts, d) {
|
|
242
|
+
const bag = new ParamBag(d);
|
|
243
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
244
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
|
|
245
|
+
const range = rangeClause(bag, opts);
|
|
246
|
+
const scene = sceneClause(bag, opts);
|
|
247
|
+
const source = sourceClause(bag, opts);
|
|
248
|
+
const session = sessionClause(bag, opts);
|
|
249
|
+
return {
|
|
250
|
+
query: `
|
|
251
|
+
SELECT
|
|
252
|
+
floor(j.ox / ${cellSize}) AS cam_vx,
|
|
253
|
+
floor(j.oy / ${cellSize}) AS cam_vy,
|
|
254
|
+
floor(j.oz / ${cellSize}) AS cam_vz,
|
|
255
|
+
avg(j.ox) AS origin_x,
|
|
256
|
+
avg(j.oy) AS origin_y,
|
|
257
|
+
avg(j.oz) AS origin_z,
|
|
258
|
+
floor(j.hx / ${cellSize}) AS hit_vx,
|
|
259
|
+
floor(j.hy / ${cellSize}) AS hit_vy,
|
|
260
|
+
floor(j.hz / ${cellSize}) AS hit_vz,
|
|
261
|
+
avg(j.hx) AS hit_x,
|
|
262
|
+
avg(j.hy) AS hit_y,
|
|
263
|
+
avg(j.hz) AS hit_z,
|
|
264
|
+
j.mesh AS mesh,
|
|
265
|
+
count() AS count
|
|
266
|
+
FROM (
|
|
267
|
+
SELECT
|
|
268
|
+
c.hx AS hx, c.hy AS hy, c.hz AS hz, c.mesh AS mesh,
|
|
269
|
+
CASE WHEN c.has_ray THEN c.rox WHEN m.cam_present = 1 THEN m.px END AS ox,
|
|
270
|
+
CASE WHEN c.has_ray THEN c.roy WHEN m.cam_present = 1 THEN m.py END AS oy,
|
|
271
|
+
CASE WHEN c.has_ray THEN c.roz WHEN m.cam_present = 1 THEN m.pz END AS oz
|
|
272
|
+
FROM (
|
|
273
|
+
SELECT session_id, ts,
|
|
274
|
+
hit_point[1] AS hx, hit_point[2] AS hy, hit_point[3] AS hz, mesh,
|
|
275
|
+
length(ray_origin) = 3 AS has_ray,
|
|
276
|
+
ray_origin[1] AS rox, ray_origin[2] AS roy, ray_origin[3] AS roz
|
|
277
|
+
FROM events
|
|
278
|
+
WHERE project_id = ${pid}
|
|
279
|
+
AND event_type = 'pointer_click'
|
|
280
|
+
AND length(hit_point) = 3${range}${scene}${source}${session}
|
|
281
|
+
) AS c
|
|
282
|
+
${d.asofLeftJoin} (
|
|
283
|
+
SELECT session_id, ts, 1 AS cam_present,
|
|
284
|
+
position[1] AS px, position[2] AS py, position[3] AS pz
|
|
285
|
+
FROM events
|
|
286
|
+
WHERE project_id = ${pid}
|
|
287
|
+
AND event_type = 'camera_sample'
|
|
288
|
+
AND length(position) = 3${range}${scene}${session}
|
|
289
|
+
) AS m
|
|
290
|
+
ON c.session_id = m.session_id AND c.ts >= m.ts
|
|
291
|
+
) AS j
|
|
292
|
+
WHERE j.ox IS NOT NULL
|
|
293
|
+
GROUP BY cam_vx, cam_vy, cam_vz, hit_vx, hit_vy, hit_vz, mesh
|
|
294
|
+
ORDER BY count DESC
|
|
295
|
+
LIMIT ${bag.add("limit", "u32", opts.limit ?? 500)}
|
|
296
|
+
`,
|
|
297
|
+
query_params: bag.values,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Aggregate gaze→mesh flow links: ASOF-join each `pointer_click` to the nearest
|
|
302
|
+
* preceding `camera_sample` in the same session, then group by
|
|
303
|
+
* `(direction-bin, mesh)`. Each row is one weighted link from a direction bin to
|
|
304
|
+
* a clicked mesh.
|
|
305
|
+
*
|
|
306
|
+
* Position-aware mode (design §7.8): when `groupByOrigin` is set or an
|
|
307
|
+
* `originVoxel` filter is given, the click-time camera **position** is restored
|
|
308
|
+
* as a source dimension — rows additionally carry the standpoint voxel
|
|
309
|
+
* (`origin_v*`) and its averaged world point (`origin_*`). The standpoint origin
|
|
310
|
+
* prefers the click's own ray origin for pose sources (ADR 0011), falling back
|
|
311
|
+
* to the joined `camera_sample` position. Omitting both options reproduces the
|
|
312
|
+
* §7.5 direction-only links unchanged.
|
|
313
|
+
*/
|
|
314
|
+
export function buildFlowHeatmap(projectId, opts, d) {
|
|
315
|
+
const bag = new ParamBag(d);
|
|
316
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
317
|
+
const bins = bag.add("bins", "u32", opts.bins ?? 24);
|
|
318
|
+
const range = rangeClause(bag, opts);
|
|
319
|
+
const scene = sceneClause(bag, opts);
|
|
320
|
+
const session = sessionClause(bag, opts);
|
|
321
|
+
const cameraMode = cameraModeClause(bag, d, projectId, opts);
|
|
322
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 150);
|
|
323
|
+
// §7.8 is opt-in: only restructure the query when a position dimension is asked
|
|
324
|
+
// for, so the default §7.5 flow renders byte-for-byte as before.
|
|
325
|
+
const positionAware = opts.groupByOrigin === true || opts.originVoxel != null;
|
|
326
|
+
if (!positionAware) {
|
|
327
|
+
return {
|
|
328
|
+
query: `
|
|
329
|
+
SELECT
|
|
330
|
+
floor((atan2(m.dz, m.dx) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
|
|
331
|
+
floor((asin(m.dy / greatest(sqrt(m.dx * m.dx + m.dy * m.dy + m.dz * m.dz), 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
|
|
332
|
+
c.mesh AS mesh,
|
|
333
|
+
count() AS count
|
|
334
|
+
FROM (
|
|
335
|
+
SELECT session_id, ts, mesh
|
|
336
|
+
FROM events
|
|
337
|
+
WHERE project_id = ${pid}
|
|
338
|
+
AND event_type = 'pointer_click'
|
|
339
|
+
AND mesh != ''${range}${scene}${session}${cameraMode}
|
|
340
|
+
) AS c
|
|
341
|
+
${d.asofInnerJoin} (
|
|
342
|
+
SELECT session_id, ts, direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
|
|
343
|
+
FROM events
|
|
344
|
+
WHERE project_id = ${pid}
|
|
345
|
+
AND event_type = 'camera_sample'
|
|
346
|
+
AND length(direction) = 3${range}${scene}${session}${cameraMode}
|
|
347
|
+
) AS m
|
|
348
|
+
ON c.session_id = m.session_id AND c.ts >= m.ts
|
|
349
|
+
GROUP BY azimuth_bin, elevation_bin, mesh
|
|
350
|
+
ORDER BY count DESC
|
|
351
|
+
LIMIT ${limit}
|
|
352
|
+
`,
|
|
353
|
+
query_params: bag.values,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
|
|
357
|
+
let originFilter = "";
|
|
358
|
+
if (opts.originVoxel != null) {
|
|
359
|
+
const ovx = bag.add("originVx", "f64", opts.originVoxel[0]);
|
|
360
|
+
const ovy = bag.add("originVy", "f64", opts.originVoxel[1]);
|
|
361
|
+
const ovz = bag.add("originVz", "f64", opts.originVoxel[2]);
|
|
362
|
+
originFilter = `
|
|
363
|
+
WHERE floor(j.ox / ${cellSize}) = ${ovx}
|
|
364
|
+
AND floor(j.oy / ${cellSize}) = ${ovy}
|
|
365
|
+
AND floor(j.oz / ${cellSize}) = ${ovz}`;
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
query: `
|
|
369
|
+
SELECT
|
|
370
|
+
floor((atan2(j.dz, j.dx) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
|
|
371
|
+
floor((asin(j.dy / greatest(sqrt(j.dx * j.dx + j.dy * j.dy + j.dz * j.dz), 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
|
|
372
|
+
floor(j.ox / ${cellSize}) AS origin_vx,
|
|
373
|
+
floor(j.oy / ${cellSize}) AS origin_vy,
|
|
374
|
+
floor(j.oz / ${cellSize}) AS origin_vz,
|
|
375
|
+
avg(j.ox) AS origin_x,
|
|
376
|
+
avg(j.oy) AS origin_y,
|
|
377
|
+
avg(j.oz) AS origin_z,
|
|
378
|
+
j.mesh AS mesh,
|
|
379
|
+
count() AS count
|
|
380
|
+
FROM (
|
|
381
|
+
SELECT
|
|
382
|
+
c.mesh AS mesh,
|
|
383
|
+
m.dx AS dx, m.dy AS dy, m.dz AS dz,
|
|
384
|
+
CASE WHEN c.has_ray THEN c.rox ELSE m.px END AS ox,
|
|
385
|
+
CASE WHEN c.has_ray THEN c.roy ELSE m.py END AS oy,
|
|
386
|
+
CASE WHEN c.has_ray THEN c.roz ELSE m.pz END AS oz
|
|
387
|
+
FROM (
|
|
388
|
+
SELECT session_id, ts, mesh,
|
|
389
|
+
length(ray_origin) = 3 AS has_ray,
|
|
390
|
+
ray_origin[1] AS rox, ray_origin[2] AS roy, ray_origin[3] AS roz
|
|
391
|
+
FROM events
|
|
392
|
+
WHERE project_id = ${pid}
|
|
393
|
+
AND event_type = 'pointer_click'
|
|
394
|
+
AND mesh != ''${range}${scene}${session}${cameraMode}
|
|
395
|
+
) AS c
|
|
396
|
+
${d.asofInnerJoin} (
|
|
397
|
+
SELECT session_id, ts,
|
|
398
|
+
position[1] AS px, position[2] AS py, position[3] AS pz,
|
|
399
|
+
direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
|
|
400
|
+
FROM events
|
|
401
|
+
WHERE project_id = ${pid}
|
|
402
|
+
AND event_type = 'camera_sample'
|
|
403
|
+
AND length(direction) = 3
|
|
404
|
+
AND length(position) = 3${range}${scene}${session}${cameraMode}
|
|
405
|
+
) AS m
|
|
406
|
+
ON c.session_id = m.session_id AND c.ts >= m.ts
|
|
407
|
+
) AS j${originFilter}
|
|
408
|
+
GROUP BY azimuth_bin, elevation_bin, origin_vx, origin_vy, origin_vz, mesh
|
|
409
|
+
ORDER BY count DESC
|
|
410
|
+
LIMIT ${limit}
|
|
411
|
+
`,
|
|
412
|
+
query_params: bag.values,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** Most-interacted meshes (from `mesh_interaction` and pointer hits). */
|
|
416
|
+
export function buildTopMeshes(projectId, opts, d) {
|
|
417
|
+
const bag = new ParamBag(d);
|
|
418
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
419
|
+
const range = rangeClause(bag, opts);
|
|
420
|
+
const session = sessionClause(bag, opts);
|
|
421
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
422
|
+
return {
|
|
423
|
+
query: `
|
|
424
|
+
SELECT mesh, count() AS count
|
|
425
|
+
FROM events
|
|
426
|
+
WHERE project_id = ${pid} AND mesh != ''${range}${session}
|
|
427
|
+
GROUP BY mesh
|
|
428
|
+
ORDER BY count DESC
|
|
429
|
+
LIMIT ${limit}
|
|
430
|
+
`,
|
|
431
|
+
query_params: bag.values,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Per-object attention / dwell from `mesh_visibility` summaries (#37). Sums the
|
|
436
|
+
* bucketed on-screen and gaze-centred time per mesh and tracks the peak screen
|
|
437
|
+
* fraction, ranked by total dwell. The 3D analog of time-on-element.
|
|
438
|
+
*/
|
|
439
|
+
export function buildMeshDwell(projectId, opts, d) {
|
|
440
|
+
const bag = new ParamBag(d);
|
|
441
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
442
|
+
const range = rangeClause(bag, opts);
|
|
443
|
+
const scene = sceneClause(bag, opts);
|
|
444
|
+
const session = sessionClause(bag, opts);
|
|
445
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
446
|
+
return {
|
|
447
|
+
query: `
|
|
448
|
+
SELECT
|
|
449
|
+
mesh,
|
|
450
|
+
sum(visible_ms) AS visible_ms,
|
|
451
|
+
sum(centered_ms) AS centered_ms,
|
|
452
|
+
max(screen_fraction) AS max_screen_fraction,
|
|
453
|
+
count() AS samples
|
|
454
|
+
FROM events
|
|
455
|
+
WHERE project_id = ${pid} AND event_type = 'mesh_visibility' AND mesh != ''${range}${scene}${session}
|
|
456
|
+
GROUP BY mesh
|
|
457
|
+
ORDER BY visible_ms DESC
|
|
458
|
+
LIMIT ${limit}
|
|
459
|
+
`,
|
|
460
|
+
query_params: bag.values,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Dead-click rate (#46): of all `pointer_click` events, how many hit nothing
|
|
465
|
+
* (the hit-test missed, so `mesh` is empty / no `hitMesh`). A high dead-click
|
|
466
|
+
* rate is a 3D discoverability problem — users click where they expect something
|
|
467
|
+
* interactive and get no response. Reuses `inputSourceShape` filters (ADR 0011).
|
|
468
|
+
*/
|
|
469
|
+
export function buildDeadClicks(projectId, opts, d) {
|
|
470
|
+
const bag = new ParamBag(d);
|
|
471
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
472
|
+
const range = rangeClause(bag, opts);
|
|
473
|
+
const scene = sceneClause(bag, opts);
|
|
474
|
+
const source = sourceClause(bag, opts);
|
|
475
|
+
const session = sessionClause(bag, opts);
|
|
476
|
+
return {
|
|
477
|
+
query: `
|
|
478
|
+
SELECT
|
|
479
|
+
count() AS total_clicks,
|
|
480
|
+
sum(CASE WHEN mesh = '' THEN 1 ELSE 0 END) AS dead_clicks
|
|
481
|
+
FROM events
|
|
482
|
+
WHERE project_id = ${pid} AND event_type = 'pointer_click'${range}${scene}${source}${session}
|
|
483
|
+
`,
|
|
484
|
+
query_params: bag.values,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Rage clicks (#47): rapid repeated clicks on the same mesh — a frustration
|
|
489
|
+
* signal ("I keep clicking this and nothing happens"). Derived purely from the
|
|
490
|
+
* `pointer_click` stream: clicks are bucketed into fixed `interval`-second
|
|
491
|
+
* windows per `(session, mesh)`, and a bucket with at least `minRepeats` clicks
|
|
492
|
+
* is reported as a rage cluster, ranked by burst size. Only clicks that hit a
|
|
493
|
+
* mesh count here; rapid clicks on empty space are the dead-click signal (#46).
|
|
494
|
+
* Reuses `inputSourceShape` filters (ADR 0011).
|
|
495
|
+
*/
|
|
496
|
+
export function buildRageClicks(projectId, opts, d) {
|
|
497
|
+
const bag = new ParamBag(d);
|
|
498
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
499
|
+
const interval = bag.add("interval", "u32", opts.interval ?? 2);
|
|
500
|
+
const range = rangeClause(bag, opts);
|
|
501
|
+
const scene = sceneClause(bag, opts);
|
|
502
|
+
const source = sourceClause(bag, opts);
|
|
503
|
+
const session = sessionClause(bag, opts);
|
|
504
|
+
const minRepeats = bag.add("minRepeats", "u32", opts.minRepeats ?? 3);
|
|
505
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 100);
|
|
506
|
+
return {
|
|
507
|
+
query: `
|
|
508
|
+
SELECT
|
|
509
|
+
session_id,
|
|
510
|
+
mesh,
|
|
511
|
+
${d.timeBucketMs("ts", interval)} AS bucket,
|
|
512
|
+
count() AS clicks
|
|
513
|
+
FROM events
|
|
514
|
+
WHERE project_id = ${pid} AND event_type = 'pointer_click' AND mesh != ''${range}${scene}${source}${session}
|
|
515
|
+
GROUP BY session_id, mesh, bucket
|
|
516
|
+
HAVING count() >= ${minRepeats}
|
|
517
|
+
ORDER BY clicks DESC
|
|
518
|
+
LIMIT ${limit}
|
|
519
|
+
`,
|
|
520
|
+
query_params: bag.values,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Hover hesitation from `hover_dwell` summaries (#48): per mesh, the total time
|
|
525
|
+
* visitors lingered on an object *without clicking it*, the number of hesitation
|
|
526
|
+
* episodes, and the longest single hover. High dwell with few interactions flags
|
|
527
|
+
* objects that look interactive but aren't — or aren't obviously clickable. The
|
|
528
|
+
* connector emits one bucketed episode per hover (its `dwellMs` is stored in the
|
|
529
|
+
* shared `visible_ms` column). Reuses `inputSourceShape` filters (ADR 0011).
|
|
530
|
+
*/
|
|
531
|
+
export function buildHoverDwell(projectId, opts, d) {
|
|
532
|
+
const bag = new ParamBag(d);
|
|
533
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
534
|
+
const range = rangeClause(bag, opts);
|
|
535
|
+
const scene = sceneClause(bag, opts);
|
|
536
|
+
const source = sourceClause(bag, opts);
|
|
537
|
+
const session = sessionClause(bag, opts);
|
|
538
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
539
|
+
return {
|
|
540
|
+
query: `
|
|
541
|
+
SELECT
|
|
542
|
+
mesh,
|
|
543
|
+
sum(visible_ms) AS dwell_ms,
|
|
544
|
+
max(visible_ms) AS max_dwell_ms,
|
|
545
|
+
count() AS episodes
|
|
546
|
+
FROM events
|
|
547
|
+
WHERE project_id = ${pid} AND event_type = 'hover_dwell' AND mesh != ''${range}${scene}${source}${session}
|
|
548
|
+
GROUP BY mesh
|
|
549
|
+
ORDER BY dwell_ms DESC
|
|
550
|
+
LIMIT ${limit}
|
|
551
|
+
`,
|
|
552
|
+
query_params: bag.values,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Shader / pipeline compile stalls (#42, design §C): per phase, the number of
|
|
557
|
+
* compile hitches and their total / average / worst main-thread duration.
|
|
558
|
+
* Compilation is the #1 source of first-interaction jank, so this surfaces the
|
|
559
|
+
* felt cost that `frame_perf` averages away. The connector emits one
|
|
560
|
+
* `compile_stall` per compile; its `durationMs` is stored in the shared
|
|
561
|
+
* `visible_ms` column and its coarse `phase` label in the `name` column.
|
|
562
|
+
*/
|
|
563
|
+
export function buildCompileStalls(projectId, opts, d) {
|
|
564
|
+
const bag = new ParamBag(d);
|
|
565
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
566
|
+
const range = rangeClause(bag, opts);
|
|
567
|
+
const scene = sceneClause(bag, opts);
|
|
568
|
+
const session = sessionClause(bag, opts);
|
|
569
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
570
|
+
return {
|
|
571
|
+
query: `
|
|
572
|
+
SELECT
|
|
573
|
+
name AS phase,
|
|
574
|
+
count() AS stalls,
|
|
575
|
+
sum(visible_ms) AS total_ms,
|
|
576
|
+
avg(visible_ms) AS avg_ms,
|
|
577
|
+
max(visible_ms) AS max_ms
|
|
578
|
+
FROM events
|
|
579
|
+
WHERE project_id = ${pid} AND event_type = 'compile_stall'${range}${scene}${session}
|
|
580
|
+
GROUP BY name
|
|
581
|
+
ORDER BY total_ms DESC
|
|
582
|
+
LIMIT ${limit}
|
|
583
|
+
`,
|
|
584
|
+
query_params: bag.values,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* GPU / memory footprint summary from `resource_sample` samples (#44, design §C).
|
|
589
|
+
* Reports the average and peak of each footprint metric over the range — the
|
|
590
|
+
* actual cost the scene asked of the device (vs. `session_start.device` caps).
|
|
591
|
+
* Unreported metrics are stored as `0`; `NULLIF(..., 0)` keeps those out of the
|
|
592
|
+
* averages so a metric one engine omits doesn't dilute another's.
|
|
593
|
+
*/
|
|
594
|
+
export function buildResourceSummary(projectId, opts, d) {
|
|
595
|
+
const bag = new ParamBag(d);
|
|
596
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
597
|
+
const range = rangeClause(bag, opts);
|
|
598
|
+
const session = sessionClause(bag, opts);
|
|
599
|
+
return {
|
|
600
|
+
query: `
|
|
601
|
+
SELECT
|
|
602
|
+
count() AS samples,
|
|
603
|
+
avg(NULLIF(js_heap_bytes, 0)) AS avg_js_heap_bytes,
|
|
604
|
+
max(js_heap_bytes) AS max_js_heap_bytes,
|
|
605
|
+
avg(NULLIF(triangles, 0)) AS avg_triangles,
|
|
606
|
+
max(triangles) AS max_triangles,
|
|
607
|
+
avg(NULLIF(vertices, 0)) AS avg_vertices,
|
|
608
|
+
max(vertices) AS max_vertices,
|
|
609
|
+
avg(NULLIF(texture_bytes, 0)) AS avg_texture_bytes,
|
|
610
|
+
max(texture_bytes) AS max_texture_bytes,
|
|
611
|
+
avg(NULLIF(geometry_bytes, 0)) AS avg_geometry_bytes,
|
|
612
|
+
max(geometry_bytes) AS max_geometry_bytes
|
|
613
|
+
FROM events
|
|
614
|
+
WHERE project_id = ${pid} AND event_type = 'resource_sample'${range}${session}
|
|
615
|
+
`,
|
|
616
|
+
query_params: bag.values,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* GPU/memory footprint **percentiles**, computed per-session then aggregated
|
|
621
|
+
* (ADR 0028 §1), from `resource_sample`. Reports a typical (p50) and a peak
|
|
622
|
+
* (p95) value per session for JS heap, texture bytes, and triangle count, then
|
|
623
|
+
* summarizes each as the median across sessions — so a single heavy session does
|
|
624
|
+
* not set the headline footprint. Unreported metrics (stored `0`) are excluded
|
|
625
|
+
* via `nullIf`. Complements {@link buildResourceSummary}'s pooled avg/max with a
|
|
626
|
+
* distribution-honest view.
|
|
627
|
+
*/
|
|
628
|
+
export function buildResourcePercentiles(projectId, opts, d) {
|
|
629
|
+
const bag = new ParamBag(d);
|
|
630
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
631
|
+
const range = rangeClause(bag, opts);
|
|
632
|
+
const scene = sceneClause(bag, opts);
|
|
633
|
+
const session = sessionClause(bag, opts);
|
|
634
|
+
return {
|
|
635
|
+
query: `
|
|
636
|
+
SELECT
|
|
637
|
+
count() AS sessions,
|
|
638
|
+
sum(s_samples) AS samples,
|
|
639
|
+
${d.quantile("s_heap_p50", 0.5)} AS p50_js_heap_bytes,
|
|
640
|
+
${d.quantile("s_heap_p95", 0.5)} AS p95_js_heap_bytes,
|
|
641
|
+
${d.quantile("s_tex_p50", 0.5)} AS p50_texture_bytes,
|
|
642
|
+
${d.quantile("s_tex_p95", 0.5)} AS p95_texture_bytes,
|
|
643
|
+
${d.quantile("s_tri_p50", 0.5)} AS p50_triangles,
|
|
644
|
+
${d.quantile("s_tri_p95", 0.5)} AS p95_triangles
|
|
645
|
+
FROM (
|
|
646
|
+
SELECT
|
|
647
|
+
session_id,
|
|
648
|
+
count() AS s_samples,
|
|
649
|
+
${d.quantile("nullIf(js_heap_bytes, 0)", 0.5)} AS s_heap_p50,
|
|
650
|
+
${d.quantile("nullIf(js_heap_bytes, 0)", 0.95)} AS s_heap_p95,
|
|
651
|
+
${d.quantile("nullIf(texture_bytes, 0)", 0.5)} AS s_tex_p50,
|
|
652
|
+
${d.quantile("nullIf(texture_bytes, 0)", 0.95)} AS s_tex_p95,
|
|
653
|
+
${d.quantile("nullIf(triangles, 0)", 0.5)} AS s_tri_p50,
|
|
654
|
+
${d.quantile("nullIf(triangles, 0)", 0.95)} AS s_tri_p95
|
|
655
|
+
FROM events
|
|
656
|
+
WHERE project_id = ${pid} AND event_type = 'resource_sample'${range}${scene}${session}
|
|
657
|
+
GROUP BY session_id
|
|
658
|
+
) per_session
|
|
659
|
+
`,
|
|
660
|
+
query_params: bag.values,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Stability-incident counts over the range: WebGL/WebGPU context losses
|
|
665
|
+
* (`context_lost`) and shader/pipeline compile stalls (`compile_stall`), plus the
|
|
666
|
+
* total incident count. These are the hard failures `frame_perf` cannot show — a
|
|
667
|
+
* context loss blanks the canvas, a compile stall freezes first interaction. An
|
|
668
|
+
* empty range reports `0` (not NULL) via `coalesce`.
|
|
669
|
+
*/
|
|
670
|
+
export function buildStabilityCounts(projectId, opts, d) {
|
|
671
|
+
const bag = new ParamBag(d);
|
|
672
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
673
|
+
const range = rangeClause(bag, opts);
|
|
674
|
+
const scene = sceneClause(bag, opts);
|
|
675
|
+
const session = sessionClause(bag, opts);
|
|
676
|
+
return {
|
|
677
|
+
query: `
|
|
678
|
+
SELECT
|
|
679
|
+
coalesce(sum(CASE WHEN event_type = 'context_lost' THEN 1 ELSE 0 END), 0) AS context_losses,
|
|
680
|
+
coalesce(sum(CASE WHEN event_type = 'compile_stall' THEN 1 ELSE 0 END), 0) AS compile_stalls,
|
|
681
|
+
count() AS incidents
|
|
682
|
+
FROM events
|
|
683
|
+
WHERE project_id = ${pid} AND event_type IN ('context_lost', 'compile_stall')${range}${scene}${session}
|
|
684
|
+
`,
|
|
685
|
+
query_params: bag.values,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Capability / fidelity transitions from `capability_change` (#49, design §E):
|
|
690
|
+
* per (kind, from, to), how many times the app reported that fallback or
|
|
691
|
+
* recovery. Explains perf / visual-fidelity variance across the user base (e.g.
|
|
692
|
+
* how many sessions fell back WebGPU→WebGL2). `kind` is carried by the shared
|
|
693
|
+
* `name` column; the capability tokens live in `cap_from` / `cap_to`.
|
|
694
|
+
*/
|
|
695
|
+
export function buildCapabilityChanges(projectId, opts, d) {
|
|
696
|
+
const bag = new ParamBag(d);
|
|
697
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
698
|
+
const range = rangeClause(bag, opts);
|
|
699
|
+
const scene = sceneClause(bag, opts);
|
|
700
|
+
const session = sessionClause(bag, opts);
|
|
701
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 50);
|
|
702
|
+
return {
|
|
703
|
+
query: `
|
|
704
|
+
SELECT
|
|
705
|
+
name AS kind,
|
|
706
|
+
cap_from AS "from",
|
|
707
|
+
cap_to AS "to",
|
|
708
|
+
count() AS changes
|
|
709
|
+
FROM events
|
|
710
|
+
WHERE project_id = ${pid} AND event_type = 'capability_change'${range}${scene}${session}
|
|
711
|
+
GROUP BY name, cap_from, cap_to
|
|
712
|
+
ORDER BY changes DESC
|
|
713
|
+
LIMIT ${limit}
|
|
714
|
+
`,
|
|
715
|
+
query_params: bag.values,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Camera-navigation gesture breakdown from `camera_gesture` (ADR 0025): per
|
|
720
|
+
* gesture `kind` (orbit / pan / dolly / zoom / roll / fly / navigate), how often
|
|
721
|
+
* users moved the viewpoint and how long each gesture lasted. This separates
|
|
722
|
+
* deliberate navigation intent from object selection (a click that doesn't move
|
|
723
|
+
* the camera emits no gesture), revealing how an audience explores a scene. The
|
|
724
|
+
* gesture `kind` is carried by the shared `name` column and its `durationMs` by
|
|
725
|
+
* the shared `visible_ms` column.
|
|
726
|
+
*/
|
|
727
|
+
export function buildCameraGestures(projectId, opts, d) {
|
|
728
|
+
const bag = new ParamBag(d);
|
|
729
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
730
|
+
const range = rangeClause(bag, opts);
|
|
731
|
+
const scene = sceneClause(bag, opts);
|
|
732
|
+
const source = sourceClause(bag, opts);
|
|
733
|
+
const session = sessionClause(bag, opts);
|
|
734
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 25);
|
|
735
|
+
return {
|
|
736
|
+
query: `
|
|
737
|
+
SELECT
|
|
738
|
+
name AS kind,
|
|
739
|
+
count() AS gestures,
|
|
740
|
+
sum(visible_ms) AS total_ms,
|
|
741
|
+
avg(visible_ms) AS avg_ms,
|
|
742
|
+
max(visible_ms) AS max_ms
|
|
743
|
+
FROM events
|
|
744
|
+
WHERE project_id = ${pid} AND event_type = 'camera_gesture'${range}${scene}${source}${session}
|
|
745
|
+
GROUP BY name
|
|
746
|
+
ORDER BY gestures DESC
|
|
747
|
+
LIMIT ${limit}
|
|
748
|
+
`,
|
|
749
|
+
query_params: bag.values,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/** Aggregate rendering-performance summary from `frame_perf` samples. */
|
|
753
|
+
export function buildPerfSummary(projectId, opts, d) {
|
|
754
|
+
const bag = new ParamBag(d);
|
|
755
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
756
|
+
const range = rangeClause(bag, opts);
|
|
757
|
+
const session = sessionClause(bag, opts);
|
|
758
|
+
return {
|
|
759
|
+
query: `
|
|
760
|
+
SELECT
|
|
761
|
+
count() AS samples,
|
|
762
|
+
avg(fps) AS avg_fps,
|
|
763
|
+
min(fps) AS min_fps,
|
|
764
|
+
${d.quantile("fps", 0.5)} AS p50_fps
|
|
765
|
+
FROM events
|
|
766
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${session}
|
|
767
|
+
`,
|
|
768
|
+
query_params: bag.values,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* FPS distribution, computed **per-session then aggregated** (ADR 0028 §1). Each
|
|
773
|
+
* session contributes its own p05/p50/p95 FPS, so neither long sessions nor
|
|
774
|
+
* high-frame-rate devices dominate the headline; the reported percentiles are
|
|
775
|
+
* the median across sessions of each per-session percentile ("median-of-medians").
|
|
776
|
+
* `sessions` is the number of contributing sessions and `samples` the total
|
|
777
|
+
* `frame_perf` sample count.
|
|
778
|
+
*/
|
|
779
|
+
export function buildPerfDistribution(projectId, opts, d) {
|
|
780
|
+
const bag = new ParamBag(d);
|
|
781
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
782
|
+
const range = rangeClause(bag, opts);
|
|
783
|
+
const scene = sceneClause(bag, opts);
|
|
784
|
+
const session = sessionClause(bag, opts);
|
|
785
|
+
return {
|
|
786
|
+
query: `
|
|
787
|
+
SELECT
|
|
788
|
+
count() AS sessions,
|
|
789
|
+
sum(s_samples) AS samples,
|
|
790
|
+
${d.quantile("s_p05", 0.5)} AS p05_fps,
|
|
791
|
+
${d.quantile("s_p50", 0.5)} AS p50_fps,
|
|
792
|
+
${d.quantile("s_p95", 0.5)} AS p95_fps
|
|
793
|
+
FROM (
|
|
794
|
+
SELECT
|
|
795
|
+
session_id,
|
|
796
|
+
count() AS s_samples,
|
|
797
|
+
${d.quantile("fps", 0.05)} AS s_p05,
|
|
798
|
+
${d.quantile("fps", 0.5)} AS s_p50,
|
|
799
|
+
${d.quantile("fps", 0.95)} AS s_p95
|
|
800
|
+
FROM events
|
|
801
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
802
|
+
GROUP BY session_id
|
|
803
|
+
) per_session
|
|
804
|
+
`,
|
|
805
|
+
query_params: bag.values,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Histogram of **per-session median FPS** (ADR 0028 §1). One session contributes
|
|
810
|
+
* a single data point — its median FPS — bucketed into `bucket`-wide FPS bins
|
|
811
|
+
* (default 10). `bucket` is the inclusive lower bound of each bin and `sessions`
|
|
812
|
+
* the number of sessions whose median FPS falls in it. Plotting session medians
|
|
813
|
+
* rather than raw samples keeps the shape honest about how many *experiences*
|
|
814
|
+
* were smooth, instead of letting a few chatty sessions skew the curve.
|
|
815
|
+
*/
|
|
816
|
+
export function buildFpsHistogram(projectId, opts, d) {
|
|
817
|
+
const bag = new ParamBag(d);
|
|
818
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
819
|
+
const range = rangeClause(bag, opts);
|
|
820
|
+
const scene = sceneClause(bag, opts);
|
|
821
|
+
const session = sessionClause(bag, opts);
|
|
822
|
+
const bucket = bag.add("bucket", "u32", opts.bucket ?? 10);
|
|
823
|
+
return {
|
|
824
|
+
query: `
|
|
825
|
+
SELECT
|
|
826
|
+
floor(s_p50 / ${bucket}) * ${bucket} AS bucket,
|
|
827
|
+
count() AS sessions
|
|
828
|
+
FROM (
|
|
829
|
+
SELECT
|
|
830
|
+
session_id,
|
|
831
|
+
${d.quantile("fps", 0.5)} AS s_p50
|
|
832
|
+
FROM events
|
|
833
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
834
|
+
GROUP BY session_id
|
|
835
|
+
) per_session
|
|
836
|
+
GROUP BY bucket
|
|
837
|
+
ORDER BY bucket ASC
|
|
838
|
+
`,
|
|
839
|
+
query_params: bag.values,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Frame-time percentiles in milliseconds, computed **per-session then
|
|
844
|
+
* aggregated** (ADR 0028 §1). `p50_ms` is the median across sessions of each
|
|
845
|
+
* session's median `frame_time_ms` (the typical frame cost); `p95_ms` is the
|
|
846
|
+
* median across sessions of each session's worst-window `frame_time_p95_ms` —
|
|
847
|
+
* the SDK already reports a per-window p95, so the tail is read from that
|
|
848
|
+
* promoted column rather than re-derived from window means. Zero-valued detail
|
|
849
|
+
* (unreported by older samples / non-`frame_perf` rows) is ignored via `nullIf`.
|
|
850
|
+
*/
|
|
851
|
+
export function buildFrameTimePercentiles(projectId, opts, d) {
|
|
852
|
+
const bag = new ParamBag(d);
|
|
853
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
854
|
+
const range = rangeClause(bag, opts);
|
|
855
|
+
const scene = sceneClause(bag, opts);
|
|
856
|
+
const session = sessionClause(bag, opts);
|
|
857
|
+
return {
|
|
858
|
+
query: `
|
|
859
|
+
SELECT
|
|
860
|
+
count() AS sessions,
|
|
861
|
+
sum(s_samples) AS samples,
|
|
862
|
+
${d.quantile("s_p50", 0.5)} AS p50_ms,
|
|
863
|
+
${d.quantile("s_p95", 0.5)} AS p95_ms
|
|
864
|
+
FROM (
|
|
865
|
+
SELECT
|
|
866
|
+
session_id,
|
|
867
|
+
count() AS s_samples,
|
|
868
|
+
${d.quantile("nullIf(frame_time_ms, 0)", 0.5)} AS s_p50,
|
|
869
|
+
max(nullIf(frame_time_p95_ms, 0)) AS s_p95
|
|
870
|
+
FROM events
|
|
871
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
872
|
+
GROUP BY session_id
|
|
873
|
+
) per_session
|
|
874
|
+
`,
|
|
875
|
+
query_params: bag.values,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Jank rate, computed **per-session then aggregated** (ADR 0028 §1). Each
|
|
880
|
+
* session's rate is its total `long_frames` divided by its `frame_perf` sample
|
|
881
|
+
* windows; the headline is the median session rate plus the worst-decile (p90)
|
|
882
|
+
* session rate, so a handful of janky sessions surface instead of being averaged
|
|
883
|
+
* away. `total_long_frames` is the raw jank count and `sessions` the number of
|
|
884
|
+
* contributing sessions.
|
|
885
|
+
*/
|
|
886
|
+
export function buildJankRate(projectId, opts, d) {
|
|
887
|
+
const bag = new ParamBag(d);
|
|
888
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
889
|
+
const range = rangeClause(bag, opts);
|
|
890
|
+
const scene = sceneClause(bag, opts);
|
|
891
|
+
const session = sessionClause(bag, opts);
|
|
892
|
+
return {
|
|
893
|
+
query: `
|
|
894
|
+
SELECT
|
|
895
|
+
count() AS sessions,
|
|
896
|
+
sum(s_long) AS total_long_frames,
|
|
897
|
+
${d.quantile("s_rate", 0.5)} AS median_rate,
|
|
898
|
+
${d.quantile("s_rate", 0.9)} AS worst_decile_rate
|
|
899
|
+
FROM (
|
|
900
|
+
SELECT
|
|
901
|
+
session_id,
|
|
902
|
+
sum(long_frames) AS s_long,
|
|
903
|
+
sum(long_frames) * 1.0 / count() AS s_rate
|
|
904
|
+
FROM events
|
|
905
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
906
|
+
GROUP BY session_id
|
|
907
|
+
) per_session
|
|
908
|
+
`,
|
|
909
|
+
query_params: bag.values,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* FPS segmented by device class, computed **per-session then aggregated** (ADR
|
|
914
|
+
* 0028 §2). Each session's median FPS is attributed to the graphics backend,
|
|
915
|
+
* mobile flag, and GPU `renderer` recorded in its `session_start.device` block —
|
|
916
|
+
* data already on the wire, so there is no SDK or schema change. Device fields
|
|
917
|
+
* are read from the `session_start` payload JSON (they are not promoted columns)
|
|
918
|
+
* and `coalesce`d to `''` when a session never reported them. `p50_fps` is the
|
|
919
|
+
* median across sessions in the group of each session's median FPS.
|
|
920
|
+
*/
|
|
921
|
+
export function buildPerfByDevice(projectId, opts, d) {
|
|
922
|
+
const bag = new ParamBag(d);
|
|
923
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
924
|
+
const range = rangeClause(bag, opts);
|
|
925
|
+
const scene = sceneClause(bag, opts);
|
|
926
|
+
const session = sessionClause(bag, opts);
|
|
927
|
+
const engine = d.jsonText("payload", "device", "engine");
|
|
928
|
+
const isMobile = d.jsonText("payload", "device", "isMobile");
|
|
929
|
+
const renderer = d.jsonText("payload", "device", "renderer");
|
|
930
|
+
return {
|
|
931
|
+
query: `
|
|
932
|
+
WITH session_device AS (
|
|
933
|
+
SELECT
|
|
934
|
+
session_id,
|
|
935
|
+
${engine} AS engine,
|
|
936
|
+
${isMobile} AS is_mobile,
|
|
937
|
+
${renderer} AS renderer
|
|
938
|
+
FROM events
|
|
939
|
+
WHERE project_id = ${pid} AND event_type = 'session_start'
|
|
940
|
+
),
|
|
941
|
+
session_perf AS (
|
|
942
|
+
SELECT
|
|
943
|
+
session_id,
|
|
944
|
+
count() AS s_samples,
|
|
945
|
+
${d.quantile("fps", 0.5)} AS s_p50
|
|
946
|
+
FROM events
|
|
947
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
948
|
+
GROUP BY session_id
|
|
949
|
+
)
|
|
950
|
+
SELECT
|
|
951
|
+
coalesce(dev.engine, '') AS engine,
|
|
952
|
+
coalesce(dev.is_mobile, '') AS is_mobile,
|
|
953
|
+
coalesce(dev.renderer, '') AS renderer,
|
|
954
|
+
count() AS sessions,
|
|
955
|
+
sum(perf.s_samples) AS samples,
|
|
956
|
+
${d.quantile("perf.s_p50", 0.5)} AS p50_fps
|
|
957
|
+
FROM session_perf perf
|
|
958
|
+
LEFT JOIN session_device dev ON dev.session_id = perf.session_id
|
|
959
|
+
GROUP BY engine, is_mobile, renderer
|
|
960
|
+
ORDER BY sessions DESC
|
|
961
|
+
`,
|
|
962
|
+
query_params: bag.values,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* FPS segmented by scene, computed **per-session then aggregated** (ADR 0028 §1).
|
|
967
|
+
* Each session's median FPS is attributed to its scene (a session renders one
|
|
968
|
+
* scene); `p50_fps` is the median across the scene's sessions of each session's
|
|
969
|
+
* median FPS, so neither long sessions nor a busy scene's traffic skews the
|
|
970
|
+
* comparison between scenes.
|
|
971
|
+
*/
|
|
972
|
+
export function buildPerfByScene(projectId, opts, d) {
|
|
973
|
+
const bag = new ParamBag(d);
|
|
974
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
975
|
+
const range = rangeClause(bag, opts);
|
|
976
|
+
const scene = sceneClause(bag, opts);
|
|
977
|
+
const session = sessionClause(bag, opts);
|
|
978
|
+
return {
|
|
979
|
+
query: `
|
|
980
|
+
SELECT
|
|
981
|
+
scene_id,
|
|
982
|
+
count() AS sessions,
|
|
983
|
+
sum(s_samples) AS samples,
|
|
984
|
+
${d.quantile("s_p50", 0.5)} AS p50_fps
|
|
985
|
+
FROM (
|
|
986
|
+
SELECT
|
|
987
|
+
session_id,
|
|
988
|
+
${d.anyValue("scene_id")} AS scene_id,
|
|
989
|
+
count() AS s_samples,
|
|
990
|
+
${d.quantile("fps", 0.5)} AS s_p50
|
|
991
|
+
FROM events
|
|
992
|
+
WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
|
|
993
|
+
GROUP BY session_id
|
|
994
|
+
) per_session
|
|
995
|
+
GROUP BY scene_id
|
|
996
|
+
ORDER BY sessions DESC
|
|
997
|
+
`,
|
|
998
|
+
query_params: bag.values,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Daily rendering-performance trend, read from the `perf_daily` materialized
|
|
1003
|
+
* view (migration 0003/0004). Aggregate states are merged with `-Merge`.
|
|
1004
|
+
*/
|
|
1005
|
+
export function buildPerfDaily(projectId, opts, d) {
|
|
1006
|
+
const bag = new ParamBag(d);
|
|
1007
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1008
|
+
const range = dayRangeClause(bag, d, opts);
|
|
1009
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 30);
|
|
1010
|
+
return {
|
|
1011
|
+
query: `
|
|
1012
|
+
SELECT
|
|
1013
|
+
${d.toText("day")} AS day,
|
|
1014
|
+
${d.countMerge("samples_state")} AS samples,
|
|
1015
|
+
${d.avgMerge("avg_fps_state")} AS avg_fps,
|
|
1016
|
+
min(min_fps) AS min_fps,
|
|
1017
|
+
${d.quantileMerge("p50_fps_state", 0.5)} AS p50_fps
|
|
1018
|
+
FROM perf_daily
|
|
1019
|
+
WHERE project_id = ${pid}${range}
|
|
1020
|
+
GROUP BY day
|
|
1021
|
+
ORDER BY day DESC
|
|
1022
|
+
LIMIT ${limit}
|
|
1023
|
+
`,
|
|
1024
|
+
query_params: bag.values,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Daily event-count trend per event type, read from the `events_daily`
|
|
1029
|
+
* materialized view (migration 0005/0006). SummingMergeTree counts are summed.
|
|
1030
|
+
*/
|
|
1031
|
+
export function buildEventsDaily(projectId, opts, d) {
|
|
1032
|
+
const bag = new ParamBag(d);
|
|
1033
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1034
|
+
const range = dayRangeClause(bag, d, opts);
|
|
1035
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 200);
|
|
1036
|
+
return {
|
|
1037
|
+
query: `
|
|
1038
|
+
SELECT
|
|
1039
|
+
${d.toText("day")} AS day,
|
|
1040
|
+
event_type,
|
|
1041
|
+
sum(events) AS events
|
|
1042
|
+
FROM events_daily
|
|
1043
|
+
WHERE project_id = ${pid}${range}
|
|
1044
|
+
GROUP BY day, event_type
|
|
1045
|
+
ORDER BY day DESC, events DESC
|
|
1046
|
+
LIMIT ${limit}
|
|
1047
|
+
`,
|
|
1048
|
+
query_params: bag.values,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Distinct developer-assigned scenes for a project (ADR 0010), with an event
|
|
1053
|
+
* `count` and the most recent activity `last_seen`. Time-range aware.
|
|
1054
|
+
*/
|
|
1055
|
+
export function buildDistinctScenes(projectId, opts, d) {
|
|
1056
|
+
const bag = new ParamBag(d);
|
|
1057
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1058
|
+
const range = rangeClause(bag, opts);
|
|
1059
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 200);
|
|
1060
|
+
return {
|
|
1061
|
+
query: `
|
|
1062
|
+
SELECT
|
|
1063
|
+
scene_id,
|
|
1064
|
+
count() AS events,
|
|
1065
|
+
max(ts) AS last_seen
|
|
1066
|
+
FROM events
|
|
1067
|
+
WHERE project_id = ${pid}${range}
|
|
1068
|
+
GROUP BY scene_id
|
|
1069
|
+
ORDER BY events DESC
|
|
1070
|
+
LIMIT ${limit}
|
|
1071
|
+
`,
|
|
1072
|
+
query_params: bag.values,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Event-volume time-series: bucket events into fixed `interval`-second windows
|
|
1077
|
+
* and return the per-bucket count plus the average FPS of any `frame_perf`
|
|
1078
|
+
* samples in that bucket. Optionally scoped to one scene and/or event type.
|
|
1079
|
+
*/
|
|
1080
|
+
export function buildTimeseries(projectId, opts, d) {
|
|
1081
|
+
const bag = new ParamBag(d);
|
|
1082
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1083
|
+
const interval = bag.add("interval", "u32", opts.interval ?? 3600);
|
|
1084
|
+
const range = rangeClause(bag, opts);
|
|
1085
|
+
const scene = sceneClause(bag, opts);
|
|
1086
|
+
const type = opts.type != null && opts.type.length > 0
|
|
1087
|
+
? ` AND event_type = ${bag.add("type", "string", opts.type)}`
|
|
1088
|
+
: "";
|
|
1089
|
+
return {
|
|
1090
|
+
query: `
|
|
1091
|
+
SELECT
|
|
1092
|
+
${d.timeBucketMs("ts", interval)} AS bucket,
|
|
1093
|
+
count() AS events,
|
|
1094
|
+
${d.avgIf("fps", "event_type = 'frame_perf'")} AS avg_fps
|
|
1095
|
+
FROM events
|
|
1096
|
+
WHERE project_id = ${pid}${range}${scene}${type}
|
|
1097
|
+
GROUP BY bucket
|
|
1098
|
+
ORDER BY bucket ASC
|
|
1099
|
+
`,
|
|
1100
|
+
query_params: bag.values,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Per-event-type counts over the range (optionally one scene). Powers the scene
|
|
1105
|
+
* health panel — error rate, context-loss incidents, focus/visibility gaps, etc.
|
|
1106
|
+
*/
|
|
1107
|
+
export function buildEventTypeCounts(projectId, opts, d) {
|
|
1108
|
+
const bag = new ParamBag(d);
|
|
1109
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1110
|
+
const range = rangeClause(bag, opts);
|
|
1111
|
+
const scene = sceneClause(bag, opts);
|
|
1112
|
+
return {
|
|
1113
|
+
query: `
|
|
1114
|
+
SELECT event_type, count() AS count
|
|
1115
|
+
FROM events
|
|
1116
|
+
WHERE project_id = ${pid}${range}${scene}
|
|
1117
|
+
GROUP BY event_type
|
|
1118
|
+
ORDER BY count DESC
|
|
1119
|
+
`,
|
|
1120
|
+
query_params: bag.values,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Scene coverage / dead zones (derived, ADR — scene-metrics §B): voxel-bin the
|
|
1125
|
+
* camera *position* of `camera_sample` into a uniform grid of `cellSize`-sized
|
|
1126
|
+
* cubes. Each row is an occupied voxel with its visit `count`. Exploration
|
|
1127
|
+
* completeness ("saw 40% of the scene") and never-visited regions are computed by
|
|
1128
|
+
* the consumer against the scene AABB voxel count — the AABB lives in the scene
|
|
1129
|
+
* registry, not the events table, so it is layered in at presentation time.
|
|
1130
|
+
*/
|
|
1131
|
+
export function buildSceneCoverage(projectId, opts, d) {
|
|
1132
|
+
const bag = new ParamBag(d);
|
|
1133
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1134
|
+
const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
|
|
1135
|
+
const range = rangeClause(bag, opts);
|
|
1136
|
+
const scene = sceneClause(bag, opts);
|
|
1137
|
+
const session = sessionClause(bag, opts);
|
|
1138
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 5000);
|
|
1139
|
+
return {
|
|
1140
|
+
query: `
|
|
1141
|
+
SELECT
|
|
1142
|
+
floor(position[1] / ${cellSize}) AS vx,
|
|
1143
|
+
floor(position[2] / ${cellSize}) AS vy,
|
|
1144
|
+
floor(position[3] / ${cellSize}) AS vz,
|
|
1145
|
+
count() AS count
|
|
1146
|
+
FROM events
|
|
1147
|
+
WHERE project_id = ${pid}
|
|
1148
|
+
AND event_type = 'camera_sample'
|
|
1149
|
+
AND length(position) = 3${range}${scene}${session}
|
|
1150
|
+
GROUP BY vx, vy, vz
|
|
1151
|
+
ORDER BY count DESC
|
|
1152
|
+
LIMIT ${limit}
|
|
1153
|
+
`,
|
|
1154
|
+
query_params: bag.values,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Camera distance / zoom distribution (derived, scene-metrics §B): histogram the
|
|
1159
|
+
* distance from the camera *position* of each `camera_sample` to a reference
|
|
1160
|
+
* `center` (the scene-AABB center, passed in world units; defaults to the
|
|
1161
|
+
* origin), bucketed into `bucketSize`-wide bins. A proxy for engagement intensity
|
|
1162
|
+
* — how close visitors get to the subject.
|
|
1163
|
+
*/
|
|
1164
|
+
export function buildCameraDistance(projectId, opts, d) {
|
|
1165
|
+
const bag = new ParamBag(d);
|
|
1166
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1167
|
+
const [cx0, cy0, cz0] = opts.center ?? [0, 0, 0];
|
|
1168
|
+
const cx = bag.add("centerX", "f64", cx0);
|
|
1169
|
+
const cy = bag.add("centerY", "f64", cy0);
|
|
1170
|
+
const cz = bag.add("centerZ", "f64", cz0);
|
|
1171
|
+
const bucketSize = bag.add("bucketSize", "f64", opts.bucketSize ?? 1);
|
|
1172
|
+
const range = rangeClause(bag, opts);
|
|
1173
|
+
const scene = sceneClause(bag, opts);
|
|
1174
|
+
const session = sessionClause(bag, opts);
|
|
1175
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 1000);
|
|
1176
|
+
return {
|
|
1177
|
+
query: `
|
|
1178
|
+
SELECT
|
|
1179
|
+
floor(sqrt(
|
|
1180
|
+
(position[1] - ${cx}) * (position[1] - ${cx}) +
|
|
1181
|
+
(position[2] - ${cy}) * (position[2] - ${cy}) +
|
|
1182
|
+
(position[3] - ${cz}) * (position[3] - ${cz})
|
|
1183
|
+
) / ${bucketSize}) AS bucket,
|
|
1184
|
+
count() AS count
|
|
1185
|
+
FROM events
|
|
1186
|
+
WHERE project_id = ${pid}
|
|
1187
|
+
AND event_type = 'camera_sample'
|
|
1188
|
+
AND length(position) = 3${range}${scene}${session}
|
|
1189
|
+
GROUP BY bucket
|
|
1190
|
+
ORDER BY bucket ASC
|
|
1191
|
+
LIMIT ${limit}
|
|
1192
|
+
`,
|
|
1193
|
+
query_params: bag.values,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Navigation effort / friction (derived, scene-metrics §B): per session, ASOF
|
|
1198
|
+
* self-join each `camera_sample` to the immediately preceding one and accumulate
|
|
1199
|
+
* the inter-sample travel distance. `total_distance` is the path length; segments
|
|
1200
|
+
* whose distance clears `moveThreshold` count as *active* (the rest are idle
|
|
1201
|
+
* dwell), so a high segment count with low active distance flags a "stuck" / lost
|
|
1202
|
+
* visitor. World units throughout.
|
|
1203
|
+
*/
|
|
1204
|
+
export function buildNavigationStats(projectId, opts, d) {
|
|
1205
|
+
const bag = new ParamBag(d);
|
|
1206
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1207
|
+
const moveThreshold = bag.add("moveThreshold", "f64", opts.moveThreshold ?? 0.05);
|
|
1208
|
+
const range = rangeClause(bag, opts);
|
|
1209
|
+
const scene = sceneClause(bag, opts);
|
|
1210
|
+
const session = sessionClause(bag, opts);
|
|
1211
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 500);
|
|
1212
|
+
const sampleSelect = `
|
|
1213
|
+
SELECT session_id, ts, position[1] AS px, position[2] AS py, position[3] AS pz
|
|
1214
|
+
FROM events
|
|
1215
|
+
WHERE project_id = ${pid}
|
|
1216
|
+
AND event_type = 'camera_sample'
|
|
1217
|
+
AND length(position) = 3${range}${scene}${session}`;
|
|
1218
|
+
return {
|
|
1219
|
+
query: `
|
|
1220
|
+
SELECT
|
|
1221
|
+
session_id,
|
|
1222
|
+
count() AS segments,
|
|
1223
|
+
sum(dist) AS total_distance,
|
|
1224
|
+
sum(CASE WHEN dist >= ${moveThreshold} THEN 1 ELSE 0 END) AS active_segments,
|
|
1225
|
+
sum(CASE WHEN dist >= ${moveThreshold} THEN dist ELSE 0 END) AS active_distance
|
|
1226
|
+
FROM (
|
|
1227
|
+
SELECT
|
|
1228
|
+
c.session_id AS session_id,
|
|
1229
|
+
sqrt(
|
|
1230
|
+
(c.px - m.px) * (c.px - m.px) +
|
|
1231
|
+
(c.py - m.py) * (c.py - m.py) +
|
|
1232
|
+
(c.pz - m.pz) * (c.pz - m.pz)
|
|
1233
|
+
) AS dist
|
|
1234
|
+
FROM (${sampleSelect}
|
|
1235
|
+
) AS c
|
|
1236
|
+
${d.asofInnerJoin} (${sampleSelect}
|
|
1237
|
+
) AS m
|
|
1238
|
+
ON c.session_id = m.session_id AND c.ts > m.ts
|
|
1239
|
+
) AS seg
|
|
1240
|
+
GROUP BY session_id
|
|
1241
|
+
ORDER BY total_distance DESC
|
|
1242
|
+
LIMIT ${limit}
|
|
1243
|
+
`,
|
|
1244
|
+
query_params: bag.values,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
/** XR input sources that distinguish hand-tracking, controllers and gaze. */
|
|
1248
|
+
const XR_SOURCES = "('xr-controller', 'hand', 'gaze', 'transient')";
|
|
1249
|
+
/**
|
|
1250
|
+
* Event types that carry the shared input-source vocabulary (ADR 0011) — i.e.
|
|
1251
|
+
* the interactions actually *triggered by* an input source. Restricting to these
|
|
1252
|
+
* keeps non-interaction events (`camera_sample`, `frame_perf`, …), whose `source`
|
|
1253
|
+
* column is the realized `'mouse'` default, out of the source breakdown.
|
|
1254
|
+
*/
|
|
1255
|
+
const INPUT_SOURCE_EVENT_TYPES = "('pointer_move', 'pointer_click', 'pointer_down', 'pointer_up', " +
|
|
1256
|
+
"'mesh_interaction', 'hover_dwell', 'camera_gesture', 'input_action')";
|
|
1257
|
+
/**
|
|
1258
|
+
* Input-source breakdown (ADR 0011): for every interaction event that carries an
|
|
1259
|
+
* input source, how many fired per `(event_type, source)`, and how many distinct
|
|
1260
|
+
* sessions used that pairing. This turns `source` from a filter-only dimension
|
|
1261
|
+
* into an actual insight — e.g. how many `mesh_interaction`s came from an
|
|
1262
|
+
* `xr-controller` vs a `mouse`, or whether `pointer_click`s skew to `touch`.
|
|
1263
|
+
* Honors the same scene/source/session filters as the rest of the surface.
|
|
1264
|
+
*/
|
|
1265
|
+
export function buildInteractionsBySource(projectId, opts, d) {
|
|
1266
|
+
const bag = new ParamBag(d);
|
|
1267
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1268
|
+
const range = rangeClause(bag, opts);
|
|
1269
|
+
const scene = sceneClause(bag, opts);
|
|
1270
|
+
const source = sourceClause(bag, opts);
|
|
1271
|
+
const session = sessionClause(bag, opts);
|
|
1272
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 100);
|
|
1273
|
+
return {
|
|
1274
|
+
query: `
|
|
1275
|
+
SELECT
|
|
1276
|
+
event_type,
|
|
1277
|
+
source,
|
|
1278
|
+
count() AS count,
|
|
1279
|
+
count(DISTINCT session_id) AS sessions
|
|
1280
|
+
FROM events
|
|
1281
|
+
WHERE project_id = ${pid}
|
|
1282
|
+
AND event_type IN ${INPUT_SOURCE_EVENT_TYPES}${range}${scene}${source}${session}
|
|
1283
|
+
GROUP BY event_type, source
|
|
1284
|
+
ORDER BY count DESC
|
|
1285
|
+
LIMIT ${limit}
|
|
1286
|
+
`,
|
|
1287
|
+
query_params: bag.values,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* XR motion-sickness proxy (derived, scene-metrics §F): per session, the
|
|
1292
|
+
* head/view rotation *rate* over the `camera_sample` pose stream. Each sample is
|
|
1293
|
+
* ASOF self-joined to its immediate predecessor and the angle between the two
|
|
1294
|
+
* (normalized) view directions is accumulated — `total_turn_rad` is the angular
|
|
1295
|
+
* path, `max_turn_rad` the worst single jerk, and `rapid_segments` counts steps
|
|
1296
|
+
* whose turn clears `rapidTurn` (rad). The pose cadence is fixed by the sampling
|
|
1297
|
+
* profile, so the per-sample angular delta is a discomfort proxy: rapid view
|
|
1298
|
+
* rotation correlates with simulator sickness, most acutely in a headset.
|
|
1299
|
+
*/
|
|
1300
|
+
export function buildXrRotationRate(projectId, opts, d) {
|
|
1301
|
+
const bag = new ParamBag(d);
|
|
1302
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1303
|
+
const rapidTurn = bag.add("rapidTurn", "f64", opts.rapidTurn ?? 0.5);
|
|
1304
|
+
const range = rangeClause(bag, opts);
|
|
1305
|
+
const scene = sceneClause(bag, opts);
|
|
1306
|
+
const session = sessionClause(bag, opts);
|
|
1307
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 500);
|
|
1308
|
+
const sampleSelect = `
|
|
1309
|
+
SELECT session_id, ts, direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
|
|
1310
|
+
FROM events
|
|
1311
|
+
WHERE project_id = ${pid}
|
|
1312
|
+
AND event_type = 'camera_sample'
|
|
1313
|
+
AND length(direction) = 3${range}${scene}${session}`;
|
|
1314
|
+
return {
|
|
1315
|
+
query: `
|
|
1316
|
+
SELECT
|
|
1317
|
+
session_id,
|
|
1318
|
+
count() AS samples,
|
|
1319
|
+
avg(turn) AS avg_turn_rad,
|
|
1320
|
+
max(turn) AS max_turn_rad,
|
|
1321
|
+
sum(turn) AS total_turn_rad,
|
|
1322
|
+
sum(CASE WHEN turn >= ${rapidTurn} THEN 1 ELSE 0 END) AS rapid_segments
|
|
1323
|
+
FROM (
|
|
1324
|
+
SELECT
|
|
1325
|
+
c.session_id AS session_id,
|
|
1326
|
+
acos(least(1, greatest(-1,
|
|
1327
|
+
(c.dx * m.dx + c.dy * m.dy + c.dz * m.dz) /
|
|
1328
|
+
(sqrt(c.dx * c.dx + c.dy * c.dy + c.dz * c.dz) *
|
|
1329
|
+
sqrt(m.dx * m.dx + m.dy * m.dy + m.dz * m.dz))
|
|
1330
|
+
))) AS turn
|
|
1331
|
+
FROM (${sampleSelect}
|
|
1332
|
+
) AS c
|
|
1333
|
+
${d.asofInnerJoin} (${sampleSelect}
|
|
1334
|
+
) AS m
|
|
1335
|
+
ON c.session_id = m.session_id AND c.ts > m.ts
|
|
1336
|
+
) AS seg
|
|
1337
|
+
GROUP BY session_id
|
|
1338
|
+
ORDER BY total_turn_rad DESC
|
|
1339
|
+
LIMIT ${limit}
|
|
1340
|
+
`,
|
|
1341
|
+
query_params: bag.values,
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* XR input-source usage (derived, scene-metrics §F): hand vs. controller (vs.
|
|
1346
|
+
* gaze / transient) split, read from `source` on the existing interaction events.
|
|
1347
|
+
* One row per XR `source` with its interaction `count` and the number of
|
|
1348
|
+
* `sessions` that used it — flat-screen sources (`mouse`, `touch`, …) are
|
|
1349
|
+
* excluded so the breakdown is purely the immersive input mix.
|
|
1350
|
+
*/
|
|
1351
|
+
export function buildXrSourceUsage(projectId, opts, d) {
|
|
1352
|
+
const bag = new ParamBag(d);
|
|
1353
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1354
|
+
const range = rangeClause(bag, opts);
|
|
1355
|
+
const scene = sceneClause(bag, opts);
|
|
1356
|
+
const session = sessionClause(bag, opts);
|
|
1357
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 50);
|
|
1358
|
+
return {
|
|
1359
|
+
query: `
|
|
1360
|
+
SELECT
|
|
1361
|
+
source,
|
|
1362
|
+
count() AS interactions,
|
|
1363
|
+
count(DISTINCT session_id) AS sessions
|
|
1364
|
+
FROM events
|
|
1365
|
+
WHERE project_id = ${pid}
|
|
1366
|
+
AND source IN ${XR_SOURCES}${range}${scene}${session}
|
|
1367
|
+
GROUP BY source
|
|
1368
|
+
ORDER BY interactions DESC
|
|
1369
|
+
LIMIT ${limit}
|
|
1370
|
+
`,
|
|
1371
|
+
query_params: bag.values,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* XR session abandonment (derived, scene-metrics §F): for every session that
|
|
1376
|
+
* used an XR input source, its first/last timestamps and event/interaction
|
|
1377
|
+
* counts. Comfort drop-off is read by the consumer as a short
|
|
1378
|
+
* `ended_at - started_at` span (a headset session cut short) — the wall-clock
|
|
1379
|
+
* bounds are engine-specific and excluded from parity, while the counts are
|
|
1380
|
+
* compared. Sessions with no XR input are omitted entirely.
|
|
1381
|
+
*/
|
|
1382
|
+
export function buildXrAbandonment(projectId, opts, d) {
|
|
1383
|
+
const bag = new ParamBag(d);
|
|
1384
|
+
const pid = bag.add("projectId", "string", projectId);
|
|
1385
|
+
const range = rangeClause(bag, opts);
|
|
1386
|
+
const scene = sceneClause(bag, opts);
|
|
1387
|
+
const session = sessionClause(bag, opts);
|
|
1388
|
+
const limit = bag.add("limit", "u32", opts.limit ?? 500);
|
|
1389
|
+
return {
|
|
1390
|
+
query: `
|
|
1391
|
+
SELECT
|
|
1392
|
+
session_id,
|
|
1393
|
+
count() AS events,
|
|
1394
|
+
sum(CASE WHEN source IN ${XR_SOURCES} THEN 1 ELSE 0 END) AS xr_interactions,
|
|
1395
|
+
min(ts) AS started_at,
|
|
1396
|
+
max(ts) AS ended_at
|
|
1397
|
+
FROM events
|
|
1398
|
+
WHERE project_id = ${pid}${range}${scene}${session}
|
|
1399
|
+
AND session_id IN (
|
|
1400
|
+
SELECT session_id
|
|
1401
|
+
FROM events
|
|
1402
|
+
WHERE project_id = ${pid}
|
|
1403
|
+
AND source IN ${XR_SOURCES}
|
|
1404
|
+
)
|
|
1405
|
+
GROUP BY session_id
|
|
1406
|
+
ORDER BY started_at DESC
|
|
1407
|
+
LIMIT ${limit}
|
|
1408
|
+
`,
|
|
1409
|
+
query_params: bag.values,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
//# sourceMappingURL=aggregations.js.map
|