Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active March 10, 2023 21:03
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save nitaku/e03a1a5c1a4a95f06c3b to your computer and use it in GitHub Desktop.
Save nitaku/e03a1a5c1a4a95f06c3b to your computer and use it in GitHub Desktop.
Isometric "treemap"

A treemap algorithm is used to area-encode an array of random values, while an isometric projection is used to length-encode a second random value. Occlusion is extremely limited by the ordering of the treemap algorithm, making the diagram fairly readable (compare with a bar chart example that does not exhibit this property). This is possible because parallelepipedons are drawn by following the same ordering used by the treemap (to be honest, we tried and succedeed in using this method, but we are not aware of the internals of the treemap ordering algorithm that makes this possible).

An interaction tecnique is also put into place to let users focus on specific parallelepipedons by making the other ones translucent. This can be used to better evaluate their height. Because there are no fully occluded parallelepipedons, there is always a way to select a specific one.

Implementation note: we formerly used z as the name for the z axis, but this conflicted with the way d3.js handles sticky treemaps, so we renamed it as h.

svg = d3.select('svg')
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# append a group for zoomable content
zoomable_layer = svg.append('g')
# define a zoom behavior
zoom = d3.behavior.zoom()
.scaleExtent([1,10]) # min-max zoom
.on 'zoom', () ->
# GEOMETRIC ZOOM
zoomable_layer
.attr
transform: "translate(#{zoom.translate()})scale(#{zoom.scale()})"
# bind the zoom behavior to the main SVG
svg.call(zoom)
vis = zoomable_layer.append('g')
.attr
class: 'vis'
transform: "translate(#{width/2},#{height/3})"
# [x, y, h] -> [-Math.sqrt(3)/2*x+Math.sqrt(3)/2*y, 0.5*x+0.5*y-h]
isometric = (_3d_p) -> [-Math.sqrt(3)/2*_3d_p[0]+Math.sqrt(3)/2*_3d_p[1], +0.5*_3d_p[0]+0.5*_3d_p[1]-_3d_p[2]]
parallelepipedon = (d) ->
d.x = 0 if not d.x?
d.y = 0 if not d.y?
d.h = 0 if not d.h?
d.dx = 10 if not d.dx?
d.dy = 10 if not d.dy?
d.dh = 10 if not d.dh?
fb = isometric [d.x, d.y, d.h],
mlb = isometric [d.x+d.dx, d.y, d.h],
nb = isometric [d.x+d.dx, d.y+d.dy, d.h],
mrb = isometric [d.x, d.y+d.dy, d.h],
ft = isometric [d.x, d.y, d.h+d.dh],
mlt = isometric [d.x+d.dx, d.y, d.h+d.dh],
nt = isometric [d.x+d.dx, d.y+d.dy, d.h+d.dh],
mrt = isometric [d.x, d.y+d.dy, d.h+d.dh]
d.iso = {
face_bottom: [fb, mrb, nb, mlb],
face_left: [mlb, mlt, nt, nb],
face_right: [nt, mrt, mrb, nb],
face_top: [ft, mrt, nt, mlt],
outline: [ft, mrt, mrb, nb, mlb, mlt]
far_point: fb # used to control the z-index of iso objects
}
return d
iso_layout = (data, shape, scale) ->
scale = 1 if not scale?
data.forEach (d) ->
shape(d, scale)
# this uses the treemap ordering in some way... (!!!)
data.sort (a,b) -> b.dh - a.dh
path_generator = (d) -> 'M' + d.map((p)->p.join(' ')).join('L') + 'z'
treemap = d3.layout.treemap()
.size([300, 300])
.value((d) -> d.area)
.sort((a,b) -> a.dh-b.dh)
.ratio(1)
.round(false) # bugfix: d3 wrong ordering
color = d3.scale.category20()
data = d3.range(30).map () -> {area: Math.random(), dh: Math.random()*150}
data = treemap.nodes({children: data}).filter (n) -> n.depth is 1
iso_layout(data, parallelepipedon)
pipedons = vis.selectAll('.pipedon')
.data(data)
enter_pipedons = pipedons.enter().append('g')
.attr
class: 'pipedon'
enter_pipedons.append('path')
.attr
class: 'iso face bottom'
d: (d) -> path_generator(d.iso.face_bottom)
enter_pipedons.append('path')
.attr
class: 'iso face left template'
d: (d) -> path_generator(d.iso.face_left)
fill: (d, i) -> color(i)
enter_pipedons.append('path')
.attr
class: 'iso face right'
d: (d) -> path_generator(d.iso.face_right)
fill: (d) ->
# right face is darker than the template (left face)
color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'))
return d3.hcl(color.h, color.c, color.l-12)
enter_pipedons.append('path')
.attr
class: 'iso face top'
d: (d) -> path_generator(d.iso.face_top)
fill: (d) ->
# right face is brighter than the template (left face)
color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'))
return d3.hcl(color.h, color.c, color.l+12)
enter_pipedons.append('path')
.attr
class: 'iso outline'
d: (d) -> path_generator(d.iso.outline)
.iso.face.bottom {
fill: brown;
}
.iso.outline {
stroke: white;
fill: none;
vector-effect: non-scaling-stroke;
}
.vis:hover .pipedon:not(:hover) * {
opacity: 0.3;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Isometric Projection</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="http://d3js.org/d3.v3.min.js"></script>
</head>
<body>
<svg width="960px" height="500px"></svg>
<script src="index.js"></script>
</body>
</html>
// Generated by CoffeeScript 1.4.0
(function() {
var color, data, enter_pipedons, height, iso_layout, isometric, parallelepipedon, path_generator, pipedons, svg, treemap, vis, width, zoom, zoomable_layer;
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
zoomable_layer = svg.append('g');
zoom = d3.behavior.zoom().scaleExtent([1, 10]).on('zoom', function() {
return zoomable_layer.attr({
transform: "translate(" + (zoom.translate()) + ")scale(" + (zoom.scale()) + ")"
});
});
svg.call(zoom);
vis = zoomable_layer.append('g').attr({
"class": 'vis',
transform: "translate(" + (width / 2) + "," + (height / 3) + ")"
});
isometric = function(_3d_p) {
return [-Math.sqrt(3) / 2 * _3d_p[0] + Math.sqrt(3) / 2 * _3d_p[1], +0.5 * _3d_p[0] + 0.5 * _3d_p[1] - _3d_p[2]];
};
parallelepipedon = function(d) {
var fb, ft, mlb, mlt, mrb, mrt, nb, nt;
if (!(d.x != null)) {
d.x = 0;
}
if (!(d.y != null)) {
d.y = 0;
}
if (!(d.h != null)) {
d.h = 0;
}
if (!(d.dx != null)) {
d.dx = 10;
}
if (!(d.dy != null)) {
d.dy = 10;
}
if (!(d.dh != null)) {
d.dh = 10;
}
fb = isometric([d.x, d.y, d.h], mlb = isometric([d.x + d.dx, d.y, d.h], nb = isometric([d.x + d.dx, d.y + d.dy, d.h], mrb = isometric([d.x, d.y + d.dy, d.h], ft = isometric([d.x, d.y, d.h + d.dh], mlt = isometric([d.x + d.dx, d.y, d.h + d.dh], nt = isometric([d.x + d.dx, d.y + d.dy, d.h + d.dh], mrt = isometric([d.x, d.y + d.dy, d.h + d.dh]))))))));
d.iso = {
face_bottom: [fb, mrb, nb, mlb],
face_left: [mlb, mlt, nt, nb],
face_right: [nt, mrt, mrb, nb],
face_top: [ft, mrt, nt, mlt],
outline: [ft, mrt, mrb, nb, mlb, mlt],
far_point: fb
};
return d;
};
iso_layout = function(data, shape, scale) {
if (!(scale != null)) {
scale = 1;
}
data.forEach(function(d) {
return shape(d, scale);
});
return data.sort(function(a, b) {
return b.dh - a.dh;
});
};
path_generator = function(d) {
return 'M' + d.map(function(p) {
return p.join(' ');
}).join('L') + 'z';
};
treemap = d3.layout.treemap().size([300, 300]).value(function(d) {
return d.area;
}).sort(function(a, b) {
return a.dh - b.dh;
}).ratio(1).round(false);
color = d3.scale.category20();
data = d3.range(30).map(function() {
return {
area: Math.random(),
dh: Math.random() * 150
};
});
data = treemap.nodes({
children: data
}).filter(function(n) {
return n.depth === 1;
});
iso_layout(data, parallelepipedon);
pipedons = vis.selectAll('.pipedon').data(data);
enter_pipedons = pipedons.enter().append('g').attr({
"class": 'pipedon'
});
enter_pipedons.append('path').attr({
"class": 'iso face bottom',
d: function(d) {
return path_generator(d.iso.face_bottom);
}
});
enter_pipedons.append('path').attr({
"class": 'iso face left template',
d: function(d) {
return path_generator(d.iso.face_left);
},
fill: function(d, i) {
return color(i);
}
});
enter_pipedons.append('path').attr({
"class": 'iso face right',
d: function(d) {
return path_generator(d.iso.face_right);
},
fill: function(d) {
color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'));
return d3.hcl(color.h, color.c, color.l - 12);
}
});
enter_pipedons.append('path').attr({
"class": 'iso face top',
d: function(d) {
return path_generator(d.iso.face_top);
},
fill: function(d) {
color = d3.hcl(d3.select(this.parentNode).select('.template').style('fill'));
return d3.hcl(color.h, color.c, color.l + 12);
}
});
enter_pipedons.append('path').attr({
"class": 'iso outline',
d: function(d) {
return path_generator(d.iso.outline);
}
});
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment