ICSPM22のアルバムページ のバックアップの現在との差分(No.1)

更新


  • 追加された行はこの色です。
  • 削除された行はこの色です。
[[公開メモ]]

#contents

* ICSPM22 のアルバムページを作るときにやったこと [#h9e93bb1]

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

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

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

PC からでもスマホからでも見られるようにするため、
-- 画面が小さくても写真を見られる
-- 写真をめくるのにフリックが使える

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

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

* 前提 [#cd431c72]

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

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

が含まれています。

要件としては、

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

といったあたりです。

* 写真ファイルの置き場 [#he4801e9]

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

集合写真は
>public/images/photo/icspm22_photo.jpg
>public/images/photo/icspm22_photo_small.jpg

スナップ写真は
>public/images/photo/XXX/xxxx.jpg
>public/images/photo/(folder名)/(写真名).jpg

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

* コントローラの作成&ルート設定 [#b6af5ba9]

今回作る 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

* 集合写真を表示する [#x5c0d8dd]

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 から要素を参照できるようにするためです。次項を参照。

** 写真のサイズ調整 [#p27ce0f2]

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

app/assets/javascript/photo.js.less
LANG:less
 .photo {
 
     // =========================== conference photo
 
     img#conference_photo {
         width: 100%;
         max-width: 753px;
     }
 
 }

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

* スナップ写真の一覧を作成する [#zbd7bf28]

public/images/photo/*/*.jpg に置いた元データから
- サムネール
- スライドショー
- 拡大画像

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

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

 LANG: console
 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 しているのは、
恐らくその方が実行効率がよいのではないかという配慮ですが、
本当に効果的かどうかは自信がないです。
恐らくその方が実行効率がよいのではないかという配慮です。
http://koshian.hatenablog.com/entry/2014/10/09/190955

app/assets/stylesheets/photo.css.less
  // =========================== thumbnails
   // =========================== thumbnails
 
   .images:after {
     // clearfix
     content: "."; 
     display: block; 
     height: 0; 
     font-size:0;	
     clear: both; 
     visibility:hidden;
   }
 
   .images div {
     float: left;
     border: solid white 2px;
   }

  .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 でリキッドスタイルにしていますが、
float: left で [[Google:リキッドレイアウト]] にしていますが、
そのままだと次のフォルダの前で改行してくれないので、
div.images に clearfix を施しています。[[google:clearfix]]
div.images に clearfix を施しています。[[Google:clearfix]]

* サムネールをスプライトを使って表示する [#g62f2c52]
* サムネールを css スプライトを使って表示する [#g62f2c52]

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

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

* サムネールのクリックでフルスクリーン表示する [#p47b8edb]

フルスクリーン表示部分の 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#enlarge{href: '#', target: '_blank',   title: 'enlarge image'}
       %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% でなく 99% になっているのは、
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', '')   # dispose old image
     $('.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 イベントも割り当てています。

* 全画面表示を解除する [#c5f51232]

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()

* 「戻る」ボタンへの対応 [#ta6bc4f5]

上記のままだと、全画面表示の状態でブラウザの「戻る」ボタンを押されたときに、
全画面表示を解除するだけでなく、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 ボタンで画像をめくる [#gfeca756]

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

画像番号を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()

* 画面のフリックで写真をめくる [#d9a03f3b]

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

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

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

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

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

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

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

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

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

* キーボード操作 [#rbb44e13]

次の動作を実現します
- 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

* ソースコード [#f16d9449]

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

** app/constollers/photo_controller.rb [#gf5af678]

 LANG:ruby
 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 [#z97da8d6]

 LANG: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#enlarge{href: '#', target: '_blank',   title: 'enlarge image'}
       %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 [#he19ca4b]

 LANG: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 [#f7e88bf6]

 LANG:coffeescript
 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)
     $('.photo #image_frame').bind 'mousemove touchmove', (e)=> @dragMove(e)
     $('.photo #image_frame').bind 'mouseup touchend', (e)=> @dragEnd(e)
     $('.photo #image_frame').bind 'touchcancel', (e)=> @dragCancel(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', '')   # dispose old image
     $('.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: ()->
   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: ()->
   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()
         result = @prevImage(true)
       else
         result = @nextImage()
         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

* rmagick は遅いので、他のライブラリを使った方がいいかもしれませんね [#k0ed2e6d]

こちらで紹介されている speedpedal というのを試してみたいと思っています。

https://www.kaeruspoon.net/articles/692

https://github.com/tsukasaoishi/speedpetal

* コメント&質問 [#e5f8b806]

#article_kcaptcha


Counter: 4242 (from 2010/06/03), today: 2, yesterday: 1