Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8573,7 +8573,8 @@ function Leaderboard({
onContextColumn,
collapsedLimit = 6,
expanded = false,
onExpandedChange
onExpandedChange,
containerRef
}) {
const selected = new Set(selectedValues);
const visibleRows = expanded ? rows : rows.slice(0, collapsedLimit);
Expand All @@ -8582,6 +8583,7 @@ function Leaderboard({
const showContext = contextColumn !== "none";
const rowGrid = showContext ? "grid-cols-[minmax(0,1fr)_auto_auto]" : "grid-cols-[minmax(0,1fr)_auto]";
return /* @__PURE__ */ jsx_runtime5.jsxs("section", {
ref: containerRef,
"data-testid": "dimension-leaderboard",
"data-dimension": dimension,
"data-expanded": expanded || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2065,7 +2065,8 @@ function Leaderboard({
onContextColumn,
collapsedLimit = 6,
expanded = false,
onExpandedChange
onExpandedChange,
containerRef
}) {
const selected = new Set(selectedValues);
const visibleRows = expanded ? rows : rows.slice(0, collapsedLimit);
Expand All @@ -2074,6 +2075,7 @@ function Leaderboard({
const showContext = contextColumn !== "none";
const rowGrid = showContext ? "grid-cols-[minmax(0,1fr)_auto_auto]" : "grid-cols-[minmax(0,1fr)_auto]";
return /* @__PURE__ */ jsxs13("section", {
ref: containerRef,
"data-testid": "dimension-leaderboard",
"data-dimension": dimension,
"data-expanded": expanded || undefined,
Expand Down
55 changes: 0 additions & 55 deletions sidemantic-rs/ui/assets/index-DqXBaPTo.js

This file was deleted.

55 changes: 55 additions & 0 deletions sidemantic-rs/ui/assets/index-iemRTgYP.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic-rs/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-DqXBaPTo.js"></script>
<script type="module" crossorigin src="/assets/index-iemRTgYP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
55 changes: 0 additions & 55 deletions sidemantic/ui/static/assets/index-DqXBaPTo.js

This file was deleted.

55 changes: 55 additions & 0 deletions sidemantic/ui/static/assets/index-iemRTgYP.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-DqXBaPTo.js"></script>
<script type="module" crossorigin src="/assets/index-iemRTgYP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
14 changes: 7 additions & 7 deletions sidemantic/widget/static/widget.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions webapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ and deep-linkable URL state.
brush-zoom (sets the date range), double-click to clear.
- **Period-over-period** — set a date range (or brush) and KPIs + the chart show a dashed previous
period and percentage deltas.
- **Lazy leaderboards** — each dimension panel issues its query when it comes near the viewport, so
the focused chart's queries go out first and panels far below the fold cost nothing until reached.
- **Dark mode** — toggle in the top bar, persisted to localStorage (respects `prefers-color-scheme`).
- **Live status** — a spinner shows while queries are in flight; **Reset** clears filters + range.

Expand Down
5 changes: 5 additions & 0 deletions webapp/src/components/Leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Ref } from "react";
import { displayDimValue, type Tone } from "../lib/format";
import type { ContextColumn } from "../state/explorerState";

Expand Down Expand Up @@ -25,6 +26,8 @@ type LeaderboardProps = {
collapsedLimit?: number;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** Attached to the root section, so an owner can measure or observe the panel (lazy mounting). */
containerRef?: Ref<HTMLElement>;
};

const CONTEXT_TONE: Record<Tone, string> = {
Expand All @@ -48,6 +51,7 @@ export function Leaderboard({
collapsedLimit = 6,
expanded = false,
onExpandedChange,
containerRef,
}: LeaderboardProps) {
const selected = new Set(selectedValues);
const visibleRows = expanded ? rows : rows.slice(0, collapsedLimit);
Expand All @@ -62,6 +66,7 @@ export function Leaderboard({

return (
<section
ref={containerRef}
data-testid="dimension-leaderboard"
data-dimension={dimension}
data-expanded={expanded || undefined}
Expand Down
21 changes: 17 additions & 4 deletions webapp/src/components/LeaderboardPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { NULL_TOKEN, queryAlias, type CatalogDimension, type CatalogMetric, type CatalogModel } from "../data/types";
import { formatCompact, formatDeltaAbs, formatDeltaPct, formatPercentOfTotal, sqlLiteral, type Tone } from "../lib/format";
import { catalogDimTypes, composeFilters, dimensionLeaderboard } from "../lib/queries";
import type { DateRange } from "../lib/time";
import { whenNearViewport } from "../lib/viewport";
import type { ContextColumn } from "../state/explorerState";
import { useExplorer } from "../state/ExplorerContext";
import { useQueryResult } from "../state/useQueryResult";
Expand All @@ -22,7 +23,11 @@ const EMPTY_SEGMENTS: string[] = [];

/** A self-contained leaderboard: ranks one dimension by a metric, owning its own query so panels
* load independently and crossfilter clicks toggle the dimension's filter. When a context column is
* active it renders an extra compact figure per row (% of total, or the period-over-period delta). */
* active it renders an extra compact figure per row (% of total, or the period-over-period delta).
*
* The panel shows a skeleton and issues nothing until it is near the viewport, so a wide breakdown
* grid neither queries for panels the user may never scroll to nor lets its own queries overtake
* the view's chart queries (see lib/viewport). */
export function LeaderboardPanel({
dim,
model,
Expand Down Expand Up @@ -58,6 +63,13 @@ export function LeaderboardPanel({
onExpandedChange?: (expanded: boolean) => void;
}) {
const { state, dispatch, backend, catalog } = useExplorer();
// Hold the query until the panel is near the viewport; the skeleton is rendered meanwhile.
const panel = useRef<HTMLElement>(null);
const [near, setNear] = useState(false);
useEffect(() => {
if (near || !panel.current) return;
return whenNearViewport(panel.current, () => setNear(true));
}, [near]);
const timeRef = timeDimensionRef ?? model.timeDimension?.ref;
const types = useMemo(() => catalogDimTypes(catalog), [catalog]);
const filters = useMemo(
Expand All @@ -67,7 +79,7 @@ export function LeaderboardPanel({
);
const { result, loading, error } = useQueryResult(
backend,
dimensionLeaderboard(rankMetric.ref, dim.ref, filters, limit, baseSegments, usePreaggregations),
near ? dimensionLeaderboard(rankMetric.ref, dim.ref, filters, limit, baseSegments, usePreaggregations) : null,
);

// Only an include-mode selection is a "checked" row; exclude/contains filters don't highlight
Expand Down Expand Up @@ -170,7 +182,8 @@ export function LeaderboardPanel({
title={dim.label}
metricLabel={rankMetric.label}
rows={rows}
loading={loading || stale}
containerRef={panel}
loading={!near || loading || stale}
selectedValues={selectedValues}
formatMetric={(value) => formatCompact(value, hint)}
onToggle={(value) => dispatch({ type: "toggleFilter", dim: dim.ref, value })}
Expand Down
75 changes: 75 additions & 0 deletions webapp/src/lib/viewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, test } from "bun:test";
import { NEAR_VIEWPORT_MARGIN, whenNearViewport } from "./viewport";

type ObserverCallback = (entries: { isIntersecting: boolean }[]) => void;

// The test runner has no DOM, so stand in a minimal IntersectionObserver we can drive by hand.
class FakeObserver {
static last: FakeObserver | undefined;
observed: Element[] = [];
disconnects = 0;

constructor(
private readonly callback: ObserverCallback,
readonly options: { root?: Element | null; rootMargin?: string },
) {
FakeObserver.last = this;
}

observe(element: Element) {
this.observed.push(element);
}

disconnect() {
this.disconnects += 1;
}

report(isIntersecting: boolean) {
this.callback([{ isIntersecting }]);
}
}

const globals = globalThis as { IntersectionObserver?: unknown };
const element = {} as Element;

function install() {
globals.IntersectionObserver = FakeObserver;
FakeObserver.last = undefined;
}

afterEach(() => {
delete globals.IntersectionObserver;
});

describe("whenNearViewport", () => {
test("waits for the element to come near the viewport, then stops observing", () => {
install();
let entered = 0;
whenNearViewport(element, () => (entered += 1));
const observer = FakeObserver.last;
expect(observer?.observed).toEqual([element]);
expect(observer?.options.rootMargin).toBe(NEAR_VIEWPORT_MARGIN);

observer?.report(false);
expect(entered).toBe(0);

observer?.report(true);
expect(entered).toBe(1);
expect(observer?.disconnects).toBe(1);
});

test("disposing before the element arrives cancels the observation", () => {
install();
let entered = 0;
const dispose = whenNearViewport(element, () => (entered += 1));
dispose();
expect(FakeObserver.last?.disconnects).toBe(1);
expect(entered).toBe(0);
});

test("enters immediately where IntersectionObserver is unavailable", () => {
let entered = 0;
whenNearViewport(element, () => (entered += 1));
expect(entered).toBe(1);
});
});
50 changes: 50 additions & 0 deletions webapp/src/lib/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Lazy-mount helper for panels that own their own query. Deferring until the panel is near the
// viewport does two things: an offscreen panel costs nothing, and an onscreen one starts its query
// from an IntersectionObserver callback — which is delivered after the first paint, so it lands
// behind the queries its parent view issued during mount effects. React flushes child effects
// before parent effects, so without this a panel's query would otherwise queue ahead of the primary
// chart's.

/** How far outside the visible area a panel starts loading: roughly one scroll nudge, so a panel
* just below the fold is already populated by the time it arrives. */
export const NEAR_VIEWPORT_MARGIN = "600px";

/** The scrolling ancestor the element actually moves inside, or null for the viewport. rootMargin
* only expands the observer's root, never an intermediate clip: the app scrolls inside a container
* rather than the document, so observing against the viewport would prefetch nothing at all. */
function scrollParent(element: Element): Element | null {
for (let node = element.parentElement; node; node = node.parentElement) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}

/**
* Call `onEnter` once `element` comes within `rootMargin` of the visible area, then stop observing.
* Returns a disposer.
*
* Where IntersectionObserver is unavailable (older browsers, non-DOM test runners) no visibility
* callback can ever arrive, so `onEnter` fires immediately — content is never withheld from a
* client that cannot report visibility.
*/
export function whenNearViewport(
element: Element,
onEnter: () => void,
rootMargin: string = NEAR_VIEWPORT_MARGIN,
): () => void {
if (typeof IntersectionObserver === "undefined") {
onEnter();
return () => {};
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
onEnter();
},
{ root: scrollParent(element), rootMargin },
);
observer.observe(element);
return () => observer.disconnect();
}
Loading