from io import BytesIO from typing import Any from PIL import Image, ImageDraw, ImageFont WIDTH = 1200 HEIGHT = 720 FINANCE_HEIGHT = 1200 BACKGROUND = (248, 250, 252) FOREGROUND = (17, 24, 39) MUTED = (75, 85, 99) GRID = (209, 213, 219) SERIES = ((37, 99, 235), (245, 158, 11), (220, 38, 38)) def render_lifecycle_chart(chart_data: dict[str, Any]) -> bytes: finance = chart_data.get("finance") or {} image = Image.new("RGB", (WIDTH, FINANCE_HEIGHT if finance else HEIGHT), BACKGROUND) draw = ImageDraw.Draw(image) title_font = _font(36) section_font = _font(28) label_font = _font(22) value_font = _font(22) draw.text((60, 35), "Lifecycle Analysis", fill=FOREGROUND, font=title_font) draw.text((60, 82), str(chart_data.get("period") or ""), fill=MUTED, font=label_font) projects = chart_data.get("projects") or {} draw.text((60, 135), "Project Portfolio", fill=FOREGROUND, font=section_font) _stacked_bar( draw, y=190, values=[ ("Unarchived", int(projects.get("unarchived") or 0), SERIES[0]), ("Archived", int(projects.get("archived") or 0), GRID), ], total=max(int(projects.get("total") or 0), 1), label_font=label_font, value_font=value_font, ) risks = chart_data.get("risks") or {} draw.text((60, 295), "Current Risks", fill=FOREGROUND, font=section_font) _horizontal_bars( draw, y=345, values=[ ("Overdue milestones", int(risks.get("overdue_milestones") or 0)), ("Overdue tasks", int(risks.get("overdue_tasks") or 0)), ("Open events", int(risks.get("open_events") or 0)), ], label_font=label_font, value_font=value_font, ) if finance: cashflows = finance.get("cashflows") or {} receivables = finance.get("receivables") or {} draw.text((60, 640), "Project Finance Needs (CNY)", fill=FOREGROUND, font=section_font) _horizontal_bars( draw, y=695, values=[ ("Confirmed inflow", int(cashflows.get("confirmed_inflow") or 0)), ("Confirmed outflow", int(cashflows.get("confirmed_outflow") or 0)), ("Pending outflow", int(cashflows.get("pending_outflow") or 0)), ("Overdue receivable", int(receivables.get("overdue") or 0)), ("Receivable due 30d", int(receivables.get("due_30d") or 0)), ], label_font=label_font, value_font=value_font, ) top_projects = finance.get("top_projects") or [] draw.text((650, 640), "Top 5 Funding Needs", fill=FOREGROUND, font=section_font) _horizontal_bars( draw, x=650, y=695, width=480, values=[ (str(item.get("name") or "Project"), int(item.get("amount") or 0)) for item in top_projects[:5] ], label_font=label_font, value_font=value_font, ) people = chart_data.get("people") or {} draw.text((650, 295), "People Coverage", fill=FOREGROUND, font=section_font) _horizontal_bars( draw, x=650, y=345, width=480, values=[ ("Active", int(people.get("active") or 0)), ("Needs attention", int(people.get("attention") or 0)), ("Attendance mapped", int(people.get("attendance_mapped") or 0)), ], label_font=label_font, value_font=value_font, ) output = BytesIO() image.save(output, format="PNG", optimize=True) return output.getvalue() def lifecycle_chart_alt(chart_data: dict[str, Any]) -> str: projects = chart_data.get("projects") or {} risks = chart_data.get("risks") or {} people = chart_data.get("people") or {} content = ( f"项目总数 {projects.get('total', 0)},未归档 {projects.get('unarchived', 0)}," f"里程碑逾期 {risks.get('overdue_milestones', 0)},任务逾期 {risks.get('overdue_tasks', 0)}," f"在职人员 {people.get('active', 0)},需关注人员 {people.get('attention', 0)}" ) finance = chart_data.get("finance") or {} if finance: cashflows = finance.get("cashflows") or {} content += ( f",已确认资金流入 {cashflows.get('confirmed_inflow', 0)} 元," f"待确认支出 {cashflows.get('pending_outflow', 0)} 元" ) return content def _stacked_bar( draw: ImageDraw.ImageDraw, y: int, values: list[tuple[str, int, tuple[int, int, int]]], total: int, label_font: ImageFont.ImageFont, value_font: ImageFont.ImageFont, ) -> None: x = 60 width = 1070 cursor = x for label, value, color in values: segment = round(width * value / total) if segment: draw.rectangle((cursor, y, cursor + segment, y + 42), fill=color) cursor += segment label_x = x for label, value, color in values: draw.rectangle((label_x, y + 62, label_x + 18, y + 80), fill=color) draw.text((label_x + 28, y + 56), f"{label} {value}", fill=FOREGROUND, font=value_font) label_x += 280 def _horizontal_bars( draw: ImageDraw.ImageDraw, y: int, values: list[tuple[str, int]], label_font: ImageFont.ImageFont, value_font: ImageFont.ImageFont, x: int = 60, width: int = 520, ) -> None: maximum = max((value for _, value in values), default=0) or 1 for index, (label, value) in enumerate(values): row_y = y + index * 90 draw.text((x, row_y), label, fill=MUTED, font=label_font) bar_y = row_y + 34 draw.rectangle((x, bar_y, x + width, bar_y + 24), fill=GRID) bar_width = round(width * value / maximum) if bar_width: draw.rectangle( (x, bar_y, x + bar_width, bar_y + 24), fill=SERIES[index % len(SERIES)], ) label = f"{value:,}" label_width = draw.textlength(label, font=value_font) draw.text((x + width - label_width, row_y), label, fill=FOREGROUND, font=value_font) def _font(size: int) -> ImageFont.ImageFont: try: return ImageFont.truetype("DejaVuSans.ttf", size=size) except OSError: return ImageFont.load_default(size=size)