Skip to content

Commit 0e51aa3

Browse files
feat(map): pull extreme outliers closer + centered computing overlay (#5665)
Closes #5664. ## Summary Two UX improvements to the `/map` view, both motivated by feedback that non-technical users misread the initial state as "broken": - **Outliers no longer flatten the cluster.** A new custom d3-force, `outlier-squash`, only activates beyond the 95th-percentile distance from the cluster centroid. Inside the threshold the inner geometry is bit-identical to before; outside, distances are compressed via a sigmoid-like map `r' = R + (r - R) / (1 + (r - R)/k)` so outliers stay visibly separate but bounded (asymptote = R + k). This avoids the alternative — stronger global gravity — which would also crush the inner cluster. - **Loading state communicates "working", not "broken".** The corner "arranging" pill is replaced by a centered spinner + branded label (`map.simulate()`, matching the `any.plot()` function-notation style) + slim progress bar over a dimmed, slightly-blurred backdrop. Progress is driven by `onEngineTick` (throttled to ~10 Hz). The overlay fades on `opacity` instead of unmounting, so subsequent re-derives (filter / weight slider) fade it back in. ## Technical notes - Outlier-squash force registers after the standard four (charge / link / collide / center) so its velocity correction is the last word per tick. Multiplies by `alpha`, so the correction tapers off naturally over `cooldownTicks`. - **Tunables (as shipped on this branch):** `OUTLIER_THRESHOLD_PERCENTILE = 0.95`, `OUTLIER_SQUASH_K = 120` (tightened from 200 in `293e1d1` — ~40% tighter asymptote, pulls extreme outliers further toward the threshold ring), `OUTLIER_SQUASH_STRENGTH = 0.18`. All `const`; not user-toggled per the issue's open question (always-on). - **Cooldown timing** (also `293e1d1`): `COOLDOWN_TICKS` reduced 450 → 300, `d3AlphaMin` raised 0.001 → 0.01 so the engine stops where motion stops being perceptible, and `d3AlphaDecay` is derived from those two so the simulation stops exactly when the progress bar reaches 100%. - The progress bar is a hand-rolled 160×3 px DOM element, slimmer than MUI's `LinearProgress` and avoids an extra import; updates throttled to once every 6 ticks via a ref + `setTickProgress`. - The percentile cutoff uses `floor((n - 1) * p)` (numpy linear interpolation) so small filtered subsets don't degenerate to "no outliers" — fixed in `e87bf1c` after a Copilot review caught the off-by-one with `floor(n * p)`. - Bonus: cleaned up the four `react-hooks/set-state-in-effect` violations and two `exhaustive-deps` warnings in `MapPage.tsx`. Each was refactored to a render-time pattern (last-non-null ref for the hover panel, "store previous prop" for the gate re-arm, JSX gating for the pin marker, inline reset in the search input handler). ## Test plan - [x] `yarn type-check` — clean - [x] `yarn lint src/pages/MapPage.tsx` — zero findings on this file - [x] `yarn test` — full suite green; new `outlierSquashForce` unit tests cover empty/single-node graphs, inner-cluster preservation, the off-by-one regression, bounded correction at extreme distances, and alpha scaling. Two new integration tests cover `onRenderFramePre` registering the custom force and `onEngineTick` advancing `aria-valuenow` on the progress bar. - [x] `yarn build` — succeeds - [ ] Manual: load `/map`, confirm centered spinner + progress bar appears immediately and disappears smoothly when layout settles - [ ] Manual: drag a weight slider → verify the overlay fades back in for the new cooling phase - [ ] Manual: confirm extreme outliers (null-bucket specs) sit visibly outside the main cluster but inside the viewport on first framing https://claude.ai/code/session_01XcRMey5z2WQNhZJQnX2Vtu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e5740b commit 0e51aa3

2 files changed

Lines changed: 454 additions & 53 deletions

File tree

app/src/pages/MapPage.test.tsx

Lines changed: 209 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { forwardRef, useImperativeHandle } from 'react';
3+
import { fireEvent } from '@testing-library/react';
34

45
import { act, render, screen, waitFor } from '../test-utils';
5-
import { MapPage } from './MapPage';
6+
import { MapPage, outlierSquashForce, type SimNode } from './MapPage';
67

78

89
vi.mock('react-helmet-async', () => ({
@@ -53,11 +54,20 @@ type FgInstance = {
5354
refresh: ReturnType<typeof vi.fn>;
5455
__forcesWired?: boolean;
5556
};
57+
// Captures the (forceName, forceFn) pairs registered via `fg.d3Force(...)`
58+
// during onRenderFramePre. Lets tests assert that the custom outlier-squash
59+
// force was wired up — without it the d3Force mock would just throw away
60+
// the function and we couldn't tell apart the four built-in forces from
61+
// the new one.
62+
const registeredForces: Map<string, unknown> = new Map();
5663
const fgInstance: FgInstance = {
5764
centerAt: vi.fn(),
5865
zoom: vi.fn().mockReturnValue(1),
5966
zoomToFit: vi.fn(),
60-
d3Force: vi.fn().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }),
67+
d3Force: vi.fn().mockImplementation((name: string, fn?: unknown) => {
68+
if (fn !== undefined) registeredForces.set(name, fn);
69+
return { strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() };
70+
}),
6171
d3ReheatSimulation: vi.fn(),
6272
refresh: vi.fn(),
6373
};
@@ -165,7 +175,11 @@ describe('MapPage', () => {
165175
fgInstance.centerAt.mockReset();
166176
fgInstance.zoom.mockReset().mockReturnValue(1);
167177
fgInstance.zoomToFit.mockReset();
168-
fgInstance.d3Force.mockReset().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() });
178+
registeredForces.clear();
179+
fgInstance.d3Force.mockReset().mockImplementation((name: string, fn?: unknown) => {
180+
if (fn !== undefined) registeredForces.set(name, fn);
181+
return { strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() };
182+
});
169183
fgInstance.d3ReheatSimulation.mockReset();
170184
fgInstance.refresh.mockReset();
171185
fgInstance.__forcesWired = undefined;
@@ -332,13 +346,16 @@ describe('MapPage', () => {
332346
render(<MapPage />);
333347
await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument());
334348

335-
// Gate is visible while the engine is still cooling.
336-
expect(screen.getByText(/arranging/i)).toBeInTheDocument();
349+
// Gate is visible (role="status" is reachable) while the engine is still cooling.
350+
expect(screen.getByRole('status')).toHaveTextContent(/map\.simulate\(\)/);
337351

338-
// Engine stops → settled flips → overlay disappears.
352+
// Engine stops → settled flips → overlay sets aria-hidden=true and fades.
353+
// We keep the node in the DOM for the fade transition, so query-by-role
354+
// (which filters aria-hidden by default) is the right invariant: it
355+
// becomes unreachable to assistive tech the moment the gate is settled.
339356
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
340357
act(() => onEngineStop());
341-
await waitFor(() => expect(screen.queryByText(/arranging/i)).not.toBeInTheDocument());
358+
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
342359
});
343360

344361
it('frames the bbox via centerAt + zoom on engine stop', async () => {
@@ -381,4 +398,189 @@ describe('MapPage', () => {
381398
expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic');
382399
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec: 'scatter-basic' });
383400
});
401+
402+
describe('outlierSquashForce', () => {
403+
// Pure unit tests that exercise the force math directly. We bypass the
404+
// d3-force harness because the force's contract is "modify vx/vy of
405+
// outlier nodes in place"; the harness adds nothing beyond invoking
406+
// force(alpha) and force.initialize(nodes).
407+
type Sim = SimNode & { x: number; y: number; vx: number; vy: number };
408+
const makeNode = (x: number, y: number): Sim => ({ x, y, vx: 0, vy: 0 });
409+
410+
it('is a no-op when there are no nodes', () => {
411+
const force = outlierSquashForce(0.95, 200, 0.18);
412+
// initialize with empty array; force(alpha) must not throw.
413+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
414+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize([]);
415+
expect(() => force(1)).not.toThrow();
416+
});
417+
418+
it('is a no-op for graphs of fewer than 2 nodes', () => {
419+
const force = outlierSquashForce(0.95, 200, 0.18);
420+
const nodes: Sim[] = [makeNode(1000, 0)];
421+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes);
422+
force(1);
423+
expect(nodes[0].vx).toBe(0);
424+
expect(nodes[0].vy).toBe(0);
425+
});
426+
427+
it('leaves nodes inside the threshold untouched (inner geometry preserved)', () => {
428+
// 99 inner nodes co-located at the origin + 1 far outlier. All inner
429+
// distances to the centroid are exactly equal, so the percentile
430+
// cutoff R lands exactly at the inner radius and the early
431+
// `r <= R → continue` short-circuit fires for every inner node.
432+
// The outlier is the only node above R.
433+
const force = outlierSquashForce(0.95, 200, 0.18);
434+
const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0));
435+
const outlier = makeNode(5000, 0);
436+
const nodes: Sim[] = [...inner, outlier];
437+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes);
438+
force(1);
439+
for (const n of inner) {
440+
expect(n.vx).toBe(0);
441+
expect(n.vy).toBe(0);
442+
}
443+
// Outlier was pulled inward — vx is opposite-sign to its position.
444+
expect(outlier.vx).toBeLessThan(0);
445+
expect(outlier.vy).toBe(0);
446+
});
447+
448+
it('still squashes outliers in small graphs (off-by-one regression guard)', () => {
449+
// Naive `floor(length * p)` would pick index 19 (the max) on n = 20
450+
// and never trigger the squash. The (n - 1) * p indexing must keep
451+
// at least the most-outlying node above R.
452+
const force = outlierSquashForce(0.95, 200, 0.18);
453+
const nodes: Sim[] = Array.from({ length: 20 }, (_, i) => makeNode(i, 0));
454+
// Push the last node much further so it's the unambiguous outlier.
455+
nodes[19] = makeNode(10_000, 0);
456+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes);
457+
force(1);
458+
// The outlier must have a non-zero inward correction.
459+
expect(nodes[19].vx).not.toBe(0);
460+
expect(nodes[19].vx).toBeLessThan(0);
461+
});
462+
463+
it('keeps the velocity correction finite even for distant outliers', () => {
464+
// The compression map r' = R + (r - R)/(1 + (r - R)/k) has an
465+
// asymptote at R + k, so even a node at distance 1e6 produces a
466+
// bounded velocity correction. This is the property that prevents
467+
// a single rogue node from blowing up the simulation.
468+
const force = outlierSquashForce(0.95, 200, 0.18);
469+
const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0));
470+
const far = makeNode(1_000_000, 0);
471+
const nodes: Sim[] = [...inner, far];
472+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize(nodes);
473+
force(1);
474+
expect(far.vx).toBeLessThan(0);
475+
expect(Number.isFinite(far.vx)).toBe(true);
476+
// |vx| upper bound: |position - 0| * strength * alpha = 1e6 * 0.18.
477+
// The actual value is much smaller because (targetR - r) / r is
478+
// close to -1 once r >> R, so the correction approaches -position.
479+
expect(Math.abs(far.vx)).toBeLessThan(1_000_000);
480+
});
481+
482+
it('scales the velocity correction with alpha', () => {
483+
const force = outlierSquashForce(0.95, 200, 0.18);
484+
const inner: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0));
485+
const hot = makeNode(5_000, 0);
486+
const cool = makeNode(5_000, 0);
487+
// First simulation — alpha = 1 (hot).
488+
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize([...inner, hot]);
489+
force(1);
490+
// Second simulation — alpha = 0.1 (cooling).
491+
const force2 = outlierSquashForce(0.95, 200, 0.18);
492+
const inner2: Sim[] = Array.from({ length: 99 }, () => makeNode(0, 0));
493+
(force2 as unknown as { initialize: (n: SimNode[]) => void }).initialize([...inner2, cool]);
494+
force2(0.1);
495+
// Cooler alpha → ~10× smaller velocity correction.
496+
expect(Math.abs(hot.vx)).toBeGreaterThan(Math.abs(cool.vx) * 5);
497+
});
498+
});
499+
500+
it('registers an outlier-squash force on the simulation via onRenderFramePre', async () => {
501+
mockFetchSuccess();
502+
render(<MapPage />);
503+
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
504+
505+
// Trigger the lazy force-wiring side effect — onRenderFramePre is
506+
// idempotent, so we can call it directly.
507+
const onRenderFramePre = lastFgProps.current!.onRenderFramePre as () => void;
508+
act(() => onRenderFramePre());
509+
510+
// The custom force key must have been registered alongside the
511+
// built-in charge / link / collide / center forces.
512+
expect(registeredForces.has('outlier-squash')).toBe(true);
513+
expect(typeof registeredForces.get('outlier-squash')).toBe('function');
514+
});
515+
516+
it('advances the progress bar as onEngineTick fires', async () => {
517+
mockFetchSuccess();
518+
render(<MapPage />);
519+
// Wait for the canvas mount; once it's there, `ready` is true and the
520+
// overlay's progressbar is in the DOM. (Same pattern as the existing
521+
// settling-overlay test.)
522+
await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument());
523+
524+
// Two elements have role="progressbar" — MUI's CircularProgress (the
525+
// spinner) and our hand-rolled determinate bar. Disambiguate via the
526+
// bar's unique aria-label.
527+
const bar = screen.getByRole('progressbar', { name: 'Layout computation progress' });
528+
expect(bar).toHaveAttribute('aria-valuenow', '0');
529+
530+
// Tick progress is throttled to a flush every 6 ticks; fire enough
531+
// ticks to cross the throttle boundary at least once.
532+
const onEngineTick = lastFgProps.current!.onEngineTick as () => void;
533+
act(() => {
534+
for (let i = 0; i < 18; i++) onEngineTick();
535+
});
536+
537+
const after = Number(bar.getAttribute('aria-valuenow'));
538+
expect(after).toBeGreaterThan(0);
539+
expect(after).toBeLessThanOrEqual(100);
540+
});
541+
542+
it('re-arms the settling overlay when graphData re-derives (weight change)', async () => {
543+
// Regression guard for the gate re-arm path. When the user moves a
544+
// weights slider, `weights` state changes → graphData useMemo
545+
// re-derives with a new identity → the prevGraphData ≠ graphData
546+
// branch in render must reset settled/tickProgress so the user sees
547+
// the loading indicator return for the new cooling phase. Without
548+
// this test, that path is exercised only via manual UI interaction.
549+
mockFetchSuccess();
550+
render(<MapPage />);
551+
await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument());
552+
553+
// 1. Initial state: gate is visible, progressbar reachable.
554+
expect(
555+
screen.getByRole('progressbar', { name: 'Layout computation progress' }),
556+
).toHaveAttribute('aria-valuenow', '0');
557+
558+
// 2. Cool the simulation. settled flips true → gate gets
559+
// aria-hidden=true → queryByRole filters it out.
560+
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
561+
act(() => onEngineStop());
562+
await waitFor(() =>
563+
expect(
564+
screen.queryByRole('progressbar', { name: 'Layout computation progress' }),
565+
).not.toBeInTheDocument(),
566+
);
567+
568+
// 3. Open the weights panel and bump a slider. The first slider in
569+
// the panel is for the `plot_type` category — changing it
570+
// triggers setWeights, weights changes, graphData re-derives.
571+
fireEvent.click(screen.getByText(/^weights/));
572+
const sliders = await screen.findAllByRole('slider');
573+
expect(sliders.length).toBeGreaterThan(0);
574+
// MUI Slider's hidden <input type="range"> responds to change events.
575+
act(() => {
576+
fireEvent.change(sliders[0], { target: { value: '4' } });
577+
});
578+
579+
// 4. Gate re-armed: progressbar reachable again, aria-valuenow back
580+
// to 0 (tickCountRef was reset alongside settled).
581+
await waitFor(() => {
582+
const bar = screen.getByRole('progressbar', { name: 'Layout computation progress' });
583+
expect(bar).toHaveAttribute('aria-valuenow', '0');
584+
});
585+
});
384586
});

0 commit comments

Comments
 (0)