Skip to content

Instantly share code, notes, and snippets.

@vlandham
Created April 12, 2013 14:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vlandham/5372278 to your computer and use it in GitHub Desktop.
Save vlandham/5372278 to your computer and use it in GitHub Desktop.
attempt to grow and shrink bubbles on mouse over not a very good solution - but it works
root = exports ? this
Bubbles = () ->
# standard variables accessible to
# the rest of the functions inside Bubbles
width = 900
height = 510
data = []
node = null
label = null
margin = {top: 5, right: 0, bottom: 0, left: 0}
# largest size for our bubbles
maxRadius = 65
# this scale will be used to size our bubbles
rScale = d3.scale.sqrt().range([0,maxRadius])
# I've abstracted the data value used to size each
# into its own function. This should make it easy
# to switch out the underlying dataset
rValue = (d) -> parseInt(d.count)
# function to define the 'id' of a data element
# - used to bind the data uniquely to the force nodes
# and for url creation
# - should make it easier to switch out dataset
# for your own
idValue = (d) -> d.name
# function to define what to display in each bubble
# again, abstracted to ease migration to
# a different dataset if desired
textValue = (d) -> d.name
# constants to control how
# collision look and act
collisionPadding = 8
minCollisionRadius = 12
# variables that can be changed
# to tweak how the force layout
# acts
# - jitter controls the 'jumpiness'
# of the collisions
jitter = 0.5
# ---
# tweaks our dataset to get it into the
# format we want
# - for this dataset, we just need to
# ensure the count is a number
# - for your own dataset, you might want
# to tweak a bit more
# ---
transformData = (rawData) ->
rawData.forEach (d) ->
d.count = parseInt(d.count)
rawData.sort(() -> 0.5 - Math.random())
rawData
# ---
# tick callback function will be executed for every
# iteration of the force simulation
# - moves force nodes towards their destinations
# - deals with collisions of force nodes
# - updates visual bubbles to reflect new force node locations
# ---
tick = (e) ->
dampenedAlpha = e.alpha * 0.1
# Most of the work is done by the gravity and collide
# functions.
node
.each(gravity(dampenedAlpha))
.each(collide(jitter))
.attr("transform", (d) -> "translate(#{d.x},#{d.y})")
# As the labels are created in raw html and not svg, we need
# to ensure we specify the 'px' for moving based on pixels
label
.style("left", (d) -> ((margin.left + d.x) - d.dx / 2) + "px")
.style("top", (d) -> ((margin.top + d.y) - d.dy / 2) + "px")
# The force variable is the force layout controlling the bubbles
# here we disable gravity and charge as we implement custom versions
# of gravity and collisions for this visualization
force = d3.layout.force()
.gravity(0)
.charge(0)
.size([width, height])
.on("tick", tick)
# ---
# Creates new chart function. This is the 'constructor' of our
# visualization
# Check out http://bost.ocks.org/mike/chart/
# for a explanation and rational behind this function design
# ---
chart = (selection) ->
selection.each (rawData) ->
# first, get the data in the right format
data = transformData(rawData)
# setup the radius scale's domain now that
# we have some data
maxDomainValue = d3.max(data, (d) -> rValue(d))
rScale.domain([0, maxDomainValue])
# a fancy way to setup svg element
svg = d3.select(this).selectAll("svg").data([data])
svgEnter = svg.enter().append("svg")
svg.attr("width", width + margin.left + margin.right )
svg.attr("height", height + margin.top + margin.bottom )
# node will be used to group the bubbles
node = svgEnter.append("g").attr("id", "bubble-nodes")
.attr("transform", "translate(#{margin.left},#{margin.top})")
# clickable background rect to clear the current selection
node.append("rect")
.attr("id", "bubble-background")
.attr("width", width)
.attr("height", height)
.on("click", clear)
# label is the container div for all the labels that sit on top of
# the bubbles
# - remember that we are keeping the labels in plain html and
# the bubbles in svg
label = d3.select(this).selectAll("#bubble-labels").data([data])
.enter()
.append("div")
.attr("id", "bubble-labels")
update()
# see if url includes an id already
hashchange()
# automatically call hashchange when the url has changed
d3.select(window)
.on("hashchange", hashchange)
setForceR = (d) ->
d.forceR = Math.max(minCollisionRadius, rScale(rValue(d)))
# ---
# update starts up the force directed layout and then
# updates the nodes and labels
# ---
update = () ->
# add a radius to our data nodes that will serve to determine
# when a collision has occurred. This uses the same scale as
# the one used to size our bubbles, but it kicks up the minimum
# size to make it so smaller bubbles have a slightly larger
# collision 'sphere'
data.forEach (d,i) ->
setForceR(d)
# start up the force layout
force.nodes(data).start()
# call our update methods to do the creation and layout work
updateNodes()
updateLabels()
# ---
# updateNodes creates a new bubble for each node in our dataset
# ---
updateNodes = () ->
# here we are using the idValue function to uniquely bind our
# data to the (currently) empty 'bubble-node selection'.
# if you want to use your own data, you just need to modify what
# idValue returns
node = node.selectAll(".bubble-node").data(data, (d) -> idValue(d))
# we don't actually remove any nodes from our data in this example
# but if we did, this line of code would remove them from the
# visualization as well
node.exit().remove()
# nodes are just links with circles inside.
# the styling comes from the css
node.enter()
.append("a")
.attr("class", "bubble-node")
.attr("xlink:href", (d) -> "##{encodeURIComponent(idValue(d))}")
.call(force.drag)
.call(connectEvents)
.append("circle")
.attr("r", (d) -> rScale(65))
# ---
# updateLabels is more involved as we need to deal with getting the sizing
# to work well with the font size
# ---
updateLabels = () ->
# as in updateNodes, we use idValue to define what the unique id for each data
# point is
label = label.selectAll(".bubble-label").data(data, (d) -> idValue(d))
label.exit().remove()
# labels are anchors with div's inside them
# labelEnter holds our enter selection so it
# is easier to append multiple elements to this selection
labelEnter = label.enter().append("a")
.attr("class", "bubble-label")
.attr("href", (d) -> "##{encodeURIComponent(idValue(d))}")
.call(force.drag)
.call(connectEvents)
labelEnter.append("div")
.attr("class", "bubble-label-name")
.text((d) -> textValue(d))
labelEnter.append("div")
.attr("class", "bubble-label-value")
.text((d) -> rValue(d))
# label font size is determined based on the size of the bubble
# this sizing allows for a bit of overhang outside of the bubble
# - remember to add the 'px' at the end as we are dealing with
# styling divs
label
.style("font-size", (d) -> Math.max(8, rScale(rValue(d) / 2)) + "px")
.style("width", (d) -> 2.5 * rScale(rValue(d)) + "px")
# interesting hack to get the 'true' text width
# - create a span inside the label
# - add the text to this span
# - use the span to compute the nodes 'dx' value
# which is how much to adjust the label by when
# positioning it
# - remove the extra span
label.append("span")
.text((d) -> textValue(d))
.each((d) -> d.dx = Math.max(2.5 * rScale(rValue(d)), this.getBoundingClientRect().width))
.remove()
# reset the width of the label to the actual width
label
.style("width", (d) -> d.dx + "px")
# compute and store each nodes 'dy' value - the
# amount to shift the label down
# 'this' inside of D3's each refers to the actual DOM element
# connected to the data node
label.each((d) -> d.dy = this.getBoundingClientRect().height)
# ---
# custom gravity to skew the bubble placement
# ---
gravity = (alpha) ->
# start with the center of the display
cx = width / 2
cy = height / 2
# use alpha to affect how much to push
# towards the horizontal or vertical
ax = alpha / 8
ay = alpha
# return a function that will modify the
# node's x and y values
(d) ->
d.x += (cx - d.x) * ax
d.y += (cy - d.y) * ay
collideR = (d) ->
d.forceR
# ---
# custom collision function to prevent
# nodes from touching
# This version is brute force
# we could use quadtree to speed up implementation
# (which is what Mike's original version does)
# ---
collide = (jitter) ->
# return a function that modifies
# the x and y of a node
(d) ->
data.forEach (d2) ->
# check that we aren't comparing a node
# with itself
if d != d2
# use distance formula to find distance
# between two nodes
x = d.x - d2.x
y = d.y - d2.y
distance = Math.sqrt(x * x + y * y)
# find current minimum space between two nodes
# using the forceR that was set to match the
# visible radius of the nodes
minDistance = collideR(d) + collideR(d2) + collisionPadding
# if the current distance is less then the minimum
# allowed then we need to push both nodes away from one another
if distance < minDistance
# scale the distance based on the jitter variable
distance = (distance - minDistance) / distance * jitter
# move our two nodes
moveX = x * distance
moveY = y * distance
d.x -= moveX
d.y -= moveY
d2.x += moveX
d2.y += moveY
# ---
# adds mouse events to element
# ---
connectEvents = (d) ->
d.on("click", click)
d.on("mouseover", mouseover)
d.on("mouseout", mouseout)
# ---
# clears currently selected bubble
# ---
clear = () ->
location.replace("#")
# ---
# changes clicked bubble by modifying url
# ---
click = (d) ->
location.replace("#" + encodeURIComponent(idValue(d)))
d3.event.preventDefault()
# ---
# called when url after the # changes
# ---
hashchange = () ->
id = decodeURIComponent(location.hash.substring(1)).trim()
updateActive(id)
# ---
# activates new node
# ---
updateActive = (id) ->
node.classed("bubble-selected", (d) -> id == idValue(d))
# if no node is selected, id will be empty
if id.length > 0
d3.select("#status").html("<h3>The word <span class=\"active\">#{id}</span> is now active</h3>")
else
d3.select("#status").html("<h3>No word is active</h3>")
# ---
# hover event
# ---
mouseover = (d) ->
node.classed("bubble-hover", (p) -> p == d)
node.filter((p) -> p == d).selectAll('circle').transition().duration(300).attr("r", (d) -> rScale(200))
.each 'start', (d) ->
d.count = 200
setForceR(d)
force.start()
# ---
# remove hover class
# ---
mouseout = (d) ->
node.classed("bubble-hover", false)
node.filter((p) -> p == d).selectAll('circle').transition().duration(300).attr("r", (d) -> rScale(65))
.each 'end', (d) ->
d.count = 65
setForceR(d)
force.start()
# ---
# public getter/setter for jitter variable
# ---
chart.jitter = (_) ->
if !arguments.length
return jitter
jitter = _
force.start()
chart
# ---
# public getter/setter for height variable
# ---
chart.height = (_) ->
if !arguments.length
return height
height = _
chart
# ---
# public getter/setter for width variable
# ---
chart.width = (_) ->
if !arguments.length
return width
width = _
chart
# ---
# public getter/setter for radius function
# ---
chart.r = (_) ->
if !arguments.length
return rValue
rValue = _
chart
# final act of our main function is to
# return the chart function we have created
return chart
# ---
# Helper function that simplifies the calling
# of our chart with it's data and div selector
# specified
# ---
root.plotData = (selector, data, plot) ->
d3.select(selector)
.datum(data)
.call(plot)
texts = [
{key:"gulliver",file:"top_gulliver.csv",name:"Presentation BB"}
]
# ---
# jQuery document ready.
# ---
$ ->
# create a new Bubbles chart
plot = Bubbles()
# ---
# function that is called when
# data is loaded
# ---
display = (data) ->
plotData("#vis", data, plot)
# we are storing the current text in the search component
# just to make things easy
key = decodeURIComponent(location.search).replace("?","")
text = texts.filter((t) -> t.key == key)[0]
# default to the first text if something gets messed up
if !text
text = texts[0]
# select the current text in the drop-down
$("#text-select").val(key)
# bind change in jitter range slider
# to update the plot's jitter
d3.select("#jitter")
.on "input", () ->
plot.jitter(parseFloat(this.output.value))
# bind change in drop down to change the
# search url and reset the hash url
d3.select("#text-select")
.on "change", (e) ->
key = $(this).val()
location.replace("#")
location.search = encodeURIComponent(key)
# set the book title from the text name
d3.select("#book-title").html(text.name)
# load our data
d3.csv("data/#{text.file}", display)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment