Skip to content

Commit 5405c88

Browse files
fix(app): respect plot aspect ratio (16:9) when drawing map nodes
Plots are typically rendered with figsize=(16, 9), but the canvas draw + hitbox code was forcing them into a square 22×22 slot, which squashed everything horizontally. Read the intrinsic aspect ratio from any loaded thumbnail variant (naturalWidth/naturalHeight) and fit the draw rect / hit area into a NODE_SIZE box without distortion — longer side stays at NODE_SIZE so nodes share a consistent layout scale. Falls back to a square when nothing is loaded yet (matches the placeholder). New helpers: nodeAspectRatio, fitToBox (both pure, unit-tested). Refs #5646 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca8bad5 commit 5405c88

4 files changed

Lines changed: 68 additions & 7 deletions

File tree

app/src/pages/MapPage.helpers.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildVariantUrl,
1010
pickTier,
1111
pickBestLoadedTier,
12+
fitToBox,
1213
type SpecMapItem,
1314
} from './MapPage.helpers';
1415

@@ -212,6 +213,31 @@ describe('pickTier', () => {
212213
});
213214

214215

216+
describe('fitToBox', () => {
217+
it('returns a square for 1:1 aspect ratio', () => {
218+
expect(fitToBox(22, 1)).toEqual({ w: 22, h: 22 });
219+
});
220+
221+
it('keeps width = box and shrinks height for 16:9', () => {
222+
const r = fitToBox(22, 16 / 9);
223+
expect(r.w).toBe(22);
224+
expect(r.h).toBeCloseTo(22 * 9 / 16);
225+
});
226+
227+
it('keeps height = box and shrinks width for portrait (9:16)', () => {
228+
const r = fitToBox(22, 9 / 16);
229+
expect(r.h).toBe(22);
230+
expect(r.w).toBeCloseTo(22 * 9 / 16);
231+
});
232+
233+
it('falls back to a square for invalid aspect ratios', () => {
234+
expect(fitToBox(22, 0)).toEqual({ w: 22, h: 22 });
235+
expect(fitToBox(22, NaN)).toEqual({ w: 22, h: 22 });
236+
expect(fitToBox(22, Infinity)).toEqual({ w: 22, h: 22 });
237+
});
238+
});
239+
240+
215241
describe('pickBestLoadedTier', () => {
216242
function img(): HTMLImageElement {
217243
return document.createElement('img');

app/src/pages/MapPage.helpers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,33 @@ export function pickBestLoadedTier(
204204
return null;
205205
}
206206

207+
/**
208+
* Read a node's intrinsic aspect ratio (width/height) from any already-loaded
209+
* thumbnail variant. Defaults to 1 when nothing is loaded yet (and the page
210+
* draws a square fallback rect anyway). Most plots are 16:9 (figsize=(16,9)),
211+
* so the typical return value is ~1.78.
212+
*/
213+
export function nodeAspectRatio(node: MapNode): number {
214+
for (const t of RESOLUTION_TIERS) {
215+
const img = node.imgs.get(t);
216+
if (img && img.naturalWidth > 0 && img.naturalHeight > 0) {
217+
return img.naturalWidth / img.naturalHeight;
218+
}
219+
}
220+
return 1;
221+
}
222+
223+
/**
224+
* Given a target box size and an aspect ratio, return the (width, height) that
225+
* fits inside the box without distortion (longer side = boxSize). Used for both
226+
* canvas drawing and hit-area painting so they always agree.
227+
*/
228+
export function fitToBox(boxSize: number, aspectRatio: number): { w: number; h: number } {
229+
if (!isFinite(aspectRatio) || aspectRatio <= 0) return { w: boxSize, h: boxSize };
230+
if (aspectRatio >= 1) return { w: boxSize, h: boxSize / aspectRatio };
231+
return { w: boxSize * aspectRatio, h: boxSize };
232+
}
233+
207234
/**
208235
* Lazily fetch the requested tier for a node and call `onLoad` when it lands.
209236
* Idempotent — safe to call repeatedly from `nodeCanvasObject` on every paint.

app/src/pages/MapPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ describe('MapPage', () => {
221221

222222
const paintHitbox = lastFgProps.current!.nodePointerAreaPaint as (n: unknown, c: string, ctx: unknown) => void;
223223
const ctx = makeCtxStub();
224-
paintHitbox({ id: 'scatter-basic', x: 80, y: 60 }, '#ff00ff', ctx);
224+
paintHitbox({ id: 'scatter-basic', x: 80, y: 60, imgs: new Map(), pendingTiers: new Set() }, '#ff00ff', ctx);
225225

226226
expect(ctx.fillStyle).toBe('#ff00ff');
227227
expect(ctx.fillRect).toHaveBeenCalled();

app/src/pages/MapPage.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import {
1515
buildKNNLinks,
1616
computeIDF,
1717
ensureNodeTier,
18+
fitToBox,
1819
flattenTags,
20+
nodeAspectRatio,
1921
pickBestLoadedTier,
2022
pickTier,
2123
preloadImages,
@@ -233,8 +235,6 @@ export function MapPage() {
233235
const isNeighbor = !isHover && hoverId != null && neighbors.get(hoverId)?.has(n.id);
234236
const dim = hoverId != null && !isHover && !isNeighbor;
235237
const baseSize = NODE_SIZE * (isHover ? HOVER_SCALE : isNeighbor ? 1.15 : 1);
236-
const x = n.x - baseSize / 2;
237-
const y = n.y - baseSize / 2;
238238

239239
// Pick the smallest variant whose source resolution comfortably
240240
// covers the on-screen size, then lazy-load it if not yet present.
@@ -248,24 +248,32 @@ export function MapPage() {
248248
}
249249
const img = n.imgs ? pickBestLoadedTier(n.imgs, desired) : null;
250250

251+
// Match draw size to the source aspect ratio (most plots are 16:9
252+
// from figsize=(16,9)) — keep the longer side at baseSize so nodes
253+
// share a consistent bounding-box scale.
254+
const { w, h } = fitToBox(baseSize, nodeAspectRatio(n));
255+
const x = n.x - w / 2;
256+
const y = n.y - h / 2;
257+
251258
ctx.save();
252259
if (dim) ctx.globalAlpha = 0.18;
253260
if (img) {
254-
ctx.drawImage(img, x, y, baseSize, baseSize);
261+
ctx.drawImage(img, x, y, w, h);
255262
} else {
256263
ctx.fillStyle = isDark ? '#242420' : '#FFFDF6';
257-
ctx.fillRect(x, y, baseSize, baseSize);
264+
ctx.fillRect(x, y, w, h);
258265
}
259266
ctx.lineWidth = isHover ? 2 : 1;
260267
ctx.strokeStyle = strokeFor(isDark, !!isHover);
261-
ctx.strokeRect(x, y, baseSize, baseSize);
268+
ctx.strokeRect(x, y, w, h);
262269
ctx.restore();
263270
}}
264271
nodePointerAreaPaint={(node, color, ctx) => {
265272
const n = node as WithCoords;
266273
if (n.x == null || n.y == null) return;
274+
const { w, h } = fitToBox(NODE_SIZE, nodeAspectRatio(n));
267275
ctx.fillStyle = color;
268-
ctx.fillRect(n.x - NODE_SIZE / 2, n.y - NODE_SIZE / 2, NODE_SIZE, NODE_SIZE);
276+
ctx.fillRect(n.x - w / 2, n.y - h / 2, w, h);
269277
}}
270278
linkColor={(l: MapLink) => {
271279
const involved = hoverId && (l.source === hoverId || l.target === hoverId);

0 commit comments

Comments
 (0)