勉強会でAppleの基調講演みたいなスライド作るな
学生LTを名古屋で開いているSigmaです。学生LTとはその名の通り学生のLT会で、5分のLTを多くの人がする、発表出来る枠の多い勉強会です。
そんなことをやっていることもあり、「良いスライドとは?」ということを考えることが多いのですが、素晴らしいスライドと言えばご存知スティーブ・ジョブズ、ということで、スティーブ・ジョブズ驚異のプレゼン(日経BP)を読みました。
結論から言うと、勉強会におけるスライドのプラクティスと基調講演におけるそれは違って当たり前だと感じました。我々はものを売っているわけではないですし、参加者は学ぶ気のある人たちです。何もかも状況が違いすぎます。
Appleの基調講演が見事であることは確かにそうですが、勉強会におけるスライドのベストプラクティスはこれから見つけて行かなきゃいけないものなのでは無いかと感じました。
スライドツールをもう一度考え直す
勉強会におけるスライドのルーツは、おそらくスライド・プロジェクターまで遡れます。安価で鮮明に映写でき、写真のフィルムを流用出来るスライド・プロジェクターは、教育の場でも多用されたようです。ソースはWikipediaなので、このことに関して参考文献があれば是非紹介して欲しいです。
留意すべきことは、スライドショーを作るPowerPointなどのツールよりも前から教育向けにスライドショーが使われていたことです。PowerPointやKeynoteが製品のプレゼンテーションに最適化されたツールなのであれば、教育向けスライドショーツールは、未だ存在していないことになります。
教育向けだと広いので、エンジニアが勉強会で使いたくなるツールの特徴を考えてみました。
- Markdownっぽく書ける。(Vimで書きたいので)
- 共有する環境がある。
後から気づきましたが、大体Qiitaスライドです。
なにはともあれ、作った
なにはともあれ、スライドツールを作りました。最初に決めたのはスライドを表現するJSONです。余談ですが、オブジェクト指向プログラミングやリソース指向アーキテクチャなどのコアになる考え方はインターフェースから決めて見通しよく開発しろということだと考えています。
{
"slide_0": {
"step_0": {
"text_0": {
"textClass": "slide",
"text": "# LT会は製品発表会じゃ無い件\n\n### by Sigma\n### Sigmaとは全く新しいタイプの人間です。最新のテクノロジーによってプロダクティビティを最大28%向上させます(†1)。日々変化するビジネス環境の中で、Sigmaは強い味方になります。ビジネス環境は日々変化が激しいですが、これは日々全く新しいバリューが生み出されていると換言できます。その中で発生するオポチュニティにコミットすることで、マクロ的にサステイナブルなビジネスモデルを構築するために、あなたもSigmaにジョインしましょう。\n\n†1 当社比\n"
}
}
},
"slide_1": {
"step_0": {
"text_0": {
"textClass": "slide",
"text": "スライドの書き方について\n===\n"
}
},
"step_1": {
"text_0": {
"textClass": "slide",
"text": "### Appleの基調講演が手本? アホくさ\n"
}
},
"step_2": {
"text_0": {
"textClass": "slide",
"text": "### モノ売ってるわけじゃないんだが\n"
},
"text_1": {
"textClass": "note",
"text": "勉強会に参加するエンジニアは**説明**がしたいと考えている。顧客に製品を売り込みたいわけではなく、状況が異なる。異なる状況に同じプラクティスは当てはまらない。\n"
},
"text_2": {
"textClass": "slide",
"text": ""
}
}
},
...続く
slideの中にstepを定義し、その中にテキストを突っ込む形を考えました。テキストにはnoteクラスやslide本体のクラスがある形で定義し、その中にMarkdownを突っ込みました。
パース時にMarkdownをHTMLにパースしてしまうか悩みましたが、Markdonwそのものがマークアップのはずなので、Markdownで持つべきだと判断しました。
文法は、改スライドに===
、次ステップに===!
、ノートを+++
で囲む形で定義しました。以下の通りに書けます。
# こんな感じにスライドを書きたい
## タイトルとサブタイトルはこう
===========================================
スライドのタイトルはこんな感じ
===========================================
スライドの内容はこんな感じ
===========================================
スライドの制御はクリックされたら表示されるやつだけ
===========================================
===!
* クリックする毎に表示される
===!
* これだけ
===!
* 増やすと可読性が下がるので
===========================================
スライドで無視されるノート
===========================================
スライドかつドキュメント
+++++++++++++++++++++++++++++++++++++++++++
これはスライドかつドキュメントなので、こんな感じのノート機能が必要。スライドはスッキリしてるべきだし、かといってスカスカなドキュメントじゃダメなので。
+++++++++++++++++++++++++++++++++++++++++++
このあたりまで定義した時点で、前述のスティーブ・ジョブズ驚異のプレゼン(日経BP)でアンチパターンとして紹介されていた「スライデュメント」をテーマにしていくことを決めました。単一ファイルからスライドとドキュメントを同時生成し、スライドにある内容についてのドキュメントが手元で見れる状態を、出来る限り手間をかけずに作れるプラットフォームが現在思い描いている形です。
適当にパーサを書きました。
import re
def splitIntoSlides(mdSlides):
return re.compile('^\n+^===+$\n^\n*',re.M).split(mdSlides)
def splitIntoSteps(slide):
return re.compile('^\n*^===+!$\n^\n*', re.M).split(slide)
def escapeNote(steppedSlide):
return re.compile('^\n*^\+\+\++$\n^\n*',re.M).split(steppedSlide)
def findDocsIndex(docs):
index = []
for sectionTitle in re.compile('(?<=^===$\n)(^.+$\n)+(?=^===$)',re.M).finditer(docs):
index.append(sectionTitle.group())
return index
def plain(name):
with open(name,"r") as md:
mdDocs = md.read()
docsIndex = findDocsIndex(mdDocs)
return docsIndex, mdDocs
def parse(name):
with open(name,"r") as md:
mdSlides = md.read()
slides = splitIntoSlides(mdSlides)
steppedSlides = map(splitIntoSteps, slides)
escaped = map(lambda x:map(escapeNote, x), steppedSlides)
slideDict = {}
for index, slide in enumerate(escaped):
slideIndex = "slide_"+str(index)
slideDict[slideIndex] = {}
for index, step in enumerate(slide):
stepIndex = "step_"+str(index)
slideDict[slideIndex][stepIndex] = {}
for index, text in enumerate(step):
textIndex = "text_"+str(index)
if index%2 == 0:
slideDict[slideIndex][stepIndex][textIndex] = {"textClass":"slide", "text":text}
else:
slideDict[slideIndex][stepIndex][textIndex] = {"textClass":"note", "text":text}
return slideDict
データに対して階層的にマップしていく形です。テストもへったくれも無いパーサなので、時間があればテストを書いてバグを発見して色々書き直すと思います。とりあえず動きました。
これで、上のMarkdownをその上のJSONに書き換えるまでが出来ました。
JSONを受け取るやつを書いていきます。クライアントサイドというやつです。Nuxt.jsのスターターテンプレートに以下のVueコンポーネントを書きました。Markdownのパーサにvue-markdownをシンタックスハイライトにprismを使っています。
<template>
<div id="app">
<div id="next" v-on:click="nextStep()"/>
<div id="prev" v-on:click="prevStep()"/>
<transition appear name="slideTransition">
<div class="slideFrame" v-show="showSlide">
<div v-for="step in stepNum+1" :key="step">
<div class="slideText" v-for="textId in textLength(step)" :key="textId">
<transition appear name="stepTransition">
<div v-show="showStep[step-1]">
<vue-markdown
v-show="slide[slideId]['step_'+String(step-1)]['text_'+String(textId-1)]['textClass'] == 'slide'"
:source="slide[slideId]['step_'+String(step-1)]['text_'+String(textId-1)]['text']"/>
</div>
</transition>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import VueMarkdown from 'vue-markdown'
import 'prismjs'
import 'prismjs/components/prism-python.min.js'
import axios from 'axios'
const API_URLS = {
get_slide: 'http://localhost:3333/slide/markdown-slide.md'
}
export default {
name: 'App',
async fetch({store, prames}){
let {data} = await axios.get(API_URLS.get_slide);
store.commit('slide/set', data);
},
data() {
return {
slideNum: 0,
stepNum: 0,
showSlide: true,
showStep: [],
}
},
updated() {
Prism.highlightAll();
},
computed: {
slide(){
return this.$store.state.slide.slide;
},
slideId(){
return "slide_" + String(this.slideNum);
},
stepId(){
return "step_" + String(this.stepNum);
},
nextSlideId(){
return "slide_" + String(this.slideNum + 1);
},
nextStepId(){
return "step_" + String(this.stepNum + 1);
},
prevSlideId(){
return "slide_" + String(this.slideNum - 1);
},
prevStepId(){
return "step_" + String(this.stepNum - 1);
},
stepLength(){
return Object.keys(this.slide[this.slideId]).length;
}
},
created(){
this.initShowStep();
},
methods: {
initShowStep(){
this.showStep = Array.apply(
null, Array(this.stepLength)
).map(function(){
return false
});
this.showStep[0] = true;
},
nextStep(){
if (this.slide[this.slideId][this.nextStepId]){
this.showStep[this.stepNum + 1] = false;
setTimeout(() => {
this.stepNum += 1;
this.showStep[this.stepNum] = true;
},300);
}else if (this.slide[this.nextSlideId]){
this.showSlide = false;
this.stepNum = 0;
setTimeout(() => {
this.slideNum += 1;
this.showSlide = true;
this.initShowStep;
},300);
}
setTimeout(Prism.highlightAll(),500);
},
prevStep(){
if (this.slide[this.slideId][this.prevStepId]){
this.stepNum -= 1;
}else if (this.slide[this.prevSlideId]){
this.stepNum = 0
this.slideNum -= 1;
}
},
textLength(step){
if (this.slide[this.slideId]["step_"+String(step-1)]){
return Object.keys(this.slide[this.slideId]["step_"+String(step-1)]).length;
}else{
return 0;
}
}
},
components: {
VueMarkdown
}
}
</script>
<style>
@import 'prismjs/themes/prism.css';
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
margin-left: 4rem;
margin-right: 4rem;
padding: 2rem;
background-color: #ccc;
}
#next {
position: absolute;
z-index: 1;
right: 1rem;
border-left: 2rem solid black;
border-top: 2rem solid transparent;
border-bottom: 2rem solid transparent;
}
#prev {
position: absolute;
z-index: 1;
left: 1rem;
border-top: 2rem solid transparent;
border-right: 2rem solid black;
border-bottom: 2rem solid transparent;
}
html{font-size: 20px}
.slideFrame
{
}
.slideText h1
{
font-size: 4rem;
margin-left: -1rem;
padding-bottom: 2rem;
}
.slideText h2,
.slideText h3
{
font-size: 2.4rem;
padding-top: 0.5rem;
padding-bottom: 1rem;
}
.slideText h4,
.slideText h5,
.slideText h6
{
font-size: 2rem;
padding-top: 0.25rem;
padding-bottom: 0.5rem;
}
.slideText > div > div > p,
.slideText > div > div > pre,
.slideText > div > div > ul > *,
.slideText > div > div > ol > *
{
font-size: 2rem;
padding-top: 0.25rem;
padding-bottom: 0.5rem;
}
.slideText > div > div > ul > li > ul > *,
.slideText > div > div > ol > li > ul > *,
.slideText > div > div > ul > li > ol > *,
.slideText > div > div > ol > li > ol > *
{
font-size: 1.6rem;
padding-top: 0.25rem;
padding-bottom: 0.5rem;
}
.slideTransition-enter-active,
.stepTransition-enter-active
{
transition: opacity 1s;
}
.slideTransition-leave-active,
.stepTransition-leave-active
{
}
.slideTransition-enter,
.stepTransition-enter
{
opacity: 0;
}
.slideTransition-leave-to,
.stepTransition-leave-to
{
}
</style>
vueはtransitionが扱いやすく、アニメーションの制御が簡単に書けたと思います。
最後にエンドポイントlocalhost:3333/slide/
をbottleで適当に用意して完成です。
from bottle import route, run
from parse import parse
import json
@route('/slide/:name')
def getParsedSlide(name='sample.md'):
slide = parse(name)
return json.dumps(slide, indent=2, ensure_ascii=False)
run(host='localhost', port=3333)
このスライドツールで学生LTに登壇した時のアーカイブが以下です。
自分で自分のLT見るの辛い…