Skip to content

Commit eb1415d

Browse files
feat: first working version of new graph component
- also fixed NaN bug in reports API that caused NaN values to be dropped from the data instead of being displayed as 0. - removed old graph component Signed-off-by: Henry Gressmann <[email protected]>
1 parent 426f711 commit eb1415d

File tree

13 files changed

+559
-435
lines changed

13 files changed

+559
-435
lines changed

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"**/node_modules/*",
3333
"**/dist/*",
3434
"**/target/*",
35+
"**/.astro/*",
3536
"**/api/dashboard.ts",
3637
"tracker/script.min.js",
3738
"tracker/script.d.ts"

src/app/core/reports.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,13 @@ fn metric_sql(metric: Metric) -> String {
194194
// total sessions: no time_to_next_event / time_to_next_event is null
195195
// bounce sessions: time to next / time to prev are both null or both > interval '30 minutes'
196196
"--sql
197-
count(distinct sd.visitor_id)
198-
filter (where (sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes') and
199-
(sd.time_from_last_event is null or sd.time_from_last_event > interval '30 minutes')) /
200-
count(distinct sd.visitor_id) filter (where sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes')
197+
coalesce(
198+
count(distinct sd.visitor_id)
199+
filter (where (sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes') and
200+
(sd.time_from_last_event is null or sd.time_from_last_event > interval '30 minutes')) /
201+
nullif(count(distinct sd.visitor_id) filter (where sd.time_to_next_event is null or sd.time_to_next_event > interval '30 minutes'), 0),
202+
1
203+
)
201204
"
202205
}
203206
Metric::AvgTimeOnSite => {

web/bun.lockb

1.02 KB
Binary file not shown.

web/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"typecheck": "bun run --bun tsc --noEmit"
1010
},
1111
"dependencies": {
12-
"@astrojs/react": "4.0.0-beta.2",
12+
"@astrojs/react": "4.0.0",
1313
"@explodingcamera/css": "^0.0.4",
1414
"@fontsource-variable/outfit": "^5.1.0",
1515
"@icons-pack/react-simple-icons": "^10.2.0",
@@ -18,8 +18,9 @@
1818
"@radix-ui/react-accordion": "^1.2.1",
1919
"@radix-ui/react-dialog": "^1.1.2",
2020
"@radix-ui/react-tabs": "^1.1.1",
21-
"@tanstack/react-query": "^5.62.0",
21+
"@tanstack/react-query": "^5.62.2",
2222
"d3-array": "^3.2.4",
23+
"d3-axis": "^3.0.0",
2324
"d3-ease": "^3.0.1",
2425
"d3-geo": "^3.1.1",
2526
"d3-scale": "^4.0.2",
@@ -31,7 +32,7 @@
3132
"fets": "^0.8.4",
3233
"fuzzysort": "^3.1.0",
3334
"little-date": "^1.0.0",
34-
"lucide-react": "0.462.0",
35+
"lucide-react": "0.465.0",
3536
"react": "19.0.0-rc.1",
3637
"react-dom": "19.0.0-rc.1",
3738
"react-tag-autocomplete": "^7.4.0",
@@ -43,18 +44,19 @@
4344
"@million/lint": "^1.0.13",
4445
"@types/bun": "^1.1.14",
4546
"@types/d3-array": "^3.2.1",
47+
"@types/d3-axis": "^3.0.6",
4648
"@types/d3-ease": "^3.0.2",
4749
"@types/d3-geo": "^3.1.0",
4850
"@types/d3-scale": "^4.0.8",
4951
"@types/d3-selection": "^3.0.11",
5052
"@types/d3-shape": "^3.1.6",
5153
"@types/d3-transition": "^3.0.9",
5254
"@types/d3-zoom": "^3.0.8",
53-
"@types/react": "^18.3.12",
55+
"@types/react": "^18.3.13",
5456
"@types/react-dom": "^18.3.1",
5557
"@types/topojson-client": "^3.1.5",
5658
"@types/topojson-specification": "^1.0.5",
57-
"astro": "5.0.0-beta.12",
59+
"astro": "5.0.2",
5860
"rollup-plugin-license": "^3.5.3",
5961
"typescript": "^5.7.2"
6062
},

web/src/components/graph/axis.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Based on https://github.com/d3/d3-axis/blob/main/src/axis.js,
2+
// which is licensed under the ISC License (ISC)
3+
// Modified to allow disabling the domain line and tick lines
4+
// biome-ignore lint:
5+
type ANY = any;
6+
7+
const identity = (d: ANY) => d;
8+
const top = 1;
9+
const right = 2;
10+
const bottom = 3;
11+
const left = 4;
12+
const epsilon = 1e-6;
13+
14+
const translateX = (x: number) => `translate(${x},0)`;
15+
const translateY = (y: number) => `translate(0,${y})`;
16+
const number = (scale: ANY) => (d: ANY) => +scale(d);
17+
18+
function center(scale: ANY, offset: ANY) {
19+
let _offset = Math.max(0, scale.bandwidth() - offset * 2) / 2;
20+
if (scale.round()) _offset = Math.round(_offset);
21+
return (d: ANY) => +scale(d) + _offset;
22+
}
23+
24+
function entering() {
25+
// @ts-expect-error
26+
return !this.__axis;
27+
}
28+
29+
function axis<Domain>(orient: number, scale: ANY) {
30+
type Axis = typeof axis;
31+
32+
let tickArguments: unknown[] = [];
33+
let tickValues: ANY = null;
34+
let tickFormat: ANY = null;
35+
let tickSizeInner = 6;
36+
let tickSizeOuter = 6;
37+
let tickPadding = 3;
38+
let offset = typeof window !== "undefined" && window.devicePixelRatio > 1 ? 0 : 0.5;
39+
const k = orient === top || orient === left ? -1 : 1;
40+
const x = orient === left || orient === right ? "x" : "y";
41+
const transform = orient === top || orient === bottom ? translateX : translateY;
42+
43+
let disableDomain = false;
44+
let disableTicks = false;
45+
46+
function axis(context: ANY) {
47+
const values =
48+
tickValues == null ? (scale.ticks ? scale.ticks.apply(scale, tickArguments) : scale.domain()) : tickValues;
49+
const format =
50+
tickFormat == null ? (scale.tickFormat ? scale.tickFormat.apply(scale, tickArguments) : identity) : tickFormat;
51+
const spacing = Math.max(tickSizeInner, 0) + tickPadding;
52+
const range = scale.range();
53+
const range0 = +range[0] + offset;
54+
const range1 = +range[range.length - 1] + offset;
55+
const position = (scale.bandwidth ? center : number)(scale.copy(), offset);
56+
const selection = context.selection ? context.selection() : context;
57+
let path = selection.selectAll(".domain").data([null]);
58+
let tick = selection.selectAll(".tick").data(values, scale).order();
59+
let tickExit = tick.exit();
60+
const tickEnter = tick.enter().append("g").attr("class", "tick");
61+
let line = tick.select("line");
62+
let text = tick.select("text");
63+
64+
if (!disableDomain)
65+
path = path.merge(path.enter().insert("path", ".tick").attr("class", "domain").attr("stroke", "currentColor"));
66+
67+
tick = tick.merge(tickEnter);
68+
69+
if (!disableTicks)
70+
line = line.merge(
71+
tickEnter
72+
.append("line")
73+
.attr("stroke", "currentColor")
74+
.attr(`${x}2`, k * tickSizeInner),
75+
);
76+
77+
text = text.merge(
78+
tickEnter
79+
.append("text")
80+
.attr("fill", "currentColor")
81+
.attr(x, k * spacing)
82+
.attr("dy", orient === top ? "0em" : orient === bottom ? "0.71em" : "0.32em"),
83+
);
84+
85+
if (context !== selection) {
86+
path = path.transition(context);
87+
tick = tick.transition(context);
88+
line = line.transition(context);
89+
text = text.transition(context);
90+
91+
tickExit = tickExit
92+
.transition(context)
93+
.attr("opacity", epsilon)
94+
.attr("transform", function (this: ANY, d: ANY) {
95+
const _d = position(d);
96+
return Number.isFinite(_d) ? transform(_d + offset) : <ANY>this.getAttribute("transform");
97+
});
98+
99+
tickEnter.attr("opacity", epsilon).attr("transform", function (this: ANY, d: ANY) {
100+
let p = <ANY>this.parentNode.__axis;
101+
let t = 0;
102+
103+
if (p) {
104+
p = p(d);
105+
t = Number.isFinite(p) ? p : position(d);
106+
}
107+
108+
return transform(t + offset);
109+
});
110+
}
111+
112+
tickExit.remove();
113+
114+
path.attr(
115+
"d",
116+
orient === left || orient === right
117+
? tickSizeOuter
118+
? `M${k * tickSizeOuter},${range0}H${offset}V${range1}H${k * tickSizeOuter}`
119+
: `M${offset},${range0}V${range1}`
120+
: tickSizeOuter
121+
? `M${range0},${k * tickSizeOuter}V${offset}H${range1}V${k * tickSizeOuter}`
122+
: `M${range0},${offset}H${range1}`,
123+
);
124+
125+
tick.attr("opacity", 1).attr("transform", (d: ANY) => transform(position(d) + offset));
126+
127+
line.attr(`${x}2`, k * tickSizeInner);
128+
129+
text.attr(x, k * spacing).text(format);
130+
131+
selection
132+
.filter(entering)
133+
.attr("fill", "none")
134+
.attr("font-size", 10)
135+
.attr("font-family", "sans-serif")
136+
.attr("text-anchor", orient === right ? "start" : orient === left ? "end" : "middle");
137+
138+
selection.each(function (this: ANY) {
139+
this.__axis = position;
140+
});
141+
}
142+
143+
axis.disableDomain = () => {
144+
disableDomain = true;
145+
return axis as Axis;
146+
};
147+
148+
axis.disableTicks = () => {
149+
disableTicks = true;
150+
return axis as Axis;
151+
};
152+
153+
axis.ticks = (...args: ANY[]) => {
154+
tickArguments = args;
155+
return axis as Axis;
156+
};
157+
158+
axis.tickArguments = (...args: ANY[]) => {
159+
if (args.length) {
160+
tickArguments = args[0] == null ? [] : Array.from(args[0]);
161+
return axis as Axis;
162+
}
163+
return tickArguments.slice();
164+
};
165+
166+
axis.tickValues = (...args: ANY[]) => {
167+
if (args.length) {
168+
tickValues = args[0] == null ? null : Array.from(args[0]);
169+
return axis as Axis;
170+
}
171+
return tickValues?.slice();
172+
};
173+
174+
axis.tickFormat = (format: (domainValue: Domain, index: number) => string) => {
175+
tickFormat = format;
176+
return axis as Axis;
177+
};
178+
179+
axis.tickSize = (...args: ANY[]) => {
180+
if (args.length) {
181+
tickSizeInner = tickSizeOuter = +args[0];
182+
return axis as Axis;
183+
}
184+
185+
return tickSizeInner;
186+
};
187+
188+
axis.tickSizeInner = (...args: ANY[]) => {
189+
if (args.length) {
190+
tickSizeInner = +args[0];
191+
return axis as Axis;
192+
}
193+
return tickSizeInner;
194+
};
195+
196+
axis.tickSizeOuter = (...args: ANY[]) => {
197+
if (args.length) {
198+
tickSizeOuter = +args[0];
199+
return axis as Axis;
200+
}
201+
return tickSizeOuter;
202+
};
203+
204+
axis.tickPadding = (...args: ANY[]) => {
205+
if (args.length) {
206+
tickPadding = +args[0];
207+
return axis as Axis;
208+
}
209+
return tickPadding;
210+
};
211+
212+
axis.offset = (...args: ANY[]) => {
213+
if (args.length) {
214+
offset = +args[0];
215+
return axis as Axis;
216+
}
217+
return offset;
218+
};
219+
220+
return axis as Axis;
221+
}
222+
223+
export type AxisDomain = number | string | Date | { valueOf(): number };
224+
export interface AxisScale<Domain> {
225+
(x: Domain): number | undefined;
226+
domain(): Domain[];
227+
range(): number[];
228+
copy(): this;
229+
bandwidth?(): number;
230+
}
231+
232+
export function axisTop<Domain extends AxisDomain>(scale: AxisScale<Domain>) {
233+
return axis<Domain>(top, scale);
234+
}
235+
236+
export function axisRight<Domain extends AxisDomain>(scale: AxisScale<Domain>) {
237+
return axis<Domain>(right, scale);
238+
}
239+
240+
export function axisBottom<Domain extends AxisDomain>(scale: AxisScale<Domain>) {
241+
return axis<Domain>(bottom, scale);
242+
}
243+
244+
export function axisLeft<Domain extends AxisDomain>(scale: AxisScale<Domain>) {
245+
return axis<Domain>(left, scale);
246+
}

web/src/components/graph/graph.module.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
.graph {
2+
height: 100%;
3+
background-color: transparent;
4+
5+
:global(.tick) {
6+
pointer-events: none;
7+
}
8+
}
9+
110
.tooltip {
211
background-color: var(--pico-secondary-background);
312
padding: 0.4rem 0.5rem;

0 commit comments

Comments
 (0)