SVG from Zero to Senior · Part 15 — Interactive Maps with Pan & Zoom
The grand finale: drive the viewBox with the mouse to build pan and zoom-toward-cursor navigation — the engine behind every web map. Drag-to-pan math, the zoom-to-point formula, non-scaling strokes, and choropleth regions. With a live map.
We end where we began — with the viewBox from Part 1 — and turn it into the most satisfying interaction in the whole series. {Ta kết thúc ở nơi bắt đầu — với viewBox từ Phần 1 — và biến nó thành tương tác đã tay nhất cả series.} A pannable, zoomable map. {Một bản đồ pan và zoom được.} The beautiful part: the shapes never move. {Phần đẹp: các hình không hề di chuyển.} We only slide and resize the camera window. {Ta chỉ trượt và đổi kích thước cửa sổ máy ảnh.} Master this and you understand the engine behind Google Maps, every SVG floor plan, and every zoomable diagram. {Làm chủ cái này là con hiểu cỗ máy sau Google Maps, mọi sơ đồ mặt bằng SVG, và mọi diagram zoom được.}
Drag to pan, scroll to zoom toward the cursor, click a region. {Kéo để pan, lăn để zoom về con trỏ, click một vùng.}
Open the full demo {Mở demo đầy đủ}: /tools/svg-map-demo/.
The core idea: move the window, not the world {Ý tưởng cốt lõi: di cửa sổ, không di thế giới}
Recall Part 1: viewBox="min-x min-y width height" is a camera window over your drawing. {Nhớ Phần 1: viewBox là cửa sổ máy ảnh trên bản vẽ.} So: {Vậy:}
- Pan = change
min-x/min-y. {Pan = đổimin-x/min-y.} - Zoom = change
width/height(smaller = zoomed in). {Zoom = đổiwidth/height(nhỏ hơn = zoom vào).}
We keep a JS object vb = { x, y, w, h }, mutate it on input, and write it back: {Ta giữ một object JS vb = { x, y, w, h }, biến đổi nó khi có input, và ghi lại:}
function apply() {
svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
}
No transform, no re-rendering shapes — one attribute does all the work. {Không transform, không render lại hình — một thuộc tính làm tất cả.}
Screen pixels → user units {Pixel màn hình → user unit}
The map is displayed at some CSS pixel size, but everything is computed in viewBox user units. {Bản đồ hiển thị ở kích thước pixel CSS nào đó, nhưng mọi thứ tính trong user unit của viewBox.} So we convert mouse position from pixels to user space, accounting for the current viewBox: {Nên ta đổi vị trí chuột từ pixel sang user space, tính theo viewBox hiện tại:}
function toUser(e) {
const rect = svg.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
return {
x: vb.x + (px / rect.width) * vb.w,
y: vb.y + (py / rect.height) * vb.h,
};
}
(In Part 9 we used getScreenCTM().inverse() — that also works; this explicit version makes the viewBox math visible.) {(Ở Phần 9 ta dùng getScreenCTM().inverse() — cũng được; bản tường minh này làm toán viewBox lộ ra.)}
Pan by dragging {Pan bằng kéo}
On drag, subtract the cursor’s movement (in user units) from the window position: {Khi kéo, trừ chuyển động con trỏ (theo user unit) khỏi vị trí cửa sổ:}
function onMove(e) {
const u = toUser(e);
vb.x -= u.x - last.x; // dragging right moves the window left
vb.y -= u.y - last.y;
apply();
last = toUser(e); // re-read in the NEW viewBox to stay 1:1
}
The subtle bit: after moving the window, re-read last in the updated viewBox so the content tracks the cursor exactly, with no drift. {Điểm tinh tế: sau khi di cửa sổ, đọc lại last trong viewBox đã cập nhật để nội dung bám con trỏ chính xác, không trôi.}
Zoom toward the cursor (the good part) {Zoom về phía con trỏ (phần hay)}
Cheap zoom just scales w/h around the top-left — which feels wrong, the content flies off. {Zoom rẻ tiền chỉ scale w/h quanh góc trên-trái — cảm giác sai, nội dung bay đi.} Good zoom keeps the point under the cursor pinned in place. {Zoom tốt giữ điểm dưới con trỏ đứng yên.} The formula: {Công thức:}
function zoomAt(factor, ux, uy) { // ux,uy = cursor in user units
vb.w *= factor;
vb.h *= factor;
// pin (ux,uy): its fractional position in the window must stay constant
vb.x = ux - (ux - vb.x) * factor;
vb.y = uy - (uy - vb.y) * factor;
apply();
}
factor < 1 zooms in, > 1 zooms out. {factor < 1 zoom vào, > 1 zoom ra.} Wire it to wheel (convert deltaY to a factor) and to +/− buttons (zoom around the centre). {Nối nó với wheel (đổi deltaY thành factor) và nút +/− (zoom quanh tâm).} Clamp w/h to a min and max so users can’t zoom into infinity or lose the map. {Giới hạn w/h trong khoảng min/max để người dùng không zoom vô hạn hay lạc mất bản đồ.}
Two production details {Hai chi tiết production}
vector-effect="non-scaling-stroke"on region borders keeps them a constant pixel width no matter the zoom — otherwise borders balloon as you zoom in. {vector-effect="non-scaling-stroke"trên viền vùng giữ chúng độ rộng pixel không đổi dù zoom — nếu không viền phình khi zoom vào.}- Click vs drag detection. A drag that ends on a region shouldn’t also fire a “click region” action. Track whether the pointer moved past a small threshold and ignore the click if so. {Phân biệt click vs kéo. Một cú kéo kết thúc trên một vùng không nên kích hoạt “click vùng”. Theo dõi xem con trỏ có di chuyển quá ngưỡng nhỏ không và bỏ qua click nếu có.}
Choropleth: data-colored regions {Choropleth: vùng tô theo dữ liệu}
Real maps are a set of <path>/<polygon> regions (exported from GeoJSON via a projection like d3-geo). {Bản đồ thật là tập các vùng <path>/<polygon> (export từ GeoJSON qua một phép chiếu như d3-geo).} Color each by a data value — population, sales, temperature — and you have a choropleth: {Tô mỗi vùng theo một giá trị dữ liệu — dân số, doanh thu, nhiệt độ — và con có một choropleth:}
region.style.fill = colorScale(data[region.id]); // value → color
Combine that with the pan/zoom engine and per-region click handlers (event delegation from Part 9) and you’ve built a real interactive data map — no mapping library required. {Kết hợp với cỗ máy pan/zoom và handler click từng vùng (uỷ quyền sự kiện từ Phần 9) và con đã dựng một bản đồ dữ liệu tương tác thật — không cần thư viện bản đồ.}
The master’s warnings {Lời cảnh báo của sư phụ}
- Pin the cursor when zooming, or the map feels broken. The
factor-based formula is the whole secret. {Ghim con trỏ khi zoom, nếu không bản đồ cảm giác hỏng. Công thức theofactorlà toàn bộ bí quyết.} non-scaling-strokefor borders so lines don’t fatten on zoom. {non-scaling-strokecho viền để line không béo lên khi zoom.}- Separate click from drag with a movement threshold, or every pan ends in an accidental selection. {Tách click khỏi kéo bằng ngưỡng di chuyển, nếu không mỗi lần pan kết thúc bằng một lựa chọn ngoài ý muốn.}
- Clamp the zoom range to keep users from getting lost. {Giới hạn khoảng zoom để người dùng không lạc.}
Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}
- Pan + wheel-zoom {Pan + zoom con lăn}: implement the
vbobject and thezoomAtformula on any SVG scene. {cài objectvbvà công thứczoomAttrên một cảnh SVG bất kỳ.} - Zoom-to-region {Zoom-về-vùng}: on region click, animate the
viewBoxto that region’s bounding box (getBBox()). {khi click vùng, animateviewBoxvề bounding box của vùng đó (getBBox()).} - Mini choropleth {Choropleth nhỏ}: color five regions from a data array and add a legend. {tô năm vùng từ một mảng dữ liệu và thêm chú giải.}
The journey, truly complete {Hành trình, thực sự hoàn tất}
Fifteen parts ago you couldn’t explain viewBox. {Mười lăm phần trước con chưa giải thích nổi viewBox.} Now you’ve used it to build a map engine — and along the way mastered shapes, paths, paint, text, transforms, animation, filters, masks, scripting, production craft, morphing, gauges, generative art, and framework integration. {Giờ con đã dùng nó dựng một cỗ máy bản đồ — và trên đường làm chủ hình, path, sơn, chữ, transform, animation, filter, mask, scripting, nghề production, morphing, đồng hồ, art sinh tạo, và tích hợp framework.} That is, without exaggeration, senior-level command of SVG. {Đó là, không phóng đại, trình độ điều khiển SVG cấp senior.} Now go draw the web sharper. {Giờ đi vẽ web sắc nét hơn.} The master bows, for real this time. {Sư phụ cúi chào, lần này là thật.}