ICSPM22のアルバムページ のバックアップ(No.3)

更新


公開メモ

ICSPM22 のアルバムページを作るときにやったこと

最近 ICSPM22 という学会がありまして、そのホームページ作成を担当しました。

そのアルバムページがこれなのですが、

http://dora.bk.tsukuba.ac.jp/event/ICSPM22/photo

PC からでもスマホからでも見られるようにするため、

  • 画面が小さくても写真を見られる
  • 写真をめくるのにフリックが使える

といったあたりを工夫しています。

このページを作る際に、いろいろ調べたことをまとめます。

前提

このページは Ruby on Rails で作られていて、Gemfile には

  • gem 'haml-rails'
  • gem 'coffee-rails'

が含まれています。

要件としては、

  • 画面サイズが小さい場合にも集合写真がはみ出さないようにサイズを調整する
  • 画面サイズが小さい場合にもスナップ写真のサムネイルがはみ出さないように 横幅が変わる(リキッドデザイン)
  • サムネイルは数が多いので css スプライトを使って処理を軽くする
  • サムネイルなどの縮小写真はオリジナルの画像からコントローラにて自動生成する
  • サムネイルをクリックすると全画面でスライドショー
    • ブラウザの戻るボタンで全画面終了
    • 画像のフリックで写真をめくれる
    • 画面サイズが小さい場合にも写真がはみ出さないようサイズを調整する

といったあたりです。

写真ファイルの置き場

まず、写真ファイルを次の場所に置きました。

集合写真は

public/images/photo/icspm22_photo.jpg

public/images/photo/icspm22_photo_small.jpg

スナップ写真は

public/images/photo/(folder名)/(写真名).jpg

LANG:console
$ ls public/images/photo/
 Dec11  Dec12  Dec12_Banquet  Dec13  icspm22_photo.jpg  icspm22_photo_small.jpg

コントローラの作成&ルート設定

今回作る photo コントローラは photo#index という1つのページしか持ちません。

LANG:console
$ rails generate controller photo index
       create  app/controllers/photo_controller.rb
       invoke  haml
       create    app/views/photo
       create    app/views/photo/index.html.haml
       invoke  test_unit
       create    test/controllers/photo_controller_test.rb
       invoke  helper
       create    app/helpers/photo_helper.rb
       invoke  test_unit
       create      test/helpers/photo_helper_test.rb
       invoke  assets
       invoke  coffee
       create      app/assets/javascripts/photo.js.coffee
       invoke  less
       create      app/assets/stylesheets/photo.css.less
$ jed config/routes.rb
 scope "(:locale)", locale: locale_regexp do
   ...
   
   get 'photo' => 'photo#index'
   ...
$ rails s

ここまでで、

http://localhost:3000/event/ICSPM22/photo

へのアクセスによりデフォルトの表示を確認できました。

Photo#index

Find me in app/views/photo/index.html.haml

集合写真を表示する

app/controllers/photo_controller.rb

LANG:ruby
class PhotoController < ApplicationController
  def index
    @photo_root = '/event/ICSPM22/images/photo'
  end
end

app/views/photo/index.html.haml

LANG:haml
- content_for :title do
    Photo

.photo

    -# =========================== conference photo

    %h1 Conference Photo

    = link_to "#{@photo_root}/icspm22_photo.jpg", target: '_blank' do

        = image_tag "#{@photo_root}/icspm22_photo_small.jpg", id: 'conference_photo'

集合写真をクリックすると拡大表示するところまでOKです。

全体を .photo で囲っているのはアプリケーション全体で結合された css から要素を参照できるようにするためです。次項を参照。

写真のサイズ調整

そのままだと画面サイズの小さいスマホなどからアクセスしたときに 写真が画面からはみ出てしまうので、写真が画面の横幅よりも大きくならない ように調整します。

app/assets/javascript/photo.js.less LANG:less

.photo {

    // =========================== conference photo

    img#conference_photo {
        width: 100%;
        max-width: 753px;
    }

}

rails ではデフォルトで、すべてのコントローラの css が1つのファイルに 結合されていっぺんに読み込まれるため、.photo という div で全体を括っておき、 目的の要素以外に影響を及ぼさないようにしています。

スナップ写真の一覧を作成する

public/images/photo/*/*.jpg に置いた元データから

  • サムネール
  • スライドショー
  • 拡大画像

の3サイズの画像を自動生成します。

これには rmagick というライブラリを使います。

LANG: console
$ jed Gemfile
 ...
  
 gem 'rmagick', '2.13.2'
  
$ bin/bundle install

app/controller/photo_controller.rb

LANG:ruby
#coding: utf-8
require 'rubygems'
require 'RMagick'

class PhotoController < ApplicationController

  def index
    # 画像ファイルの url
    @photo_root = '/event/ICSPM22/images/photo'
    
    # 画像ファイルのファイルパスとサブフォルダ名
    @photo_folder = 'public/images/photo/'
    @folders = ['Dec11', 'Dec12', 'Dec12_Banquet', 'Dec13']
    
    # 画像一覧の作成
    @images = []
    @folders.each do |folder|
      @images << [folder, enum_images(@photo_folder+folder)]
    end
  end
  
  def enum_images(folder)
    json_index = "#{folder}/index2.json"
  
    # 一覧を作成済みならそれを読んで返す
    if File.exists? json_index
      return JSON.restore File.read(json_index)
    end

    # 画像サイズ一覧
    thumbnail_height = 60.0
    small_image_height = 480.0
    large_image_limit = 1000
    
    # 画像一覧及び縮小画像を作成する
    thumb_folder = confirm_existence_of_folder "#{folder}/thumb"
    small_folder = confirm_existence_of_folder "#{folder}/small"
    large_folder = confirm_existence_of_folder "#{folder}/large"

    # 画像サイズを収集し、縮小画像を作る
    data = []
    image_files_in_folder(folder).each do |file|
      image = Magick::Image.read("#{folder}/#{file}").first
      data << [file, image.columns, image.rows]
      prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows)
      prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows)
      prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max)
    end
    
    # インデックスを保存
    open(json_index, "w") do |f|
      f.write JSON.dump(data)
    end

    # 使用済み画像データを開放する
    run_gc

    data
  end

  # フォルダが存在しなければ作成する(1段階)
  # 与えられたフォルダ名を返す
  def confirm_existence_of_folder(folder)
    Dir.mkdir folder unless Dir.exists? folder
    folder
  end
  
  # 与えられたフォルダにある画像ファイル名を
  # 古い物から順に並べて返す
  def image_files_in_folder(folder)
    `ls -tr #{folder}`.
        split(/\n/).
        select{ |file| file=~/\.(jpg|JPG)$/ }
  end
  
  # image の縮小画像を指定のスケールで作成する
  def prepare_small_image(image, file, scale)
    image.resize(scale).write(file) unless File.exists? file
  end

  # ガーベージコレクションを走らせて、不要メモリを回収する
  def run_gc
    fDisabled = GC.enable
    GC.start
    GC.disable if fDisabled
  end

end

app/views/photo/index.html.haml

LANG:haml
  -#=========================== image list
  
  :javascript
  
   (function(){
     var images = #{JSON.dump @images};
     var photo_root = "#{@photo_root}";
     
     var photo = new Photo(photo_root, images);
     photo.outputFolderIndex();
     photo.outputFolders();
   })();

app/assets/javascript/photo.js.coffee

LANG:coffeescript
root = exports ? @

class root.Photo
  constructor: (@photo_root, @images)->

  # フォルダ一覧を出力する
  outputFolderIndex: ->
    src = []
    src.push '<h1>Snaps</h1>'
    src.push '<ul class="nav nav-pills nav-stacked">'
    for folder_entry in @images
      folder = folder_entry[0]
      src.push  "<li><a href=\"##{folder}\">#{@humanizeFolder(folder)}</a></li>"
    src.push '</ul>'
    document.write src.join('\n')

  # 指定フォルダの画像一覧を出力する
  outputFolder: (i)->
    [folder, images] = @images[i]

    src = []
    src.push "<h1>#{@humanizeFolder(folder)}</h1>"
    src.push '<div class="images">'
    for image in images
      src.push "<div>" + 
               "<img src=\"#{@photo_root}/#{folder}/thumb/#{image[0]}\" />" + 
               "</div>"
    src.push '</div>'
    document.write src.join('\n')

  # すべてのフォルダの画像一覧を出力する
  outputFolders: ->
    for i in [0..(@images.length-1)]
      @outputFolder(i)

  # folder 名の '_' を ' ' に変えて読みやすくする
  humanizeFolder: (folder)->
       folder.replace(/_/, ' ')

outputFolderIndex は bootstrap の nav-pills を使って フォルダ一覧を出力します。

一旦配列に溜めてからまとめて join しているのは、 恐らくその方が実行効率がよいのではないかという配慮ですが、 本当に効果的かどうかは自信がないです。

app/assets/stylesheets/photo.css.less

  // =========================== thumbnails

  .images:after {
    // clearfix
    content: "."; 
    display: block; 
    height: 0; 
    font-size:0;	
    clear: both; 
    visibility:hidden;
  }

  .images div {
    float: left;
    border: solid white 2px;
  }

サムネール画像は div.images の下の div の中に入ります。

様々な画面サイズのデバイスに対応するために、 float: left で Google:リキッドレイアウト にしていますが、 そのままだと次のフォルダの前で改行してくれないので、 div.images に clearfix を施しています。Google:clearfix

サムネールを css スプライトを使って表示する

件のページではサムネール画像の数が多いので、 上記のように普通に表示するとネットワークへの負荷が高まり、 レスポンスが低下してしまいます。

そこで、サムネール画像を複数横に繋げたファイルを1つ用意することで 画像のダウンロード回数を1回に減らし、 各 div にはそれを切り分けて表示することにします。 これは Google:css スプライト と呼ばれる手法です。

まず、コントローラ側でスプライト画像を作成します。

app/controller/photo_controller.rb の enum_images(folder) を以下のように変更します。

  • サムネール画像を thumbs リストに保存しておき
  • sprite という画像に結合して保存
LANG:ruby
    # 画像サイズを収集し、縮小画像を作る
    thumbs = Magick::ImageList.new  # サムネール画像のリスト
    data = []                       # 画像データ
    image_files_in_folder(folder).each do |file|
      image = Magick::Image.read("#{folder}/#{file}").first
      data << [file, image.columns, image.rows]
      thumb = prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows) ||
              Magick::Image.read("#{thumb_folder}/#{file}").first # すでにあれば読み込む
      small = prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows)
      large = prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max)

      thumbs << thumb # サムネール画像をリストに追加
    end
    
    # サムネールのスプライト画像を作成&保存
    columns = thumbs.inject(0){ |sum, thumb| sum + thumb.columns }
    sprite = Magick::Image.new( columns, thumbs[0].rows )
    thumbs.inject(0){ |offset, thumb| 
      sprite.composite!(thumb, offset, 0, Magick::CopyCompositeOp)
      offset + thumb.columns
    }
    sprite.write("#{thumb_folder}/_sprite.jpg")

その際、prepare_small_image も以下のように変更しました。

  • 新たに作成したならその画像を返す
  • すでに存在したなら nil を返す
LANG:ruby
  # image に与えられた画像を scale で縮小した画像を作成する
  def prepare_small_image(image, file, scale)
    unless File.exists? file
      small = image.resize(scale)
      small.write(file)
      small
    else
      nil
    end
  end

そして表示では、

app/assets/javascript/photo.js.coffee

LANG:coffeescript
    offset = 0
    sprite = "#{@photo_root}/#{folder}/thumb/_sprite.jpg"
    for image, j in images
      width = Math.round(image[1] * 60.0 / image[2])
      style = "width:#{width}px;" +
              "background:url(&quot;#{sprite}&quot;) -#{offset}px 0px"
      id = "#{i}-#{j}"
      src.push "<div style=\"#{style}\" id=\"#{id}\"></div>"
      offset += width

のように、スプライト画像を背景に設定した空の div を並べます。 これで css で高さを指定すれば、一見すると多数の画像が並んでいるように見えることになります。

後で画像をクリックできるようにするため、cursor: pointer も追加しておきます。

LANG:less
  .images div {
    float: left;
    border: solid white 2px;
    height: 60px;
    cursor: pointer;
  }

サムネールのクリックでフルスクリーン表示する

フルスクリーン表示部分の haml コード

app/views/photo.html.haml

LANG:haml
  -#=========================== full screen controls

  #full_screen
    #image_frame
      %img#image
    #controls
      %a#back{href: 'javascript:close_image()', title: 'close image'}
        %i.fa{class: 'fa-reply'}
      %a#prev{href: 'javascript:prev_image()',  title: 'prev image'}
        %i.fa{class: 'fa-arrow-left'}
      %a#next{href: 'javascript:next_image()',  title: 'next image'}
        %i.fa{class: 'fa-arrow-right'}
      %a#large{href: '#', target: '_blank',     title: 'enlarge image'}
        %i.fa{class: 'fa-external-link'}

app/assets/photo.css.less

LANG:less
  // 全画面画像表示用スクリーン
  #full_screen {
    display: none;
    position: fixed;
    top: 0px;
    left: 0px;
    bottom: 0px;
    right: 0px;
    z-index: 2000;                // 1050 以下だと navbar に隠れる
    background: rgba(0,0,0,0.9);  // 少し透ける

    // 画像を含む部分
    #image_frame {
      position: absolute;
      left: 0px;
      top: 0px;
      width: 100%;
      height: 90%;
      overflow: hidden;
      text-align: center;
      // 中に含まれる画像を上下方向に中央に配置するための hack
      // http://blog.3streamer.net/html5-css3/css-box-be-centerd-483/
      &:before {
        content: "";
        display: inline-block;
        height: 100%;
        vertical-align: middle;
        margin-right: -0.25em;
      }
      // 画像は親要素のサイズを越えないよう必要に応じて縮小される
      img#image {
        display: inline-block;
        vertical-align:middle;
        max-width: 96%;        // 100% だとうまく行かない場合がある?
        max-height: 100%;
      }
    }
    
    // ボタンを含む部分
    #controls {
      position: absolute;
      left: 0px;
      bottom: 0px;
      width: 100%;
      height: 10%;
      text-align: center;

      // ボタン
      a {
        display: inline-block;
        width: 15%;
        height: 100%;
        font-size: 7vh;
        &:link, &:visited {
          color: grey;
          text-decoration: none;
        }
        &:hover {
          color: white;
          text-decoration: none;
        }
      }
    }
  }

img#image の max-width が 100% でなく 96% になっているのは、 なぜか production 環境のみで表示が崩れるため。理由はよく調べていない。

html からイベントのバインドを要請

LANG:javascript
      var photo = new Photo(photo_root, images);
      photo.outputFolderIndex();
      photo.outputFolders();
      $(function(){ photo.registerEvents(); });

javascript でイベント処理

  • サムネールをクリックしたら全画面で画像を表示
LANG:coffeescript
  # イベントハンドラの割り当て
  registerEvents: ()->
   $('.photo .images div').bind 'click', (e)=>
     [folder_id, image_id] = e.target.id.split('-')
     @openImage(folder_id, image_id)
  
    $(window).bind 'resize', (e)=>
      @resizeComponents()

  openImage: (folder_id, image_id)->
    @folder_id = parseInt folder_id
    @image_id = parseInt image_id
    @folder = @images[@folder_id][0]
    @image = @images[@folder_id][1][@image_id]
    
    @resizeComponents()
    $('.photo #image').attr('src', "#{@photo_root}/#{@folder}/small/#{@image[0]}")
    $('.photo #large').attr('href', "#{@photo_root}/#{@folder}/large/#{@image[0]}")
    $('.photo #full_screen').show()
  
  resizeComponents: ()->
    // android ブラウザで font-size を vh 単位で指定できない(?) ため
    $("#controls a").css('font-size', Math.round(window.innerHeight*7/100) + "px");

画像を開く際、#large ボタンの飛び先を拡大画像のアドレスに設定しています。

また、resizeComponents にてボタンのフォントサイズを設定しています。

本当は font-size: 7vh; という css の設定だけで良いはずなのですが、 android のブラウザではこれがうまくいかなかったため、 仕方無く javascript で値を計算してピクセル単位で設定しています。

画面サイズが変わったときに、フォントサイズを変更するため、resize イベントも割り当てています。

全画面表示を解除する

a#back を押された時に全画面表示を解除するのは $('#full_screen').hide() だけで簡単にできます。

LANG:coffeescript
  registerEvents: ()->
    ...
    
    $('.photo a#back').bind 'click', (e)-> false
    $('.photo a#back').bind 'mousedown', (e)=>
      @closeImage()

  closeImage: ->
    $('#full_screen').hide()

「戻る」ボタンへの対応

上記のままだと、全画面表示の状態でブラウザの「戻る」ボタンを押されたときに、 全画面表示を解除するだけでなく、photo のページのさらに前のページに戻ってしまいます。

ユーザーからすると全画面表示を終了して画像一覧ページに戻りたいところではないでしょうか?

これを実現するには google:html5 history API というのを使えば良いようです。

まず openImage にて、全画面表示時に

LANG:coffeescript
    window.location.hash = "browsing";

します。これにより、ブラウザのアドレスバーに表示されるアドレスに #browsing というハッシュが付き、あたかもページを移動したかのような履歴が残ります。

つまり、ここでブラウザの「戻る」ボタンを押すと #browsing が付く前のアドレスに移動します。 この移動は、window の popstate イベントを発火させるため、

LANG:coffeescript
  registerEvents: ()->
    ...
 
    $(window).bind 'popstate', (e)=>
      if (window.location.hash != "#browsing") and $("#image_frame").is(':visible')
        @closeImage()

のようにして、全画面表示を終わらせます。

closeImage() はボタンからも呼ばれるので、location.hash に #browsing が付いていれば 履歴を1つ遡ることにします。

LANG:coffeescript
  closeImage: ->
    $('#full_screen').hide()
    if (window.location.hash == "#browsing")
        window.history.back()

以上でブラウザの「戻る」ボタンで全画面表示を終了できるようになりました。

prev / next ボタンで画像をめくる

難しいところはありません。

画像番号を1つずつ増やしたり減らしたりして openImage を呼ぶだけです。

ボタンが押されたら背景をグレーにして、ユーザーにフィードバックします。

先頭や末尾まで来たらボタンを赤くして、それ以上画像がないことを知らせます。

後で使うので、prevImage, nextImage は移動したか、しなかったかを論理値で返します。

  registerEvents: ()->
    ...
    
    $('.photo a#next').bind 'click', (e)-> false
    $('.photo a#next').bind 'mousedown', (e)=>
      @nextImage()
      false
   
    $('.photo a#prev').bind 'click', (e)-> false
    $('.photo a#prev').bind 'mousedown', (e)=>
      @prevImage()
      false

  prevImage: ()->
    if @image_id > 0
      @openImage(@folder_id, @image_id - 1)
    else if @folder_id > 0
      @openImage(@folder_id - 1, @images[@folder_id - 1][1].length - 1)
    else
      @flashButton $(".photo #prev"), 'red'
      return false
    @flashButton $(".photo #prev"), '#333'
    true
 
  nextImage: ()->
    if @image_id < @images[@folder_id][1].length - 1
      @openImage(@folder_id, @image_id + 1)
    else if @folder_id < @images.length - 1
      @openImage(@folder_id + 1, 0)
    else
      @flashButton $(".photo #next"), 'red'
      return false
    @flashButton $(".photo #next"), '#333'
    true

  // ボタンを一定時間、指定の色で光らせます
  flashButton: (button)->
    button.css('background', 'red')
          .delay(300)
          .queue ->
            $(@).css('background', '')
                .dequeue()

画面のフリックで写真をめくる

スマホだと、ボタンを押して移動というのはなじまないので、 画面を横にフリックすることで写真をめくれるようにします。

それに合わせて、動作確認のしやすさなどもあるので、 マウスでもフリックできるようにしておきます。

マウスによる画面のドラッグは、mousedown / mousemove / mouseup で、
タッチによるフリックは touchstart / touchmove / touchend / touchcancel で、
それぞれ検出します。

ほとんど同じ動作になるため、イベントハンドラは共有しています。

唯一異なるのはイベントオブジェクトから マウス/指 の位置を取り出す部分なのですが、 その部分は @pageX(e) に切り出してあります。

タッチの時は e.originalEvent.changedTouches[0].pageX に、
マウスの時は e.pageX に、それぞれ座標が入っています。

ドラッグ動作は次の通りです。

  1. dragStart でドラッグ開始座標を @drag.start_x に保存しておく
  2. dragMove で #image_frame を左右に動かす
  3. dragEnd で #image_frame の位置に応じて
    • ある程度以上動かしていれば prevImage / nextImage のどちらかを呼ぶ
    • あまり動かしていなければ #image_frame の位置を初期位置まで戻す
    • まったく動かしていなければ closeImage を呼ぶ
  4. dragCancel では #image_frame の位置を初期位置まで戻す

dragStart は #image_frame に割り当てられていますが、
dragMove, dragEnd, dragCancel は document に割り当てられています。
このようにすることで、マウスが #image_frame の外に出てもイベントを追えるようになります。

LANG:coffeescript
  registerEvents: ()->
    ...
    
    $('.photo #image_frame').bind 'mousedown touchstart', (e)=> @dragStart(e)
    $(document).bind 'mousemove touchmove', (e)=> @dragMove(e)
    $(document).bind 'mouseup touchend', (e)=> @dragEnd(e)
    $(document).bind 'touchcancel', (e)=> @dragCancel(e)

  dragStart: (e)->
    return true if @drag
    @drag = { start_x: @pageX(e) }
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    false

  dragMove: (e)->
    return true unless @drag
    image_frame = $("#image_frame")
    image_frame.css("left", ( @pageX(e) - @drag.start_x ) + 'px');
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    if Math.abs(image_frame.position().left) > 5
      @drag.not_to_close = true
    false

  dragEnd: (e)->
    return true unless @drag
    image_frame = $("#image_frame")
    if Math.abs(image_frame.position().left) > window.innerWidth * 15 / 100
      image = $('#image')
      image.css('opacity', 0)
      image.animate({opacity: 1}, 300)
      if image_frame.position().left > 0
        result = @prevImage()
      else
        result = @nextImage()

      if result
          image_frame.css('left', '0px')
      else
          image_frame.animate({left: '0px'}, 200)
    else if @drag.not_to_close
      image_frame.animate({left: '0px'}, 200)
    else
      @closeImage()
      image_frame.css('left', '0px')
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    @drag = null
    false
  
  dragCancel: (e)->
    return true unless @drag
    $("#image_frame").animate({left: '0px'}, 200)
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    @drag = null
    false
  
  pageX: (e)->
    if e.originalEvent.changedTouches
      e.originalEvent.changedTouches[0].pageX
    else
      e.pageX

キーボード操作

次の動作を実現します

  • ESC あるいは Back Space で全画面表示の終了
  • Left / Right で画像をめくる
LANG:coffeescript
    $('body').bind 'keydown', (e)=>
      return true unless $("#image_frame").is(':visible')
      switch e.keyCode
        when 27           # Esc
          @closeImage()
          false
        when 37           # ArrowLeft
          @prevImage()
          false
        when 39           # ArrowRight
          @nextImage()
          false
        when 8            # BackSpace
          @closeImage()
          false
        else
          true

ソースコード

以上のコードをまとめて掲載しておきます。

app/constollers/photo_controller.rb

LANG:ruby(linenumber)
#coding: utf-8
require 'rubygems'
require 'RMagick'

class PhotoController < ApplicationController
 
  def index
    # 画像ファイルの url
    @photo_root = '/event/ICSPM22/images/photo'
    
    # 画像ファイルのファイルパスとサブフォルダ名
    @photo_folder = 'public/images/photo/'
    @folders = ['Dec11', 'Dec12', 'Dec12_Banquet', 'Dec13']
    
    # 画像一覧の作成
    @images = []
    @folders.each do |folder|
      @images << [folder, enum_images(@photo_folder+folder)]
    end
  end
  
  def enum_images(folder)
    json_index = "#{folder}/index2.json"
  
    # 一覧を作成済みならそれを読んで返す
    if File.exists? json_index
      return JSON.restore File.read(json_index)
    end

    # 画像サイズ一覧
    thumbnail_height = 60.0
    small_image_height = 480.0
    large_image_limit = 1000
    
    # 画像一覧及び縮小画像を作成する
    thumb_folder = confirm_existence_of_folder "#{folder}/thumb"
    small_folder = confirm_existence_of_folder "#{folder}/small"
    large_folder = confirm_existence_of_folder "#{folder}/large"

    # 画像サイズを収集し、縮小画像を作る
    thumbs = Magick::ImageList.new  # サムネール画像のリスト
    data = []                       # 画像データ
    image_files_in_folder(folder).each do |file|
      image = Magick::Image.read("#{folder}/#{file}").first
      data << [file, image.columns, image.rows]
      thumb = prepare_small_image(image, "#{thumb_folder}/#{file}", thumbnail_height/image.rows) ||
              Magick::Image.read("#{thumb_folder}/#{file}").first # すでにあれば読み込む
      small = prepare_small_image(image, "#{small_folder}/#{file}", small_image_height/image.rows)
      large = prepare_small_image(image, "#{large_folder}/#{file}", 1.0/[1, image.rows/large_image_limit].max)

      thumbs << thumb # サムネール画像をリストに追加
    end
    
    # サムネールのスプライト画像を作成&保存
    columns = thumbs.inject(0){ |sum, thumb| sum + thumb.columns }
    sprite = Magick::Image.new( columns, thumbs[0].rows )
    thumbs.inject(0){ |offset, thumb| 
      sprite.composite!(thumb, offset, 0, Magick::CopyCompositeOp)
      offset + thumb.columns
    }
    sprite.write("#{thumb_folder}/_sprite.jpg")

    # インデックスを保存
    open(json_index, "w") do |f|
      f.write JSON.dump(data)
    end

    # 使用済み画像データを開放する
    run_gc

    data
  end

  # フォルダが存在しなければ作成する(1段階)
  # 与えられたフォルダ名を返す
  def confirm_existence_of_folder(folder)
    Dir.mkdir folder unless Dir.exists? folder
    folder
  end
  
  # 与えられたフォルダにある画像ファイル名を
  # 古い物から順に並べて返す
  def image_files_in_folder(folder)
    `ls -tr #{folder}`.
        split(/\n/).
        select{ |file| file=~/\.(jpg|JPG)$/ }
  end
  
  # image に与えられた画像を scale で縮小した画像を作成する
  def prepare_small_image(image, file, scale)
    unless File.exists? file
      small = image.resize(scale)
      small.write(file)
      small
    else
      nil
    end
  end

  # ガーベージコレクションを走らせて、不要メモリを回収する
  def run_gc
    fDisabled = GC.enable
    GC.start
    GC.disable if fDisabled
  end

end

app/views/photo/index.html.haml

LANG:haml(linenumber)
-content_for :title do
  Photo

.photo

  -#=========================== conference photo

  %h1 Conference Photo

  = link_to "#{@photo_root}/icspm22_photo.jpg", target: '_blank' do

    = image_tag "#{@photo_root}/icspm22_photo_small.jpg", id: 'conference_photo'

  -#=========================== full screen controls

  #full_screen
    #image_frame
      %img#image
    #controls
      %a#back{href: 'javascript:close_image()', title: 'close image'}
        %i.fa{class: 'fa-reply'}
      %a#prev{href: 'javascript:prev_image()',  title: 'prev image'}
        %i.fa{class: 'fa-arrow-left'}
      %a#next{href: 'javascript:next_image()',  title: 'next image'}
        %i.fa{class: 'fa-arrow-right'}
      %a#large{href: '#', target: '_blank',     title: 'enlarge image'}
        %i.fa{class: 'fa-external-link'}

  -#=========================== image list
  
  :javascript
  
    (function(){
      var images = #{JSON.dump @images};
      var photo_root = "#{@photo_root}";
      
      var photo = new Photo(photo_root, images);
      photo.outputFolderIndex();
      photo.outputFolders();
      $(function(){ photo.registerEvents(); });
    })();

app/assets/stylesheets/photo.css.less

LANG:less(linenumber)
// Place all the styles related to the photo controller here.
// They will automatically be included in application.css.
// You can use Less here: http://lesscss.org/

.photo {

  // =========================== conference photo
    
  img#conference_photo {
    width: 100%;
    max-width: 753px;
  }

  // =========================== thumbnails

  .images:after {
    // clearfix
    content: "."; 
    display: block; 
    height: 0; 
    font-size:0;	
    clear: both; 
    visibility:hidden;
  }

  .images div {
    float: left;
    border: solid white 2px;
    height: 60px;
    cursor: pointer;
  }

  // 全画面画像表示用スクリーン
  #full_screen {
    display: none;
    position: fixed;
    top: 0px;
    left: 0px;
    bottom: 0px;
    right: 0px;
    z-index: 2000;                // 1050 以下だと navbar に隠れる
    background: rgba(0,0,0,0.9);  // 少し透ける

    // 画像を含む部分
    #image_frame {
      position: absolute;
      left: 0px;
      top: 0px;
      width: 100%;
      height: 90%;
      overflow: hidden;
      text-align: center;
      // 中に含まれる画像を上下方向に中央に配置するための hack
      // http://blog.3streamer.net/html5-css3/css-box-be-centerd-483/
      &:before {
        content: "";
        display: inline-block;
        height: 100%;
        vertical-align: middle;
        margin-right: -0.25em;
      }
      // 画像は親要素のサイズを越えないよう必要に応じて縮小される
      img#image {
        display: inline-block;
        vertical-align: middle;
        max-width: 96%;
        max-height: 100%;
      }
    }
    
    // ボタンを含む部分
    #controls {
      position: absolute;
      left: 0px;
      bottom: 0px;
      width: 100%;
      height: 10%;
      text-align: center;

      // ボタン
      a {
        display: inline-block;
        width: 15%;
        height: 100%;
        font-size: 7vh;
        &:link, &:visited {
          color: grey;
          text-decoration: none;
        }
        &:hover {
          color: white;
          text-decoration: none;
        }
      }
    }
  }

}

app/assets/javascripts/photo.js.coffee

LANG:coffeescript(linenumber)
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

root = exports ? @

class root.Photo
  constructor: (@photo_root, @images)->

  # フォルダ一覧を出力する
  outputFolderIndex: ->
    src = []
    src.push '<h1>Snaps</h1>'
    src.push '<ul class="nav nav-pills nav-stacked">'
    for folder_entry in @images
      folder = folder_entry[0]
      src.push  "<li><a href=\"##{folder}\">#{@humanizeFolder(folder)}</a></li>"
    src.push '</ul>'
    document.write src.join('\n')

  # 指定フォルダの画像一覧を出力する
  outputFolder: (i)->
    [folder, images] = @images[i]

    src = []
    src.push "<h1>#{@humanizeFolder(folder)}</h1>"
    src.push '<div class="images">'
    offset = 0
    sprite = "#{@photo_root}/#{folder}/thumb/_sprite.jpg"
    for image, j in images
      width = Math.round(image[1] * 60.0 / image[2])
      style = "width:#{width}px;" +
              "background:url(&quot;#{sprite}&quot;) -#{offset}px 0px"
      id = "#{i}-#{j}"
      src.push "<div style=\"#{style}\" id=\"#{id}\"></div>"
      offset += width
    src.push '</div>'
    document.write src.join('\n')

  # すべてのフォルダの画像一覧を出力する
  outputFolders: ->
    for i in [0..(@images.length-1)]
      @outputFolder(i)

  # folder 名の '_' を ' ' に変えて読みやすくする
  humanizeFolder: (folder)->
       folder.replace(/_/, ' ')

  # イベントハンドラの割り当て
  registerEvents: ()->
    $('.photo .images div').bind 'click', (e)=>
      [folder_id, image_id] = e.target.id.split('-')
      @openImage(folder_id, image_id)
    
    $('.photo a#back').bind 'click', (e)-> false
    $('.photo a#back').bind 'mousedown', (e)=>
      @closeImage()

    $(window).bind 'popstate', (e)=>
      if (window.location.hash != "#browsing") and $("#image_frame").is(':visible')
        @closeImage()
    
    $(window).bind 'resize', (e)=>
      @resizeComponents()
    
    $('.photo a#next').bind 'click', (e)-> false
    $('.photo a#next').bind 'mousedown', (e)=>
      @nextImage()
      false
    
    $('.photo a#prev').bind 'click', (e)-> false
    $('.photo a#prev').bind 'mousedown', (e)=>
      @prevImage()
      false
    
    $('.photo #image_frame').bind 'mousedown touchstart', (e)=> @dragStart(e)
    $(document).bind 'mousemove touchmove', (e)=> @dragMove(e)
    $(document).bind 'mouseup touchend', (e)=> @dragEnd(e)
    $(document).bind 'touchcancel', (e)=> @dragCancel(e)

  openImage: (folder_id, image_id)->
    @folder_id = parseInt folder_id
    @image_id = parseInt image_id
    @folder = @images[@folder_id][0]
    @image = @images[@folder_id][1][@image_id]
    
    @resizeComponents()
    $('.photo #image').attr('src', "#{@photo_root}/#{@folder}/small/#{@image[0]}")
    $('.photo #large').attr('href', "#{@photo_root}/#{@folder}/large/#{@image[0]}")
    $('.photo #full_screen').show()

    $('body').bind 'keydown', (e)=>
      return true unless $("#image_frame").is(':visible')
      switch e.keyCode
        when 27           # Esc
          @closeImage()
          false
        when 37           # ArrowLeft
          @prevImage()
          false
        when 39           # ArrowRight
          @nextImage()
          false
        when 8            # BackSpace
          @closeImage()
          false
        else
          true
    
    window.location.hash = "browsing";
  
  prevImage: (clear = false)->
    if @image_id > 0
      @discardImage() if clear
      @openImage(@folder_id, @image_id - 1)
    else if @folder_id > 0
      @discardImage() if clear
      @openImage(@folder_id - 1, @images[@folder_id - 1][1].length - 1)
    else
      @flashButton $(".photo #prev"), 'red'
      return false
    @flashButton $(".photo #prev"), '#333'
    true
  
  nextImage: (clear = false)->
    if @image_id < @images[@folder_id][1].length - 1
      @discardImage() if clear
      @openImage(@folder_id, @image_id + 1)
    else if @folder_id < @images.length - 1
      @discardImage() if clear
      @openImage(@folder_id + 1, 0)
    else
      @flashButton $(".photo #next"), 'red'
      return false
    @flashButton $(".photo #next"), '#333'
    true
  
  closeImage: ->
    $('#full_screen').hide()
    if (window.location.hash == "#browsing")
        window.history.back()

  discardImage: ->
       $('.photo #image').attr('src', '')

  resizeComponents: ()->
    $("#controls a").css('font-size', Math.round(window.innerHeight*7/100) + "px");

  # ボタンを一定時間、指定の色で光らせます
  flashButton: (button, color)->
    button.css('background', color)
          .delay(300)
          .queue ->
            $(@).css('background', '')
                .dequeue()

  dragStart: (e)->
    return true if @drag
    @drag = { start_x: @pageX(e) }
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    false

  dragMove: (e)->
    return true unless @drag
    image_frame = $("#image_frame")
    image_frame.css("left", ( @pageX(e) - @drag.start_x ) + 'px');
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    if Math.abs(image_frame.position().left) > 5
      @drag.not_to_close = true
    false

  dragEnd: (e)->
    return true unless @drag
    image_frame = $("#image_frame")
    if Math.abs(image_frame.position().left) > window.innerWidth * 15 / 100
      image = $('#image')
      image.css('opacity', 0)
      image.animate({opacity: 1}, 300)
      if image_frame.position().left > 0
        result = @prevImage(true)
      else
        result = @nextImage(true)

      if result
          image_frame.css('left', '0px')
      else
          image_frame.animate({left: '0px'}, 200)
    else if @drag.not_to_close
      image_frame.animate({left: '0px'}, 200)
    else
      @closeImage()
      image_frame.css('left', '0px')
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    @drag = null
    false
  
  dragCancel: (e)->
    return true unless @drag
    $("#image_frame").animate({left: '0px'}, 200)
    e.originalEvent.preventDefault()
    e.originalEvent.stopPropagation()
    @drag = null
    false
  
  pageX: (e)->
    if e.originalEvent.changedTouches
      e.originalEvent.changedTouches[0].pageX
    else
      e.pageX

コメント&質問





Counter: 4244 (from 2010/06/03), today: 3, yesterday: 0