PlantUML gantt用クラス定義

# PlantUML gantt
PlantUMLのガントチャートガントチャートの記述方法は、文章的な構文であり、タスクごとの開始日、期間などの属性の定義と1対1に対応している感じではない。
また、並び順をタスク、マイルストーンの記述順にしようとすると、ダミーの宣言を必要とするケースがある。

これらを解決するためのクラス定義を作成したので、紹介する。

# 基本構想
全体をクラス``gantt``とし、その要素としてタスクと区切り線に対するクラスのオブジェクトを用いることとした。

それぞれのクラスは、``__str__``メソッドを持ち、これによりPlantUML用の記述を生成させる。
クラス``Task``に関しては、依存関係に対する記述を生成するメソッドを``post``として分離した。これにより、依存関係を適切な順に吐き出せるようにした。

# 関連ページ
* [PlantUMLにおけるガントチャートの書き方](https://qiita.com/asobinin_kinsan/items/e8552dcab508b414a19a)
* [kroki.ioを用いてダイアログを図形データに変換するモジュール](https://qiita.com/asobinin_kinsan/items/a45c346f0b9f4f2a0379)


# コード
以下が実際のコード
```python:
"""
palntuml_gantt : PlantUMLを用いてガントチャートを作成するためのモジュール

written on 2023/2/27
""";
import sys
import datetime
import Kroki as kk

def daystr(x):
    """ 単一またはリスト、タプルの中の値として格納されている日付データを日付を示す文字列に変換する """
    if isinstance(x, datetime.datetime) or isinstance(x, datetime.date):
        return x.strftime("%Y/%m/%d")
    elif isinstance(x, str):
        return x
    elif isinstance(x,list):
        ret = []
        for y in x:
            ret.append(daystr(y))
        return ret
    elif isinstance(x,tuple):
        return tuple(daystr(list(x)))
    else:
        raise ValueError("Unexpected type of data")

class Task:
    """ タスクを定義するためのクラス
    
    Attributes:
        name (str) : タスク名
        
        以下はオプションの属性(但し、period,start, endのうち少なくとも2つは定義する必要あり)
        period (int) : 期間(正確には、このタスクを完了するのに必要な日数)
            period ==0 の時はマイルストーンの定義となる
        startday (str) : 開始日
        endday   (str) : 終了日
        start (list of dict) : 他タスク依存の開始日の定義
            1つまたは複数の先行タスクによる定義
                dict型は以下のキーと値を持つ
                    キー："type", 値："F"または"S"
                        "F"  : 先行タスクの終了日が基準日
                        "S"  : 先行タスクの開始日が基準日
                    キー: "task", 値: 先行タスクの名前(str型)
                    キー: "delay", 値: 基準日のずれの日数(int型)
        end (list of dict) : 他タスク依存の終了日の定義 (値は属性startと同様)
        compl_rate (float): 進捗度(1.0 -> 100%) (default : None)
        color : タスクの塗りつぶし色、枠の色
            None : 無指定
            str  : 色名(塗りつぶしと枠を同じ色に)
            [str,str] : 塗りつぶし色、枠の色
        resource (str) : リソース (例: "{person1} {person2:50%}")
        
    Methods:
        __init__ : タスクオブジェクトの生成
        __str__  : タスク宣言の文字列を返す
        post()   : 依存関係などタスクの宣言より後に指定する文字列
        depend() : このタスクが依存しているタスクの名前のリストを返す
    """

    @staticmethod
    def isdepend(*d):
        """ start, endが他のタスクに依存しているか否かの判定用関数
            引数が二つの時は両方とも依存している時にTrueを返す
            
            属性のデータ形式に依存するゆえ関数化する
        """
        for x in d:
            if not isinstance(x, list):
                return False
        return True

    
    def __init__(self,name, period=None,
                 startday = None, endday=None,
                 start = None, end=None,
                 compl_rate=None, color=None, resource=None):
        self.name = name
        if period:
            self.period = int(period)
        else:
            self.period = None
        if isinstance(startday, datetime.datetime):
            startday = startday.strftime("%Y/%m/%d")
        self.startday = startday
        if isinstance(start, dict):
            self.start = [start]
        else:
            self.start = start
        if isinstance(endday, datetime.datetime):
            endday = endday.strftime("%Y/%m/%d")
        self.endday = endday
        if isinstance(end, dict):
            self.end = [end]
        else:
            self.end = end
        if compl_rate is not None:
            compl_rate = str(int(100*compl_rate))
        self.compl_rate = compl_rate
        if isinstance(color,str):
            color = [color,color]
        if isinstance(color,list):
            self.color = f"{color[0]}/{color[1]}"
        else:
            self.color = None
        self.resource = resource
        # return self
    
    def depend(self):
        ret = []
        for d in [self.start, self.end]:
            if not Task.isdepend(d):
                continue
            for x in d:
                ret.append(x["task"])
        return ret
            
    def __str__(self):
        if self.period == 0: # マイルストーンの場合
            # print(f"{self.start=}, {self.end=}")
            if self.startday is not None:
                return f"[{self.name}] happens {self.startday}"
            elif self.endday is not None:
                return f"[{self.name}] happens {self.endday}"
            else:
                return f"[{self.name}]  lasts 1 days" # 日付が他タスクに依存するゆえ、ダミーで定義する
        else:
            if self.resource is not None:
                R = "on " + self.resource + " "
            else:
                R = ""

            ret = []
            if self.period is not None:
                ret.append(f"[{self.name}] {R} lasts {self.period} days")
            if self.startday is not None:
                ret.append(f"[{self.name}] {R} starts at {self.startday}")
            if self.endday is not None:
                ret.append(f"[{self.name}] {R} ends at {self.endday}")
            if len(ret) > 0:
                return "\n".join(ret)
            else:
                return f"[{self.name}] {R} lasts 1 days" # 日付が他タスクに依存するゆえ、ダミーで定義する

    def post(self):
        ret = []
        verb = ["starts", "ends"]
        i = -1
        for d in [self.start, self.end]:
            i = i+1
            if Task.isdepend(d):
                for x in d:
                    when = "at"
                    if "delay" in x and x["delay"] is not None:
                        delay = int(x["delay"])
                        if delay > 0:
                            when = f"{delay} days after"
                        elif delay < 0:
                            when = f"{-delay} days before"
                    if x["type"] == "F":
                        ret.append(f"[{self.name}] {verb[i]} {when} [{x['task']}]'s end")
                    elif x["type"] == "S":
                        ret.append(f"[{self.name}] {verb[i]} {when} [{x['task']}]'s start")
                    else:
                        raise ValueError("unknown type of task relation")
        if self.compl_rate is not None:
            ret.append(f"[{self.name}] is {self.compl_rate}% completed")
        if self.color is not None:
            ret.append(f"[{self.name}] is colored in {self.color}")
        return ret

class DivLine:
    divline_num = 0
    def __init__(self,msg=None):
        self.msg = msg
        DivLine.divline_num +=1 
        self.name = f"_divline{DivLine.divline_num}"
    def __str__(self):
        if self.msg is None:
            return "--"
        else:
            return f"-- {self.msg} --"

    def depend(self):
        return []

    def post(self):
        return []

class Gantt:
    """ ガントチャートを定義するクラス
    Attributes:
        tasks (list):
            Taskクラスのオブジェクトのリスト
        start (str or datetime.datetime):
            プロジェクト開始日
        holiday:
            休日(後述参照)
        workday:
            稼働日(後述参照)
        title (str):
            ガントチャートのタイトル(上に表示される)
        caption (str):
            ガントチャートの表題(下に表示される)
        scale (str):
            表示単位
        zoom (int):
            ズーム倍率
        hide_footbox (bool):
            下部の日付表示を無しとする
        hide_ressources_names (bool):
            リソースを使用しているタスクごとに書かれるリソース名を非表示にする
        hide_ressources_footbox (bool):
            下部に書かれるリソースごとのサマリーを非表示にする
        language (str):
            日付に用いる言語

        holiday及びworkdayの型及び内容は以下のいずれか
            str : 日付または曜日を表す文字列
            datetime.datetime : 日付
            list : 各要素の型及び内容は以下のいずれか
                str : 日付または曜日を表す文字列
                datetime.datetime : 日付
                tupple : 期間の開始と終了の日付　(要素数は2のtupple)

    """
    def __init__(self, tasks, start=None, holiday=None, workday=None,
                 title=None,
                 caption=None,
                 scale=None,
                 zoom=None,
                 hide_footbox=True,
                 hide_ressources_names=False,
                 hide_ressources_footbox=False,
                 language=None
                ):
        
        self.tasks = tasks
        if isinstance(start, datetime.datetime):
            start = start.strftime("%Y/%m/%d")
        self.start = start
        self.tasks_dict = {}
        for t in tasks:
            # if isinstance(t,DivLine):
            #     continue
            if t.name in self.tasks_dict:
                raise ValueError("A task could not be defined twice")
            self.tasks_dict[t.name] = t
        if holiday is not None:
            if not isinstance(holiday,list):
                holiday = [holiday]
            self.holiday = daystr(holiday)
        else:
            self.holiday = None
        if workday is not None:
            if not isinstance(workday,list):
                workday = [workday]
            self.workday = daystr(workday)
        else:
            self.workday = None
        self.title = title
        self.caption = caption
        self.scale = scale
        self.zoom = zoom
        self.hide_footbox=hide_footbox
        self.hide_ressources_names=hide_ressources_names
        self.hide_ressources_footbox=hide_ressources_footbox
        self.language = language

    
    def __str__(self):
        ret = ["@startgantt"]
        if self.language:
            ret.append(f"language {self.language}")
        if self.title:
            ret.append(f"title {self.title}")
        if self.caption:
            ret.append(f"caption {self.caption}")
        if self.scale:
            ret.append(f"projectscale {self.scale}")
        if self.zoom:
            ret.append(f"zoom {int(self.zoom)}")

        if self.start is not None:
            ret.append(f"Project start {self.start}")
        if self.holiday is not None:
            for d in self.holiday:
                if isinstance(d,tuple):
                    ret.append(f"{d[0]} to {d[1]} are closed")
                else:
                    ret.append(f"{d} are closed")
        if self.workday is not None:
            for d in self.workday:
                if isinstance(d,tuple):
                    ret.append(f"{d[0]} to {d[1]} are open")
                else:
                    ret.append(f"{d} are open")
        if self.hide_footbox:
            ret.append("hide footbox")
        if self.hide_ressources_names:
            ret.append("hide ressources names")
        if self.hide_ressources_footbox:
            ret.append("hide ressources footbox")


        for t in self.tasks:
            ret.append(str(t))
        ordered = []
        n = len(self.tasks)
        while len(ordered) < n:
            m = len(ordered)
            for t in self.tasks:
                # if isinstance(t,DivLine):
                #     continue
                if t.name in ordered:
                    continue
                allok = True
                for x in t.depend():
                    if x not in ordered:
                        allok = False
                        break
                if allok:
                    # print(t.name, " is ok")
                    ordered.append(t.name)
            if m == len(ordered):
                raise ValueError("Could not reorder tasks of circular reference")
        for tname in ordered:
            r = self.tasks_dict[tname].post()
            for s in r:
                ret.append(s)
        ret.append("@endgantt")
        return "\n".join(ret)


# ---
# # 以下テスト兼使用例

Test = __name__ == "__main__" and "ipykernel" in sys.modules # jupyter上でのテスト時のみTrue

if Test:
    g = Gantt([
        Task("TaskA", period=5,startday="2023/1/10",compl_rate=0.3,color="green"),
        Task("TaskB", period=10,start={"type":"F", "task":"TaskD"},resource="{person1}"),
        Task("TaskC", period=8,start={"type":"S", "task":"TaskB"}),
        DivLine(),
        Task("TaskD", period=6,end={"type":"F", "task":"TaskA"}),
        Task("TaskE", period=4,end={"type":"S", "task":"TaskA","delay":-2}),
        Task("TaskF", period=3,endday="2023/1/15"),
        Task("Milestone", period=0, start={"type":"F", "task":"TaskE"}),
    ],
        start="2023/1/1",
        holiday=["saturday", "sunday", ("2023/1/10", "2023/1/14"), "2023/1/20"],
        workday="2023/1/19",
        language = "ja"
    )
    diagram=str(g)
    print(diagram)

if Test:
    data,img=kk.kroki(diagram, "plantuml", "png")
    display(img)
```