読み書きプログラミング

日常のプログラミングで気づいたことを綴っています

Tweets in the World

世界中のツイートを地図上で表示するサイト"Tweets in the World"を作りました。

http://tweets-in-the-world.herokuapp.com


具体的には、位置情報を含むツイートをマーカーでプロットし、2秒に一度、適当にツイートの中身を表示しています。Photo Filterをオンにすると、写真を含むツイートに限定します。

旅行先の情報収集とかに使えないかアイデア検討中です。

開発備忘録

サーバー

Sinatraを使いました。こういう規模のウェブアプリケーションにちょうどいいですね。


Twitter Streaming APIは認証が必要で、しかも認証1つに対して1つしかストリーム接続できません。なので接続を1つ張り取り込んだツイートを各クライアントに配信する必要があります。このためには、
1. Twitter Streaming用にスレッドを1つ起こす必要がある。
2. クライアントとはlong pollなりweb socketなりで接続する必要がある。

1に関して苦労しました。

require 'sinatra'

get '/' do
    # process
end

Thread.new do
    # twitter streaming
end

とすると、Sinatraが起動直後に終了してしまいます。
単にスレッド起こすだけなら終了しません。スレッド内でEventMachineを使うと終了します。使用したライブラリtweetstreamがEventMachineを使って実装されていました。なぜ終了するのかよくわかりません。
(EveentMachineというライブラリを初めて知りました。Node.jsの前にこういうものがあったとは。)
結局、以下を参考にrunの初期化で2つのプロセスを起動するようにしたら、なんとなくうまくいきました。

http://stackoverflow.com/questions/2999430/any-success-with-sinatra-working-together-with-eventmachine-websockets


2はSinatraにstream helperという処理中に順次出力するヘルパーがあったのでこれを利用しました。httpのロングポールになります。


# -*- coding: utf-8 -*-
#
# Tweets in the World
# Author: ICHIKAWA, Yuji
# Copyright (C) 2012 ICHIKAWA, Yuji (New 3 Rs) All rights reserved.

require "sinatra/base"
require 'thin'
require 'tweetstream'

TweetStream.configure do |config|
    config.consumer_key = ENV['CONSUMER_KEY']
    config.consumer_secret = ENV['CONSUMER_SECRET']
    config.oauth_token = ENV['ACCESS_TOKEN']
    config.oauth_token_secret = ENV['ACCESS_TOKEN_SECRET']
    config.auth_method = :oauth
end

class App < Sinatra::Base
    @@outs = []
    
    def App.outs
        @@outs
    end

    get '/' do
        redirect '/index.html'
    end
    
    get '/tweets' do
        stream(:keep_open) do |out|
            @@outs.push out
            out.callback { @@outs.delete out }
            out.errback { @@outs.delete out }
        end
    end
end


EM.run do
    buffer = []
    dumping = false # true if process to dump tweets exists
    EM.defer do
        TweetStream::Client.new.filter({locations: '-180,-90,180,90'}) do |status|
            next unless status.geo # There are statuses without geo even if filtering by location.
            buffer.unshift status
            buffer = buffer[0, 10]
            unless dumping
                dumping = true
                callback = proc { |result| dumping = false }
                EM.defer(nil, callback) do
                    while status = buffer.shift
                        App.outs.each do |out|
                            out << JSON.generate({
                                screen_name: status.user.screen_name,
                                profile_image_url: status.user.profile_image_url,
                                text: status.text,
                                geo: status.geo.coordinates,
                                media_url: status.media.empty? ? nil : status.media[0].media_url
                            }) + "\n"
                        end
                    end        
                end
            end
        end
    end

    Thin::Server.start App, '0.0.0.0', ENV['PORT']
end

ひたすらTwitter streamingをFIFOに読むプロセスを作り、その中で別途FIFOの内容をクライアントに吐き出すプロセスをdeferします。(Twitter streamingを待たせてしまうとTwitter streamingがストールします。)
ウェブサーバーは所定のURLにアクセスがあると、sinatra streamを上記のクライアントに吐き出すプロセスが扱うようにするだけ。

クライアント側

ロングポールをJavaScriptで扱うのに、XMLHTTPRequestのprogressイベントを使いました。
httpの接続時間はブラウザ側に制限が設けられているので、切断したら再度接続するようにします。

# Tweets in the World
# Copyright (C) 2012 ICHIKAWA, Yuji (New 3 Rs)

lifeTime = 2000

photoFilter = false

startReceiving = -> $.ajax
    url: "/tweets",
    xhr: ->
        xhr = new window.XMLHttpRequest()
        
        progressHandler = (event) ->
            now = new Date().getTime()

            # delete last incomplete line, cut previous part.
            difference = xhr.responseText.replace(/\r?\n.*?$/, '').slice(progressHandler.lastLength)
            lines = difference.split /\r?\n/
            lines = lines.filter (e) -> e isnt '' # There are empty lines.
            try
                tweets = lines.map (e) -> JSON.parse e
                bounds = map.getBounds()
                for e in tweets
                    continue if photoFilter and not e.media_url?
                    latLng = new google.maps.LatLng e.geo[0], e.geo[1]
                    if bounds.contains latLng
                        marker = new google.maps.Marker
                            icon: if photoFilter and e.profile_image_url? then { url: e.profile_image_url } else null
                            map: map
                            position: latLng
                        if now - progressHandler.lastTime > lifeTime
                            infoWindow = new google.maps.InfoWindow
                                content: (if e.media_url? then "<img height=\"75\" class=\"info-content\" src=\"#{e.media_url}\">" else '') + "<p>#{e.text}</p>" 
                                disableAutoPan: true
                            infoWindow.open map, marker
                            progressHandler.lastTime = now
                        else
                            infoWindow = null
                        setTimeout ((marker, infoWindow) -> ->
                            infoWindow.close() if infoWindow?
                            marker.setMap null
                        )(marker, infoWindow), lifeTime
            catch error
                console.log error
            progressHandler.lastLength = xhr.responseText.replace(/\r?\n.+?$/, '').length
        progressHandler.lastLength = 0
        progressHandler.lastTime = new Date().getTime()

        xhr.addEventListener 'progress', progressHandler
        xhr
    success: startReceiving
    error: (xhr, status, error) -> alert 'connection error. try to reload.'

map = new google.maps.Map $('#map')[0],
      center: new google.maps.LatLng(37.77663, -122.417108)
      mapTypeId: google.maps.MapTypeId.ROADMAP
      zoom: 2

$('input[name="photo-filter"]:radio').change ->  
    photoFilter = $(this).val() is 'true'

startReceiving()