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
8 changes: 8 additions & 0 deletions app/src/pages/LandingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,12 @@ describe('LandingPage', () => {
await user.click(screen.getByText(/Okabe/));
expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'palette_okabe_ito' }));
});

it('tracks the map teaser visual click', async () => {
const user = userEvent.setup();
render(<LandingPage />);

await user.click(screen.getByLabelText(/Open the interactive specifications map/));
expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'map_teaser_preview', target: '/map' });
});
});
171 changes: 171 additions & 0 deletions app/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export function LandingPage() {

<SpecsSection specCount={stats?.specs} featured={featured} />

<MapSection specCount={stats?.specs} />

<LibrariesSection
libraries={librariesData}
onLibraryClick={handleLibraryClick}
Expand All @@ -72,6 +74,175 @@ export function LandingPage() {
);
}

/**
* Map section — teases the interactive force-directed map at /map. Mirrors
* SpecsSection / PaletteSection two-column layout: short description on the
* left, decorative SVG cluster preview on the right. The preview is purely
* static (no data fetch, no force simulation) so it stays cheap on the
* landing page; it only hints at the real map's clustering aesthetic using
* the same Okabe-Ito palette.
*/
function MapSection({ specCount }: { specCount?: number }) {
const { trackEvent } = useAnalytics();
return (
<Box sx={{ py: { xs: 2, md: 3 } }}>
<SectionHeader prompt="❯" title={<em>map</em>} linkText="map.explore()" linkTo="/map" />

<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 1.1fr) minmax(0, 1fr)' },
gap: { xs: 4, md: 8, lg: 12 },
alignItems: 'center',
}}
>
<Box
sx={{
fontFamily: typography.serif,
fontSize: { xs: '1rem', md: '1.25rem' },
lineHeight: 1.55,
color: 'var(--ink-soft)',
fontWeight: 300,
maxWidth: '52ch',
}}
>
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
</Box>{' '}
zoom in for thumbnails, hover for details, click to open the spec.
</Box>
Comment on lines +77 to +114
Comment on lines +109 to +114
Comment on lines +109 to +114

<Box
component={RouterLink}
to="/map"
onClick={() => trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })}
sx={{
display: 'block',
textDecoration: 'none',
color: 'inherit',
border: '1px solid var(--rule)',
borderRadius: 2,
bgcolor: 'var(--bg-surface)',
overflow: 'hidden',
position: 'relative',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s, border-color 0.25s',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '2px',
background: colors.primary,
transform: 'scaleX(0)',
transformOrigin: 'left',
transition: 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 1,
},
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 16px 32px -12px rgba(0,0,0,0.08)',
borderColor: 'rgba(0, 158, 115, 0.2)',
'&::before': { transform: 'scaleX(1)' },
},
}}
aria-label="Open the interactive specifications map"
>
<MapClusterPreview />
</Box>
</Box>
</Box>
);
}

/**
* Decorative SVG mini-cluster — three loose groups of circles in Okabe-Ito
* cluster colours, connected by hairline edges. Static (no force simulation,
* no data fetch) so it's cheap to render; the aspect ratio matches the
* featured-thumb cards (16:10) for visual rhythm. Positions are hand-picked
* to read as "three blobs gently bridged" — like the real map at low zoom.
*/
const MAP_PREVIEW_NODES: Array<{ x: number; y: number; r: number; cluster: 0 | 1 | 2 | 3 }> = [
// Cluster A — left, brand green
{ x: 90, y: 95, r: 9, cluster: 0 },
{ x: 70, y: 130, r: 7, cluster: 0 },
{ x: 115, y: 125, r: 8, cluster: 0 },
{ x: 95, y: 160, r: 6, cluster: 0 },
{ x: 135, y: 95, r: 6, cluster: 0 },
{ x: 60, y: 100, r: 5, cluster: 0 },
// Cluster B — top-right, vermillion
{ x: 320, y: 70, r: 9, cluster: 1 },
{ x: 350, y: 100, r: 7, cluster: 1 },
{ x: 295, y: 105, r: 6, cluster: 1 },
{ x: 365, y: 60, r: 5, cluster: 1 },
{ x: 330, y: 130, r: 7, cluster: 1 },
// Cluster C — bottom-right, blue
{ x: 290, y: 200, r: 8, cluster: 2 },
{ x: 325, y: 220, r: 9, cluster: 2 },
{ x: 360, y: 195, r: 6, cluster: 2 },
{ x: 305, y: 235, r: 6, cluster: 2 },
{ x: 350, y: 240, r: 5, cluster: 2 },
// Bridges — neutral nodes
{ x: 200, y: 130, r: 6, cluster: 3 },
{ x: 225, y: 175, r: 5, cluster: 3 },
{ x: 175, y: 165, r: 4, cluster: 3 },
];

const MAP_PREVIEW_LINKS: Array<[number, number]> = [
// Cluster A internal
[0, 1], [0, 2], [1, 2], [2, 3], [0, 4], [1, 5], [0, 5],
// Cluster B internal
[6, 7], [6, 8], [7, 9], [6, 10], [7, 10],
// Cluster C internal
[11, 12], [12, 13], [11, 14], [13, 14], [14, 15], [12, 15],
// Bridges via neutrals
[2, 16], [16, 8], [3, 17], [17, 11], [16, 17], [17, 18], [18, 3],
];

const CLUSTER_PALETTE = ['#009E73', '#D55E00', '#0072B2'] as const;

function MapClusterPreview() {
return (
<Box
component="svg"
viewBox="0 0 420 280"
aria-hidden="true"
sx={{
display: 'block',
width: '100%',
height: 'auto',
aspectRatio: '16 / 10',
bgcolor: 'var(--bg-elevated)',
}}
>
<g stroke="var(--rule)" strokeWidth={0.75} fill="none" opacity={0.85}>
{MAP_PREVIEW_LINKS.map(([a, b], i) => {
const na = MAP_PREVIEW_NODES[a];
const nb = MAP_PREVIEW_NODES[b];
return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y} />;
})}
</g>
{MAP_PREVIEW_NODES.map((n, i) => {
const fill = n.cluster === 3 ? 'var(--ink-soft)' : CLUSTER_PALETTE[n.cluster];
const opacity = n.cluster === 3 ? 0.45 : 0.92;
return (
<circle
key={i}
cx={n.x}
cy={n.y}
r={n.r}
fill={fill}
opacity={opacity}
stroke="var(--bg-surface)"
strokeWidth={1.5}
/>
);
})}
</Box>
);
}

/**
* Palette section — mirrors SpecsSection's two-column layout: description on
* the left, labelled palette strip on the right.
Expand Down
25 changes: 13 additions & 12 deletions app/src/pages/MapPage.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,22 +130,23 @@ export type TagCategory = (typeof TAG_CATEGORIES)[number];
* weights panel; passing a custom `weights` map to {@link weightedJaccard}
* or {@link buildKNNLinks} replaces the defaults entirely.
*
* The defaults privilege plot_type (2.0) with light contributions from
* features and data_type (0.5 each). That gives a plot_type-dominant map
* with subtle cross-type cohesion. Users can slide secondary categories up
* via the weights panel to mix in techniques/patterns/etc. for richer
* clustering.
* The defaults privilege plot_type (2.0) so it drives the main clusters.
* Secondary semantic axes get non-zero contributions: data_type (0.6),
* features (0.5), domain (0.3), techniques (0.2). Remaining categories
* sit on a 0.1 floor so they still nudge the layout instead of being
* ignored entirely. Users can slide any of these up via the weights
* panel.
*/
export const DEFAULT_CATEGORY_WEIGHT: Record<TagCategory, number> = {
plot_type: 2.0,
features: 0.5,
techniques: 0,
patterns: 0,
dataprep: 0,
dependencies: 0,
domain: 0,
data_type: 0.5,
styling: 0,
techniques: 0.2,
patterns: 0.1,
dataprep: 0.1,
dependencies: 0.1,
domain: 0.3,
data_type: 0.6,
styling: 0.1,
};

function categoryOf(prefixedTag: string): string {
Expand Down
122 changes: 117 additions & 5 deletions app/src/pages/MapPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { forwardRef, useImperativeHandle } from 'react';

import { render, screen, waitFor } from '../test-utils';
import { act, render, screen, waitFor } from '../test-utils';
import { MapPage } from './MapPage';


Expand Down Expand Up @@ -28,9 +29,10 @@ vi.mock('../hooks/useLayoutContext', () => ({
}));

// Default to "(hover: hover)" matching → desktop behaviour. Touch-specific
// branches (e.g. tap-to-pin) need a per-test override.
// branches (e.g. tap-to-pin) need a per-test override via mockHasHover.
const mockHasHover = { current: true };
vi.mock('@mui/material/useMediaQuery', () => ({
default: () => true,
default: () => mockHasHover.current,
}));

// Capture the props passed to ForceGraph2D so individual callbacks can be exercised
Expand All @@ -39,9 +41,31 @@ vi.mock('@mui/material/useMediaQuery', () => ({
type FgProps = Record<string, unknown>;
const lastFgProps: { current: FgProps | null } = { current: null };

// Mock instance returned via ref. The page calls imperative methods like
// `fgRef.current?.centerAt(...)` from `onEngineStop`; without forwardRef the
// ref would be null and those branches would silently early-return.
type FgInstance = {
centerAt: ReturnType<typeof vi.fn>;
zoom: ReturnType<typeof vi.fn>;
zoomToFit: ReturnType<typeof vi.fn>;
d3Force: ReturnType<typeof vi.fn>;
d3ReheatSimulation: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
__forcesWired?: boolean;
};
const fgInstance: FgInstance = {
centerAt: vi.fn(),
zoom: vi.fn().mockReturnValue(1),
zoomToFit: vi.fn(),
d3Force: vi.fn().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() }),
d3ReheatSimulation: vi.fn(),
refresh: vi.fn(),
};

vi.mock('react-force-graph-2d', () => ({
default: (props: FgProps) => {
default: forwardRef<FgInstance, FgProps>((props, ref) => {
lastFgProps.current = props;
useImperativeHandle(ref, () => fgInstance, []);
const data = props.graphData as { nodes: unknown[]; links: unknown[] };
return (
<div
Expand All @@ -50,7 +74,7 @@ vi.mock('react-force-graph-2d', () => ({
data-link-count={data.links.length}
/>
);
},
}),
}));


Expand Down Expand Up @@ -137,6 +161,14 @@ describe('MapPage', () => {
mockNavigate.mockReset();
mockTrackEvent.mockReset();
lastFgProps.current = null;
mockHasHover.current = true;
fgInstance.centerAt.mockReset();
fgInstance.zoom.mockReset().mockReturnValue(1);
fgInstance.zoomToFit.mockReset();
fgInstance.d3Force.mockReset().mockReturnValue({ strength: vi.fn().mockReturnThis(), distance: vi.fn().mockReturnThis() });
fgInstance.d3ReheatSimulation.mockReset();
fgInstance.refresh.mockReset();
fgInstance.__forcesWired = undefined;
vi.stubGlobal('ResizeObserver', MockResizeObserver);
});

Expand Down Expand Up @@ -269,4 +301,84 @@ describe('MapPage', () => {
expect(large).toBeGreaterThan(small);
expect(small).toBeGreaterThan(0);
});

it('seeds initial node positions per cluster (warm start for the simulation)', async () => {
mockFetchSuccess();
render(<MapPage />);
await waitFor(() => expect(lastFgProps.current).not.toBeNull());

const nodes = (lastFgProps.current!.graphData as { nodes: Array<{ id: string; x?: number; y?: number; vx?: number; vy?: number }> }).nodes;
// Every node should have a numeric seed position before FG2D ever ticks the simulation —
// without seeding, FG2D's random initialiser would leave x/y undefined here.
for (const n of nodes) {
expect(typeof n.x).toBe('number');
expect(typeof n.y).toBe('number');
expect(Number.isFinite(n.x as number)).toBe(true);
expect(Number.isFinite(n.y as number)).toBe(true);
}
// Same plot_type (= colorBucket) should land near the same centroid; nodes from
// different buckets should land further apart on average. Take the two scatters
// (bucketed together) vs. line-basic and compare distances.
const scatterA = nodes.find(n => n.id === 'scatter-basic')!;
const scatterB = nodes.find(n => n.id === 'scatter-color-mapped')!;
const line = nodes.find(n => n.id === 'line-basic')!;
const dist = (a: typeof scatterA, b: typeof scatterA) =>
Math.hypot((a.x ?? 0) - (b.x ?? 0), (a.y ?? 0) - (b.y ?? 0));
expect(dist(scatterA, scatterB)).toBeLessThan(dist(scatterA, line));
});

it('shows the settling overlay until the simulation cools, then hides it', async () => {
mockFetchSuccess();
render(<MapPage />);
await waitFor(() => expect(screen.getByTestId('force-graph-2d')).toBeInTheDocument());

// Gate is visible while the engine is still cooling.
expect(screen.getByText(/arranging/i)).toBeInTheDocument();

// Engine stops → settled flips → overlay disappears.
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
act(() => onEngineStop());
await waitFor(() => expect(screen.queryByText(/arranging/i)).not.toBeInTheDocument());
});

it('frames the bbox via centerAt + zoom on engine stop', async () => {
mockFetchSuccess();
render(<MapPage />);
await waitFor(() => expect(lastFgProps.current).not.toBeNull());

// The percentile-trimmed fit reads node x/y; seed positions guarantee these are set.
const onEngineStop = lastFgProps.current!.onEngineStop as () => void;
act(() => onEngineStop());

expect(fgInstance.centerAt).toHaveBeenCalledTimes(1);
expect(fgInstance.zoom).toHaveBeenCalled();
// Animation duration is 0 → instant, hidden behind the gate.
const centerCall = fgInstance.centerAt.mock.calls[0];
expect(centerCall[2]).toBe(0);
});

it('first tap pins on touch devices, second tap navigates', async () => {
mockHasHover.current = false;
mockFetchSuccess();
render(<MapPage />);
await waitFor(() => expect(lastFgProps.current).not.toBeNull());

// First tap: pin (no navigation, analytics fires map_node_pin).
act(() => {
const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void;
onNodeClick({ id: 'scatter-basic' });
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_pin', { spec: 'scatter-basic' });

// Second tap on the same node: navigate. After the first tap React
// re-rendered MapPage with the new pinnedId, so lastFgProps.current
// now holds a fresh onNodeClick closure that reads the updated state.
act(() => {
const onNodeClick = lastFgProps.current!.onNodeClick as (n: { id: string }) => void;
onNodeClick({ id: 'scatter-basic' });
});
expect(mockNavigate).toHaveBeenCalledWith('/scatter-basic');
expect(mockTrackEvent).toHaveBeenCalledWith('map_node_click', { spec: 'scatter-basic' });
});
});
Loading
Loading