OBS用の行き先案内板シミュレータを作る

# いきなり成果物

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">OBS用の行き先案内板シミュレータできた～<br>※ 駅名は西鉄天神大牟田線より <a href="https://t.co/OmUpDOfunk">pic.twitter.com/OmUpDOfunk</a></p>&mdash; 国際トロッコ会社🏳️‍🌈 (@CIB_MC) <a href="https://twitter.com/CIB_MC/status/1510978449468698635?ref_src=twsrc%5Etfw">April 4, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 制作の経緯

駅にあるパタパタ式の行き先案内板、浪漫ががありますよね。
(日本国内には稼働中の現物は存在しないようですが。)

というわけでOBS用にJavaScriptで作ってみました。

# 成果物の概要

OBSのソース種別「ブラウザ」で読み込むように作成しました。
ソースとしてのプロパティは下記の設定で動作を確認しています。
幅: 1950
高さ: 800

Google FontsとjQueryをCDNから読み込むので、動作させるにはインターネット接続が必要です。
配信するならネット接続はあるはずだから、きっと大丈夫(？)

種別の選択はselectになっているのですがOBS上だとクリックしても選択肢が出ないので、クリックしてからキーボードの上下キーで値を変えて「設定」を押してください。

OBS上では操作パネルを画面外に追い出す形で配置してください。
初期状態は配信画面下側配置用に作っています。
なお「位置入替」ボタンを押すと操作パネルが上側に移動します。


# コードの主要部分の解説

技術的な解説もしますよ、Qiitaなので。
(コードだけほしい方はページ最下部まで飛ばしてください。)

## HTMLの骨組み

とりあえずHTML Canvasを用意します。
各フリップ窓ごとに「次の上パネル・今の上パネル・次の下パネル・今の下パネル」用のキャンバス計4つを用意しておくのがポイントです。

```html:行き先等の表示部分
<div class="clock">
	<div class="flip_window" id="train_type">
		<div class="top">
			<canvas class="flip next" width="700" height="150"></canvas>
			<canvas class="flip current" width="700" height="150"></canvas>
		</div>
		<div class="bottom">
			<canvas class="flip current" width="700" height="150"></canvas>
			<canvas class="flip next" width="700" height="150"></canvas>
		</div>
	</div>
	<div class="flip_window" id="destination">
		<div class="top">
			<canvas class="flip next" width="1200" height="150"></canvas>
			<canvas class="flip current" width="1200" height="150"></canvas>
		</div>
		<div class="bottom">
			<canvas class="flip current" width="1200" height="150"></canvas>
			<canvas class="flip next" width="1200" height="150"></canvas>
		</div>
	</div>
</div>
```

## フリップを回す

回しの進捗度合い(0～99の範囲)に応じてフリップを回す動きに相当する処理を行っています。

### 回し進捗前半(0～49)
上側の今のパネル： 進捗度合いに応じて下方向に圧縮
下側の次のパネル： とりあえず高さゼロになっててもらう。(鬼)

### 回し進捗後半(50～99)
上側の今のパネル： とりあえず高さゼロになっててもらう。(鬼その2)
下側の次のパネル： 進捗度合いに応じて下方向に伸ばす

```javascript: canvasの変形
if (progress < 50) {
	ctx_top_current.transform(1, 0, 0, (1 - (progress * 2 / 100)), 0, 150 * (progress * 2 / 100));
	ctx_bottom_next.transform(1, 0, 0, 0, 0, 0);
} else {
	ctx_top_current.transform(1, 0, 0, 0, 0, 0);
	ctx_bottom_next.transform(1, 0, 0, ((progress - 50) * 2 / 100) , 0, 0);
}
```

## フリップ回しを止める

止める際には`pos_dest`に表示したい項目の配列インデックスを指定させます。
`pos_dest`に負数の「-1」を指定すると永遠に回るようになっています。
ある意味、回っているのが正常な状態とも言えます。

```javascript:フリップ回し部分
let count_destination = 0;
let pos_dest = -1;

$('#set').on('click', function(){
	pos_dest = $('#dest').val();
});

setInterval(function(){
	if (count_destination != pos_dest) animateFlipDestination(destination[count_destination], destination[ count_destination = (count_destination + 1) % destination.length ], $('#destination'));
}, 300);
```

## コードの残りの部分

表示系がほとんどなのでつぶしが効きません。
よって解説はありません。(などと)



# 全体のコード(HTMLファイルの中身)

`train_type`と`destination`の中身を変えるとフリップの内容が変わります。
任意の名前でHTMLファイルとして保存してご利用ください。
ライセンスはMITです。

<details><summary>コードを表示する</summary><div>

```html:flip_display.html
<!DOCTYPE html>
<html>
<!--
MIT License

Copyright (c) 2022 CIB_MC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tenjin-Omuta Line</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500&display=swap" rel="stylesheet">
<style>

* {
	font-family: 'M PLUS Rounded 1c', sans-serif;
	box-sizing: content-box;
	margin: 0;
	padding: 0;
}

.container {
	display:flex;
	flex-direction:column;
	justify-content: space-between;
	height: 800px;
}

.clock {
	display: flex;
	background-color: transparent;
}

.flip_window {
	height 300px;
	margin-right: 20px;
}

.flip_window .top,
.flip_window .bottom {
	height: 150px;
	position: relative;
}

#train_type .top,
#train_type .bottom {
	height: 150px;
	width: 700px;
}

#destination .top,
#destination .bottom {
	height: 150px;
	width: 1200px;
}

.flip_window .top > * {
	bottom: 0;
	position: absolute;
}

.flip_window .bottom > * {
	top: 0;
	position: absolute;
}

.flip_window .top {
	margin-bottom: 7px;
	border-width: 7px 7px 0 7px;
	border-style: solid;
	border-color: gray;
}

.flip_window .bottom {
	border-width: 0 7px 7px 7px;
	border-style: solid;
	border-color: gray; 
}

.control {
	display:flex;
	justify-content: space-between;
	background-color: gray;
}

.control * {
	font-size: 50px;
}

.control .panel{
	margin: 10px 10px 10px 10px;
}

.control .panel:not(:last-child){
	margin-right: 50px;
}

</style>
</head>
<body>
<div class="container">
	<div class="clock">
		<div class="flip_window" id="train_type">
			<div class="top">
				<canvas class="flip next" width="700" height="150"></canvas>
				<canvas class="flip current" width="700" height="150"></canvas>
			</div>
			<div class="bottom">
				<canvas class="flip current" width="700" height="150"></canvas>
				<canvas class="flip next" width="700" height="150"></canvas>
			</div>
		</div>
		<div class="flip_window" id="destination">
			<div class="top">
				<canvas class="flip next" width="1200" height="150"></canvas>
				<canvas class="flip current" width="1200" height="150"></canvas>
			</div>
			<div class="bottom">
				<canvas class="flip current" width="1200" height="150"></canvas>
				<canvas class="flip next" width="1200" height="150"></canvas>
			</div>
		</div>
	</div>

	<div class="control">
		<div class="panel">
			<label for="type">種別</label>
			<select id="type" name="type">
				<option value="-1">== 無限回し ==</option>
			</select>
		</div>
		<div class="panel">
			<label for="dest">目的地</label>
			<select id="dest" name="dest">
				<option value="-1">== 無限回し ==</option>
			</select>
		</div>
		<div class="panel">
			<button id="set" type="button">設定する</button>
		</div>
		<div class="panel">
			<button id="toggle_control_pos" type="button">位置入替</button>
		</div>
	</div>
</div>

<script>
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

$(function(){

	const train_type = [
		{name: '普通', color: 'black', 'en': 'Local'},
		{name: '急行', color: 'blue', 'en': 'Express'},
		{name: '特急', color: 'red', 'en': 'Ltd. Exp.'},
		{name: '臨時列車', color: 'green', 'en': 'Extra'},
		{name: '試運転', color: 'green', 'en': 'Test run'},
		{name: '回送', color: 'dimgray', 'en': 'Out of service'}
	];
	
	const destination = [
		{name:'福岡(天神)',en: 'Fukuoka(Tenjin)', color: 'black'},
		{name:'薬院',en: 'Yakuin', color: 'black'},
		{name:'平尾',en: 'Hirao', color: 'black'},
		{name:'高宮',en: 'Takamiya', color: 'black'},
		{name:'大橋',en: 'Ohashi', color: 'black'},
		{name:'井尻',en: 'Ijiri', color: 'black'},
		{name:'雑餉隈',en: 'Zassyonokuma', color: 'black'},
		{name:'春日原',en: 'Kasugabaru', color: 'black'},
		{name:'白木原',en: 'Shirakubaru', color: 'black'},
		{name:'下大利',en: 'Shimoori', color: 'black'},
		{name:'都府楼前',en: 'Tofuro-Mae', color: 'black'},
		{name:'二日市',en: 'Futsukaichi', color: 'black'},
		{name:'紫',en: 'Murasaki', color: 'black'},
		{name:'朝倉街道',en: 'Asakura-Gaido', color: 'black'},
		{name:'桜台',en: 'Sakuradai', color: 'black'},
		{name:'筑紫',en: 'Chikusi', color: 'black'},
		{name:'津古',en: 'Tsuko', color: 'black'},
		{name:'三国が丘',en: 'Mikunigaoka', color: 'black'},
		{name:'三沢',en: 'Mitsusawa', color: 'black'},
		{name:'大保',en: 'Oho', color: 'black'},
		{name:'小郡',en: 'Orgori', color: 'black'},
		{name:'端間',en: 'Hatama', color: 'black'},
		{name:'味坂',en: 'Ajisaka', color: 'black'},
		{name:'宮の陣',en: 'Miyanojin', color: 'black'},
		{name:'櫛原',en: 'Kushiwara', color: 'black'},
		{name:'久留米',en: 'Kurume', color: 'black'},
		{name:'花畑',en: 'Hanabatake', color: 'black'},
		{name:'試験場前',en: 'Shikenjo-Mae', color: 'black'},
		{name:'津福',en: 'Tsubuku', color: 'black'},
		{name:'安武',en: 'Yasutake', color: 'black'},
		{name:'大善寺',en: 'Daizenji', color: 'black'},
		{name:'三潴',en: 'Mizuma', color: 'black'},
		{name:'犬塚',en: 'Inuzuka', color: 'black'},
		{name:'大溝',en: 'Omizo', color: 'black'},
		{name:'八丁牟田',en: 'Hacchomuta', color: 'black'},
		{name:'蒲池',en: 'Kamachi', color: 'black'},
		{name:'矢加部',en: 'Yakabe', color: 'black'},
		{name:'柳川',en: 'Yanagawa', color: 'black'},
		{name:'徳益',en: 'Tokumasu', color: 'black'},
		{name:'塩塚',en: 'Shiotsuka', color: 'black'},
		{name:'中島',en: 'Nakashima', color: 'black'},
		{name:'江の浦',en: 'Enoura', color: 'black'},
		{name:'開',en: 'Hiraki', color: 'black'},
		{name:'渡瀬',en: 'Wataze', color: 'black'},
		{name:'倉永',en: 'Kuranaga', color: 'black'},
		{name:'東甘木',en: 'Higashi-amagi', color: 'black'},
		{name:'銀水',en: 'Ginsui', color: 'black'},
		{name:'新栄町',en: 'Shin-sakaemachi', color: 'black'},
		{name:'大牟田',en: 'Omuta', color: 'black'}
	];
	
	let count = 0;
	let count_destination = 0;
	
	let pos_type = -1;
	let pos_dest = -1;
	
	$.each(train_type, function(index, val){
		$('#type').append('<option value=" ' + index + '">' + val.name + '</option>');
	});
	
	$.each(destination, function(index, val){
		$('#dest').append('<option value=" ' + index + '">' + val.name + '</option>');
	});
	
	animateFlipType(train_type[count], train_type[ count = (count + 1) % train_type.length ], $('#train_type'));
	animateFlipDestination(destination[count_destination], destination[ count_destination = (count_destination + 1) % destination.length ], $('#destination'));
	setInterval(function(){
		if (count != pos_type) animateFlipType(train_type[count], train_type[ count = (count + 1) % train_type.length ], $('#train_type'));
	}, 300);
	
	setInterval(function(){
		if (count_destination != pos_dest) animateFlipDestination(destination[count_destination], destination[ count_destination = (count_destination + 1) % destination.length ], $('#destination'));
	}, 300);
	
	$('#set').on('click', function(){
		pos_type = $('#type').val();
		pos_dest = $('#dest').val();
	});
	
	$('#toggle_control_pos').on('click', function(){
		let direction_current = $('.container').css('flex-direction');
		if (direction_current == 'column') {
			$('.container').css('flex-direction', 'column-reverse');
		} else {
			$('.container').css('flex-direction', 'column');
		}
	});

	async function animateFlipType(train_type_current, train_type_next, root_jqobj) {
		
		for(let progress = 0; progress < 100; progress += 7) {
			drawFlipsType(train_type_current, train_type_next, progress, root_jqobj);
			await _sleep(12);
		}
		drawFlipsType(train_type_next, {name: '', color: 'white'}, 0, root_jqobj);
	}
	
	async function animateFlipDestination(hash_current, hash_next, root_jqobj) {
		for(let progress_ap = 0; progress_ap < 100; progress_ap += 7) {
			drawFlipsDestination(hash_current, hash_next, progress_ap, root_jqobj);
			await _sleep(12);
		}
		drawFlipsDestination(hash_next, {name: '', color: 'white'}, 0, root_jqobj);
	}
	
	function drawFlipsType(train_type_current, train_type_next, progress, root_jqobj) {
		let ctx_top_next = root_jqobj.find('.top > .flip.next')[0].getContext("2d");
		let ctx_top_current = root_jqobj.find('.top > .flip.current')[0].getContext("2d");
		let ctx_bottom_current = root_jqobj.find('.bottom > .flip.current')[0].getContext("2d");
		let ctx_bottom_next = root_jqobj.find('.bottom > .flip.next')[0].getContext("2d");
		
		let gradient_top_next = ctx_top_next.createLinearGradient(0, 0, 0, 150);
		gradient_top_next.addColorStop(0, 'dimgray');
		gradient_top_next.addColorStop(0.8, 'white');
		
		let gradient_top_current = ctx_top_current.createLinearGradient(0, 0, 0, 150);
		gradient_top_current.addColorStop(0, 'dimgray');
		gradient_top_current.addColorStop(0.8, 'white');
		
		let gradient_bottom_next = ctx_bottom_next.createLinearGradient(0, 0, 0, 150);
		gradient_bottom_next.addColorStop(0, 'dimgray');
		gradient_bottom_next.addColorStop(0.8, 'white');

		ctx_top_current.setTransform(1,0,0,1,0,0);
		ctx_top_current.clearRect(0, 0, 1200, 150);
		ctx_bottom_next.setTransform(1,0,0,1,0,0);
		ctx_bottom_next.clearRect(0, 0, 1200, 150);

		if (progress < 50) {
			ctx_top_current.transform(1, 0, 0, (1 - (progress * 2 / 100)), 0, 150 * (progress * 2 / 100));
			ctx_bottom_next.transform(1, 0, 0, 0, 0, 0);
		} else {
			ctx_top_current.transform(1, 0, 0, 0, 0, 0);
			ctx_bottom_next.transform(1, 0, 0, ((progress - 50) * 2 / 100) , 0, 0);
		}

		ctx_top_next.fillStyle = gradient_top_next
		ctx_top_next.fillRect(0, 0, 1200, 150);
		ctx_top_next.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_top_next.fillStyle = train_type_next.color;
		ctx_top_next.textAlign = "center";
		ctx_top_next.fillText(train_type_next.name,350,180);

		ctx_top_current.fillStyle = gradient_top_current;
		ctx_top_current.fillRect(0, 0, 1200, 150);
		ctx_top_current.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_top_current.fillStyle = train_type_current.color;
		ctx_top_current.textAlign = "center";
		ctx_top_current.fillText(train_type_current.name,350, 180);

		ctx_bottom_current.fillStyle = "white";
		ctx_bottom_current.fillRect(0, 0, 1200, 150);
		ctx_bottom_current.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_bottom_current.fillStyle = train_type_current.color;
		ctx_bottom_current.textAlign = "center";
		ctx_bottom_current.fillText(train_type_current.name,350,28);

		ctx_bottom_current.font = "normal 50px 'M PLUS Rounded 1c'";
		ctx_bottom_current.fillStyle = train_type_current.color;
		ctx_bottom_current.textAlign = "center";
		ctx_bottom_current.fillText(train_type_current.en,350,110);
		
		ctx_bottom_next.fillStyle = "white";
		ctx_bottom_next.shadowColor = 'gray';
		ctx_bottom_next.shadowOffsetY = 20;
		ctx_bottom_next.shadowBlur = 5; 
		ctx_bottom_next.fillRect(0, 0, 1200, 150);
		ctx_bottom_next.shadowColor = 'transparent';
		ctx_bottom_next.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_bottom_next.fillStyle = train_type_next.color;
		ctx_bottom_next.textAlign = "center";
		ctx_bottom_next.fillText(train_type_next.name,350,28);

		ctx_bottom_next.font = "normal 50px 'M PLUS Rounded 1c'";
		ctx_bottom_next.fillStyle = train_type_next.color;
		ctx_bottom_next.textAlign = "center";
		ctx_bottom_next.fillText(train_type_next.en,350,110);

	}
	
	function drawFlipsDestination(hash_current, hash_next, progress, root_jqobj) {
		let ctx_top_next = root_jqobj.find('.top > .flip.next')[0].getContext("2d");
		let ctx_top_current = root_jqobj.find('.top > .flip.current')[0].getContext("2d");
		let ctx_bottom_current = root_jqobj.find('.bottom > .flip.current')[0].getContext("2d");
		let ctx_bottom_next = root_jqobj.find('.bottom > .flip.next')[0].getContext("2d");
		
		let gradient_top_next = ctx_top_next.createLinearGradient(0, 0, 0, 150);
		gradient_top_next.addColorStop(0, 'dimgray');
		gradient_top_next.addColorStop(0.8, 'white');
		
		let gradient_top_current = ctx_top_current.createLinearGradient(0, 0, 0, 150);
		gradient_top_current.addColorStop(0, 'dimgray');
		gradient_top_current.addColorStop(0.8, 'white');
		
		let gradient_bottom_next = ctx_bottom_next.createLinearGradient(0, 0, 0, 150);
		gradient_bottom_next.addColorStop(0, 'dimgray');
		gradient_bottom_next.addColorStop(0.8, 'white');

		ctx_top_current.setTransform(1,0,0,1,0,0);
		ctx_top_current.clearRect(0, 0, 1200, 150);
		ctx_bottom_next.setTransform(1,0,0,1,0,0);
		ctx_bottom_next.clearRect(0, 0, 1200, 150);
		if (progress < 50) {
			ctx_top_current.transform(1, 0, 0, (1 - (progress * 2 / 100)), 0, 150 * (progress * 2 / 100));
			ctx_bottom_next.transform(1, 0, 0, 0, 0, 0);
		} else {
			ctx_top_current.transform(1, 0, 0, 0, 0, 0);
			ctx_bottom_next.transform(1, 0, 0, ((progress - 50) * 2 / 100) , 0, 0);
		}
		
		
		ctx_top_next.fillStyle = gradient_top_next
		ctx_top_next.fillRect(0, 0, 1200, 150);
		ctx_top_next.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_top_next.fillStyle = hash_next.color;
		ctx_top_next.textAlign = "left";
		ctx_top_next.fillText(hash_next.name,100,180);

		ctx_top_current.fillStyle = gradient_top_current;
		ctx_top_current.fillRect(0, 0, 1200, 150);
		ctx_top_current.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_top_current.fillStyle = hash_current.color;
		ctx_top_current.textAlign = "left";
		ctx_top_current.fillText(hash_current.name,100, 180);

		ctx_bottom_current.fillStyle = "white";
		ctx_bottom_current.fillRect(0, 0, 1200, 150);
		ctx_bottom_current.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_bottom_current.fillStyle = hash_current.color;
		ctx_bottom_current.textAlign = "left";
		ctx_bottom_current.fillText(hash_current.name,100,28);

		ctx_bottom_current.font = "normal 50px 'M PLUS Rounded 1c'";
		ctx_bottom_current.fillStyle = hash_current.color;
		ctx_bottom_current.textAlign = "left";
		ctx_bottom_current.fillText(hash_current.en,100,110);
		
		ctx_bottom_next.fillStyle = "white";
		ctx_bottom_next.shadowColor = 'gray';
		ctx_bottom_next.shadowOffsetY = 20;
		ctx_bottom_next.shadowBlur = 5; 
		ctx_bottom_next.fillRect(0, 0, 1200, 150);
		ctx_bottom_next.shadowColor = 'transparent';
		ctx_bottom_next.font = "normal 140px 'M PLUS Rounded 1c'";
		ctx_bottom_next.fillStyle = hash_next.color;
		ctx_bottom_next.textAlign = "left";
		ctx_bottom_next.fillText(hash_next.name,100,28);

		ctx_bottom_next.font = "normal 50px 'M PLUS Rounded 1c'";
		ctx_bottom_next.fillStyle = hash_next.color;
		ctx_bottom_next.textAlign = "left";
		ctx_bottom_next.fillText(hash_next.en,100,110);
	}
	
	function add_zero(str, digi) {
		return ('0'.repeat(digi) + str).slice(digi * -1);
	}
});
</script>
</body>
</html>
```
</div></details>

# さいごに

楽しい配信ライフを！