Skip to content

Commit e33bca7

Browse files
test + decay example
1 parent e0a4daf commit e33bca7

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

hiplot/fetchers_demo.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,29 @@ def demo_big_floats() -> hip.Experiment:
375375
)
376376

377377

378+
def demo_decay() -> hip.Experiment:
379+
"""Simulated exponential decay: remaining = initial_mass * exp(-ln2 / half_life * elapsed).
380+
Produces a wide numeric range in remaining_grams (spans many orders of magnitude),
381+
useful for testing log-scale tick labels alongside plain numeric columns.
382+
"""
383+
rng = random.Random(42)
384+
data: t.List[t.Dict[str, t.Any]] = []
385+
for _ in range(500):
386+
initial_mass = rng.uniform(50, 500)
387+
half_life = rng.uniform(1, 30)
388+
elapsed = rng.uniform(1, 50)
389+
remaining = initial_mass * math.exp(-math.log(2) / half_life * elapsed)
390+
data.append({
391+
'initial_mass': initial_mass,
392+
'half_life': half_life,
393+
'elapsed_years': elapsed,
394+
'remaining_grams': remaining,
395+
})
396+
xp = hip.Experiment.from_iterable(data)
397+
xp.parameters_definition["remaining_grams"].type = hip.ValueType.NUMERIC_LOG
398+
return xp
399+
400+
378401
README_DEMOS: t.Dict[str, t.Callable[[], hip.Experiment]] = {
379402
"demo": demo,
380403
"demo_3xcols": demo_3xcols,
@@ -404,4 +427,5 @@ def demo_big_floats() -> hip.Experiment:
404427
"demo_col_html": demo_col_html,
405428
"demo_disable_table": demo_disable_table,
406429
"demo_big_floats": demo_big_floats,
430+
"demo_decay": demo_decay,
407431
}

tests/log-scale-ticks.spec.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
async function loadDemo(page, uri = "demo_decay") {
4+
await page.goto("/");
5+
await page.waitForFunction(() => (window as any).hiplot?.render, { timeout: 15000 });
6+
await expect(page.locator("#hiplot_element_id")).toBeVisible();
7+
const input = page.locator('textarea[placeholder="Experiments to load"]');
8+
await expect(input).toBeVisible();
9+
await input.fill(uri);
10+
await input.press("Enter");
11+
await page.waitForSelector("svg g.dimension", { state: "attached", timeout: 15000 });
12+
await expect(page.locator("text=Loading HiPlot...")).toHaveCount(0);
13+
}
14+
15+
/** Find the index of a parallel-plot dimension by its label text. */
16+
async function findDimIndex(page, labelName: string): Promise<number> {
17+
return page.evaluate((name: string) => {
18+
const dims = document.querySelectorAll("svg g.dimension");
19+
for (let i = 0; i < dims.length; i++) {
20+
const label = dims[i].querySelector(".pplot-label");
21+
if (!label) continue;
22+
// Extract only direct text nodes, ignoring <title> children
23+
const text = Array.from(label.childNodes)
24+
.filter((n) => n.nodeType === Node.TEXT_NODE)
25+
.map((n) => (n.textContent || "").trim())
26+
.join("");
27+
if (text === name) return i;
28+
}
29+
return -1;
30+
}, labelName);
31+
}
32+
33+
/** Get tick label texts for a parallel-plot dimension (excluding nan/inf/null). */
34+
async function getTickLabels(page, dimIndex: number): Promise<string[]> {
35+
return page.evaluate((idx: number) => {
36+
const dim = document.querySelectorAll("svg g.dimension")[idx];
37+
if (!dim) return [];
38+
const ticks = Array.from(dim.querySelectorAll(".tick text"));
39+
return ticks
40+
.map((t) => (t.textContent || "").trim())
41+
.filter((t) => t.length > 0 && t !== "nan/inf/null");
42+
}, dimIndex);
43+
}
44+
45+
/**
46+
* Get bounding boxes of all tick labels within a parallel-plot dimension,
47+
* sorted top-to-bottom by vertical midpoint.
48+
*/
49+
async function getTickBBoxes(
50+
page,
51+
dimIndex: number,
52+
): Promise<{ text: string; top: number; bottom: number }[]> {
53+
return page.evaluate((idx: number) => {
54+
const dim = document.querySelectorAll("svg g.dimension")[idx];
55+
if (!dim) return [];
56+
const ticks = Array.from(dim.querySelectorAll(".tick text"));
57+
return ticks
58+
.map((el) => {
59+
const r = (el as Element).getBoundingClientRect();
60+
return { text: (el.textContent || "").trim(), top: r.top, bottom: r.bottom };
61+
})
62+
.filter((t) => t.text.length > 0)
63+
.sort((a, b) => (a.top + a.bottom) / 2 - (b.top + b.bottom) / 2);
64+
}, dimIndex);
65+
}
66+
67+
/**
68+
* Check whether adjacent tick labels overlap vertically.
69+
* Returns the fraction of adjacent pairs that overlap.
70+
*/
71+
function overlapFraction(bboxes: { top: number; bottom: number }[]): number {
72+
if (bboxes.length < 2) return 0;
73+
let overlapping = 0;
74+
for (let i = 0; i < bboxes.length - 1; i++) {
75+
if (bboxes[i].bottom > bboxes[i + 1].top + 1) {
76+
overlapping++;
77+
}
78+
}
79+
return overlapping / (bboxes.length - 1);
80+
}
81+
82+
test.describe("parallel plot log-scale tick labels", () => {
83+
test("log-scale column uses scientific notation tick labels", async ({ page }) => {
84+
await loadDemo(page);
85+
const dimIndex = await findDimIndex(page, "remaining_grams");
86+
expect(dimIndex).toBeGreaterThanOrEqual(0);
87+
const labels = await getTickLabels(page, dimIndex);
88+
expect(labels.length).toBeGreaterThan(0);
89+
// All labels should be in scientific notation (e.g. "1.0e-5")
90+
for (const label of labels) {
91+
expect(label).toMatch(/e[+-]?\d+/i);
92+
}
93+
});
94+
95+
test("log-scale tick labels do not overlap", async ({ page }) => {
96+
await loadDemo(page);
97+
const dimIndex = await findDimIndex(page, "remaining_grams");
98+
expect(dimIndex).toBeGreaterThanOrEqual(0);
99+
const bboxes = await getTickBBoxes(page, dimIndex);
100+
expect(bboxes.length).toBeGreaterThan(1);
101+
expect(overlapFraction(bboxes)).toBe(0);
102+
});
103+
104+
test("non-log columns are not corrupted by shared axis state", async ({ page }) => {
105+
await loadDemo(page);
106+
// Verify that plain numeric columns have tick labels that are NOT all
107+
// in scientific notation (regression: shared axis tickValues leak)
108+
const plainCols = ["initial_mass", "half_life", "elapsed_years"];
109+
for (const col of plainCols) {
110+
const dimIndex = await findDimIndex(page, col);
111+
expect(dimIndex, `column ${col} not found in parallel plot`).toBeGreaterThanOrEqual(0);
112+
const labels = await getTickLabels(page, dimIndex);
113+
expect(labels.length, `no tick labels for ${col}`).toBeGreaterThan(0);
114+
const allSci = labels.every((l) => /e[+-]?\d+/i.test(l));
115+
expect(allSci, `${col} tick labels are all scientific notation: ${labels.join(", ")}`).toBe(
116+
false,
117+
);
118+
}
119+
});
120+
121+
test("non-log columns tick labels do not overlap", async ({ page }) => {
122+
await loadDemo(page);
123+
const plainCols = ["initial_mass", "half_life", "elapsed_years"];
124+
for (const col of plainCols) {
125+
const dimIndex = await findDimIndex(page, col);
126+
expect(dimIndex).toBeGreaterThanOrEqual(0);
127+
const bboxes = await getTickBBoxes(page, dimIndex);
128+
expect(bboxes.length).toBeGreaterThan(1);
129+
const overlap = overlapFraction(bboxes);
130+
expect(overlap, `tick labels overlap on ${col}`).toBe(0);
131+
}
132+
});
133+
});
134+
135+
test.describe("distribution plot log-scale tick labels", () => {
136+
test("log-scale distribution axis uses scientific notation and does not overlap", async ({
137+
page,
138+
}) => {
139+
await loadDemo(page);
140+
// Open the distribution plot for remaining_grams via context menu
141+
const labels = page.locator(".pplot-label");
142+
await expect(labels.first()).toBeVisible();
143+
const labelTexts = await labels.evaluateAll((nodes) =>
144+
nodes.map((node) => {
145+
const textParts = Array.from(node.childNodes)
146+
.filter((child) => child.nodeType === Node.TEXT_NODE)
147+
.map((child) => (child.textContent || "").trim())
148+
.filter((text) => text.length > 0);
149+
return textParts.join(" ").trim();
150+
}),
151+
);
152+
const targetIndex = labelTexts.findIndex((t) => t === "remaining_grams");
153+
expect(targetIndex).toBeGreaterThanOrEqual(0);
154+
155+
await labels.nth(targetIndex).click({ button: "right" });
156+
await expect(page.locator(".context-menu.show")).toBeVisible();
157+
await page.locator(".context-menu .dropdown-item", { hasText: "View distribution" }).click();
158+
await expect(
159+
page.locator('[data-testid="distribution-plot"][data-axis="remaining_grams"]'),
160+
).toBeVisible();
161+
162+
const result = await page.evaluate(() => {
163+
const root = document.querySelector(
164+
'[data-testid="distribution-plot"][data-axis="remaining_grams"]',
165+
);
166+
if (!root) return { error: "no-distribution-plot" };
167+
// The data axis is axisBottom in vertical mode
168+
const axisTicks = Array.from(root.querySelectorAll(".axisBottom .tick text"));
169+
const tickData = axisTicks
170+
.map((t) => {
171+
const r = (t as Element).getBoundingClientRect();
172+
return {
173+
text: (t.textContent || "").trim(),
174+
left: r.left,
175+
right: r.right,
176+
};
177+
})
178+
.filter((t) => t.text.length > 0)
179+
.sort((a, b) => a.left - b.left);
180+
const sciCount = tickData.filter((l) => /e[+-]?\d+/i.test(l.text)).length;
181+
// Check horizontal overlap for bottom axis
182+
let overlapCount = 0;
183+
for (let i = 0; i < tickData.length - 1; i++) {
184+
if (tickData[i].right > tickData[i + 1].left + 1) {
185+
overlapCount++;
186+
}
187+
}
188+
return {
189+
count: tickData.length,
190+
sciCount,
191+
overlapCount,
192+
sampleLabels: tickData.slice(0, 5).map((l) => l.text),
193+
};
194+
});
195+
expect(result.error).toBeUndefined();
196+
expect(result.count).toBeGreaterThan(1);
197+
// All labels should be scientific notation
198+
expect(result.sciCount).toBe(result.count);
199+
// No overlapping labels
200+
expect(result.overlapCount).toBe(0);
201+
});
202+
});

0 commit comments

Comments
 (0)