{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# WEEK 11 · 질문에 맞는 데이터 포스터 미션\n",
    "\n",
    "이 노트북은 **범주별 프로그램 수 합계 가로 막대그래프**와 **시설 24개의 위치 좌표 산점도**를 한 장의 1600 × 2200 포스터로 만듭니다.\n",
    "\n",
    "- 수정할 곳은 `STEP 1 · EDIT`와 `STEP 5 · EDIT` 두 셀뿐입니다.\n",
    "- 나머지 셀은 위에서 아래로 실행하고 코드를 수정하지 않습니다.\n",
    "- 마지막에 모든 초록 확인과 `WEEK 11 DATA POSTER COMPLETE`가 보이면 자동 검사를 통과한 것입니다.\n",
    "- 최종 완료와 귀가는 두 파일을 제출하고, 제목이 현재 데이터로 답할 수 있다는 교수 확인을 받은 뒤 승인됩니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 0 · 실행 환경과 수업용 데이터 준비 — 이 셀은 수정하지 않습니다.\n",
    "from pathlib import Path\n",
    "import hashlib\n",
    "import importlib.util\n",
    "from importlib.metadata import PackageNotFoundError, version as package_version\n",
    "import re\n",
    "import subprocess\n",
    "import sys\n",
    "\n",
    "required_packages = {\n",
    "    \"pandas\": (\"pandas\", \"2.3.3\"),\n",
    "    \"matplotlib\": (\"matplotlib\", \"3.10.8\"),\n",
    "    \"seaborn\": (\"seaborn\", \"0.13.2\"),\n",
    "    \"PIL\": (\"Pillow\", \"12.3.0\"),\n",
    "}\n",
    "packages_to_install = []\n",
    "for module_name, (package_name, required_version) in required_packages.items():\n",
    "    try:\n",
    "        installed_version = package_version(package_name)\n",
    "    except PackageNotFoundError:\n",
    "        installed_version = None\n",
    "    if (\n",
    "        importlib.util.find_spec(module_name) is None\n",
    "        or installed_version != required_version\n",
    "    ):\n",
    "        packages_to_install.append(f\"{package_name}=={required_version}\")\n",
    "if packages_to_install:\n",
    "    subprocess.check_call(\n",
    "        [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *packages_to_install]\n",
    "    )\n",
    "\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from matplotlib import font_manager\n",
    "from matplotlib import image as mpimg\n",
    "from matplotlib.colors import to_rgba\n",
    "from matplotlib.markers import MarkerStyle\n",
    "import seaborn as sns\n",
    "\n",
    "try:\n",
    "    from google.colab import files\n",
    "except ImportError:\n",
    "    files = None\n",
    "\n",
    "mission_step0_execution = get_ipython().execution_count\n",
    "\n",
    "SAMPLE_CSV_PATH = \"week11_public_facilities_clean.csv\"\n",
    "SAMPLE_CSV = 'place_id,place_name,category,program_count,latitude,longitude\\nC001,햇살도서관,도서관,48,37.5665,126.9780\\nC002,나무도서관,도서관,35,37.5720,126.9900\\nC003,구름도서관,도서관,62,37.5840,127.0120\\nC004,샘물도서관,도서관,29,37.5510,126.9650\\nC005,새봄도서관,도서관,54,37.5380,126.9920\\nC006,한강도서관,도서관,41,37.5200,126.9400\\nC007,별빛도서관,도서관,67,37.6030,127.0250\\nC008,마루도서관,도서관,33,37.6120,126.9580\\nC009,모양박물관,박물관,23,37.5790,126.9480\\nC010,시간박물관,박물관,38,37.5900,126.9820\\nC011,기록박물관,박물관,57,37.5610,127.0280\\nC012,생활박물관,박물관,31,37.5430,127.0550\\nC013,도시박물관,박물관,72,37.5280,127.0180\\nC014,소리박물관,박물관,26,37.5110,126.9740\\nC015,빛박물관,박물관,45,37.5960,127.0670\\nC016,종이박물관,박물관,34,37.6170,127.0020\\nC017,푸른문화센터,문화센터,52,37.5700,127.0440\\nC018,열린문화센터,문화센터,64,37.5480,126.9250\\nC019,다온문화센터,문화센터,28,37.5320,126.9550\\nC020,누리문화센터,문화센터,49,37.5150,127.0410\\nC021,이음문화센터,문화센터,70,37.5880,126.9300\\nC022,마을문화센터,문화센터,37,37.6070,127.0480\\nC023,함께문화센터,문화센터,58,37.6250,126.9850\\nC024,오늘문화센터,문화센터,42,37.5020,127.0120\\n'\n",
    "EXPECTED_CSV_SHA256 = \"dc0da6c249327470fa967dd0682eb0b0a62bd9f487ef014c2a8686db65d4cd94\"\n",
    "Path(SAMPLE_CSV_PATH).write_text(SAMPLE_CSV, encoding=\"utf-8\")\n",
    "\n",
    "dataset_title = \"수업용 가상 공공문화시설 정제 데이터\"\n",
    "dataset_source = \"Contents Programming Practice Week 11 · 교수자 제공 가상 자료\"\n",
    "dataset_license = \"수업 목적 사용 허용\"\n",
    "reference_date = \"2026-08-18\"\n",
    "observation_unit = \"공공문화시설 한 곳\"\n",
    "expected_metadata = (\n",
    "    dataset_title,\n",
    "    dataset_source,\n",
    "    dataset_license,\n",
    "    reference_date,\n",
    "    observation_unit,\n",
    ")\n",
    "\n",
    "POSTER_PAPER = \"#f3efe5\"\n",
    "POSTER_INK = \"#202523\"\n",
    "POSTER_MUTED = \"#59615e\"\n",
    "POSTER_CORAL = \"#a23d34\"\n",
    "\n",
    "category_markers = {\n",
    "    \"도서관\": \"o\",\n",
    "    \"박물관\": \"s\",\n",
    "    \"문화센터\": \"^\",\n",
    "}\n",
    "\n",
    "\n",
    "def font_has_korean_glyphs(font_path):\n",
    "    try:\n",
    "        font = font_manager.get_font(font_path)\n",
    "    except (OSError, RuntimeError):\n",
    "        return False\n",
    "    return all(\n",
    "        font.get_char_index(ord(character))\n",
    "        for character in \"한글이름출처\"\n",
    "    )\n",
    "\n",
    "\n",
    "def find_korean_font():\n",
    "    preferred_tokens = (\n",
    "        \"nanumgothic\",\n",
    "        \"notosanscjk\",\n",
    "        \"notosanskr\",\n",
    "        \"applesdgothic\",\n",
    "        \"malgun\",\n",
    "        \"pretendard\",\n",
    "    )\n",
    "    supporting_fonts = [\n",
    "        font_path\n",
    "        for font_path in sorted(font_manager.findSystemFonts())\n",
    "        if font_has_korean_glyphs(font_path)\n",
    "    ]\n",
    "    for font_path in supporting_fonts:\n",
    "        compact_name = Path(font_path).name.lower().replace(\" \", \"\")\n",
    "        if any(token in compact_name for token in preferred_tokens):\n",
    "            return font_path\n",
    "    return supporting_fonts[0] if supporting_fonts else None\n",
    "\n",
    "\n",
    "korean_font_path = find_korean_font()\n",
    "if korean_font_path is None and Path(\"/etc/debian_version\").exists():\n",
    "    subprocess.check_call([\"apt-get\", \"update\", \"-qq\"])\n",
    "    subprocess.check_call([\"apt-get\", \"install\", \"-y\", \"-qq\", \"fonts-nanum\"])\n",
    "    korean_font_path = find_korean_font()\n",
    "\n",
    "if korean_font_path is None:\n",
    "    raise RuntimeError(\n",
    "        \"한글 글꼴을 찾지 못했습니다. Colab 새 런타임에서 STEP 0부터 다시 실행하세요.\"\n",
    "    )\n",
    "\n",
    "font_manager.fontManager.addfont(korean_font_path)\n",
    "korean_font_name = font_manager.FontProperties(\n",
    "    fname=korean_font_path\n",
    ").get_name()\n",
    "plt.rcParams[\"font.family\"] = korean_font_name\n",
    "plt.rcParams[\"axes.unicode_minus\"] = False\n",
    "sns.set_theme(style=\"whitegrid\", font=korean_font_name)\n",
    "\n",
    "print(\"준비 파일:\", SAMPLE_CSV_PATH)\n",
    "print(\"데이터:\", dataset_title)\n",
    "print(\"한글 글꼴:\", korean_font_name)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 1 · 제출 정보와 시각 규칙\n",
    "\n",
    "학번·이름을 입력하고, 데이터로 답할 수 있는 질문형 제목을 작성합니다. 세 범주에는 서로 다른 여섯 자리 HEX 색상을 지정합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 1 · EDIT — 학번·이름, 질문형 제목과 세 범주 색상을 수정합니다.\n",
    "mission_step1_execution = get_ipython().execution_count\n",
    "\n",
    "student_id = \"학번\"\n",
    "student_name = \"이름\"\n",
    "poster_title = \"EDIT: 어느 시설 범주의 프로그램 수 합계가 큰가?\"\n",
    "\n",
    "category_palette = {\n",
    "    \"도서관\": \"#6b7280\",\n",
    "    \"박물관\": \"#6b7280\",\n",
    "    \"문화센터\": \"#6b7280\",\n",
    "}\n",
    "\n",
    "print(\"제출자:\", student_id, student_name)\n",
    "print(\"포스터 질문:\", poster_title)\n",
    "print(\"범주 색상:\", category_palette)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 2 · 정제 데이터 확인\n",
    "\n",
    "수업용 24행 CSV를 불러오고 열, 행, 범주, 좌표의 유효성을 확인합니다. 제공 파일과 DataFrame은 수정하지 않습니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 2 · 정제 CSV 불러오기와 구조 확인 — 이 셀은 수정하지 않습니다.\n",
    "mission_step2_execution = get_ipython().execution_count\n",
    "\n",
    "source_path = Path(SAMPLE_CSV_PATH)\n",
    "source_bytes_before = source_path.read_bytes()\n",
    "assert (\n",
    "    hashlib.sha256(source_bytes_before).hexdigest() == EXPECTED_CSV_SHA256\n",
    "), \"제공 CSV 내용이 수업 기준과 다릅니다. 새 런타임에서 다시 실행하세요.\"\n",
    "facility_df = pd.read_csv(source_path)\n",
    "source_snapshot = facility_df.copy(deep=True)\n",
    "\n",
    "required_columns = {\n",
    "    \"place_id\",\n",
    "    \"place_name\",\n",
    "    \"category\",\n",
    "    \"program_count\",\n",
    "    \"latitude\",\n",
    "    \"longitude\",\n",
    "}\n",
    "missing_columns = sorted(required_columns - set(facility_df.columns))\n",
    "if missing_columns:\n",
    "    raise KeyError(\"필요한 열이 없습니다: \" + \", \".join(missing_columns))\n",
    "\n",
    "assert len(facility_df) == 24, \"수업용 정제 데이터는 24행이어야 합니다.\"\n",
    "assert facility_df[\"category\"].nunique() == 3, \"시설 범주는 세 개여야 합니다.\"\n",
    "assert facility_df[\"place_id\"].nunique() == 24, \"place_id가 중복되었습니다.\"\n",
    "assert facility_df[[\"program_count\", \"latitude\", \"longitude\"]].notna().all().all()\n",
    "assert facility_df[\"latitude\"].between(-90, 90).all()\n",
    "assert facility_df[\"longitude\"].between(-180, 180).all()\n",
    "\n",
    "print(\"데이터 크기:\", facility_df.shape)\n",
    "print(\"범주별 시설 수:\")\n",
    "print(facility_df[\"category\"].value_counts().sort_index())\n",
    "print(\"앞 5행:\")\n",
    "print(facility_df.head().to_string(index=False))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 3 · 범주별 합계\n",
    "\n",
    "시설 24행을 세 범주로 묶고 프로그램 수를 더한 뒤 작은 값부터 정렬합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 3 · 범주별 프로그램 수 합계 만들기 — 이 셀은 수정하지 않습니다.\n",
    "mission_step3_execution = get_ipython().execution_count\n",
    "\n",
    "category_summary = (\n",
    "    facility_df\n",
    "    .groupby(\"category\", as_index=False)[\"program_count\"]\n",
    "    .sum()\n",
    "    .sort_values(\"program_count\")\n",
    "    .reset_index(drop=True)\n",
    ")\n",
    "\n",
    "expected_totals = {\n",
    "    \"박물관\": 326,\n",
    "    \"도서관\": 369,\n",
    "    \"문화센터\": 400,\n",
    "}\n",
    "actual_totals = dict(\n",
    "    zip(\n",
    "        category_summary[\"category\"],\n",
    "        category_summary[\"program_count\"],\n",
    "    )\n",
    ")\n",
    "assert actual_totals == expected_totals, \"범주별 합계가 수업 기준과 다릅니다.\"\n",
    "\n",
    "print(category_summary.to_string(index=False))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 4 · 두 그래프\n",
    "\n",
    "위쪽 Axes에는 0에서 시작하는 막대 세 개를, 아래쪽 Axes에는 시설 24개의 좌표 점을 만듭니다. 좌표 점의 색상과 표식은 범주, 면적은 프로그램 수를 나타냅니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 4 · 가로 막대그래프와 위치 좌표 산점도 만들기 — 이 셀은 수정하지 않습니다.\n",
    "mission_step4_execution = get_ipython().execution_count\n",
    "\n",
    "fig, axes = plt.subplots(\n",
    "    nrows=2,\n",
    "    ncols=1,\n",
    "    figsize=(8, 11),\n",
    "    gridspec_kw={\"height_ratios\": [0.82, 1.28]},\n",
    ")\n",
    "fig.patch.set_facecolor(POSTER_PAPER)\n",
    "fig.subplots_adjust(\n",
    "    left=0.14,\n",
    "    right=0.74,\n",
    "    top=0.77,\n",
    "    bottom=0.18,\n",
    "    hspace=0.58,\n",
    ")\n",
    "\n",
    "sns.barplot(\n",
    "    data=category_summary,\n",
    "    x=\"program_count\",\n",
    "    y=\"category\",\n",
    "    hue=\"category\",\n",
    "    palette=category_palette,\n",
    "    errorbar=None,\n",
    "    legend=False,\n",
    "    edgecolor=POSTER_INK,\n",
    "    ax=axes[0],\n",
    ")\n",
    "axes[0].set_xlim(left=0)\n",
    "axes[0].set_xlabel(\"프로그램 수 합계\")\n",
    "axes[0].set_ylabel(\"\")\n",
    "axes[0].set_title(\n",
    "    \"어느 시설 범주의 프로그램 수 합계가 큰가?\",\n",
    "    loc=\"left\",\n",
    "    fontweight=\"bold\",\n",
    ")\n",
    "\n",
    "for bar in axes[0].patches:\n",
    "    value = int(round(bar.get_width()))\n",
    "    axes[0].text(\n",
    "        value + 6,\n",
    "        bar.get_y() + bar.get_height() / 2,\n",
    "        str(value),\n",
    "        va=\"center\",\n",
    "        fontweight=\"bold\",\n",
    "    )\n",
    "\n",
    "bar_count = len(axes[0].patches)\n",
    "bar_axis_left_limit = axes[0].get_xlim()[0]\n",
    "\n",
    "scatter_plot = sns.scatterplot(\n",
    "    data=facility_df,\n",
    "    x=\"longitude\",\n",
    "    y=\"latitude\",\n",
    "    hue=\"category\",\n",
    "    style=\"category\",\n",
    "    size=\"program_count\",\n",
    "    palette=category_palette,\n",
    "    markers=category_markers,\n",
    "    sizes=(60, 300),\n",
    "    alpha=0.82,\n",
    "    edgecolor=POSTER_INK,\n",
    "    linewidth=0.8,\n",
    "    legend=\"brief\",\n",
    "    ax=axes[1],\n",
    ")\n",
    "axes[1].set_xlabel(\"경도\")\n",
    "axes[1].set_ylabel(\"위도\")\n",
    "axes[1].set_title(\n",
    "    \"시설 24개는 서로 어디에 놓였는가?\",\n",
    "    loc=\"left\",\n",
    "    fontweight=\"bold\",\n",
    ")\n",
    "axes[1].set_xticks([126.90, 126.95, 127.00, 127.05, 127.10])\n",
    "axes[1].set_aspect(\"equal\", adjustable=\"datalim\")\n",
    "scatter_legend = axes[1].legend(\n",
    "    bbox_to_anchor=(1.02, 1.0),\n",
    "    loc=\"upper left\",\n",
    "    borderaxespad=0,\n",
    "    frameon=True,\n",
    "    framealpha=0.94,\n",
    "    fontsize=8,\n",
    ")\n",
    "legend_label_map = {\n",
    "    \"category\": \"시설 범주\",\n",
    "    \"program_count\": \"프로그램 수\",\n",
    "}\n",
    "for legend_text in scatter_legend.get_texts():\n",
    "    legend_text.set_text(\n",
    "        legend_label_map.get(legend_text.get_text(), legend_text.get_text())\n",
    "    )\n",
    "\n",
    "plotted_collections = [\n",
    "    collection\n",
    "    for collection in axes[1].collections\n",
    "    if len(collection.get_offsets()) > 0\n",
    "]\n",
    "plotted_offsets = [\n",
    "    collection.get_offsets() for collection in plotted_collections\n",
    "]\n",
    "scatter_point_count = sum(len(offsets) for offsets in plotted_offsets)\n",
    "\n",
    "\n",
    "def marker_path_signature(path):\n",
    "    return (\n",
    "        path.codes.tobytes() if path.codes is not None else b\"\",\n",
    "        path.vertices.round(6).tobytes(),\n",
    "    )\n",
    "\n",
    "\n",
    "scatter_unique_sizes = len({\n",
    "    round(float(size), 6)\n",
    "    for collection in plotted_collections\n",
    "    for size in collection.get_sizes()\n",
    "})\n",
    "scatter_unique_colors = len({\n",
    "    tuple(round(float(channel), 6) for channel in color)\n",
    "    for collection in plotted_collections\n",
    "    for color in collection.get_facecolors()\n",
    "})\n",
    "scatter_marker_signatures = {\n",
    "    marker_path_signature(path)\n",
    "    for collection in plotted_collections\n",
    "    for path in collection.get_paths()\n",
    "}\n",
    "scatter_unique_markers = len(scatter_marker_signatures)\n",
    "primary_scatter_collection = (\n",
    "    plotted_collections[0] if len(plotted_collections) == 1 else None\n",
    ")\n",
    "if primary_scatter_collection is None:\n",
    "    actual_scatter_offsets = []\n",
    "    actual_scatter_colors = []\n",
    "    actual_scatter_markers = []\n",
    "    actual_scatter_sizes = []\n",
    "else:\n",
    "    actual_scatter_offsets = [\n",
    "        tuple(round(float(coordinate), 6) for coordinate in point)\n",
    "        for point in primary_scatter_collection.get_offsets()\n",
    "    ]\n",
    "    actual_scatter_colors = [\n",
    "        tuple(round(float(channel), 6) for channel in color)\n",
    "        for color in primary_scatter_collection.get_facecolors()\n",
    "    ]\n",
    "    actual_scatter_markers = [\n",
    "        marker_path_signature(path)\n",
    "        for path in primary_scatter_collection.get_paths()\n",
    "    ]\n",
    "    actual_scatter_sizes = [\n",
    "        round(float(size), 6)\n",
    "        for size in primary_scatter_collection.get_sizes()\n",
    "    ]\n",
    "\n",
    "expected_scatter_offsets = [\n",
    "    (round(float(longitude), 6), round(float(latitude), 6))\n",
    "    for longitude, latitude in zip(\n",
    "        facility_df[\"longitude\"],\n",
    "        facility_df[\"latitude\"],\n",
    "    )\n",
    "]\n",
    "expected_scatter_colors = [\n",
    "    tuple(\n",
    "        round(float(channel), 6)\n",
    "        for channel in to_rgba(category_palette[category], alpha=0.82)\n",
    "    )\n",
    "    for category in facility_df[\"category\"]\n",
    "]\n",
    "expected_marker_signatures = {}\n",
    "for category, marker_symbol in category_markers.items():\n",
    "    marker_style = MarkerStyle(marker_symbol)\n",
    "    marker_path = marker_style.get_path().transformed(\n",
    "        marker_style.get_transform()\n",
    "    )\n",
    "    expected_marker_signatures[category] = marker_path_signature(marker_path)\n",
    "expected_scatter_markers = [\n",
    "    expected_marker_signatures[category]\n",
    "    for category in facility_df[\"category\"]\n",
    "]\n",
    "program_counts = facility_df[\"program_count\"].tolist()\n",
    "expected_size_order = sorted(\n",
    "    range(len(program_counts)),\n",
    "    key=program_counts.__getitem__,\n",
    ")\n",
    "actual_size_order = sorted(\n",
    "    range(len(actual_scatter_sizes)),\n",
    "    key=actual_scatter_sizes.__getitem__,\n",
    ")\n",
    "scatter_offsets_match_rows = actual_scatter_offsets == expected_scatter_offsets\n",
    "scatter_colors_follow_category = actual_scatter_colors == expected_scatter_colors\n",
    "scatter_markers_follow_category = actual_scatter_markers == expected_scatter_markers\n",
    "scatter_sizes_follow_program_count = (\n",
    "    len(actual_scatter_sizes) == len(program_counts)\n",
    "    and actual_size_order == expected_size_order\n",
    ")\n",
    "plotted_place_ids = facility_df[\"place_id\"].tolist()\n",
    "\n",
    "print(\"막대 수:\", bar_count)\n",
    "print(\"좌표 점 수:\", scatter_point_count)\n",
    "print(\n",
    "    \"좌표 표현:\",\n",
    "    f\"색상 {scatter_unique_colors} · 표식 {scatter_unique_markers} · \"\n",
    "    f\"크기 단계 {scatter_unique_sizes}\",\n",
    ")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 5 · 관찰과 한계\n",
    "\n",
    "그래프에서 실제로 확인한 합계를 포함해 관찰 문장을 작성합니다. 그다음 가상 자료와 좌표 그래프로 단정할 수 없는 내용을 한계로 적습니다. 두 문장은 줄바꿈 없이 각각 한 줄로 작성합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 5 · EDIT — 그래프에서 확인한 관찰과 해석의 한계를 작성합니다.\n",
    "mission_step5_execution = get_ipython().execution_count\n",
    "\n",
    "main_observation = (\n",
    "    \"EDIT: 합계 326·369·400 중 하나를 근거로 30자 이상 관찰하세요.\"\n",
    ")\n",
    "limitation_statement = (\n",
    "    \"EDIT: 가상 자료만으로 단정할 수 없는 내용을 30자 이상 적으세요.\"\n",
    ")\n",
    "\n",
    "print(\"관찰:\", main_observation)\n",
    "print(\"한계:\", limitation_statement)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 6 · 포스터 저장\n",
    "\n",
    "제목, 두 그래프, 관찰, 한계, 출처를 Figure 안에 배치하고 1600 × 2200 PNG로 저장합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 6 · 포스터 설명 배치와 1600 × 2200 PNG 저장 — 이 셀은 수정하지 않습니다.\n",
    "mission_step6_execution = get_ipython().execution_count\n",
    "\n",
    "safe_student_id = str(student_id).strip()\n",
    "safe_student_name = str(student_name).strip()\n",
    "if \"\\n\" in poster_title or \"\\r\" in poster_title:\n",
    "    raise AssertionError(\"질문형 제목은 줄바꿈 없이 한 줄로 작성하세요.\")\n",
    "if any(\n",
    "    line_break in text\n",
    "    for text in (main_observation, limitation_statement)\n",
    "    for line_break in (\"\\n\", \"\\r\")\n",
    "):\n",
    "    raise AssertionError(\"관찰과 한계는 줄바꿈 없이 각각 한 줄로 작성하세요.\")\n",
    "safe_name_pattern = re.compile(r\"^[0-9A-Za-z가-힣_-]+$\")\n",
    "if not (\n",
    "    safe_name_pattern.fullmatch(safe_student_id)\n",
    "    and safe_name_pattern.fullmatch(safe_student_name)\n",
    "):\n",
    "    raise AssertionError(\n",
    "        \"학번과 이름에는 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.\"\n",
    "    )\n",
    "input_text_lengths_safe = (\n",
    "    len(poster_title.strip()) <= 50\n",
    "    and len(main_observation.strip()) <= 90\n",
    "    and len(limitation_statement.strip()) <= 90\n",
    ")\n",
    "if not input_text_lengths_safe:\n",
    "    raise AssertionError(\n",
    "        \"제목은 50자, 관찰과 한계는 각각 90자 이내로 다듬어 주세요.\"\n",
    "    )\n",
    "output_filename = (\n",
    "    f\"week11_{safe_student_id}_{safe_student_name}_data_poster.png\"\n",
    ")\n",
    "\n",
    "title_artist = fig.suptitle(\n",
    "    poster_title,\n",
    "    x=0.10,\n",
    "    y=0.95,\n",
    "    ha=\"left\",\n",
    "    fontsize=22,\n",
    "    fontweight=\"bold\",\n",
    ")\n",
    "subtitle_artist = fig.text(\n",
    "    0.10,\n",
    "    0.865,\n",
    "    \"같은 24행 데이터를 범주별 합계와 상대적 위치로 다시 읽기\",\n",
    "    fontsize=11,\n",
    "    color=POSTER_MUTED,\n",
    ")\n",
    "observation_label_artist = fig.text(\n",
    "    0.10, 0.125, \"핵심 관찰\", fontsize=10, fontweight=\"bold\", color=POSTER_CORAL\n",
    ")\n",
    "observation_artist = fig.text(\n",
    "    0.10, 0.098, main_observation, fontsize=9.5, wrap=True\n",
    ")\n",
    "limitation_label_artist = fig.text(\n",
    "    0.10, 0.068, \"해석의 한계\", fontsize=10, fontweight=\"bold\", color=POSTER_CORAL\n",
    ")\n",
    "limitation_artist = fig.text(\n",
    "    0.10, 0.041, limitation_statement, fontsize=9.5, wrap=True\n",
    ")\n",
    "source_artist = fig.text(\n",
    "    0.10,\n",
    "    0.012,\n",
    "    f\"출처 · {dataset_source} · 기준일 {reference_date} · {len(facility_df)}행\",\n",
    "    fontsize=7.5,\n",
    "    color=POSTER_MUTED,\n",
    ")\n",
    "\n",
    "fig.canvas.draw()\n",
    "renderer = fig.canvas.get_renderer()\n",
    "figure_bounds = fig.bbox\n",
    "poster_text_artists = (\n",
    "    title_artist,\n",
    "    subtitle_artist,\n",
    "    observation_label_artist,\n",
    "    observation_artist,\n",
    "    limitation_label_artist,\n",
    "    limitation_artist,\n",
    "    source_artist,\n",
    ")\n",
    "poster_text_inside_canvas = all(\n",
    "    text_artist.get_window_extent(renderer).x0 >= figure_bounds.x0\n",
    "    and text_artist.get_window_extent(renderer).y0 >= figure_bounds.y0\n",
    "    and text_artist.get_window_extent(renderer).x1 <= figure_bounds.x1\n",
    "    and text_artist.get_window_extent(renderer).y1 <= figure_bounds.y1\n",
    "    for text_artist in poster_text_artists\n",
    ")\n",
    "observation_bounds = observation_artist.get_window_extent(renderer)\n",
    "limitation_label_bounds = limitation_label_artist.get_window_extent(renderer)\n",
    "limitation_bounds = limitation_artist.get_window_extent(renderer)\n",
    "source_bounds = source_artist.get_window_extent(renderer)\n",
    "footer_blocks_separated = (\n",
    "    observation_bounds.y0 > limitation_label_bounds.y1\n",
    "    and limitation_bounds.y0 > source_bounds.y1\n",
    ")\n",
    "if not poster_text_inside_canvas or not footer_blocks_separated:\n",
    "    raise AssertionError(\n",
    "        \"제목·관찰·한계가 포스터 경계를 넘거나 서로 겹칩니다. 문장을 줄여 주세요.\"\n",
    "    )\n",
    "\n",
    "fig.savefig(\n",
    "    output_filename,\n",
    "    dpi=200,\n",
    "    facecolor=POSTER_PAPER,\n",
    ")\n",
    "output_path = Path(output_filename)\n",
    "output_bytes = output_path.read_bytes()\n",
    "saved_image = mpimg.imread(output_path)\n",
    "\n",
    "print(\"저장 파일:\", output_filename)\n",
    "print(\"저장 크기:\", saved_image.shape[:2])\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## STEP 7 · 자동 검사와 내려받기\n",
    "\n",
    "수정하지 않습니다. 실패한 조건의 한글 설명을 읽고 STEP 1 또는 STEP 5만 고친 뒤, 새 런타임에서 모두 실행합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# STEP 7 · FINAL CHECK — 이 셀은 수정하지 않습니다.\n",
    "mission_step7_execution = get_ipython().execution_count\n",
    "\n",
    "execution_sequence = (\n",
    "    mission_step0_execution,\n",
    "    mission_step1_execution,\n",
    "    mission_step2_execution,\n",
    "    mission_step3_execution,\n",
    "    mission_step4_execution,\n",
    "    mission_step5_execution,\n",
    "    mission_step6_execution,\n",
    "    mission_step7_execution,\n",
    ")\n",
    "hex_color_pattern = re.compile(r\"^#[0-9A-Fa-f]{6}$\")\n",
    "palette_values = list(category_palette.values())\n",
    "title_data_terms = (\n",
    "    \"프로그램\",\n",
    "    \"범주\",\n",
    "    \"합계\",\n",
    "    \"개수\",\n",
    "    \"시설 수\",\n",
    "    \"위도\",\n",
    "    \"경도\",\n",
    "    \"좌표\",\n",
    "    \"위치\",\n",
    "    \"어디\",\n",
    ")\n",
    "common_unsupported_title_terms = (\n",
    "    \"좋은\",\n",
    "    \"최고\",\n",
    "    \"인기\",\n",
    "    \"만족\",\n",
    "    \"추천\",\n",
    "    \"유익\",\n",
    "    \"우수\",\n",
    "    \"효율\",\n",
    ")\n",
    "title_has_data_clue = (\n",
    "    any(term in poster_title for term in title_data_terms)\n",
    "    and not any(term in poster_title for term in common_unsupported_title_terms)\n",
    ")\n",
    "current_metadata = (\n",
    "    dataset_title,\n",
    "    dataset_source,\n",
    "    dataset_license,\n",
    "    reference_date,\n",
    "    observation_unit,\n",
    ")\n",
    "\n",
    "checks = [\n",
    "    (\n",
    "        execution_sequence == (1, 2, 3, 4, 5, 6, 7, 8),\n",
    "        \"새 런타임에서 STEP 0부터 여덟 셀을 순서대로 실행\",\n",
    "    ),\n",
    "    (\n",
    "        safe_student_id != \"\"\n",
    "        and safe_student_name != \"\"\n",
    "        and \"학번\" not in safe_student_id\n",
    "        and \"이름\" not in safe_student_name\n",
    "        and safe_name_pattern.fullmatch(safe_student_id)\n",
    "        and safe_name_pattern.fullmatch(safe_student_name),\n",
    "        \"학번·이름과 안전한 파일명\",\n",
    "    ),\n",
    "    (\n",
    "        not poster_title.strip().startswith(\"EDIT:\")\n",
    "        and len(poster_title.strip()) >= 15\n",
    "        and len(poster_title.strip()) <= 50\n",
    "        and poster_title.strip().endswith((\"?\", \"？\"))\n",
    "        and title_has_data_clue,\n",
    "        \"15–50자이며 데이터 단서를 포함한 질문형 제목 형식\",\n",
    "    ),\n",
    "    (\n",
    "        len(palette_values) == 3\n",
    "        and len(set(palette_values)) == 3\n",
    "        and all(hex_color_pattern.fullmatch(color) for color in palette_values),\n",
    "        \"세 범주의 서로 다른 HEX 색상\",\n",
    "    ),\n",
    "    (\n",
    "        source_path.read_bytes() == source_bytes_before\n",
    "        and hashlib.sha256(source_bytes_before).hexdigest()\n",
    "        == EXPECTED_CSV_SHA256\n",
    "        and facility_df.equals(source_snapshot),\n",
    "        \"제공 CSV와 불러온 24행 원본 보존\",\n",
    "    ),\n",
    "    (\n",
    "        len(facility_df) == 24\n",
    "        and facility_df[\"category\"].nunique() == 3\n",
    "        and facility_df[\"place_id\"].nunique() == 24,\n",
    "        \"시설 24행과 세 범주\",\n",
    "    ),\n",
    "    (\n",
    "        actual_totals == {\"박물관\": 326, \"도서관\": 369, \"문화센터\": 400},\n",
    "        \"범주별 프로그램 수 합계 326·369·400\",\n",
    "    ),\n",
    "    (\n",
    "        bar_count == 3 and abs(bar_axis_left_limit) < 1e-9,\n",
    "        \"0에서 시작하는 막대 세 개\",\n",
    "    ),\n",
    "    (\n",
    "        scatter_point_count == len(facility_df) == 24\n",
    "        and len(plotted_place_ids) == len(set(plotted_place_ids)) == 24,\n",
    "        \"정제 24행과 좌표 점 24개\",\n",
    "    ),\n",
    "    (\n",
    "        scatter_unique_colors == 3\n",
    "        and scatter_unique_markers == 3\n",
    "        and scatter_unique_sizes > 1,\n",
    "        \"색상·표식·크기로 구분한 좌표 점\",\n",
    "    ),\n",
    "    (\n",
    "        scatter_offsets_match_rows\n",
    "        and scatter_colors_follow_category\n",
    "        and scatter_markers_follow_category\n",
    "        and scatter_sizes_follow_program_count,\n",
    "        \"원본 데이터와 색상·표식·크기의 대응\",\n",
    "    ),\n",
    "    (\n",
    "        not main_observation.strip().startswith(\"EDIT:\")\n",
    "        and len(main_observation.strip()) >= 30\n",
    "        and re.search(r\"326|369|400\", main_observation),\n",
    "        \"실제 합계를 포함한 30자 이상의 관찰 문장\",\n",
    "    ),\n",
    "    (\n",
    "        not limitation_statement.strip().startswith(\"EDIT:\")\n",
    "        and len(limitation_statement.strip()) >= 30,\n",
    "        \"30자 이상의 해석 한계\",\n",
    "    ),\n",
    "    (\n",
    "        input_text_lengths_safe\n",
    "        and poster_text_inside_canvas\n",
    "        and footer_blocks_separated,\n",
    "        \"글자 수와 포스터 경계 안의 제목·관찰·한계\",\n",
    "    ),\n",
    "    (\n",
    "        output_path.exists()\n",
    "        and len(output_bytes) > 50000\n",
    "        and saved_image.shape[:2] == (2200, 1600),\n",
    "        \"1600 × 2200 데이터 포스터 PNG\",\n",
    "    ),\n",
    "    (\n",
    "        current_metadata == expected_metadata,\n",
    "        \"출처·이용 조건·기준일·관찰 단위\",\n",
    "    ),\n",
    "]\n",
    "\n",
    "failed_checks = []\n",
    "for passed, label in checks:\n",
    "    if passed:\n",
    "        print(\"✅\", label)\n",
    "    else:\n",
    "        print(\"❌\", label)\n",
    "        failed_checks.append(label)\n",
    "\n",
    "if failed_checks:\n",
    "    raise AssertionError(\n",
    "        \"위의 빨간 조건을 수정한 뒤 새 런타임에서 모두 실행하세요: \"\n",
    "        + \", \".join(failed_checks)\n",
    "    )\n",
    "\n",
    "print(\"🎉 WEEK 11 DATA POSTER COMPLETE\")\n",
    "print(\"※ 자동 검사 PASS입니다. 제목의 의미는 교수 확인 후 최종 승인됩니다.\")\n",
    "if files is not None:\n",
    "    files.download(output_filename)\n"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "name": "week-11-data-poster-mission.ipynb",
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
