【DOT】Python で d3-graphviz を使ってアニメーション表示ツールを作る

■ はじめに

仕事でやりたいことが全く手が付けられないので
超個人的なツールの元なるソースをメモ。

https://dk521123.hatenablog.com/entry/2023/06/18/102448
https://dk521123.hatenablog.com/entry/2023/06/14/174104

を組み合わせて、1つのDOTファイルから、
Nodeの追加・削除により、
d3-graphviz を使ってアニメーション表示ツールを作る

目次

【1】ツール仕様
 Step1: アニメーションの元になるDOTファイルを作成する
 Step2: HTML+JSによりアニメーション表示
【2】フォルダ構成例
 1)delete
 2)add
 3)input
【3】サンプル

【1】ツール仕様

* 以下。

Step1: アニメーションの元になるDOTファイルを作成する

[1] 元となるDOTファイルに対して、要素を削除・追加
[2] 削除・追加には、別のTextファイルから読み込み
 [1]のファイルの対して。
 => オプションとして、削除前に色を変更して
  削除対象を分かりやすくする
[3] そのファイルをDOTファイル群として生成する

Step2: HTML+JSによりアニメーション表示

[1] 1)のDOTファイルを随時読み込み、
 JavaScriptの配列を追加したHTMLファイルを生成する

【2】フォルダ構成例

+ dot_animation_creator.py
+ delete
  + 01.txt
  + 02.txt
  + 03.txt
+ add
  + 01.txt
  + 03.txt
+ input
  + source.dot

1)delete

01.txt

table3
table4

02.txt

table9

03.txt

table10

2)add

01.txt

    "table1" -> "table20" -> "table21";

02.txt

    "table1" -> "table22" -> "table23";

3)input

source.dot

digraph {
    ranksep=0.3;
    nodesep=0.7;
    rankdir="TB"
    {rank = source; "table1";}
    "table1"-> "table2";
    "table2" -> "table3";
    "table4" -> "table2";
    "table2" -> "table5";
    "table2" -> "table6";
    "table6" -> "table7";
    "table6" -> "table8";
    "table8" -> "table9";
    "table7" -> "table10";
    "table8" -> "table4";
    # ADD HERE, IF YOU WANT
}

【3】サンプル

import os
import re
import shutil


class DotAnimationCreator:
  REPLACED_SIGN = '    # ADD HERE, IF YOU WANT'

  def __init__(
      self,
      input_file_path='./input/source.dot',
      delete_path='./delete',
      add_path='./add',
      output_path='./out1',
      output_file_name_format='out_{:0=2}-{:0=1}.dot'
  ) -> None:
    self.input_file_path = input_file_path
    self.delete_path = delete_path
    self.add_path = add_path
    self.output_path = output_path
    self.output_file_name_format = output_file_name_format

  def _get_lines_from_file(self, file_full_path, encoding='utf-8'):
    with open(file_full_path, 'r', encoding=encoding) as file:
      return file.read().splitlines()

  def _save(self, file_full_path, content, encoding='utf-8'):
    with open(file_full_path, 'w', encoding=encoding) as file:
      file.write(content)

  def _create_dot_to_change_color(self, delete_list, no, input_list):
    dot = '\n'.join(input_list)
    for delete_data in delete_list:
      print(f'[{no}] - {delete_data}')
      if (re.search(f'"{delete_data}" \[.*\];', dot)):
        dot = re.sub(
          f'"{delete_data}" \[.*\];',
          f'"{delete_data}" [shape="box" color="red"];',
          dot
        )
      else:
        dot = dot.replace(
          self.REPLACED_SIGN,
          f'{self.REPLACED_SIGN}\n\t"{delete_data}" [shape="box" color="red"];'
        )

    output_full_path = self._get_output_full_path(no, True)
    self._save(output_full_path, dot)

  def _get_output_full_path(self, no, is_change_color=False):
    index = 0 if is_change_color else 1
    output_file_name = self.output_file_name_format.format(no, index)
    return f'{self.output_path}/{output_file_name}'

  def _marge_add_list(self, input_list, add_list):
    replaced_sign_index = input_list.index(self.REPLACED_SIGN)
    insert_index = replaced_sign_index + 1
    input_list[insert_index:insert_index] = add_list

  def _generate_dots(self, can_change_color=True):
    input_list = self._get_lines_from_file(self.input_file_path)
    for i, file_name in enumerate(os.listdir(self.delete_path)):
      no = i + 1
      print(f'[{no}] - file_name')
      
      # Delete part
      delete_file_full_path = os.path.join(self.delete_path, file_name)
      delete_list = self._get_lines_from_file(delete_file_full_path)
      if not delete_list or len(delete_list) == 0:
        print('No target to delete')
        continue

      # Before delete, change the color
      if can_change_color:
        self._create_dot_to_change_color(delete_list, no, input_list)

      # Exclusion for deleting target data
      for delete_data in delete_list:
        input_list = [line for line in input_list if f'"{delete_data}"' not in line]

      # Add part
      add_file_full_path = f'{self.add_path}/{file_name}'
      if not os.path.exists(add_file_full_path):
        print(f'[{no}] No exist {add_file_full_path}')
      else:
        # Exist add file
        add_list = self._get_lines_from_file(add_file_full_path)
        if not add_list or len(add_list) == 0:
          print(f'[{no}] No target to add')
        else:
          # Merge
          self._marge_add_list(input_list, add_list)

      output_full_path = self._get_output_full_path(no, False)
      dot = '\n'.join(input_list)
      self._save(output_full_path, dot)
    return self.output_path

  def _generate_html_with_animation(self, html_file_path, output_path):
    dot_contents = self._read_dot_contents(output_path)
    html_content = self._generate_html_content(dot_contents)
    self._save(html_file_path, html_content)
    return html_file_path

  def _read_dot_contents(self, root_output_dot_path):
    file_list = os.listdir(root_output_dot_path)
    file_list.sort()
    file_contents = []
    for dot_file_name in file_list:
      do_file_path = os.path.join(root_output_dot_path, dot_file_name)
      with open(do_file_path, 'r', encoding='UTF-8') as file:
        file_content = file.read()
        file_contents.append(file_content)
    return file_contents

  def _generate_html_content(self, dot_contents):
    content = '''
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<body>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="https://unpkg.com/viz.js@1.8.1/viz.js" type="javascript/worker"></script>
<script src="https://unpkg.com/d3-graphviz@2.1.0/build/d3-graphviz.min.js"></script>
<style>
.styled {
    border: 0;
    line-height: 2.0;
    padding: 0 20px;
    font-size: 1rem;
    text-align: center;
    color: #fff;
    text-shadow: 1px 1px 1px #000;
    border-radius: 10px;
    background-color: rgba(255,0,0,0.3);
    background-image: linear-gradient(to top left, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2) 30%, rgba(0, 0, 0, 0));
    box-shadow: inset 2px 2px 3px rgba(255, 255, 255, 0.6), inset -2px -2px 3px rgba(0, 0, 0, 0.6);
}
</style>
<button id="next" class="favorite styled" onclick="showDot(1)"> Next </button>
<button id="previous" class="favorite styled" onclick="showDot(-1)"> Previous </button>
<form>
<textarea id="textArea"  name="txt" rows="10" cols="100"></textarea>
</form>
<div id="graph" style="text-align: center;"></div>
</div>
<script>
var dots = [
    '''
    for dot in dot_contents:
      content = content + '`'
      content = content + dot + '`,\n'

    content = content + '''
];
var dotIndex = -1;
var graphviz = d3.select("#graph").graphviz()
  .transition(function () {
    return d3.transition("main")
      .ease(d3.easeBounceOut)
      .delay(500)
      .duration(1000);
  })
  .logEvents(true);

function showDot(val) {
  dotIndex = (dotIndex + (val)) % dots.length;
  if (dotIndex < 0) {
    dotIndex = dots.length -1;
  }
  var dot = dots[dotIndex];
  document.getElementById("textArea").value = dot;
  graphviz.renderDot(dot);
}
</script>
</body>
</html>
    '''
    return content

  def execute(
    self,
    html_file_path='./dot_animation_viewer.html',
    can_change_color=True
  ):
    # Step0: Copy source file to output
    source_output_path = self._get_output_full_path(0)
    shutil.copyfile(self.input_file_path, source_output_path)
    # Step1: To generate DOTs
    output_path = self._generate_dots(can_change_color)
    # Step2: To generate HTML
    return self._generate_html_with_animation(html_file_path, output_path)


if __name__ == '__main__':
  dot_creator = DotAnimationCreator()
  output = dot_creator.execute()

  print(f'DONE... See {output}')

関連記事

DOT言語表示ツール ~ d3-graphviz
https://dk521123.hatenablog.com/entry/2023/06/18/102448
Python + DOT言語で図作成するには
https://dk521123.hatenablog.com/entry/2023/06/14/174104
DOT言語 ~ 基礎知識編 ~
https://dk521123.hatenablog.com/entry/2023/06/15/004815
DOT言語 ~ 入門編 ~
https://dk521123.hatenablog.com/entry/2023/06/16/000531
Python ~ 基本編 / ファイル読込・書込 ~
https://dk521123.hatenablog.com/entry/2019/10/07/000000
Python ~ 基本編 / パス情報抽出 ~
https://dk521123.hatenablog.com/entry/2022/02/23/000000
Python ~ 基本編 / 正規表現
https://dk521123.hatenablog.com/entry/2019/09/01/000000
Python ~ 基本編 / ラムダ lambda ~
https://dk521123.hatenablog.com/entry/2019/09/23/000000