תארו לעצמכם שיש לכם לוח מחוונים תקציבי לחברה עם יותר מ-500 פריטי הוצאה. אתם מבזבזים 20 דקות בחיפוש אחר "החור". מפת העץ שלנו מראה אותו תוך 2 שניות. מפת עץ עושה דבר אחד היטב: מציגה את היחס בין חלקים במבנה היררכי. השטח של כל מלבן פרופורציונלי לערך, והצבע מקודד ממד נוסף — צמיחה/ירידה, קטגוריה, סטטוס. אם אתם צריכים להבין מה תופס הכי הרבה מקום בתקציב, בעץ קטגוריות, במערכת קבצים או בתיק נכסים, מפת עץ נקראת מהר יותר מכל טבלה. פיתוח על React 18 ו-D3.js v7 נותן שליטה מלאה על כל פיקסל: טולטיפ מותאם אישית, אנימציה בעת ירידה ברמות, ופריסה רספונסיבית לכל רזולוציה. אפילו עם 10,000 צמתים, הפריסה נבנית תוך 30–50 אלפיות שנייה — מספיק לעדכונים בזמן אמת. החל מ-$1,500 למפת עץ בסיסית, פתרון זה חוסך בדרך כלל 30% מזמן הניתוח.
אילו בעיות מפת עץ פותרת?
קריאות לקויה של טבלאות עם היררכיה. כאשר לטבלה יש 10,000 שורות, מציאת 5 הקטגוריות המובילות דורשת גלילה ומיון. מפת עץ הופכת אותן לברורות ויזואלית — המוח מעבד שטח באופן מיידי.
קושי בתפיסת פרופורציות. לטבלאות יש מספרים, אבל כדי להבין כמה תקציב השיווק עולה על תקציב הפיתוח, צריך לחלק. מפת עץ נותנת את התשובה ללא חישוב.
צורך בהשוואת חלקים בזמן אמת. כאשר נתונים מתעדכנים (לדוגמה, שערי מניות), מפת עץ עם שינויי צבע מקודדים מראה אילו נכסים עולים ואילו יורדים — במבט אחד.
כיצד אנו מיישמים מפת עץ עם React ו-D3.js
אנו משתמשים ב-React 18 וב-D3.js v7. D3 אינה ספריית תרשימים אלא אולר שוויצרי לעבודה עם נתונים ו-SVG. היא נותנת שליטה מלאה על כל מלבן. איננו משתמשים בעטיפות ברמה גבוהה כי כל פרויקט דורש פתרונות מותאמים אישית: טולטיפ מותאם, אנימציית ירידה ברמות, פריסה רספונסיבית. הליבה של ויזואליזציית ההיררכיה שלנו היא רכיב React מותאם אישית שמשתלב בצורה חלקה עם הנתונים שלכם.
הנה יישום טיפוסי של רכיב מפת עץ:
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
interface TreeNode {
name: string;
value?: number;
children?: TreeNode[];
change?: number; // % изменения для цветового кодирования
}
interface TreemapProps {
data: TreeNode;
width?: number;
height?: number;
colorBy?: 'category' | 'change';
}
export function Treemap({ data, width = 800, height = 500, colorBy = 'category' }: TreemapProps) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
// Иерархия
const root = d3.hierarchy(data)
.sum(d => d.value ?? 0)
.sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
// Layout
d3.treemap()
.size([width, height])
.paddingOuter(3)
.paddingInner(2)
.paddingTop(18)
.round(true)(root);
const colorScale = colorBy === 'change'
? d3.scaleDiverging(d3.interpolateRdYlGn).domain([-30, 0, 30])
: d3.scaleOrdinal(d3.schemeTableau10);
const tooltip = d3.select('body').append('div')
.style('position', 'absolute')
.style('display', 'none')
.style('background', 'rgba(15,23,42,0.9)')
.style('color', '#f1f5f9')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '13px')
.style('pointer-events', 'none')
.style('max-width', '220px');
// Все узлы с потомками (для группировок)
const leaves = root.leaves();
const ancestors = root.descendants().filter(d => d.depth === 1);
// Фоновые прямоугольники для групп
svg.selectAll('.group-rect')
.data(ancestors)
.join('rect')
.attr('class', 'group-rect')
.attr('x', (d: any) => d.x0)
.attr('y', (d: any) => d.y0)
.attr('width', (d: any) => d.x1 - d.x0)
.attr('height', (d: any) => d.y1 - d.y0)
.attr('fill', (d: any) => colorBy === 'change' ? '#e2e8f0' : d3.color(colorScale(d.data.name))!.brighter(0.7).toString())
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// Заголовки групп
svg.selectAll('.group-label')
.data(ancestors)
.join('text')
.attr('class', 'group-label')
.attr('x', (d: any) => d.x0 + 6)
.attr('y', (d: any) => d.y0 + 13)
.attr('font-size', 11)
.attr('font-weight', '600')
.attr('fill', '#374151')
.text((d: any) => d.data.name);
// Листья
const cell = svg.selectAll('.cell')
.data(leaves)
.join('g')
.attr('class', 'cell')
.attr('transform', (d: any) => `translate(${d.x0},${d.y0})`);
cell.append('rect')
.attr('width', (d: any) => d.x1 - d.x0)
.attr('height', (d: any) => d.y1 - d.y0)
.attr('fill', (d: any) => {
if (colorBy === 'change') return colorScale(d.data.change ?? 0);
return colorScale((d.parent?.data.name ?? '') as string);
})
.attr('fill-opacity', 0.85)
.attr('stroke', '#fff')
.attr('stroke-width', 1)
.on('mouseover', (event, d: any) => {
d3.select(event.currentTarget).attr('fill-opacity', 1);
const pct = d.data.change != null ? `<br/>Изменение: ${d.data.change > 0 ? '+' : ''}${d.data.change.toFixed(1)}%` : '';
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
.html(`<strong>${d.data.name}</strong><br/>${d3.format(',.0f')(d.value ?? 0)}${pct}`);
})
.on('mouseout', (event) => {
d3.select(event.currentTarget).attr('fill-opacity', 0.85);
tooltip.style('display', 'none');
});
// Текст внутри ячеек (только если достаточно места)
cell.append('text')
.attr('x', 4)
.attr('y', 14)
.attr('font-size', 11)
.attr('fill', '#fff')
.attr('font-weight', '500')
.text((d: any) => {
const w = d.x1 - d.x0;
const h = d.y1 - d.y0;
return w > 40 && h > 20 ? d.data.name : '';
})
.each(function(d: any) {
const el = d3.select(this);
const maxWidth = d.x1 - d.x0 - 8;
// Обрезаем текст если не помещается
let text = d.data.name;
while (this.getComputedTextLength() > maxWidth && text.length > 3) {
text = text.slice(0, -1);
el.text(text + '…');
}
});
// Значение под названием
cell.append('text')
.attr('x', 4)
.attr('y', 26)
.attr('font-size', 10)
.attr('fill', 'rgba(255,255,255,0.8)')
.text((d: any) => {
const w = d.x1 - d.x0;
const h = d.y1 - d.y0;
return w > 50 && h > 35 ? d3.format(',.0f')(d.value ?? 0) : '';
});
return () => {
tooltip.remove();
};
}, [data, width, height, colorBy]);
return <svg ref={svgRef} width={width} height={height} style={{ display: 'block' }} />;
} פרטי יישום
בקוד שלמעלה, אנו יוצרים היררכיה באמצעות d3.hierarchy, מגדירים את הפריסה עם ריפוד, מציירים מלבני רקע לקבוצות, תוויות, תאים עם טולטיפ וטקסט. התנהגות רספונסיבית מבטיחה קריאות בכל מסך.כיצד להוסיף ירידה ברמות?
ירידה ברמות היא תכונה מרכזית ללוחות מחוונים אינטראקטיביים. המשתמש לוחץ על קבוצה — ומפת העץ מצוירת מחדש עם נתוני רמת הילדים. יישמנו רכיב DrilldownTreemap עם פירורי לחם לניווט חזרה.
function DrilldownTreemap({ data }: { data: TreeNode }) {
const [currentNode, setCurrentNode] = useState<TreeNode>(data);
const [breadcrumb, setBreadcrumb] = useState<TreeNode[]>([data]);
function drillDown(node: TreeNode) {
if (!node.children?.length) return;
setCurrentNode(node);
setBreadcrumb(prev => [...prev, node]);
}
function drillUp(index: number) {
const target = breadcrumb[index];
setCurrentNode(target);
setBreadcrumb(prev => prev.slice(0, index + 1));
}
return (
<div>
<nav className="flex gap-2 text-sm mb-3">
{breadcrumb.map((node, i) => (
<span key={i}>
{i > 0 && <span className="text-gray-400 mx-1">/</span>}
<button
onClick={() => drillUp(i)}
className={i === breadcrumb.length - 1 ? 'font-semibold' : 'text-blue-600 hover:underline'}
>
{node.name}
</button>
</span>
))}
</nav>
<Treemap data={currentNode} onCellClick={drillDown} />
</div>
);
} באיזה אלגוריתם פריסה לבחור?
D3.js מספקת ארבעה אלגוריתמי ריצוף. השוואה:
| אלגוריתם | תכונה | מתי להשתמש |
|---|---|---|
| treemapSquarify | תאים מרובעים ככל האפשר, יחס גובה-רוחב טוב | ברירת מחדל — הקריאות הטובה ביותר |
| treemapSliceDice | מחליף בין פרוסות אופקיות ואנכיות | כאשר לנתונים יש היררכיה דו-כיוונית קפדנית |
| treemapSlice | רק פרוסות אופקיות | להצגת חלקים בשורה אחת |
| treemapResquarify | עושה שימוש חוזר בפריסה הקודמת, ממזער תנועה | לעדכוני נתונים מונפשים |
אנו תמיד משתמשים ב-treemapSquarify בסביבת ייצור — הוא נותן את יחס גובה-הרוחב הטוב ביותר. אם יש צורך בשינויי נתונים חלקים (לדוגמה, עדכוני מניות), אנו עוברים ל-treemapResquarify. כפי שצוין ב-תיעוד D3.js, אלגוריתם Squarify מספק יחס גובה-רוחב אופטימלי ברוב המקרים.
כיצד להכין נתונים למפת עץ?
כדי שמפת העץ תעבוד, יש להפוך נתוני צד לקוח להיררכיה. לעתים קרובות ה-API מחזיר רשימה שטוחה של שורות. הפונקציה buildHierarchy מקבצת אותן לפי קטגוריות וקטגוריות משנה:
// Трансформация плоских данных в иерархию
function buildHierarchy(items: { category: string; subcategory: string; name: string; value: number }[]): TreeNode {
const root: TreeNode = { name: 'root', children: [] };
items.forEach(item => {
let cat = root.children!.find(c => c.name === item.category);
if (!cat) {
cat = { name: item.category, children: [] };
root.children!.push(cat);
}
let subcat = cat.children!.find(c => c.name === item.subcategory);
if (!subcat) {
subcat = { name: item.subcategory, children: [] };
cat.children!.push(subcat);
}
subcat.children!.push({ name: item.name, value: item.value });
});
return root;
} תהליך עבודה ותזמון
- ניתוח — לימוד מבנה הנתונים, הגדרת רמות היררכיה, מדדים לשטח וצבע. (יום אחד)
- עיצוב — יצירת אב טיפוס על בסיס הנתונים שלכם, הסכמה על עיצוב ואינטראקטיביות. (יום אחד)
- יישום — כתיבת הרכיב ב-React + D3.js, הגדרת טולטיפ, ירידה ברמות, אנימציה. (2–3 ימים)
- בדיקות — בדיקה על נתונים אמיתיים: נפחים גדולים, עדכונים בזמן אמת. (יום אחד)
- פריסה — שילוב בפרויקט שלכם, מתן הוראות שימוש. (יום אחד)
| תוצר | לוח זמנים |
|---|---|
| מפת עץ בסיסית עם טולטיפ וירידה ברמות | 2–3 ימים |
| עם אנימציית מעבר וקידוד צבע | 4–6 ימים |
| מחזור מלא (מניתוח לפריסה) | החל משבוע אחד |
טעויות נפוצות בפיתוח מפת עץ
- התעלמות מ-paddingTop. תוויות קבוצה חופפות תאים — הטקסט הופך לבלתי קריא. אנו תמיד מגדירים paddingTop: 18.
- טקסט בתאים קטנים מדי. אנו בודקים רוחב וגובה לפני רינדור, אחרת — גלישה.
- חוסר טיפול בנתונים ריקים. אם לצומת יש ערך = 0, הוא עדיין תופס מקום. השתמשו בסינון.
- התעלמות מ-treemapResquarify לאנימציה. בלעדיו, כל רינדור מחדש מערבב תאים — משתמשים מאבדים הקשר.
מה כלול בעבודה ולמה לבחור בנו
- קוד מקור של הרכיב (React + TypeScript + D3)
- שילוב עם ה-API שלכם
- תיעוד להתקנה והתאמה אישית
- הוראות פריסה
- הדרכת צוות (עד שעתיים)
- אחריות ל-3 חודשים לפעולה ללא הפרעות
יש לנו ניסיון של 5+ שנים בוויזואליזציית נתונים בממשקים מורכבים ו-30+ פרויקטים שהושלמו עם מפות עץ, תרשימי שמש ודיאגרמות היררכיות אחרות. המפתחים שלנו מוסמכים ב-React וב-D3.js, וכל הרכיבים בנויים מאפס, ללא סיכוני רישוי. צרו קשר לייעוץ והזמינו מפת עץ שמדברת בעד עצמה. קבלו הערכת עלות לפרויקט תוך יום עסקים אחד — פשוט שלחו לנו נתונים לדוגמה ודרישות אינטראקטיביות.







