{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Week 10 · 인터랙티브 위치 데이터 지도 미션\n",
        "\n",
        "수업용 가상 공공문화시설 CSV를 정제하고, 수치는 원의 크기, 범주는 색상, 장소명과 실제 값은 툴팁·팝업으로 표현한 Folium 지도를 완성합니다.\n",
        "\n",
        "**완료 조건:** 마지막 셀의 `🎉 WEEK 10 INTERACTIVE MAP COMPLETE` 확인 + 실행 결과가 남은 노트북·정제 CSV·지도 HTML 세 파일 제출.\n",
        "\n",
        "코드 전체를 새로 작성하지 않습니다. `EDIT`라고 적힌 STEP 1과 STEP 5의 값만 자신의 내용으로 수정합니다. 나머지 셀은 삭제하거나 바꾸지 않습니다.\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 0 · 환경과 수업용 원본 준비\n",
        "\n",
        "수업용 CSV 29행을 현재 세션에 만들고 데이터의 출처·관찰 단위·개인정보 기준을 준비합니다. 이 셀은 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 0 · 수업용 데이터와 실행 환경 준비 - 이 셀은 수정하지 않습니다.\n",
        "from html import escape\n",
        "from importlib import metadata\n",
        "from pathlib import Path\n",
        "import re\n",
        "import subprocess\n",
        "import sys\n",
        "\n",
        "required_packages = {\n",
        "    \"folium\": '0.20.0',\n",
        "    \"pandas\": '3.0.5',\n",
        "}\n",
        "packages_to_install = []\n",
        "for package_name, required_version in required_packages.items():\n",
        "    try:\n",
        "        installed_version = metadata.version(package_name)\n",
        "    except metadata.PackageNotFoundError:\n",
        "        installed_version = None\n",
        "    if installed_version != required_version:\n",
        "        packages_to_install.append(f\"{package_name}=={required_version}\")\n",
        "\n",
        "if packages_to_install:\n",
        "    subprocess.check_call(\n",
        "        [sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *packages_to_install]\n",
        "    )\n",
        "\n",
        "import folium\n",
        "import pandas as pd\n",
        "\n",
        "mission_step0_execution = get_ipython().execution_count\n",
        "\n",
        "SAMPLE_CSV_PATH = \"week10_public_facilities_raw.csv\"\n",
        "SAMPLE_CSV = 'place_id,place_name,category,program_count,latitude,longitude\\nC001,햇살도서관,도서관,48,37.5665,126.978\\nC002,나무도서관,도서관,35,37.572,126.99\\nC003,구름도서관,도서관,62,37.584,127.012\\nC004,샘물도서관,도서관,29,37.551,126.965\\nC005,새봄도서관,도서관,54,37.538,126.992\\nC006,한강도서관,도서관,41,37.52,126.94\\nC007,별빛도서관,도서관,67,37.603,127.025\\nC008,마루도서관,도서관,33,37.612,126.958\\nC009,모양박물관,박물관,23,37.579,126.948\\nC010,시간박물관,박물관,38,37.59,126.982\\nC011,기록박물관,박물관,57,37.561,127.028\\nC012,생활박물관,박물관,31,37.543,127.055\\nC013,도시박물관,박물관,72,37.528,127.018\\nC014,소리박물관,박물관,26,37.511,126.974\\nC015,빛박물관,박물관,45,37.596,127.067\\nC016,종이박물관,박물관,34,37.617,127.002\\nC017,푸른문화센터,문화센터,52,37.57,127.044\\nC018,열린문화센터,문화센터,64,37.548,126.925\\nC019,다온문화센터,문화센터,28,37.532,126.955\\nC020,누리문화센터,문화센터,49,37.515,127.041\\nC021,이음문화센터,문화센터,70,37.588,126.93\\nC022,마을문화센터,문화센터,37,37.607,127.048\\nC023,함께문화센터,문화센터,58,37.625,126.985\\nC024,오늘문화센터,문화센터,42,37.502,127.012\\nX001,누락좌표문화센터,문화센터,27,,127.01\\nX002,잘못된경도박물관,박물관,31,37.555,226.99\\nX003,문자수치도서관,도서관,unknown,37.563,126.96\\nC004,샘물도서관,도서관,29,37.551,126.965\\nX004,잘못된위도문화센터,문화센터,45,95.0,127.02\\n'\n",
        "Path(SAMPLE_CSV_PATH).write_text(SAMPLE_CSV, encoding=\"utf-8\")\n",
        "\n",
        "dataset_title = \"수업용 가상 공공문화시설 위치 데이터\"\n",
        "dataset_source = \"Contents Programming Practice Week 10 · 교수자 제공 가상 자료\"\n",
        "dataset_license = \"수업 목적 사용 허용\"\n",
        "reference_date = \"2026-08-18\"\n",
        "observation_unit = \"공공문화시설 한 곳\"\n",
        "cleaning_rule_summary = (\n",
        "    \"숫자 변환 실패와 좌표 결측을 제외하고, place_id 중복을 제거한 뒤 \"\n",
        "    \"위도 -90~90·경도 -180~180 범위를 적용했다.\"\n",
        ")\n",
        "privacy_statement = (\n",
        "    \"개인의 집·학교·이동 경로를 포함하지 않은 가상 공공시설 자료만 사용했다.\"\n",
        ")\n",
        "\n",
        "print(\"준비 파일:\", SAMPLE_CSV_PATH)\n",
        "print(\"데이터:\", dataset_title)\n",
        "print(\"관찰 단위:\", observation_unit)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 1 · 제출 정보와 지도 인코딩 규칙\n",
        "\n",
        "학번·이름, 지도 제목과 질문을 자신의 내용으로 바꿉니다. 반지름은 최솟값 4 이상, 최댓값 28 이하로 정하고 두 값의 차이를 6 이상으로 만듭니다. `palette_name`에는 밝은 바탕지도와의 대비를 확인한 `해안`, `숲길`, `도시` 가운데 하나를 입력합니다.\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",
        "map_title = \"공공문화시설 프로그램 지도\"\n",
        "map_question = \"시설의 위치와 프로그램 수는 지도에서 어떤 패턴을 보이는가?\"\n",
        "\n",
        "minimum_radius = 10\n",
        "maximum_radius = 10\n",
        "\n",
        "palette_options = {'해안': {'도서관': '#25696f', '박물관': '#b94736', '문화센터': '#455b92'}, '숲길': {'도서관': '#315f49', '박물관': '#8a4b2a', '문화센터': '#56457a'}, '도시': {'도서관': '#2f5d8c', '박물관': '#8c3f57', '문화센터': '#5a651f'}}\n",
        "approved_palette_names = set(palette_options)\n",
        "palette_name = \"선택 전\"\n",
        "starter_category_colors = {'도서관': '#6b7280', '박물관': '#6b7280', '문화센터': '#6b7280'}\n",
        "category_colors = palette_options.get(\n",
        "    palette_name,\n",
        "    starter_category_colors,\n",
        ").copy()\n",
        "\n",
        "print(\"제출자:\", student_id, student_name)\n",
        "print(\"지도 제목:\", map_title)\n",
        "print(\"지도 질문:\", map_question)\n",
        "print(\"반지름 범위:\", minimum_radius, \"부터\", maximum_radius)\n",
        "print(\"선택 가능한 팔레트:\", sorted(approved_palette_names))\n",
        "print(\"선택한 팔레트:\", palette_name)\n",
        "print(\"범주 색상:\", category_colors)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 2 · 원본 구조 확인\n",
        "\n",
        "원본 29행, 여섯 열, 결측값과 중복 식별자를 확인합니다. `raw_df`와 원본 파일은 이후 단계에서도 바꾸지 않습니다.\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",
        "raw_df = pd.read_csv(source_path)\n",
        "raw_snapshot = raw_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(raw_df.columns))\n",
        "if missing_columns:\n",
        "    raise KeyError(\"필요한 열이 없습니다: \" + \", \".join(missing_columns))\n",
        "\n",
        "print(\"원본 크기:\", raw_df.shape)\n",
        "print(\"열 이름:\", list(raw_df.columns))\n",
        "print(\"열별 결측값:\")\n",
        "print(raw_df.isna().sum())\n",
        "print(\"place_id 중복 수:\", raw_df.duplicated(subset=[\"place_id\"]).sum())\n",
        "print(\"원본 앞 5행:\")\n",
        "print(raw_df.head().to_string(index=False))\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 3 · 데이터 정제\n",
        "\n",
        "작업 사본에서 숫자 변환, 결측 처리, 중복 처리, 좌표 범위 검사를 차례대로 실행합니다. 출력은 `29 → 27 → 26 → 24`행이어야 합니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 3 · 숫자·결측·중복·좌표 범위 정제 - 이 셀은 수정하지 않습니다.\n",
        "mission_step3_execution = get_ipython().execution_count\n",
        "\n",
        "clean_df = raw_df.copy(deep=True)\n",
        "numeric_columns = [\"program_count\", \"latitude\", \"longitude\"]\n",
        "\n",
        "for column_name in numeric_columns:\n",
        "    clean_df[column_name] = pd.to_numeric(\n",
        "        clean_df[column_name],\n",
        "        errors=\"coerce\",\n",
        "    )\n",
        "\n",
        "rows_after_conversion = len(clean_df)\n",
        "clean_df = clean_df.dropna(\n",
        "    subset=[\"program_count\", \"latitude\", \"longitude\"],\n",
        ")\n",
        "rows_after_missing = len(clean_df)\n",
        "clean_df = clean_df.drop_duplicates(\n",
        "    subset=[\"place_id\"],\n",
        "    keep=\"first\",\n",
        ")\n",
        "rows_after_duplicates = len(clean_df)\n",
        "\n",
        "valid_coordinate_mask = (\n",
        "    clean_df[\"latitude\"].between(-90, 90)\n",
        "    & clean_df[\"longitude\"].between(-180, 180)\n",
        ")\n",
        "clean_df = clean_df.loc[valid_coordinate_mask].copy()\n",
        "clean_df[\"program_count\"] = clean_df[\"program_count\"].astype(int)\n",
        "clean_df = clean_df.reset_index(drop=True)\n",
        "\n",
        "cleaning_counts = {\n",
        "    \"원본\": len(raw_df),\n",
        "    \"숫자 변환 직후\": rows_after_conversion,\n",
        "    \"결측 처리 후\": rows_after_missing,\n",
        "    \"중복 처리 후\": rows_after_duplicates,\n",
        "    \"좌표 범위 처리 후\": len(clean_df),\n",
        "}\n",
        "\n",
        "print(\"행 수 변화:\", cleaning_counts)\n",
        "print(\"범주별 시설 수:\")\n",
        "print(clean_df[\"category\"].value_counts().sort_index())\n",
        "print(\"제외한 행:\", len(raw_df) - len(clean_df))\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 4 · Folium 지도와 마커 생성\n",
        "\n",
        "정제된 한 행을 원형 마커 하나로 바꿉니다. 위도·경도는 위치, 프로그램 수는 반지름, 시설 범주는 색상, 장소명·범주·실제 값은 툴팁과 팝업에 사용됩니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 4 · 크기·색상·문자 규칙으로 지도 만들기 - 이 셀은 수정하지 않습니다.\n",
        "mission_step4_execution = get_ipython().execution_count\n",
        "\n",
        "minimum_value = int(clean_df[\"program_count\"].min())\n",
        "maximum_value = int(clean_df[\"program_count\"].max())\n",
        "\n",
        "\n",
        "def scale_radius(program_count):\n",
        "    if minimum_value == maximum_value:\n",
        "        return (minimum_radius + maximum_radius) / 2\n",
        "\n",
        "    value_ratio = (\n",
        "        (program_count - minimum_value)\n",
        "        / (maximum_value - minimum_value)\n",
        "    )\n",
        "    return minimum_radius + value_ratio * (\n",
        "        maximum_radius - minimum_radius\n",
        "    )\n",
        "\n",
        "\n",
        "def category_to_color(category):\n",
        "    if category not in category_colors:\n",
        "        raise ValueError(\"색상이 지정되지 않은 범주입니다: \" + str(category))\n",
        "    return category_colors[category]\n",
        "\n",
        "\n",
        "map_center = [\n",
        "    clean_df[\"latitude\"].mean(),\n",
        "    clean_df[\"longitude\"].mean(),\n",
        "]\n",
        "facility_map = folium.Map(\n",
        "    location=map_center,\n",
        "    zoom_start=12,\n",
        "    tiles=\"CartoDB positron\",\n",
        "    control_scale=True,\n",
        ")\n",
        "week10_map_overlay_css = '.week10-map-panel {\\n  box-sizing: border-box;\\n  position: fixed;\\n  z-index: 800;\\n  border: 2px solid #1c1f1e;\\n  border-radius: 4px;\\n  background: rgba(252, 250, 245, 0.96);\\n  color: #1c1f1e;\\n  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.16);\\n  font: 14px/1.45 Arial, sans-serif;\\n}\\n\\n.week10-map-info {\\n  top: 12px;\\n  left: 64px;\\n  width: min(410px, calc(100vw - 84px));\\n}\\n\\n.week10-map-info summary {\\n  min-height: 44px;\\n  padding: 10px 42px 10px 14px;\\n  display: grid;\\n  gap: 2px;\\n  align-content: center;\\n  position: relative;\\n  cursor: pointer;\\n  list-style: none;\\n  font-size: 17px;\\n  font-weight: 700;\\n}\\n\\n.week10-map-info summary::-webkit-details-marker { display: none; }\\n\\n.week10-map-info summary::after {\\n  content: \"+\";\\n  position: absolute;\\n  top: 50%;\\n  right: 15px;\\n  transform: translateY(-50%);\\n  font-size: 20px;\\n  font-weight: 400;\\n}\\n\\n.week10-map-info[open] summary::after { content: \"−\"; }\\n\\n.week10-map-info summary small {\\n  color: #555b57;\\n  font-size: 14px;\\n  font-weight: 400;\\n}\\n\\n.week10-map-info summary:focus-visible {\\n  outline: 3px solid #25696f;\\n  outline-offset: 3px;\\n}\\n\\n.week10-map-panel-body {\\n  max-height: min(42vh, 330px);\\n  padding: 0 16px 14px;\\n  overflow: auto;\\n}\\n\\n.week10-map-panel-body p { margin: 8px 0; }\\n.week10-map-panel-body > small {\\n  color: #555b57;\\n  font-size: 14px;\\n}\\n\\n.week10-map-data { margin-top: 12px; }\\n\\n.week10-map-data summary {\\n  min-height: 44px;\\n  display: flex;\\n  align-items: center;\\n  color: #25696f;\\n  font-weight: 700;\\n  cursor: pointer;\\n}\\n\\n.week10-map-data summary:focus-visible {\\n  outline: 3px solid #25696f;\\n  outline-offset: 2px;\\n}\\n\\n.week10-map-table-scroll {\\n  max-height: 230px;\\n  overflow: auto;\\n  border: 1px solid #8c918c;\\n}\\n\\n.week10-map-data table {\\n  width: 100%;\\n  border-collapse: collapse;\\n  background: #fff;\\n  font-size: 14px;\\n}\\n\\n.week10-map-data caption {\\n  padding: 8px;\\n  text-align: left;\\n  font-weight: 700;\\n}\\n\\n.week10-map-data th,\\n.week10-map-data td {\\n  padding: 7px 8px;\\n  border-top: 1px solid #c7cac6;\\n  text-align: left;\\n  white-space: nowrap;\\n}\\n\\n.week10-map-data thead th {\\n  position: sticky;\\n  top: 0;\\n  background: #edf1ee;\\n}\\n\\n.week10-map-legend {\\n  right: 16px;\\n  bottom: 32px;\\n  min-width: 164px;\\n  padding: 13px 15px;\\n}\\n\\n.week10-map-legend small { font-size: 14px; }\\n\\n.week10-map-legend ul {\\n  margin: 8px 0;\\n  padding: 0;\\n  display: grid;\\n  gap: 7px;\\n  list-style: none;\\n}\\n\\n.week10-map-legend li {\\n  margin: 0;\\n  display: flex;\\n  gap: 8px;\\n  align-items: center;\\n}\\n\\n.week10-map-swatch {\\n  width: 14px;\\n  height: 14px;\\n  border: 1px solid #1c1f1e;\\n  border-radius: 50%;\\n  display: inline-block;\\n  background: var(--marker-color);\\n}\\n\\n@media (max-width: 760px) {\\n  .week10-map-info {\\n    top: 64px;\\n    left: 12px;\\n    width: calc(100vw - 24px);\\n  }\\n\\n  .week10-map-panel-body { max-height: 28vh; }\\n\\n  .week10-map-legend {\\n    right: auto;\\n    bottom: 42px;\\n    left: 12px;\\n    max-width: calc(100vw - 24px);\\n  }\\n}'\n",
        "facility_map.get_root().header.add_child(\n",
        "    folium.Element(\"<style>\" + week10_map_overlay_css + \"</style>\")\n",
        ")\n",
        "\n",
        "marker_records = []\n",
        "\n",
        "for row_index, row in clean_df.iterrows():\n",
        "    latitude = float(row[\"latitude\"])\n",
        "    longitude = float(row[\"longitude\"])\n",
        "    program_count = int(row[\"program_count\"])\n",
        "    marker_radius = float(scale_radius(program_count))\n",
        "    marker_color = category_to_color(row[\"category\"])\n",
        "    tooltip_text = row[\"place_name\"] + \" · \" + row[\"category\"]\n",
        "    popup_text = (\n",
        "        row[\"place_name\"]\n",
        "        + \" | 범주: \"\n",
        "        + row[\"category\"]\n",
        "        + \" | 프로그램 수: \"\n",
        "        + str(program_count)\n",
        "    )\n",
        "\n",
        "    folium.CircleMarker(\n",
        "        location=[latitude, longitude],\n",
        "        radius=marker_radius,\n",
        "        color=marker_color,\n",
        "        weight=2,\n",
        "        fill=True,\n",
        "        fill_color=marker_color,\n",
        "        fill_opacity=0.78,\n",
        "        tooltip=tooltip_text,\n",
        "        popup=popup_text,\n",
        "    ).add_to(facility_map)\n",
        "\n",
        "    marker_records.append(\n",
        "        {\n",
        "            \"place_id\": row[\"place_id\"],\n",
        "            \"place_name\": row[\"place_name\"],\n",
        "            \"category\": row[\"category\"],\n",
        "            \"program_count\": program_count,\n",
        "            \"latitude\": latitude,\n",
        "            \"longitude\": longitude,\n",
        "            \"radius\": marker_radius,\n",
        "            \"color\": marker_color,\n",
        "            \"tooltip\": tooltip_text,\n",
        "            \"popup\": popup_text,\n",
        "        }\n",
        "    )\n",
        "\n",
        "legend_items = \"\".join(\n",
        "    (\n",
        "        '<li>'\n",
        "        f'<span class=\"week10-map-swatch\" style=\"--marker-color:{escape(color)}\"></span>'\n",
        "        f'<span>{escape(category)}</span></li>'\n",
        "    )\n",
        "    for category, color in category_colors.items()\n",
        ")\n",
        "legend_html = f'''<aside class=\"week10-map-panel week10-map-legend\" aria-label=\"시설 범주 범례\"><strong>시설 범주</strong><ul>{legend_items}</ul><small>원 크기 = 프로그램 수</small></aside>'''\n",
        "facility_map.get_root().html.add_child(folium.Element(legend_html))\n",
        "\n",
        "marker_count = len(marker_records)\n",
        "print(\"정제 행 수:\", len(clean_df))\n",
        "print(\"생성 마커 수:\", marker_count)\n",
        "print(\"지도 중심:\", map_center)\n",
        "facility_map\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## STEP 5 · 지도 관찰·한계 작성과 파일 저장\n",
        "\n",
        "지도를 직접 확대하고 마커를 가리키거나 누른 뒤, 확인한 패턴과 이 지도로 단정할 수 없는 내용을 각각 30자 이상 작성합니다. 셀을 실행하면 정제 CSV와 지도 HTML이 저장됩니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# STEP 5 · EDIT - 지도 관찰과 한계를 작성한 뒤 두 결과 파일을 저장합니다.\n",
        "mission_step5_execution = get_ipython().execution_count\n",
        "\n",
        "pattern_observation = (\n",
        "    \"수업용 지도를 살펴보고 위치·크기·색상에서 확인한 패턴을 자신의 문장으로 작성하세요.\"\n",
        ")\n",
        "limitation_statement = (\n",
        "    \"이 지도로 단정할 수 없는 내용과 데이터의 범위를 자신의 문장으로 작성하세요.\"\n",
        ")\n",
        "\n",
        "trimmed_student_id = str(student_id).strip()\n",
        "trimmed_student_name = str(student_name).strip()\n",
        "filename_student_id = re.sub(\n",
        "    r\"[^0-9A-Za-z가-힣_-]+\",\n",
        "    \"_\",\n",
        "    trimmed_student_id,\n",
        ").strip(\"_\")\n",
        "filename_student_name = re.sub(\n",
        "    r\"[^0-9A-Za-z가-힣_-]+\",\n",
        "    \"_\",\n",
        "    trimmed_student_name,\n",
        ").strip(\"_\")\n",
        "cleaned_filename = (\n",
        "    f\"week10_{filename_student_id}_{filename_student_name}_cleaned.csv\"\n",
        ")\n",
        "map_filename = f\"week10_{filename_student_id}_{filename_student_name}_map.html\"\n",
        "\n",
        "facility_table_rows = \"\".join(\n",
        "    \"<tr>\"\n",
        "    + f\"<th scope='row'>{escape(str(row['place_name']))}</th>\"\n",
        "    + f\"<td>{escape(str(row['category']))}</td>\"\n",
        "    + f\"<td>{int(row['program_count'])}</td>\"\n",
        "    + \"</tr>\"\n",
        "    for _, row in clean_df.iterrows()\n",
        ")\n",
        "facility_table_html = (\n",
        "    '<details class=\"week10-map-data\"><summary>시설 24개 텍스트 표</summary>'\n",
        "    '<div class=\"week10-map-table-scroll\"><table>'\n",
        "    '<caption>지도 마커의 텍스트 대체 정보</caption>'\n",
        "    '<thead><tr><th scope=\"col\">장소</th><th scope=\"col\">범주</th>'\n",
        "    '<th scope=\"col\">프로그램 수</th></tr></thead>'\n",
        "    f'<tbody>{facility_table_rows}</tbody></table></div></details>'\n",
        ")\n",
        "information_html = f'''<details class=\"week10-map-panel week10-map-info\"><summary>{escape(map_title)}<small>지도 설명 열기</small></summary><div class=\"week10-map-panel-body\"><p><b>질문</b> · {escape(map_question)}</p><p><b>관찰</b> · {escape(pattern_observation)}</p><p><b>한계</b> · {escape(limitation_statement)}</p><small>{escape(dataset_source)} · 기준일 {escape(reference_date)} · 정제 {len(clean_df)}행</small>{facility_table_html}</div></details>'''\n",
        "facility_map.get_root().html.add_child(folium.Element(information_html))\n",
        "\n",
        "clean_df.to_csv(\n",
        "    cleaned_filename,\n",
        "    index=False,\n",
        "    encoding=\"utf-8-sig\",\n",
        ")\n",
        "facility_map.save(map_filename)\n",
        "saved_map_text = Path(map_filename).read_text(encoding=\"utf-8\")\n",
        "saved_map_text = saved_map_text.replace(\n",
        "    \", maximum-scale=1.0, user-scalable=no\",\n",
        "    \"\",\n",
        ")\n",
        "saved_map_text = \"\\n\".join(\n",
        "    line.rstrip() for line in saved_map_text.splitlines()\n",
        ") + \"\\n\"\n",
        "Path(map_filename).write_text(saved_map_text, encoding=\"utf-8\")\n",
        "\n",
        "print(\"정제 CSV 저장:\", cleaned_filename)\n",
        "print(\"지도 HTML 저장:\", map_filename)\n",
        "print(\"지도 관찰:\", pattern_observation)\n",
        "print(\"해석의 한계:\", limitation_statement)\n",
        "facility_map\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## FINAL CHECK · 새 런타임 전체 실행\n",
        "\n",
        "수정이 끝나면 **런타임 → 세션 다시 시작**, 이어서 **런타임 → 모두 실행**을 한 번 선택합니다. 마지막 셀은 실행 순서, 원본 보존, 정제 결과, 지도 인코딩, 설명과 저장 파일을 함께 검사합니다. 검사 코드는 수정하지 않습니다.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "# FINAL CHECK · 새 런타임에서 STEP 0부터 모두 실행한 뒤 확인합니다.\n",
        "mission_final_execution = get_ipython().execution_count\n",
        "\n",
        "execution_order_ok = (\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_final_execution,\n",
        ") == (1, 2, 3, 4, 5, 6, 7)\n",
        "\n",
        "expected_categories = {\"도서관\", \"박물관\", \"문화센터\"}\n",
        "expected_clean_ids = {f\"C{number:03d}\" for number in range(1, 25)}\n",
        "expected_cleaned_filename = (\n",
        "    f\"week10_{filename_student_id}_{filename_student_name}_cleaned.csv\"\n",
        ")\n",
        "expected_map_filename = (\n",
        "    f\"week10_{filename_student_id}_{filename_student_name}_map.html\"\n",
        ")\n",
        "\n",
        "identity_ok = (\n",
        "    trimmed_student_id not in {\"\", \"학번\", \"20260000\"}\n",
        "    and trimmed_student_name not in {\"\", \"이름\", \"홍길동\"}\n",
        "    and filename_student_id != \"\"\n",
        "    and filename_student_name != \"\"\n",
        "    and \"/\" not in trimmed_student_id + trimmed_student_name\n",
        "    and \"\\\\\" not in trimmed_student_id + trimmed_student_name\n",
        ")\n",
        "title_question_ok = (\n",
        "    map_title != \"공공문화시설 프로그램 지도\"\n",
        "    and len(map_title.strip()) >= 8\n",
        "    and map_question != \"시설의 위치와 프로그램 수는 지도에서 어떤 패턴을 보이는가?\"\n",
        "    and len(map_question.strip()) >= 20\n",
        "    and (\"?\" in map_question or \"？\" in map_question)\n",
        ")\n",
        "source_context_ok = (\n",
        "    len(dataset_source.strip()) >= 10\n",
        "    and len(dataset_license.strip()) >= 5\n",
        "    and len(reference_date.strip()) >= 8\n",
        "    and observation_unit == \"공공문화시설 한 곳\"\n",
        "    and \"개인\" in privacy_statement\n",
        "    and \"이동 경로\" in privacy_statement\n",
        ")\n",
        "raw_preserved_ok = (\n",
        "    len(raw_df) == 29\n",
        "    and raw_df.equals(raw_snapshot)\n",
        "    and source_path.read_bytes() == source_bytes_before\n",
        ")\n",
        "cleaning_ok = (\n",
        "    cleaning_counts\n",
        "    == {\n",
        "        \"원본\": 29,\n",
        "        \"숫자 변환 직후\": 29,\n",
        "        \"결측 처리 후\": 27,\n",
        "        \"중복 처리 후\": 26,\n",
        "        \"좌표 범위 처리 후\": 24,\n",
        "    }\n",
        "    and set(clean_df[\"place_id\"]) == expected_clean_ids\n",
        "    and set(clean_df[\"category\"]) == expected_categories\n",
        "    and clean_df[\"latitude\"].between(-90, 90).all()\n",
        "    and clean_df[\"longitude\"].between(-180, 180).all()\n",
        "    and not clean_df[[\"program_count\", \"latitude\", \"longitude\"]].isna().any().any()\n",
        "    and not clean_df[\"place_id\"].duplicated().any()\n",
        ")\n",
        "radius_rule_ok = (\n",
        "    isinstance(minimum_radius, (int, float))\n",
        "    and isinstance(maximum_radius, (int, float))\n",
        "    and 4 <= minimum_radius < maximum_radius <= 28\n",
        "    and maximum_radius - minimum_radius >= 6\n",
        "    and scale_radius(minimum_value) == minimum_radius\n",
        "    and scale_radius(maximum_value) == maximum_radius\n",
        ")\n",
        "color_rule_ok = (\n",
        "    palette_name in approved_palette_names\n",
        "    and category_colors == palette_options.get(palette_name)\n",
        "    and set(category_colors) == expected_categories\n",
        "    and all(\n",
        "        re.fullmatch(r\"#[0-9a-fA-F]{6}\", color) is not None\n",
        "        for color in category_colors.values()\n",
        "    )\n",
        ")\n",
        "marker_rule_ok = (\n",
        "    marker_count == len(clean_df) == 24\n",
        "    and len(marker_records) == 24\n",
        "    and all(\n",
        "        record[\"color\"] == category_colors[record[\"category\"]]\n",
        "        and record[\"radius\"] == scale_radius(record[\"program_count\"])\n",
        "        and record[\"place_name\"] in record[\"tooltip\"]\n",
        "        and record[\"category\"] in record[\"tooltip\"]\n",
        "        and record[\"place_name\"] in record[\"popup\"]\n",
        "        and str(record[\"program_count\"]) in record[\"popup\"]\n",
        "        for record in marker_records\n",
        "    )\n",
        ")\n",
        "explanation_ok = (\n",
        "    \"작성하세요\" not in pattern_observation\n",
        "    and len(pattern_observation.strip()) >= 30\n",
        "    and \"작성하세요\" not in limitation_statement\n",
        "    and len(limitation_statement.strip()) >= 30\n",
        ")\n",
        "filenames_ok = (\n",
        "    cleaned_filename == expected_cleaned_filename\n",
        "    and map_filename == expected_map_filename\n",
        "    and Path(cleaned_filename).is_file()\n",
        "    and Path(map_filename).is_file()\n",
        "    and Path(cleaned_filename).stat().st_size > 0\n",
        "    and Path(map_filename).stat().st_size > 0\n",
        ")\n",
        "\n",
        "saved_df = pd.read_csv(cleaned_filename)\n",
        "saved_map_html = Path(map_filename).read_text(encoding=\"utf-8\")\n",
        "saved_outputs_ok = (\n",
        "    len(saved_df) == len(clean_df)\n",
        "    and set(saved_df[\"place_id\"]) == set(clean_df[\"place_id\"])\n",
        "    and saved_map_html.count(\"L.circleMarker(\") == 24\n",
        "    and escape(map_title) in saved_map_html\n",
        "    and escape(map_question) in saved_map_html\n",
        "    and escape(pattern_observation) in saved_map_html\n",
        "    and escape(limitation_statement) in saved_map_html\n",
        "    and all(name in saved_map_html for name in clean_df[\"place_name\"])\n",
        "    and \"week10-map-info\" in saved_map_html\n",
        "    and \"week10-map-legend\" in saved_map_html\n",
        "    and \"week10-map-data\" in saved_map_html\n",
        "    and \"지도 마커의 텍스트 대체 정보\" in saved_map_html\n",
        "    and \"user-scalable=no\" not in saved_map_html\n",
        ")\n",
        "\n",
        "checks = {\n",
        "    \"새 런타임에서 STEP 0부터 일곱 셀 순서대로 실행\": execution_order_ok,\n",
        "    \"학번·이름과 안전한 파일명\": identity_ok,\n",
        "    \"자신의 지도 제목과 질문\": title_question_ok,\n",
        "    \"출처·이용 조건·기준일·관찰 단위·개인정보 기록\": source_context_ok,\n",
        "    \"raw_df 29행과 원본 파일 보존\": raw_preserved_ok,\n",
        "    \"29→27→26→24행 정제와 세 범주\": cleaning_ok,\n",
        "    \"수치가 커질수록 커지는 4~28픽셀 반지름\": radius_rule_ok,\n",
        "    \"밝은 바탕지도에서 읽을 수 있는 승인 팔레트\": color_rule_ok,\n",
        "    \"정제 24행과 위치·크기·색상·문자가 있는 마커 24개\": marker_rule_ok,\n",
        "    \"30자 이상의 지도 관찰과 해석 한계\": explanation_ok,\n",
        "    \"규칙에 맞는 정제 CSV와 지도 HTML 파일\": filenames_ok,\n",
        "    \"저장 파일과 현재 데이터·지도 상태 일치\": saved_outputs_ok,\n",
        "}\n",
        "\n",
        "failed_checks = [label for label, passed in checks.items() if not passed]\n",
        "\n",
        "print(\"=\" * 64)\n",
        "print(\"WEEK 10 FINAL CHECK\")\n",
        "print(\"=\" * 64)\n",
        "for label, passed in checks.items():\n",
        "    print(\"✅\" if passed else \"❌\", label)\n",
        "\n",
        "if failed_checks:\n",
        "    print(\"\\n아직 통과하지 못한 조건:\")\n",
        "    for label in failed_checks:\n",
        "        print(\"-\", label)\n",
        "    raise AssertionError(\n",
        "        \"위 조건을 수정한 뒤 런타임을 다시 시작하고 모두 실행하세요.\"\n",
        "    )\n",
        "\n",
        "print(\"\\n정제 CSV:\", cleaned_filename)\n",
        "print(\"지도 HTML:\", map_filename)\n",
        "print(\"🎉 WEEK 10 INTERACTIVE MAP COMPLETE\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## 제출 후 귀가\n",
        "\n",
        "1. 마지막 셀에서 `🎉 WEEK 10 INTERACTIVE MAP COMPLETE`를 확인합니다.\n",
        "2. **파일 → 다운로드 → .ipynb 다운로드**로 실행 결과가 포함된 노트북을 받습니다.\n",
        "3. 왼쪽 파일 목록에서 `week10_학번_이름_cleaned.csv`와 `week10_학번_이름_map.html`을 각각 내려받습니다.\n",
        "4. 노트북·정제 CSV·지도 HTML 세 파일을 제출합니다.\n",
        "5. 업로드 완료를 확인받으면 남은 시간과 관계없이 즉시 귀가합니다.\n"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "name": "week-10-interactive-map-mission.ipynb",
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
