ICSPM22のアルバムページ の変更点
更新- 追加された行はこの色です。
- 削除された行はこの色です。
- ソフトウェア/rails/ICSPM22のアルバムページ へ行く。
- ソフトウェア/rails/ICSPM22のアルバムページ の差分を削除
[[公開メモ]]
#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/(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
$ 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
.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 スプライトを使って表示する [#g62f2c52]
件のページではサムネール画像の数が多いので、
上記のように普通に表示するとネットワークへの負荷が高まり、
レスポンスが低下してしまいます。
そこで、サムネール画像を複数横に繋げたファイルを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("#{sprite}") -#{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#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 イベントも割り当てています。
* 全画面表示を解除する [#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)
$(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(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(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 [#he19ca4b]
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(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("#{sprite}") -#{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
* rmagick は遅いので、他のライブラリを使った方がいいかもしれませんね [#k0ed2e6d]
こちらで紹介されている speedpedal というのを試してみたいと思っています。
https://www.kaeruspoon.net/articles/692
https://github.com/tsukasaoishi/speedpetal
* コメント&質問 [#e5f8b806]
#article_kcaptcha
Counter: 4892 (from 2010/06/03),
today: 1,
yesterday: 1