「感染を封じこめる」をJavaScriptとHTML(SVG, canvas)を使って可視化してみた

プログラマとして、なにかできることはないのか、、、。
リアルなウイルスに関して、無力感を感じていたところに、以下のエントリに出会った。

https://www.washingtonpost.com/graphics/2020/world/corona-simulator/

なるほど、こうやって可視化することで、パニックにならず冷静な対応をすることができるわけか、、、

このようなコードなら、プログラマには朝飯前？　（これは、普段からあまり表には出てこない、プログラマという職業が、みなさまのお役にたてるチャンスかもしれないじゃないですか。）

そして、半日後にできたコードがこちらです。お納めください。
![2020-03-16-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107592/1c85bd46-835f-ab94-b525-5ab215e1b255.gif)


## 「感染を封じ込める」を可視化する

「START」ボタンを押すと、入力した条件で、どのくらい感染を封じ込められるのか（広がるのか）がシミュレートされます。
そして、いろいろ数値を変えて実行してみることで、どのパラメータが感染に効果があるのかもわかってきます。

<p class="codepen" data-height="770" data-theme-id="light" data-default-tab="result" data-user="yamazaki3104" data-slug-hash="VwLxvdY" style="height: 770px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="I tried to visualize the infection">
  <span>See the Pen <a href="https://codepen.io/yamazaki3104/pen/VwLxvdY">
  I tried to visualize the infection</a> by yamazaki.3104 (<a href="https://codepen.io/yamazaki3104">@yamazaki3104</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

初期条件では、おそらく収束するまでに 80% 近い感染者数になると思います。
トータルの感染者数を減らすには、

・移動速度を減らす → movespeed
・素早く治療する（もしくは閉じこもる） → cure_days
・接触しない（手を洗う、マスクをする？） → range

どれも効きますが、どれが効果があると思いますか？
ここに結論を書くよりも、みなさまで実際に試してみて、なにが効果的なのか各自で感じるとよいと思います。（わたしなりの結論は、このエントリの最後に書きます。）

また、コードもすべて載せておきますので、CodePenからでも、このページからでもご自由にご利用ください。ただし、`このコードが正しく動作しているかどうかは、まったく保証できないし、計算された数値をどう解釈するかは各自の責任の範囲で使ってください`。

「こうしたほうがいいよ」とか「こういう条件も追加してシミュレートしてみたいのだけど、、、」といったコメントも歓迎しています。（ぶっちゃけ、プログラマは「すげー！」とか「ありがとう！」という言葉に飢えていると思います。）

## 全コード

```HTML
<html>
<body>
<script>
// このコードが正しく動作しているかどうかは、まったく保証できないし、計算された数値をどう解釈するかは各自の責任の範囲で使ってください

// Dashboard.mini.js 2020-06-23
const Dashboard={build_timestamp:"2020-06-23",table:[],bind:t=>{for(let i in t.view){if("[object Function]"!=toString.call(t.view[i]))continue;const e=document.querySelector(i);null!=e&&(e.innerHTML="")}return t.backup=null,t.H={},t.simple_validator="SimpleValidator"in this&&"validation_rule"in t?new SimpleValidator({object:t.validation_rule}):null,Dashboard.table.push(t),t.data},D:()=>{class t{constructor(t,i,e=null){this.T=null,this.X=e,this.C=null,this.N=[],this.S=null,this.P=i,this.A={},this.E={},this.update(t)}I(){this.N=[],this.A={},this.T=null,this.C=null,this.E={},this.P.removeChild(this.S),this.S=null}check_E(){if(null!=this.S){for(const t in this.E){if("on_resize"!=t)continue;const i=`w: ${this.S.offsetWidth}, h: ${this.S.offsetHeight}`;this.E[t].size!=i&&this.E[t].ev_func(this.S),this.E[t].size=i}for(const t of this.N)t.check_E()}}update(i){if("[object Object]"==toString.call(i)){const e={};for(let t of i.atr)for(let i in t)"on_resize"==i?"[object Function]"==toString.call(t[i])&&(this.E[i]={ev_func:t[i],size:""}):e[i]=t[i];const s=i.tag;if(this.T!=s){for(const t of this.N)t.I();null!=this.S&&this.P.removeChild(this.S),""==s?this.S=this.P:("svg"==s&&(this.X="xmlns"in e?e.xmlns:"http://www.w3.org/2000/svg"),"div"==s&&(this.X=null),null==this.X?this.S=document.createElement(s):this.S=document.createElementNS(this.X,s),this.P.appendChild(this.S)),this.T=s,this.C=null,this.A={},this.N=[]}for(const t in this.A)t in e||this.S.removeAttribute(t);for(const t in e)if("[object Function]"==toString.call(e[t]))this.S[t]=(i=>e[t](i));else if(this.S.hasAttribute(t))"input"==s&&"value"==t?this.S.value=e[t]:this.S.setAttribute(t,e[t]);else{const i=document.createAttribute(t);i.value=e[t],this.S.setAttributeNode(i)}if(this.A=e,this.N.length>i.cts.length)for(let t=this.N.length-1;t>=i.cts.length;t--)this.N[t].I(),this.N.pop();for(const e in i.cts)e<this.N.length?this.N[e].update(i.cts[e]):this.N.push(new t(i.cts[e],this.S,this.X))}else null!=this.T&&this.I(),this.C!=i&&(null!=this.S?this.S.data=i:(this.S=document.createTextNode(i),this.P.appendChild(this.S),this.T=null),this.C=i)}}const i=t=>{if("[object Array]"!=toString.call(t))return[];if(t.length<1)return[];let e=[],s=[];for(const n of t)if("[object Object]"==toString.call(n))e.push(n);else if("[object Array]"==toString.call(n))for(const t of i(n))s.push(t);else s.push(n);return s.length<1?[]:"[object Object]"==toString.call(s[0])?s:[{tag:s[0],atr:e,cts:s.slice(1)}]};for(const e of Dashboard.table){for(let t in e.view)if(t in e.H)for(const i of e.H[t])i.check_E();let s="";const n=t=>{if("[object Object]"!=toString.call(t))s+=JSON.stringify(t);else for(const i in t)"ignore"!=i&&(s+=i,n(t[i]))};if(n(e.data),e.backup!=s){e.validation=null,null!==e.simple_validator&&(e.validation=e.simple_validator.validate(e.data),e.validation.validation_rule=e.validation_rule),e.backup=s;for(let s in e.view){if("[object Function]"!=toString.call(e.view[s]))continue;const n=document.querySelector(s);if(null==n)continue;const l=i(e.view[s](e.data,e.validation));s in e.H||(e.H[s]=[]);const o=e.H[s];if(o.length>l.length)for(let t=o.length-1;t>=l.length;t--)o[t].I(),o.pop();for(const i in l)i<o.length?o[i].update(l[i]):o.push(new t(l[i],n))}}}window.requestAnimationFrame(Dashboard.D)}};window.requestAnimationFrame(Dashboard.D),window.addEventListener("resize",Dashboard.D);
</script>

<div id='div_ui'  class='Dashboard' ></div>
<div id='div_svg' class='Dashboard' ></div>
<div id='result'  class='Dashboard' ></div>
<canvas id='chart' width='555px' height='100px'></canvas>
<script>window.onload = ()=>{

class Point {
    constructor() {
        this.x = Math.random() * AREA_W
        this.y = Math.random() * AREA_H
        this.movespeed_x = (Math.random() - 0.5)
        this.movespeed_y = (Math.random() - 0.5)

        this.stat = 'nomal'
        this.elapsed_date = -1  // 経過日数
    }

    move() {    // 移動
        this.x += this.movespeed_x * ui.movespeed
        this.y += this.movespeed_y * ui.movespeed

        if ( this.x < 0 ) { this.x *= -1 ; this.movespeed_x *= -1 }
        if ( this.y < 0 ) { this.y *= -1 ; this.movespeed_y *= -1 }
        if ( this.x >= AREA_W ) { this.x = AREA_W - (AREA_W - this.x); this.movespeed_x *= -1 }
        if ( this.y >= AREA_H ) { this.y = AREA_H - (AREA_H - this.y); this.movespeed_y *= -1 }
    }

    check( _a ) {   // 確認
        if ( this.stat != 'have' ) return

        this.elapsed_date++ // 経過日数
        if ( this.elapsed_date > ui.complete_cure_days ) {
            this.stat = 'CompleteCure'
            return
        }

        const r = ui.range
        for ( const a of _a )
        {
            const rx = this.x - a.x
            if ( rx >  r ) continue
            if ( rx < -r ) continue
            const ry = this.y - a.y
            if ( ry >  r ) continue
            if ( ry < -r ) continue

            if ( rx * rx + ry * ry > r * r ) continue

            if ( a.stat == 'nomal' ) {
                a.stat = 'have'
                a.elapsed_date = 0
            }
        }
    }
}

const ui = Dashboard.bind(
{
    data: { people: 1000, movespeed: 10, complete_cure_days: 14, range: 10 },
    view: {
        'div#div_ui.Dashboard': ( _d ) => [
            [ 'div', 'people   : ', [ 'input', { type:'number', value: _d.people,    oninput: _el=>_d.people    = _el.target.value } ] ],
            [ 'div', 'movespeed: ', [ 'input', { type:'number', value: _d.movespeed, oninput: _el=>_d.movespeed = _el.target.value } ] ],
            [ 'div', 'cure_days: ', [ 'input', { type:'number', value: _d.complete_cure_days, oninput: _el=>_d.complete_cure_days = _el.target.value } ] ],
            [ 'div', 'range    : ', [ 'input', { type:'number', value: _d.range, oninput: _el=> _d.range = _el.target.value } ] ],
            [ 'button', 'START', { onclick: _el=>start_sim( _d.people ) } ],
        ],
    }
} )

const percent = (_p) => { return parseInt( _p / ui.people * 1000 ) / 10 }
const result = Dashboard.bind(
{
    data: { nomal_cnt: 0, have_cnt: 0, cure_cnt: 0, day: 0 },
    view: {
        'div#result.Dashboard': ( _d ) => [
            [ 'div', `nomal: ${ _d.nomal_cnt}, ${ percent(_d.nomal_cnt) }%` ],
            [ 'div', `have : ${ _d.have_cnt }, ${ percent(_d.have_cnt ) }%` ],
            [ 'div', `cure : ${ _d.cure_cnt }, ${ percent(_d.cure_cnt ) }%` ],
            [ 'div', `day  : ${ _d.day }` ],
        ]
    }
} )

const AREA_W = 555
const AREA_H = 333

const div_svg = Dashboard.bind(
{
    data: { p: [] },
    view: {
        'div#div_svg.Dashboard': (_d) => [
            [ 'svg', { width: AREA_W+5*2, height: AREA_H+5*2, stroke: "#111", fill: "#ddd" },
                [ 'rect',   { x: 0, y: 0, width: AREA_W+5*2, height: AREA_H+5*2, } ],
                _d.p.map( (_it)=>
                    [ 'circle', {  cx: _it.x+5, cy: _it.y+5, r: '5px',
                     fill: _it.stat=='nomal'?'hsl(95, 46%, 75%)':_it.stat=='have'?'red':'hsl(300, 41%, 59%)' } ]
                )
            ]
        ]
    }
} )

const animation = () => {
    for ( let p of div_svg.p )
        p.move()

    for ( let p of div_svg.p )
        p.check( div_svg.p )

    let nomal_cnt = 0
    let have_cnt = 0
    let cure_cnt = 0
    for ( let p of div_svg.p )
    {
        if      ( p.stat == 'nomal' ) nomal_cnt ++
        else if ( p.stat == 'have'  ) have_cnt ++
        else                          cure_cnt ++
    }
    result.nomal_cnt = nomal_cnt
    result.have_cnt  = have_cnt
    result.cure_cnt  = cure_cnt

    // チャートに線を引く
    const ctx = document.querySelector('canvas#chart').getContext('2d')
    const draw_line = ( _x1, _y1, _x2, _y2, _color ) => {
        ctx.beginPath()
        ctx.moveTo( _x1, _y1 )
        ctx.lineTo( _x2, _y2 )
        ctx.moveTo( _x1-1, _y1 )
        ctx.lineTo( _x2-1, _y2 )
        ctx.strokeStyle = _color
        ctx.stroke()
    }
    const n = percent( nomal_cnt )
    const h = percent( have_cnt )
    draw_line( result.day+1, 0, result.day+1, 100, '#fff' )
    draw_line( result.day, 0, result.day, n, 'hsl(95, 46%, 75%)' )
    draw_line( result.day, n, result.day, n+h, 'red' )
    draw_line( result.day, n+h, result.day, 100, 'hsl(300, 41%, 59%)' )
    result.day++
    if ( result.day > 555 ) result.day = 0

    if ( have_cnt > 0 )
        window.setTimeout( animation, 1000/20 ) // 20fps
}

const start_sim = () => {
    div_svg.p = []
    for ( let i=0 ; i<ui.people ; ++i )
        div_svg.p.push( new Point() )

    div_svg.p[0].stat = 'have'
    result.day = 0
    window.setTimeout( animation, 1000/20 ) // 20fps
}

}</script>
</body></html>
```

細かい解説をしなくても、緑の丸がワシャワシャ動いているメインのところを SVG で描いていて、下のチャートの部分は canvas で描いています。どちらも一長一短がありますので、適材適所で使い分けています。

いまのところ、Chrome で動作の確認を行っています。

わたしの考察を書くと
「感染には移動が効く。とにかく一定時間じっとしているといい。ぶっちゃけ感染期間に何人と接触したのか？で決まる」
です。
このコードでは、movespeed が３以下になれば、ほどんと感染が広がらないことが確認できます。

もしくは、以下の２点で感染者数が劇的に変化することがわかると思います。お試しあれ。

    movespeed: 5 ← 出歩かない（移動を半分にする）
    cure_days: 14
    range : 5 ← 感染距離を半分にする（手洗いをする）

みなさま、ふんばりどころです。がんばりましょう。
 
このコードが少しでもみなさまのお役にたてば幸いです。


## Dashboard.js

コードの先頭に minify されているコードは、Dashboard と名付けた UI ライブラリですが、まだ、テストも不十分なので、しばらく minify のままとさせてください。みなさまからの声が集まったら、しっかりテストしたのちに公開、、、するかもしれないです。ってか、UIのテストって、どうやって書くんでしょう？？
Dashboard.js に関してもコメント募集中です。よろしくお願いいたします。