При использовании зума в D3.js возникает распространённая проблема: текст масштабируется вместе с графикой. В результате подписи становятся либо слишком крупными при увеличении, либо нечитаемо маленькими при уменьшении. Особенно заметно это в:
Механизм d3.zoom() трансформирует весь контейнер через
transform, включая координаты, размеры и шрифты. Если не
контролировать размер текста отдельно, интерфейс быстро теряет
читаемость.
Поведение зума основано на трансформации SVG-группы:
const zoom = d3.zoom()
.on("zoom", zoomed);
svg.call(zoom);
function zoomed(event) {
container.attr("transform", event.transform);
}
Объект event.transform содержит:
x — смещение по X;y — смещение по Y;k — коэффициент масштаба.При увеличении k растёт, и SVG начинает масштабировать
всё содержимое группы.
SVG рассматривает текст как обычный графический объект. Если группа увеличивается:
<g transform="scale(2)">
то одновременно увеличиваются:
Например:
<text font-size="14">
при scale(2) визуально становится размером
28px.
Наиболее распространённый метод — компенсировать масштаб через
деление размера шрифта на коэффициент k.
function zoomed(event) {
container.attr("transform", event.transform);
container.selectAll("text")
.style("font-size", `${14 / event.transform.k}px`);
}
Теперь:
const svg = d3.select("svg");
const container = svg.append("g");
container.append("circle")
.attr("cx", 200)
.attr("cy", 200)
.attr("r", 50);
container.append("text")
.attr("x", 200)
.attr("y", 200)
.text("Node A")
.attr("text-anchor", "middle")
.style("font-size", "16px");
const zoom = d3.zoom()
.scaleExtent([0.5, 10])
.on("zoom", zoomed);
svg.call(zoom);
function zoomed(event) {
container.attr("transform", event.transform);
container.selectAll("text")
.style(
"font-size",
`${16 / event.transform.k}px`
);
}
Если SVG увеличился в k раз:
s_{visual}=s_{font}k
где:
s_visual — фактический отображаемый размер;s_font — установленный размер шрифта;k — zoom scale.Чтобы сохранить постоянный визуальный размер:
s_{font}=
Полностью фиксированный размер текста подходит не всегда. При сильном отдалении надписи могут перекрывать экран.
Используется ограничение диапазона:
function zoomed(event) {
container.attr("transform", event.transform);
const scale = event.transform.k;
const fontSize = Math.max(
8,
Math.min(24, 16 / scale)
);
container.selectAll("text")
.style("font-size", `${fontSize}px`);
}
В примере используется:
Math.max(min, Math.min(max, value))
Это эквивалентно функции ограничения:
f(x)=(a,(b,x))
где:
a — минимальный размер;b — максимальный размер.В сложных визуализациях часто используются:
Каждый тип текста масштабируется отдельно.
function zoomed(event) {
const k = event.transform.k;
container.attr("transform", event.transform);
container.selectAll(".node-label")
.style("font-size", `${14 / k}px`);
container.selectAll(".axis-label")
.style("font-size", `${12 / Math.sqrt(k)}px`);
container.selectAll(".title")
.style("font-size", `${24 / k}px`);
}
Иногда требуется, чтобы текст уменьшался медленнее, чем остальные элементы.
Используется корень:
s=
Такой подход делает интерфейс более естественным:
Иногда вместо изменения font-size используется обратный
scale.
container.selectAll("text")
.attr(
"transform",
`scale(${1 / event.transform.k})`
);
Однако такой метод имеет недостатки:
text-anchor;Поэтому изменение font-size считается
предпочтительным.
Существует SVG-свойство:
vector-effect: non-scaling-stroke;
Оно предотвращает масштабирование линий, но:
font-size.Поэтому для шрифтов требуется отдельная логика.
При работе с картами D3 проблема особенно заметна:
function zoomed(event) {
mapGroup.attr("transform", event.transform);
mapGroup.selectAll(".country-label")
.style(
"font-size",
`${10 / event.transform.k}px`
);
}
При сильном уменьшении экрана подписи могут создавать визуальный шум.
Текст скрывается при малом масштабе:
function zoomed(event) {
const k = event.transform.k;
labels
.style("display", k < 1.5 ? "none" : "block");
}
Более плавный вариант — изменение прозрачности.
function zoomed(event) {
const k = event.transform.k;
labels.style("opacity", Math.min(1, k - 0.5));
}
На практике часто используется сразу несколько механизмов:
function zoomed(event) {
const k = event.transform.k;
container.attr("transform", event.transform);
container.selectAll(".label")
.style("font-size", `${14 / k}px`)
.style("opacity", Math.min(1, k / 2));
}
Если SVG содержит тысячи текстовых элементов, постоянное изменение
font-size может стать дорогой операцией.
Проблемы:
let scheduled = false;
function zoomed(event) {
container.attr("transform", event.transform);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
container.selectAll("text")
.style(
"font-size",
`${14 / event.transform.k}px`
);
scheduled = false;
});
}
}
Нельзя постоянно выполнять:
container.selectAll("text")
Лучше сохранить выборку заранее.
function zoomed(event) {
container.selectAll("text")
}
const labels = container.selectAll("text");
function zoomed(event) {
labels.style(
"font-size",
`${14 / event.transform.k}px`
);
}
Эффективная архитектура SVG:
<svg>
<g class="zoom-layer"></g>
<g class="ui-layer"></g>
</svg>
| Слой | Назначение |
|---|---|
| zoom-layer | Масштабируемая графика |
| ui-layer | Интерфейс и фиксированный текст |
Иногда текст вообще не должен участвовать в zoom.
function zoomed(event) {
zoomLayer.attr(
"transform",
event.transform
);
}
Текст в ui-layer:
Такой подход используется в:
В force simulation подписи постоянно перемещаются вместе с узлами.
simulation.on("tick", () => {
nodes
.attr("cx", d => d.x)
.attr("cy", d => d.y);
labels
.attr("x", d => d.x)
.attr("y", d => d.y);
});
При zoom:
function zoomed(event) {
graph.attr("transform", event.transform);
labels.style(
"font-size",
`${12 / event.transform.k}px`
);
}
В SVG изменение font-size удобно, но дорого.
Canvas работает иначе:
context.font = `${12 / scale}px Arial`;
Canvas:
SVG:
Вместо пикселей можно использовать относительные единицы.
.style("font-size", `${1 / k}em`)
Но:
em;Для D3-визуализаций чаще применяются px.
При сильном уменьшении тонкий текст становится нечитаемым.
Используется динамический font-weight.
labels.style(
"font-weight",
event.transform.k > 3 ? 700 : 400
);
На дисплеях высокой плотности возникают проблемы:
Часто помогает:
text {
text-rendering: geometricPrecision;
}
или:
text {
shape-rendering: crispEdges;
}
При масштабе:
14 / 1.333333
получаются дробные пиксели.
Это может вызывать:
Решение:
.style(
"font-size",
`${Math.round(14 / k)}px`
)
Во время плавного zoom браузер постоянно изменяет шрифт.
D3 использует интерполяцию transform:
svg.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity.scale(4)
);
Текст также анимируется:
labels.transition()
.style("font-size", "6px");
Наиболее стабильная архитектура:
sqrt(k).const BASE_FONT = 14;
const MIN_FONT = 8;
const MAX_FONT = 24;
const labels = container.selectAll(".label");
function zoomed(event) {
const k = event.transform.k;
container.attr("transform", event.transform);
const size = Math.max(
MIN_FONT,
Math.min(
MAX_FONT,
BASE_FONT / Math.sqrt(k)
)
);
labels
.style("font-size", `${size}px`)
.style(
"opacity",
Math.min(1, k / 2)
);
}
| Подход | Преимущества | Недостатки |
|---|---|---|
| Обратный font-size | Простота | Частые DOM-обновления |
| scale(1/k) | Быстро | Сложное позиционирование |
| Отдельный UI-layer | Максимальная стабильность | Дополнительная архитектура |
| Скрытие текста | Повышение читаемости | Потеря информации |
| Нелинейное масштабирование | Естественное поведение | Более сложная настройка |
svg.attr("transform", event.transform);
Это приводит к гигантским надписям.
14 / k
При большом k текст становится микроскопическим.
function zoomed() {
container.selectAll("text")
}
Это ухудшает производительность.
.on("zoom", () => {
labels.transition()
})
Вызывает лаги и накопление анимаций.
В production-системах текст обычно делится на уровни:
| Уровень | Поведение |
|---|---|
| Critical labels | Всегда видимы |
| Secondary labels | Скрываются при уменьшении |
| Detailed labels | Показываются только при приближении |
Такой подход используется в: