PlantUML gantt
PlantUMLのガントチャートガントチャートの記述方法は、文章的な構文であり、タスクごとの開始日、期間などの属性の定義と1対1に対応している感じではない。
また、並び順をタスク、マイルストーンの記述順にしようとすると、ダミーの宣言を必要とするケースがある。
これらを解決するためのクラス定義を作成したので、紹介する。
基本構想
全体をクラスgantt
とし、その要素としてタスクと区切り線に対するクラスのオブジェクトを用いることとした。
それぞれのクラスは、__str__
メソッドを持ち、これによりPlantUML用の記述を生成させる。
クラスTask
に関しては、依存関係に対する記述を生成するメソッドをpost
として分離した。これにより、依存関係を適切な順に吐き出せるようにした。
関連ページ
コード
以下が実際のコード
"""
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)