Physics based Wordcloud

Implementation of a physics based Wordcloud. For the word count I used a Luna script (because it can ask the user for an input string, where one can use paste), resulting in a table in the data tab. Words are laid out next to each other until the screen is full. Then the physics engine is turned on to let gravity do its thing. Tapping on a word allows you to drag it to another position. Tapping anywhere else flips the gravity direction.

The most difficult part was how to get the outline of the words. Also, the setup is so computationally intensive that it needs to be done outside of the setup.

-- Wordcloud
-- Herwig Van Marck
-- Use this function to perform your initial setup
function setup()
    print("Wordcloud\
Tap on a word to move it\
Tap anywhere else to flip gravity")
    iparameter("showOutlines",0,1,0)
    wall=physics.body(CHAIN,true,vec2(0,0),vec2(WIDTH,0),vec2(WIDTH,HEIGHT),vec2(0,HEIGHT))
    wcl=WordCloud(tbl)
end

function touched(touch)
    if wcl:touched(touch)==false then
        if (touch.state==BEGAN) then
            physics.gravity(-physics.gravity())
        end
    end
end

function drawObj(body)
    pushMatrix()
        translate(body.x, body.y)
        rotate(body.angle)
    
        if body.type == STATIC then
            stroke(255,255,255,255)
        elseif body.type == DYNAMIC then
            stroke(150,255,150,255)
        elseif body.type == KINEMATIC then
            stroke(150,150,255,255)
        end
    
        if body.shapeType == POLYGON then
            strokeWidth(5.0)
            local points = body.points
            for j = 1,#points do
                a = points[j]
                b = points[(j % #points)+1]
                line(a.x, a.y, b.x, b.y)
            end
        elseif body.shapeType == CHAIN or body.shapeType == EDGE then
            strokeWidth(5.0)
            local points = body.points
            local range = #points - 1
            if (true) then
                range = #points
            end
            for j = 1, range do
                a = points[j]
                b = points[j % #points +1]
                line(a.x, a.y, b.x, b.y)
            end      
        elseif body.shapeType == CIRCLE then
            strokeWidth(5.0)
            line(0,0,body.radius-3,0)
            strokeWidth(2.5)
            ellipse(0,0,body.radius*2)
        end
        
        popMatrix()
end

function draw()
    background(0, 0, 0, 255)
    wcl:draw()
    drawObj(wall)
end

tbl={}
tbl["codea"]=5
tbl["anything"]=1
tbl["lets"]=1
tbl["create"]=1
tbl["games"]=1
tbl["simulations"]=1
tbl["any"]=1
tbl["idea"]=1
tbl["have"]=1
tbl["turn"]=1
tbl["thoughts"]=1
tbl["into"]=1
tbl["interactive"]=1
tbl["creations"]=1
tbl["make"]=2
tbl["of"]=1
tbl["features"]=1
tbl["like"]=1
tbl["multi-touch"]=1
tbl["accelerometer"]=1
tbl["we"]=1
tbl["think"]=1
tbl["most"]=1
tbl["beautiful"]=1
tbl["editor"]=1
tbl["ll"]=1
tbl["use"]=2
tbl["s"]=1
tbl["easy"]=1
tbl["designed"]=1
tbl["touch"]=1
tbl["your"]=3
tbl["code"]=2
tbl["to"]=2
tbl["change"]=1
tbl["number"]=1
tbl["just"]=2
tbl["tap"]=1
tbl["and"]=4
tbl["drag"]=1
tbl["it"]=2
tbl["how"]=1
tbl["about"]=2
tbl["color"]=1
tbl["you"]=6
tbl["or"]=2
tbl["an"]=1
tbl["image"]=1
tbl["tapping"]=1
tbl["will"]=1
tbl["bring"]=1
tbl["up"]=1
tbl["visual"]=2
tbl["editors"]=1
tbl["let"]=2
tbl["choose"]=1
tbl["exactly"]=1
tbl["what"]=1
tbl["want"]=2
tbl["is"]=3
tbl["built"]=1
tbl["lua"]=1
tbl["programming"]=1
tbl["simple"]=1
tbl["elegant"]=1
tbl["language"]=2
tbl["that"]=3
tbl["doesn"]=1
tbl["t"]=1
tbl["rely"]=1
tbl["too"]=1
tbl["much"]=1
tbl["on"]=3
tbl["symbols"]=1
tbl["a"]=4
tbl["perfect"]=1
tbl["match"]=1
tbl["for"]=2
tbl["ipad"]=4
tbl["join"]=1
tbl["the"]=4
tbl["forums"]=1

PhysicsWord = class()

function PhysicsWord:init(str,x,y,fnt,fsize)
    self.str=str
    self.fnt=fnt
    self.fsize=fsize
    self.bounce=nil
    local xofs=0;
    local tblb={}
    local tblt={}
    pushStyle()
    font(self.fnt)
    fontSize(self.fsize)
    
    for i=1,string.len(self.str) do
        local chr=string.sub(self.str,i,i)
        local xmin,ymin,xmax,ymax=self:boundingBox(chr)
        table.insert(tblb,vec2(xofs+xmin,ymin))
        table.insert(tblb,vec2(xofs+xmax,ymin))
        table.insert(tblt,1,vec2(xofs+xmin,ymax))
        table.insert(tblt,1,vec2(xofs+xmax,ymax))
        xofs = xofs + textSize(chr)
    end
    for i=1,#tblt do
        table.insert(tblb,tblt[i])
    end
    popStyle()
    self.word = physics.body(POLYGON,unpack(tblb))
    self.word.x=x
    self.word.y=y
    self.word.restitution=0.60
    self.word.gravityScale=0.2
    self.word.sleepingAllowed=false
end

function PhysicsWord:boundingBox(chr)
    pushMatrix()
    pushStyle()
    font(self.fnt)
    fontSize(self.fsize)
    local w,h=textSize(chr)
    local img=image(w,h)
    setContext(img)
    textMode(CORNER)
    fill(255, 255, 255, 255)
    text(chr,0,0)
    setContext()
    local xmin,ymin,xmax,ymax=w,h,0,0
    for x=1,w do
        for y=1,h do
            local r,g,b,a=img:get(x,y)
            if (a>0) then
                if (xmin>=x) then
                    xmin=x
                end
                if (xmax<=x) then
                    xmax=x
                end
                if (ymin>=y) then
                    ymin=y
                end
                if (ymax<=y) then
                    ymax=y
                end 
            end
        end
    end
    --workaround for text bug
    setContext(img)
    textMode(CORNER)
    text("adsrfetgy",0,0)
    setContext()
    
    popStyle()
    popMatrix()
    return xmin,ymin,xmax,ymax
end

function PhysicsWord:draw()
    local body=self.word
    pushMatrix()
    pushStyle()
    translate(body.x, body.y)
    rotate(body.angle)
        
    stroke(150,255,150,255)
    strokeWidth(5.0)
    if (showOutlines==1) then
    local points = body.points
    for j = 1,#points do
        a = points[j]
        b = points[(j % #points)+1]
        line(a.x, a.y, b.x, b.y)
    end
    end
    fontSize(self.fsize)
    font(self.fnt)
    textMode(CORNER)
    fill(255, 255, 255, 255)
    text(self.str,0,0)
    popStyle()
    popMatrix()
    if (nil) then -- waiting for inertia support
    if (math.abs(body.angle)>80) then
        if (self.bounce==nil) then
            print(body.mass)
            body:applyTorque(-120*body.angularVelocity*body.restitution*body.inertia)
            self.bounce=true
        end
    else
        self.bounce=nil
    end
    end
end

function PhysicsWord:touched(touch)
    local touchPoint=vec2(touch.x,touch.y)
    return self.word:testPoint(touchPoint)
end

Wordcloud class:

WordCloud = class()

function WordCloud:init(tbl)
    self.words = {}
    self.touchMap = {}
    self.strs = {}
    self.sizes = {}
    self.x = 5
    self.y = 5
    self.maxy = 0
    self.done = nil
    for str,sz in pairs(tbl) do
        table.insert(self.strs,1,str)
        table.insert(self.sizes,1,sz)
    end
    physics.pause()
    self:addNextWord()
end

-- adding the words in setup is to time consuming (does not work)
function WordCloud:addNextWord()
    if not(self.done) then
    pushStyle()
    font("Arial-BoldMT")
    
    fill(49, 127, 225, 255)
    local str,sz = table.remove(self.strs),table.remove(self.sizes)
    fontSize(16+16*sz)
    local w,h=textSize(str)
    if (self.x + w > WIDTH -10) then
        self.x = 0
        self.y = self.y + self.maxy
        self.maxy = 0
    end
    if (self.maxy<h) then
        self.maxy=h
    end
    if (self.y + h > HEIGHT -10) then
        self.done=true
        physics.resume()
    else
        local word=PhysicsWord(str,self.x,self.y,
            "Arial-BoldMT",fontSize())
        table.insert(self.words,word)
        self.x = self.x + w
    end
    
    popStyle()
    end
end

function WordCloud:draw()
    self:addNextWord()
    local gain = 2.0
    local damp = 0.5
    for k,v in pairs(self.touchMap) do
        local worldAnchor = v.word.word:getWorldPoint(v.anchor)
        local touchPoint = v.tp
        local diff = touchPoint - worldAnchor
        local vel = v.word.word:getLinearVelocityFromWorldPoint(worldAnchor)
        v.word.word:applyForce( (1/1) * diff * gain - vel * damp, worldAnchor)
        
        line(v.tp.x, v.tp.y, worldAnchor.x, worldAnchor.y)
    end
    
    for i,word in ipairs(self.words) do
        word:draw()
    end
end

function WordCloud:touched(touch)
    local touchPoint=vec2(touch.x,touch.y)
    if touch.state == BEGAN then
        for i,word in ipairs(self.words) do
            if word:touched(touch) then
                self.touchMap[touch.id] = 
                    {tp = touchPoint, word = word, anchor = word.word:getLocalPoint(touchPoint)}
                return true
            end
        end
    elseif touch.state == MOVING and self.touchMap[touch.id] then
        self.touchMap[touch.id].tp = touchPoint
        return true
    elseif touch.state == ENDED and self.touchMap[touch.id] then
        self.touchMap[touch.id] = nil
        return true;
    end
    return false
end

Wow! I love it. It looks funny when all words fall. Fantastic job! Thank yor for sharing the code.
It also can float like cloud.

That’s a fantastic experiment Herwig. I’m really impressed that you managed to generate polygons for the word shapes — did you compute the bounding box of each letter then merge them using an algorithm?

I hope you don’t mind but I uploaded a video of the project so others can see:

Thanks @Simeon! The bounding box of each letter is calculated (using setContext(img) ) and then concatenated into a physics body of type polygon. If you set showOutlines to 1 you can see the actual polygons.

I had to dial down the gravity a bit, otherwise the polygons tended to crash together, fixing them to one another.

Also thanks for the video! I tried to do one myself, but for some reason the physics didn’t seem to work (polygons started overlapping?).

boundingBox == genius