Skip to content

Commit dbb86d3

Browse files
authored
feat: add zoom / reset view controls and ability to pan (#30)
While exploring the repos in the visualization graph, I noticed that some basic navigation controls would be helpful. This change adds a small set of view controls to make inspection easier: - Add zoom in / zoom out / reset view controls - Enable drag-to-pan interaction https://github.com/user-attachments/assets/dc3567ae-df00-4241-990a-7ac7ff398ccb
1 parent 4a93c33 commit dbb86d3

File tree

3 files changed

+176
-7
lines changed

3 files changed

+176
-7
lines changed

css/style.css

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,53 @@ li#item-remaining::before {
372372
padding: 20px;
373373
}
374374

375+
#chart-zoom-controls {
376+
position: absolute;
377+
top: 16px;
378+
right: 16px;
379+
display: flex;
380+
flex-direction: column;
381+
gap: 8px;
382+
z-index: 2;
383+
}
384+
385+
#chart-zoom-controls button {
386+
width: 36px;
387+
height: 36px;
388+
border-radius: 6px;
389+
border: 1px solid #ddd;
390+
background: #fff;
391+
color: #443F3F;
392+
font-size: 20px;
393+
font-weight: 700;
394+
line-height: 1;
395+
cursor: pointer;
396+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
397+
transition: transform 120ms ease, border-color 120ms ease;
398+
}
399+
400+
#chart-zoom-controls svg {
401+
width: 18px;
402+
height: 18px;
403+
display: block;
404+
margin: 0 auto;
405+
}
406+
407+
#zoom-reset {
408+
width: 36px;
409+
height: 36px;
410+
padding: 0;
411+
}
412+
413+
#chart-zoom-controls button:hover {
414+
border-color: #CF3F02;
415+
transform: translateY(-1px);
416+
}
417+
418+
#chart-zoom-controls button:active {
419+
transform: translateY(0);
420+
}
421+
375422
#chart-container canvas {
376423
display: block;
377424
margin: 0;

index.js

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ const createORCAVisual = (
9999
let HOVERED_NODE = null;
100100
let CLICK_ACTIVE = false;
101101
let CLICKED_NODE = null;
102+
let zoomTransform = d3.zoomIdentity;
103+
let zoomBehavior;
104+
let zoomPanning = false;
105+
let zoomLastInteraction = 0;
106+
let zoomMoved = false;
107+
let zoomMovedAt = 0;
108+
let zoomStartTransform = d3.zoomIdentity;
109+
const ZOOM_CLICK_SUPPRESS_MS = 150;
102110

103111
// Visual Settings - Based on SF = 1
104112
const CENTRAL_RADIUS = 35; // The radius of the central repository node (reduced for less prominence)
@@ -421,13 +429,39 @@ const createORCAVisual = (
421429
/////////////////////////////////////////////////////////////
422430
setupHover();
423431
setupClick();
432+
setupZoom();
424433

425434
/////////////////////////////////////////////////////////////
426435
///////////// Set the Sizes and Draw the Visual /////////////
427436
/////////////////////////////////////////////////////////////
428437
chart.resize();
429438
} // function chart
430439

440+
/////////////////////////////////////////////////////////////////
441+
/////////////////////// Zoom Helpers ////////////////////////////
442+
/////////////////////////////////////////////////////////////////
443+
444+
function applyZoomTransform(context) {
445+
context.translate(zoomTransform.x * PIXEL_RATIO, zoomTransform.y * PIXEL_RATIO);
446+
context.scale(zoomTransform.k, zoomTransform.k);
447+
context.translate(WIDTH / 2, HEIGHT / 2);
448+
} // function applyZoomTransform
449+
450+
function redrawAll() {
451+
draw();
452+
if (CLICK_ACTIVE && CLICKED_NODE) {
453+
context_click.clearRect(0, 0, WIDTH, HEIGHT);
454+
drawHoverState(context_click, CLICKED_NODE, false);
455+
} else {
456+
context_click.clearRect(0, 0, WIDTH, HEIGHT);
457+
}
458+
if (HOVER_ACTIVE && HOVERED_NODE) {
459+
drawHoverState(context_hover, HOVERED_NODE);
460+
} else {
461+
context_hover.clearRect(0, 0, WIDTH, HEIGHT);
462+
}
463+
} // function redrawAll
464+
431465
/////////////////////////////////////////////////////////////////
432466
//////////////////////// Draw the visual ////////////////////////
433467
/////////////////////////////////////////////////////////////////
@@ -440,7 +474,7 @@ const createORCAVisual = (
440474

441475
// Move the visual to the center
442476
context.save();
443-
context.translate(WIDTH / 2, HEIGHT / 2);
477+
applyZoomTransform(context);
444478

445479
/////////////////////////////////////////////////////////////
446480
// Draw the remaining contributors as small circles outside the ORCA circles
@@ -548,8 +582,8 @@ const createORCAVisual = (
548582
// // Test to see if the delaunay works
549583
// testDelaunay(delaunay, context_hover)
550584

551-
// Draw the visual
552-
draw();
585+
// Draw the visual (and overlays if active)
586+
redrawAll();
553587
}; //function resize
554588

555589
/////////////////////////////////////////////////////////////////
@@ -2228,6 +2262,71 @@ const createORCAVisual = (
22282262
/////////////////////////////////////////////////////////////////
22292263
//////////////////////// Hover Functions ////////////////////////
22302264
/////////////////////////////////////////////////////////////////
2265+
function setupZoom() {
2266+
zoomBehavior = d3
2267+
.zoom()
2268+
.filter((event) => event.type !== "wheel" && event.type !== "dblclick")
2269+
.scaleExtent([0.4, 6])
2270+
.on("start", () => {
2271+
zoomPanning = true;
2272+
zoomMoved = false;
2273+
zoomStartTransform = zoomTransform;
2274+
})
2275+
.on("zoom", (event) => {
2276+
zoomTransform = event.transform;
2277+
zoomLastInteraction = Date.now();
2278+
if (
2279+
zoomTransform.k !== zoomStartTransform.k ||
2280+
zoomTransform.x !== zoomStartTransform.x ||
2281+
zoomTransform.y !== zoomStartTransform.y
2282+
) {
2283+
zoomMoved = true;
2284+
}
2285+
redrawAll();
2286+
})
2287+
.on("end", () => {
2288+
zoomPanning = false;
2289+
zoomLastInteraction = Date.now();
2290+
if (zoomMoved) zoomMovedAt = zoomLastInteraction;
2291+
});
2292+
2293+
const zoomTarget = d3.select("#canvas-hover");
2294+
zoomTarget.call(zoomBehavior);
2295+
2296+
const zoomInBtn = document.getElementById("zoom-in");
2297+
const zoomOutBtn = document.getElementById("zoom-out");
2298+
const zoomResetBtn = document.getElementById("zoom-reset");
2299+
function getZoomCenter() {
2300+
const rect = canvas_hover.getBoundingClientRect();
2301+
return [rect.width / 2, rect.height / 2];
2302+
}
2303+
2304+
if (zoomInBtn) {
2305+
zoomInBtn.onclick = () => {
2306+
zoomTarget
2307+
.transition()
2308+
.duration(150)
2309+
.call(zoomBehavior.scaleBy, 1.2, getZoomCenter());
2310+
};
2311+
}
2312+
if (zoomOutBtn) {
2313+
zoomOutBtn.onclick = () => {
2314+
zoomTarget
2315+
.transition()
2316+
.duration(150)
2317+
.call(zoomBehavior.scaleBy, 1 / 1.2, getZoomCenter());
2318+
};
2319+
}
2320+
if (zoomResetBtn) {
2321+
zoomResetBtn.onclick = () => {
2322+
zoomTarget
2323+
.transition()
2324+
.duration(150)
2325+
.call(zoomBehavior.transform, d3.zoomIdentity);
2326+
};
2327+
}
2328+
}
2329+
22312330
// Setup the hover on the top canvas, get the mouse position and call the drawing functions
22322331
function setupHover() {
22332332
d3.select("#canvas-hover").on("mousemove", function (event) {
@@ -2285,7 +2384,7 @@ const createORCAVisual = (
22852384
// Draw the hover canvas
22862385
context.save();
22872386
context.clearRect(0, 0, WIDTH, HEIGHT);
2288-
context.translate(WIDTH / 2, HEIGHT / 2);
2387+
applyZoomTransform(context);
22892388

22902389
/////////////////////////////////////////////////
22912390
// Get all the connected links (if not done before)
@@ -2413,6 +2512,9 @@ const createORCAVisual = (
24132512

24142513
function setupClick() {
24152514
d3.select("#canvas-hover").on("click", function (event) {
2515+
if (zoomPanning || (zoomMoved && Date.now() - zoomMovedAt < ZOOM_CLICK_SUPPRESS_MS)) {
2516+
return;
2517+
}
24162518
// Get the position of the mouse on the canvas
24172519
let [mx, my] = d3.pointer(event, this);
24182520
let [d, FOUND] = findNode(mx, my);
@@ -2458,8 +2560,16 @@ const createORCAVisual = (
24582560

24592561
// Turn the mouse position into a canvas x and y location and see if it's close enough to a node
24602562
function findNode(mx, my) {
2461-
mx = (mx * PIXEL_RATIO - WIDTH / 2) / SF;
2462-
my = (my * PIXEL_RATIO - HEIGHT / 2) / SF;
2563+
const mxDevice = mx * PIXEL_RATIO;
2564+
const myDevice = my * PIXEL_RATIO;
2565+
mx =
2566+
((mxDevice - zoomTransform.x * PIXEL_RATIO) / zoomTransform.k -
2567+
WIDTH / 2) /
2568+
SF;
2569+
my =
2570+
((myDevice - zoomTransform.y * PIXEL_RATIO) / zoomTransform.k -
2571+
HEIGHT / 2) /
2572+
SF;
24632573

24642574
// Check if mouse is within the visualization bounds (with some margin)
24652575
const MAX_RADIUS = RADIUS_CONTRIBUTOR_NON_ORCA + ORCA_RING_WIDTH + 200;
@@ -2471,7 +2581,7 @@ const createORCAVisual = (
24712581
//Get the closest hovered node
24722582
let point = delaunay.find(mx, my);
24732583
let d = nodes_delaunay[point];
2474-
2584+
24752585
// Safety check - if no node found, return early
24762586
if (!d) {
24772587
return [null, false];
@@ -3268,6 +3378,7 @@ const createORCAVisual = (
32683378
// Re-setup interaction handlers
32693379
setupHover();
32703380
setupClick();
3381+
setupZoom();
32713382

32723383
// Redraw with new scale factors
32733384
chart.resize();

templates/index.html.jinja

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@
5959

6060
<!-- The visual will be drawn here -->
6161
<div id="chart-container">
62+
<div id="chart-zoom-controls" aria-label="Zoom controls">
63+
<button id="zoom-in" type="button" aria-label="Zoom in">+</button>
64+
<button id="zoom-out" type="button" aria-label="Zoom out">−</button>
65+
<button id="zoom-reset" type="button" aria-label="Reset zoom">
66+
<svg class="zoom-reset-icon" viewBox="0 0 24 24" role="img" aria-hidden="true" focusable="false">
67+
<circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="2"/>
68+
<circle cx="12" cy="12" r="1.5" fill="currentColor"/>
69+
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
70+
</svg>
71+
</button>
72+
</div>
6273
</div>
6374

6475
</div>

0 commit comments

Comments
 (0)