{"spec_id":"map-tile-background","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nmap-tile-background: Map with Tile Background\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-05-27\n\"\"\"\n\nimport os\nimport sys\n\n\n# Work around filename shadowing the altair library\nsys.path.pop(0)\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data: European landmarks with visitor counts (millions annually)\nnp.random.seed(42)\n\nlandmarks = {\n    \"name\": [\n        \"Eiffel Tower\",\n        \"Colosseum\",\n        \"Sagrada Familia\",\n        \"Big Ben\",\n        \"Rijksmuseum\",\n        \"Brandenburg Gate\",\n        \"Prague Castle\",\n        \"Schonbrunn Palace\",\n        \"Louvre Museum\",\n        \"Vatican Museums\",\n        \"Alhambra\",\n        \"Buckingham Palace\",\n        \"Anne Frank House\",\n        \"Acropolis\",\n        \"Charles Bridge\",\n        \"Manneken Pis\",\n        \"Tower of Pisa\",\n        \"Notre-Dame\",\n        \"Trevi Fountain\",\n        \"La Rambla\",\n    ],\n    \"lat\": [\n        48.8584,\n        41.8902,\n        41.4036,\n        51.5007,\n        52.3600,\n        52.5163,\n        50.0911,\n        48.1845,\n        48.8606,\n        41.9065,\n        37.1760,\n        51.5014,\n        52.3752,\n        37.9715,\n        50.0865,\n        50.8450,\n        43.7230,\n        48.8530,\n        41.9009,\n        41.3809,\n    ],\n    \"lon\": [\n        2.2945,\n        12.4922,\n        2.1744,\n        -0.1246,\n        4.8852,\n        13.3777,\n        14.4006,\n        16.3122,\n        2.3376,\n        12.4536,\n        -3.5881,\n        -0.1419,\n        4.8840,\n        23.7257,\n        14.4114,\n        4.3499,\n        10.3966,\n        2.3499,\n        12.4833,\n        2.1734,\n    ],\n    \"visitors\": [7.0, 7.6, 4.5, 2.0, 2.7, 3.0, 1.9, 4.0, 9.6, 6.9, 2.7, 0.8, 1.3, 3.0, 1.5, 0.5, 5.5, 12.0, 3.5, 37.0],\n    \"category\": [\n        \"Monument\",\n        \"Historical\",\n        \"Religious\",\n        \"Monument\",\n        \"Museum\",\n        \"Monument\",\n        \"Historical\",\n        \"Historical\",\n        \"Museum\",\n        \"Museum\",\n        \"Historical\",\n        \"Historical\",\n        \"Museum\",\n        \"Historical\",\n        \"Monument\",\n        \"Monument\",\n        \"Monument\",\n        \"Religious\",\n        \"Monument\",\n        \"Street\",\n    ],\n}\n\ndf = pd.DataFrame(landmarks)\n\n# Tile grid: 7 wide × 4 tall gives 1.75:1 aspect ≈ 16:9 landscape\nzoom = 5\ntx_min, tx_max = 14, 20  # 7 tiles wide (lon ≈ −22° to 56°)\nty_min, ty_max = 9, 12  # 4 tiles tall (lat ≈ 32° to 62°)\n\nn_tiles_x = tx_max - tx_min + 1  # 7\nn_tiles_y = ty_max - ty_min + 1  # 4\n\n# Inner view: 560×320; tile_disp = 80 each (square tiles — no Mercator distortion)\nchart_width = 560\nchart_height = 320\ntile_disp_w = chart_width / n_tiles_x  # 80.0\ntile_disp_h = chart_height / n_tiles_y  # 80.0\n\n# Web Mercator projection: lon/lat → pixel coords within the chart\ndf[\"tile_x\"] = (df[\"lon\"] + 180) / 360 * (2**zoom)\ndf[\"lat_rad\"] = np.radians(df[\"lat\"])\ndf[\"tile_y\"] = (1 - np.log(np.tan(df[\"lat_rad\"]) + 1 / np.cos(df[\"lat_rad\"])) / np.pi) / 2 * (2**zoom)\ndf[\"x_pix\"] = (df[\"tile_x\"] - tx_min) * tile_disp_w\ndf[\"y_pix\"] = (df[\"tile_y\"] - ty_min) * tile_disp_h\n\ndf = df[(df[\"x_pix\"] >= 0) & (df[\"x_pix\"] <= chart_width) & (df[\"y_pix\"] >= 0) & (df[\"y_pix\"] <= chart_height)]\n\n# Tile provider: CartoDB Positron (light, muted) or CartoDB Dark Matter (dark)\n# Demonstrates multiple tile provider support per spec; muted basemap lets anyplot colors pop\nif THEME == \"dark\":\n    tile_base_url = \"https://basemaps.cartocdn.com/dark_all\"\n    tile_provider = \"CartoDB Dark Matter\"\nelse:\n    tile_base_url = \"https://basemaps.cartocdn.com/light_all\"\n    tile_provider = \"CartoDB Positron\"\n\n# Build tile grid for mark_image\ntiles = []\nfor tx in range(tx_min, tx_max + 1):\n    for ty in range(ty_min, ty_max + 1):\n        tiles.append(\n            {\n                \"url\": f\"{tile_base_url}/{zoom}/{tx}/{ty}.png\",\n                \"x\": (tx - tx_min + 0.5) * tile_disp_w,\n                \"y\": (ty - ty_min + 0.5) * tile_disp_h,\n            }\n        )\ntiles_df = pd.DataFrame(tiles)\n\n# Layer 1: CartoDB tile background (Positron in light, Dark Matter in dark)\ntile_layer = (\n    alt.Chart(tiles_df)\n    .mark_image(width=tile_disp_w, height=tile_disp_h)\n    .encode(\n        url=\"url:N\",\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[0, chart_width], nice=False), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[0, chart_height], nice=False), axis=None),\n    )\n)\n\n# Category → Imprint palette (canonical positions 1-5)\ncategory_colors = {\n    \"Monument\": IMPRINT_PALETTE[0],  # #009E73 green\n    \"Historical\": IMPRINT_PALETTE[1],  # #C475FD lavender\n    \"Museum\": IMPRINT_PALETTE[2],  # #4467A3 blue\n    \"Religious\": IMPRINT_PALETTE[3],  # #BD8233 ochre\n    \"Street\": IMPRINT_PALETTE[4],  # #AE3030 red\n}\n\n# Layer 2: landmark circles sized by visitors\npoints_layer = (\n    alt.Chart(df)\n    .mark_circle(opacity=0.85, stroke=\"#FFFFFF\", strokeWidth=2)\n    .encode(\n        x=alt.X(\"x_pix:Q\", scale=alt.Scale(domain=[0, chart_width], nice=False), axis=None),\n        y=alt.Y(\"y_pix:Q\", scale=alt.Scale(domain=[0, chart_height], nice=False), axis=None),\n        size=alt.Size(\n            \"visitors:Q\",\n            scale=alt.Scale(domain=[0.5, 40], range=[80, 1200]),\n            legend=alt.Legend(\n                title=\"Visitors (M/year)\", titleFontSize=14, labelFontSize=12, orient=\"bottom-left\", tickCount=4\n            ),\n        ),\n        color=alt.Color(\n            \"category:N\",\n            scale=alt.Scale(domain=list(category_colors.keys()), range=list(category_colors.values())),\n            legend=alt.Legend(title=\"Category\", titleFontSize=14, labelFontSize=12, orient=\"bottom-right\"),\n        ),\n        tooltip=[\n            alt.Tooltip(\"name:N\", title=\"Landmark\"),\n            alt.Tooltip(\"visitors:Q\", title=\"Visitors (M)\", format=\".1f\"),\n            alt.Tooltip(\"category:N\", title=\"Category\"),\n        ],\n    )\n)\n\n# Layer 3: labels for top landmarks (≥10M threshold avoids cluster overlap)\nlabels_df = df[df[\"visitors\"] >= 10].copy()\nlabels_layer = (\n    alt.Chart(labels_df)\n    .mark_text(align=\"left\", dx=14, dy=-10, fontSize=13, fontWeight=\"bold\", color=INK)\n    .encode(\n        x=alt.X(\"x_pix:Q\", scale=alt.Scale(domain=[0, chart_width], nice=False), axis=None),\n        y=alt.Y(\"y_pix:Q\", scale=alt.Scale(domain=[0, chart_height], nice=False), axis=None),\n        text=\"name:N\",\n    )\n)\n\n# Layer 4: tile provider attribution (required by CartoDB and OSM license)\nattribution_df = pd.DataFrame(\n    {\"text\": [f\"© {tile_provider} | © OpenStreetMap contributors\"], \"x\": [chart_width - 5], \"y\": [chart_height - 5]}\n)\nattribution_layer = (\n    alt.Chart(attribution_df)\n    .mark_text(align=\"right\", baseline=\"bottom\", fontSize=10, color=INK_MUTED)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[0, chart_width], nice=False), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[0, chart_height], nice=False), axis=None),\n        text=\"text:N\",\n    )\n)\n\ntitle = \"map-tile-background · python · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(tile_layer, points_layer, labels_layer, attribution_layer)\n    .properties(\n        width=chart_width,\n        height=chart_height,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=title,\n            subtitle=\"European Landmarks by Annual Visitors\",\n            fontSize=16,\n            subtitleFontSize=12,\n            anchor=\"middle\",\n            color=INK,\n            subtitleColor=INK_SOFT,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None, continuousWidth=chart_width, continuousHeight=chart_height)\n    .configure_legend(\n        fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, padding=10, cornerRadius=4\n    )\n    .interactive()\n)\n\n# PNG: scale 4.0 → inner 2240×1280; vl-convert adds title+legend padding\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# Pad to exact 3200×1800 target (no crop — preserves title/axis labels)\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# HTML: interactive zoom/pan via .interactive()\nchart.save(f\"plot-{THEME}.html\")\n"}