{ "cells": [ { "cell_type": "code", "execution_count": 1, "id": "5c77481b", "metadata": {}, "outputs": [], "source": [ "# Requirements\n", "import numpy as np\n", "import pandas as pd\n", "import io\n", "import json\n", "\n", "\n", "# In this notebook, I take the ideas from experiments-1, and we use the quest levels to discover zones instead of using hard coded zone names. " ] }, { "cell_type": "code", "execution_count": 2, "id": "d0b3427f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Total quests for level bracket 1-3: 90\n", " Elwynn Forest: 21 quests\n", " Dun Morogh: 19 quests\n", " Stormwind City: 5 quests\n", " Teldrassil: 12 quests\n", " Darkshore: 2 quests\n", " Warrior: 6 quests\n", " Paladin: 2 quests\n", " Rogue: 5 quests\n", " Priest: 4 quests\n", " Mage: 3 quests\n", " Warlock: 2 quests\n", " Hunter: 3 quests\n", " Druid: 2 quests\n", " Uncategorized: 24 quests\n", " Blackrock Depths: 1 quests\n", " Darkmoon Faire: 1 quests\n", " Winter Veil: 4 quests\n", " Ironforge: 5 quests\n", " Shaman: 1 quests\n", " Zephras Isle: 13 quests\n", "Total quests for level bracket 4-6: 130\n", " Elwynn Forest: 34 quests\n", " Dun Morogh: 32 quests\n", " Teldrassil: 33 quests\n", " Warlock: 2 quests\n", " Priest: 6 quests\n", " Miscellaneous: 1 quests\n", " Uncategorized: 70 quests\n", " Shaman: 4 quests\n", " Anvilmar: 1 quests\n", " Zephras Isle: 27 quests\n", "Total quests for level bracket 7-9: 88\n", " Elwynn Forest: 21 quests\n", " Westfall: 1 quests\n", " Dun Morogh: 20 quests\n", " Cooking: 2 quests\n", " Teldrassil: 20 quests\n", " Durotar: 1 quests\n", " Alchemy: 1 quests\n", " Uncategorized: 25 quests\n", " Zephras Isle: 25 quests\n", "Total quests for level bracket 10-12: 252\n", " Elwynn Forest: 37 quests\n", " Westfall: 13 quests\n", " Loch Modan: 13 quests\n", " Dun Morogh: 36 quests\n", " Teldrassil: 38 quests\n", " Durotar: 1 quests\n", " Darkshore: 13 quests\n", " Blacksmithing: 1 quests\n", " Fishing: 2 quests\n", " Warrior: 17 quests\n", " Paladin: 13 quests\n", " Warlock: 4 quests\n", " Mage: 6 quests\n", " Cooking: 1 quests\n", " Rogue: 7 quests\n", " Darnassus: 33 quests\n", " Priest: 28 quests\n", " Druid: 11 quests\n", " Hunter: 23 quests\n", " Deeprun Tram: 2 quests\n", " Ironforge: 37 quests\n", " Kharanos: 1 quests\n", " Winter Veil: 1 quests\n", " Miscellaneous: 2 quests\n", " Uncategorized: 34 quests\n", " Shaman: 3 quests\n", " Stormwind City: 41 quests\n", " Zephras Isle: 44 quests\n", "Total quests for level bracket 13-15: 119\n", " Westfall: 16 quests\n", " Redridge Mountains: 10 quests\n", " Loch Modan: 19 quests\n", " Stormwind City: 21 quests\n", " Darnassus: 3 quests\n", " The Barrens: 7 quests\n", " Darkshore: 25 quests\n", " Paladin: 12 quests\n", " Mage: 3 quests\n", " Ironforge: 13 quests\n", " Priest: 7 quests\n", " Druid: 5 quests\n", " Uncategorized: 8 quests\n", " Shaman: 4 quests\n", " Dun Morogh: 7 quests\n", " The Hall of Thanes: 2 quests\n", " Zephras Isle: 8 quests\n", "Total quests for level bracket 16-18: 98\n", " Westfall: 20 quests\n", " Druid: 4 quests\n", " Redridge Mountains: 14 quests\n", " Loch Modan: 14 quests\n", " The Deadmines: 3 quests\n", " The Barrens: 10 quests\n", " Herbalism: 1 quests\n", " Darkshore: 24 quests\n", " Wailing Caverns: 3 quests\n", " Ashenvale: 5 quests\n", " Leatherworking: 1 quests\n", " Blacksmithing: 1 quests\n", " Mage: 1 quests\n", " Rogue: 6 quests\n", " The Hall of Thanes: 1 quests\n", " Stormwind City: 18 quests\n", " Dun Morogh: 3 quests\n", " Zephras Isle: 0 quests\n", "Total quests for level bracket 19-21: 133\n", " Duskwood: 6 quests\n", " Redridge Mountains: 10 quests\n", " Westfall: 3 quests\n", " The Deadmines: 2 quests\n", " Loch Modan: 9 quests\n", " Wetlands: 8 quests\n", " Darkshore: 20 quests\n", " Ashenvale: 16 quests\n", " The Barrens: 8 quests\n", " Stonetalon Mountains: 12 quests\n", " Dustwallow Marsh: 1 quests\n", " Wailing Caverns: 1 quests\n", " Paladin: 4 quests\n", " Warrior: 1 quests\n", " Warlock: 6 quests\n", " Rogue: 5 quests\n", " Priest: 13 quests\n", " Warsong Gulch: 2 quests\n", " Winter Veil: 1 quests\n", " Miscellaneous: 1 quests\n", " Uncategorized: 14 quests\n", " Shaman: 2 quests\n", " Ruins of Lordaeron: 1 quests\n", " Mage: 1 quests\n", " Druid: 8 quests\n", " Zephras Isle: 0 quests\n" ] } ], "source": [ "path = rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\resources\\quests.jsonl\"\n", "df = pd.read_json(path, lines=True)\n", "\n", "if df is None:\n", " raise Exception(\"DataFrame is None. Failed to read the CSV file.\")\n", "\n", "df.describe()\n", "\n", "Alliance_only_quests = df[(df[\"side\"] == \"Alliance\") | (df[\"side\"] == \"Both\")]\n", "\n", "def strip_bracket_string(bracket_string):\n", " # Split the string by \": \" and take the second part\n", " parts = bracket_string.split(\": \")\n", " if len(parts) > 1:\n", " return parts[1]\n", " return bracket_string # Return the original string if it doesn't contain \": \"\n", "\n", "def locations_contains_zone(row, zone_name):\n", " locations = row.get(\"locations\")\n", " if not isinstance(locations, list):\n", " return False\n", " for location in locations:\n", " if location.get(\"role\") == \"start\" and zone_name in location[\"zone\"]:\n", " return True\n", " if location.get(\"role\") == \"end\" and zone_name in location[\"zone\"]:\n", " return True\n", " return False\n", "\n", "def get_quests_by_zone(_df: pd.DataFrame, zone_name: str) -> pd.DataFrame:\n", " location_mask = _df.apply(lambda row: locations_contains_zone(row, zone_name), axis=1)\n", " category_mask = (_df[\"category\"] == zone_name) | (_df[\"category2\"] == zone_name)\n", " return _df[location_mask | category_mask]\n", "\n", "def get_quests_by_level_bracket(_df: pd.DataFrame, min_level: int, max_level: int) -> pd.DataFrame:\n", " return _df[(_df[\"level\"] >= min_level) & (_df[\"level\"] <= max_level)]\n", "\n", "# Now, our brackets are going to be every three levels instead of 10. Let's use loops to make things\n", "# a little more efficient.\n", "index_prefix = 1\n", "brackets = {}\n", "for i in range(1, 21, 3):\n", " min_level = i\n", " max_level = i + 2\n", " bracket_quests = get_quests_by_level_bracket(Alliance_only_quests, min_level, max_level)\n", " print(f\"Total quests for level bracket {min_level}-{max_level}: {len(bracket_quests)}\")\n", "\n", " bracket_zones = bracket_quests[[\"category\", \"category2\"]].drop_duplicates().to_dict(orient='records')\n", " bracket_zones.append({\"category\": \"Zephras Isle\", \"category2\": \"Unknown\"}) # Add a placeholder for unknown zones\n", " \n", " \n", " for zone in bracket_zones:\n", " zone_name = zone.get(\"category\") or zone.get(\"category2\")\n", " if zone_name:\n", " zone_quests = get_quests_by_zone(bracket_quests, zone_name)\n", " print(f\" {zone_name}: {len(zone_quests)} quests\")\n", " brackets[(zone_name, f\"{index_prefix}: {min_level}-{max_level}\")] = zone_quests \n", " index_prefix += 1\n", "\n" ] }, { "cell_type": "code", "execution_count": 3, "id": "e826b3eb", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
zonebracketqueststotal_xpavg_xp
0Alchemy3: 7-91700700.0
1Anvilmar2: 4-61405405.0
2Ashenvale6: 16-1854910982.0
3Ashenvale7: 19-2116172501078.0
4Blackrock Depths1: 1-3100.0
..................
123Zephras Isle3: 7-92511665467.0
124Zephras Isle4: 10-124426290598.0
125Zephras Isle5: 13-1583590449.0
126Zephras Isle6: 16-1800NaN
127Zephras Isle7: 19-2100NaN
\n", "

128 rows × 5 columns

\n", "
" ], "text/plain": [ " zone bracket quests total_xp avg_xp\n", "0 Alchemy 3: 7-9 1 700 700.0\n", "1 Anvilmar 2: 4-6 1 405 405.0\n", "2 Ashenvale 6: 16-18 5 4910 982.0\n", "3 Ashenvale 7: 19-21 16 17250 1078.0\n", "4 Blackrock Depths 1: 1-3 1 0 0.0\n", ".. ... ... ... ... ...\n", "123 Zephras Isle 3: 7-9 25 11665 467.0\n", "124 Zephras Isle 4: 10-12 44 26290 598.0\n", "125 Zephras Isle 5: 13-15 8 3590 449.0\n", "126 Zephras Isle 6: 16-18 0 0 NaN\n", "127 Zephras Isle 7: 19-21 0 0 NaN\n", "\n", "[128 rows x 5 columns]" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "def quest_xp(rewards) -> int:\n", " if isinstance(rewards, dict) and rewards.get(\"xp\"):\n", " return rewards[\"xp\"]\n", " return 0\n", "\n", "xp_summary = pd.DataFrame([\n", " {\"zone\": zone, \"bracket\": bracket, \"quests\": len(quests), \"total_xp\": quests[\"rewards\"].apply(quest_xp).sum()}\n", " for (zone, bracket), quests in brackets.items()\n", "])\n", "xp_summary[\"avg_xp\"] = (xp_summary[\"total_xp\"] / xp_summary[\"quests\"]).round(0)\n", "\n", "show_df = xp_summary.sort_values(by=[\"zone\", \"bracket\"]).reset_index(drop=True)\n", "display(show_df)\n", "# Now we calculate the total experience available from quests for each zone and level bracket\n", "show_df.to_csv(rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\output\\quests.csv\", index=False)" ] }, { "cell_type": "code", "execution_count": null, "id": "ab76fa6f", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
questswith_objectivetotal_xptotal_to_objectivetotal_from_objectivetotal_travelavg_travelxp_per_travel
zonebracket
Alchemy3: 7-9107000.00.00.0NaNinf
Anvilmar2: 4-6104050.00.00.0NaNinf
Ashenvale6: 16-185049100.00.00.0NaNinf
7: 19-211641725043.573.4169.314.1101.9
Blackrock Depths1: 1-31000.00.00.0NaNNaN
..............................
Zephras Isle1: 1-3134147514.514.541.93.235.2
2: 4-6278811035.735.7184.06.844.1
3: 7-9251411665113.7114.6346.713.933.6
4: 10-12442026290224.1187.1623.814.542.1
5: 13-1584359064.764.7132.822.127.0
\n", "

126 rows × 8 columns

\n", "
" ], "text/plain": [ " quests with_objective total_xp \\\n", "zone bracket \n", "Alchemy 3: 7-9 1 0 700 \n", "Anvilmar 2: 4-6 1 0 405 \n", "Ashenvale 6: 16-18 5 0 4910 \n", " 7: 19-21 16 4 17250 \n", "Blackrock Depths 1: 1-3 1 0 0 \n", "... ... ... ... \n", "Zephras Isle 1: 1-3 13 4 1475 \n", " 2: 4-6 27 8 8110 \n", " 3: 7-9 25 14 11665 \n", " 4: 10-12 44 20 26290 \n", " 5: 13-15 8 4 3590 \n", "\n", " total_to_objective total_from_objective \\\n", "zone bracket \n", "Alchemy 3: 7-9 0.0 0.0 \n", "Anvilmar 2: 4-6 0.0 0.0 \n", "Ashenvale 6: 16-18 0.0 0.0 \n", " 7: 19-21 43.5 73.4 \n", "Blackrock Depths 1: 1-3 0.0 0.0 \n", "... ... ... \n", "Zephras Isle 1: 1-3 14.5 14.5 \n", " 2: 4-6 35.7 35.7 \n", " 3: 7-9 113.7 114.6 \n", " 4: 10-12 224.1 187.1 \n", " 5: 13-15 64.7 64.7 \n", "\n", " total_travel avg_travel xp_per_travel \n", "zone bracket \n", "Alchemy 3: 7-9 0.0 NaN inf \n", "Anvilmar 2: 4-6 0.0 NaN inf \n", "Ashenvale 6: 16-18 0.0 NaN inf \n", " 7: 19-21 169.3 14.1 101.9 \n", "Blackrock Depths 1: 1-3 0.0 NaN NaN \n", "... ... ... ... \n", "Zephras Isle 1: 1-3 41.9 3.2 35.2 \n", " 2: 4-6 184.0 6.8 44.1 \n", " 3: 7-9 346.7 13.9 33.6 \n", " 4: 10-12 623.8 14.5 42.1 \n", " 5: 13-15 132.8 22.1 27.0 \n", "\n", "[126 rows x 8 columns]" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Now we try to calculate travel requirements for each quest. We define travel as the distance from the quest giver to the objective area, and then from the objective area to the quest turn-in. For delivery/talk-to quests, we just measure straight from giver to turn-in.\n", "# An additional challenge is that the coordinates given are percentage based based off of the map frame.\n", "# Some digging online found some map size estimates, and they are the closest I have to a reliable source for now.\n", "\n", "OBJECTIVE_ROLES = (\"requirement\", \"sourcerequirement\")\n", "\n", "def is_point(coord):\n", " # Instance/dungeon zones have no map coords and are stored as [\"\"]\n", " return isinstance(coord, list) and len(coord) == 2 and all(isinstance(v, (int, float)) for v in coord)\n", "\n", "def location_center(loc):\n", " # An npc/object can have many spawn points, so use the center of all of them instead of the single representative coord\n", " points = [c for c in loc.get(\"coords\") or [] if is_point(c)]\n", " if points:\n", " return np.mean(np.array(points, dtype=float), axis=0)\n", " if is_point(loc.get(\"coord\")):\n", " return np.array(loc[\"coord\"], dtype=float)\n", " return None\n", "\n", "def zone_coords(locations, roles, zone_name):\n", " centers = [location_center(loc) for loc in locations\n", " if loc.get(\"role\") in roles and loc.get(\"zone\") == zone_name]\n", " return [c for c in centers if c is not None]\n", "\n", "def distance(a, b):\n", " if a is None or b is None:\n", " return np.nan\n", " return float(np.linalg.norm(np.subtract(a, b)))\n", "\n", "def quest_travel(row, zone_name):\n", " \n", " locations = row[\"locations\"] if isinstance(row[\"locations\"], list) else []\n", " starts = zone_coords(locations, (\"start\",), zone_name)\n", " ends = zone_coords(locations, (\"end\",), zone_name)\n", " objectives = zone_coords(locations, OBJECTIVE_ROLES, zone_name)\n", "\n", " start = starts[0] if starts else None\n", " end = ends[0] if ends else None\n", " \n", " # Objective area = mean of every objective npc/object's spawn center\n", " objective = np.mean(np.array(objectives), axis=0) if objectives else None\n", "\n", " if objective is None:\n", " # Delivery / talk-to quests: straight from giver to turn-in\n", " to_objective, from_objective = np.nan, np.nan\n", " total = distance(start, end)\n", " else:\n", " to_objective = distance(start, objective)\n", " from_objective = distance(objective, end)\n", " total = to_objective + from_objective\n", "\n", " return pd.Series({\n", " \"start_x\": start[0] if start is not None else np.nan,\n", " \"start_y\": start[1] if start is not None else np.nan,\n", " \"objective_x\": objective[0] if objective is not None else np.nan,\n", " \"objective_y\": objective[1] if objective is not None else np.nan,\n", " \"end_x\": end[0] if end is not None else np.nan,\n", " \"end_y\": end[1] if end is not None else np.nan,\n", " \"to_objective\": to_objective,\n", " \"from_objective\": from_objective,\n", " \"total_travel\": total,\n", " })\n", "\n", "def build_travel(quests: pd.DataFrame, zone_name: str, bracket: str) -> pd.DataFrame:\n", " travel = quests.apply(lambda row: quest_travel(row, zone_name), axis=1)\n", " travel.insert(0, \"zone\", zone_name)\n", " travel.insert(1, \"bracket\", bracket)\n", " travel.insert(2, \"id\", quests[\"id\"])\n", " travel.insert(3, \"name\", quests[\"name\"])\n", " travel.insert(4, \"level\", quests[\"level\"])\n", " travel.insert(5, \"xp\", quests[\"rewards\"].apply(quest_xp))\n", " return travel\n", "\n", "# Some zone + bracket entries have no quests (e.g. the Zephras Isle placeholder), and apply() on an empty frame doesn't return the travel columns\n", "travel_df = pd.concat(\n", " [build_travel(quests, zone, bracket) for (zone, bracket), quests in brackets.items() if not quests.empty],\n", " ignore_index=True,\n", ")\n", "\n", "travel_summary = travel_df.groupby([\"zone\", \"bracket\"]).agg(\n", " quests=(\"id\", \"count\"),\n", " with_objective=(\"objective_x\", \"count\"),\n", " total_xp=(\"xp\", \"sum\"),\n", " total_to_objective=(\"to_objective\", \"sum\"),\n", " total_from_objective=(\"from_objective\", \"sum\"),\n", " total_travel=(\"total_travel\", \"sum\"),\n", " avg_travel=(\"total_travel\", \"mean\"),\n", ")\n", "travel_summary[\"xp_per_travel\"] = (travel_summary[\"total_xp\"] / travel_summary[\"total_travel\"]).round(1)\n", "travel_summary.round(1)\n", "\n", "save_path = rf\"D:\\Project\\Py\\WowheadScraper\\quest_analyzer\\output\\travel.csv\"\n", "travel_summary.to_csv(save_path, index=True)" ] }, { "cell_type": "code", "execution_count": 5, "id": "7fe45672", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Ashenvale 19-21: 1 hotspots from 4 objectives\n", "Darkshore 10-12: 2 hotspots from 6 objectives\n", "Darkshore 13-15: 2 hotspots from 6 objectives\n", "Darkshore 16-18: 4 hotspots from 8 objectives\n", "Darkshore 19-21: 2 hotspots from 6 objectives\n", "Dun Morogh 1-3: 1 hotspots from 3 objectives\n", "Dun Morogh 4-6: 3 hotspots from 7 objectives\n", "Dun Morogh 7-9: 1 hotspots from 9 objectives\n", "Dun Morogh 10-12: 2 hotspots from 9 objectives\n", "Dun Morogh 13-15: 1 hotspots from 3 objectives\n", "Duskwood 19-21: 1 hotspots from 3 objectives\n", "Elwynn Forest 1-3: 3 hotspots from 6 objectives\n", "Elwynn Forest 4-6: 3 hotspots from 7 objectives\n", "Elwynn Forest 7-9: 1 hotspots from 11 objectives\n", "Elwynn Forest 10-12: 2 hotspots from 7 objectives\n", "Loch Modan 10-12: 1 hotspots from 4 objectives\n", "Loch Modan 13-15: 1 hotspots from 4 objectives\n", "Loch Modan 16-18: 3 hotspots from 7 objectives\n", "Loch Modan 19-21: 1 hotspots from 2 objectives\n", "Redridge Mountains 16-18: 2 hotspots from 4 objectives\n", "Redridge Mountains 19-21: 3 hotspots from 6 objectives\n", "Stonetalon Mountains 19-21: 1 hotspots from 2 objectives\n", "Stormwind City 13-15: 1 hotspots from 3 objectives\n", "Teldrassil 1-3: 1 hotspots from 3 objectives\n", "Teldrassil 4-6: 2 hotspots from 4 objectives\n", "Teldrassil 7-9: 1 hotspots from 9 objectives\n", "Teldrassil 10-12: 3 hotspots from 5 objectives\n", "The Barrens 13-15: 1 hotspots from 3 objectives\n", "The Barrens 16-18: 4 hotspots from 7 objectives\n", "The Barrens 19-21: 1 hotspots from 2 objectives\n", "Westfall 10-12: 1 hotspots from 3 objectives\n", "Westfall 13-15: 1 hotspots from 8 objectives\n", "Westfall 16-18: 2 hotspots from 4 objectives\n", "Westfall 19-21: 1 hotspots from 2 objectives\n", "Wetlands 19-21: 1 hotspots from 2 objectives\n", "Zephras Isle 1-3: 2 hotspots from 4 objectives\n", "Zephras Isle 4-6: 3 hotspots from 8 objectives\n", "Zephras Isle 7-9: 2 hotspots from 14 objectives\n", "Zephras Isle 10-12: 2 hotspots from 20 objectives\n", "Zephras Isle 13-15: 2 hotspots from 4 objectives\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
center_xcenter_yqueststotal_xpavg_to_objectiveavg_from_objective
zonebracketobjective_hotspot
Ashenvale7: 19-21021.929.84590010.918.4
Darkshore4: 10-12043.060.03273014.314.3
142.240.2329004.19.6
5: 13-15047.640.0216507.17.1
142.756.54379018.920.2
...........................
Zephras Isle3: 7-9150.476.9527354.84.8
4: 10-12062.035.15443012.512.5
159.065.0151031510.88.3
5: 13-15061.751.9191015.115.1
157.136.63182016.616.6
\n", "

72 rows × 6 columns

\n", "
" ], "text/plain": [ " center_x center_y quests total_xp \\\n", "zone bracket objective_hotspot \n", "Ashenvale 7: 19-21 0 21.9 29.8 4 5900 \n", "Darkshore 4: 10-12 0 43.0 60.0 3 2730 \n", " 1 42.2 40.2 3 2900 \n", " 5: 13-15 0 47.6 40.0 2 1650 \n", " 1 42.7 56.5 4 3790 \n", "... ... ... ... ... \n", "Zephras Isle 3: 7-9 1 50.4 76.9 5 2735 \n", " 4: 10-12 0 62.0 35.1 5 4430 \n", " 1 59.0 65.0 15 10315 \n", " 5: 13-15 0 61.7 51.9 1 910 \n", " 1 57.1 36.6 3 1820 \n", "\n", " avg_to_objective avg_from_objective \n", "zone bracket objective_hotspot \n", "Ashenvale 7: 19-21 0 10.9 18.4 \n", "Darkshore 4: 10-12 0 14.3 14.3 \n", " 1 4.1 9.6 \n", " 5: 13-15 0 7.1 7.1 \n", " 1 18.9 20.2 \n", "... ... ... \n", "Zephras Isle 3: 7-9 1 4.8 4.8 \n", " 4: 10-12 0 12.5 12.5 \n", " 1 10.8 8.3 \n", " 5: 13-15 0 15.1 15.1 \n", " 1 16.6 16.6 \n", "\n", "[72 rows x 6 columns]" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Afterwards, we use X-means clustering to find hotspots for quest objective locations.\n", "# X-means = k-means that keeps splitting clusters in two while the split improves the\n", "# BIC score (Pelleg & Moore, 2000), so we don't have to pick k ourselves.\n", "\n", "def kmeans(points: np.ndarray, centers: np.ndarray, iterations: int = 100):\n", " for _ in range(iterations):\n", " labels = np.argmin(np.linalg.norm(points[:, None] - centers[None], axis=2), axis=1)\n", " new_centers = np.array([\n", " points[labels == k].mean(axis=0) if np.any(labels == k) else centers[k]\n", " for k in range(len(centers))\n", " ])\n", " if np.allclose(new_centers, centers):\n", " break\n", " centers = new_centers\n", " return labels, centers\n", "\n", "def bic(points: np.ndarray, labels: np.ndarray, centers: np.ndarray, min_variance: float = 1.25) -> float:\n", " r, m = points.shape\n", " k = len(centers)\n", " if r <= k:\n", " return -np.inf\n", " # Many quests share the exact same objective coord, so without a floor a tiny cluster gets ~0 variance and an infinite likelihood. 1.0 = roughly 1% of the map.\n", " variance = max(np.sum((points - centers[labels]) ** 2) / (m * (r - k)), min_variance)\n", " log_likelihood = 0.0\n", " for i in range(k):\n", " r_i = np.sum(labels == i)\n", " if r_i == 0:\n", " continue\n", " log_likelihood += (r_i * np.log(r_i) - r_i * np.log(r)\n", " - r_i * m / 2 * np.log(2 * np.pi * variance)\n", " - (r_i - 1) * m / 2)\n", " free_params = k * (m + 1)\n", " return log_likelihood - free_params / 2 * np.log(r)\n", "\n", "def best_split(members: np.ndarray, rng, attempts: int):\n", " # k-means only finds a local optimum, so try a few random starts and keep the best split\n", " best_score, best_children = -np.inf, None\n", " for _ in range(attempts):\n", " child_labels, children = kmeans(members, members[rng.choice(len(members), 2, replace=False)])\n", " score = bic(members, child_labels, children)\n", " if score > best_score:\n", " best_score, best_children = score, children\n", " return best_score, best_children\n", "\n", "def xmeans(points: np.ndarray, k_max: int = 15, split_attempts: int = 10, seed: int = 0):\n", " rng = np.random.default_rng(seed)\n", " labels, centers = kmeans(points, points.mean(axis=0, keepdims=True))\n", " score = bic(points, labels, centers)\n", " while True:\n", " new_centers = []\n", " for k, center in enumerate(centers):\n", " members = points[labels == k]\n", " if len(members) < 4:\n", " new_centers.append(center)\n", " continue\n", " parent_score = bic(members, np.zeros(len(members), dtype=int), center[None])\n", " child_score, children = best_split(members, rng, split_attempts)\n", " new_centers.extend(children if child_score > parent_score else [center])\n", " if len(new_centers) == len(centers) or len(new_centers) > k_max:\n", " return labels, centers\n", " # The per-cluster test is lenient on small clusters, so only keep the splits if they also improve the BIC of the whole zone\n", " new_labels, new_centers = kmeans(points, np.array(new_centers))\n", " new_score = bic(points, new_labels, new_centers)\n", " if new_score <= score:\n", " return labels, centers\n", " labels, centers, score = new_labels, new_centers, new_score\n", "\n", "# Cluster each zone + bracket's objective areas separately, then classify every questin the travel data by the hotspot its objective falls in.\n", "# Hotspot ids are only unique within a zone + bracket.\n", "travel_df[\"objective_hotspot\"] = pd.NA\n", "travel_df[\"hotspot_distance\"] = np.nan\n", "hotspots = {}\n", "\n", "for (zone_name, bracket), bracket_travel in travel_df.groupby([\"zone\", \"bracket\"]):\n", " has_objective = bracket_travel.dropna(subset=[\"objective_x\", \"objective_y\"])\n", " points = has_objective[[\"objective_x\", \"objective_y\"]].to_numpy()\n", " if len(points) < 2:\n", " continue\n", " labels, centers = xmeans(points)\n", " hotspots[(zone_name, bracket)] = centers\n", " travel_df.loc[has_objective.index, \"objective_hotspot\"] = labels\n", " travel_df.loc[has_objective.index, \"hotspot_distance\"] = np.linalg.norm(points - centers[labels], axis=1)\n", " print(f\"{zone_name} {strip_bracket_string(bracket)}: {len(centers)} hotspots from {len(points)} objectives\")\n", "\n", "hotspot_summary = travel_df.dropna(subset=[\"objective_hotspot\"]).groupby([\"zone\", \"bracket\", \"objective_hotspot\"]).agg(\n", " center_x=(\"objective_x\", \"mean\"),\n", " center_y=(\"objective_y\", \"mean\"),\n", " quests=(\"id\", \"count\"),\n", " total_xp=(\"xp\", \"sum\"),\n", " avg_to_objective=(\"to_objective\", \"mean\"),\n", " avg_from_objective=(\"from_objective\", \"mean\"),\n", ")\n", "hotspot_summary.round(1)" ] }, { "cell_type": "code", "execution_count": 6, "id": "64996499", "metadata": {}, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "data": [ { "colorbar": { "outlinewidth": 0, "thickness": 12, "title": { "text": "Total XP" } }, "colorscale": [ [ 0, "#cde2fb" ], [ 0.16666666666666666, "#9ec5f4" ], [ 0.3333333333333333, "#6da7ec" ], [ 0.5, "#3987e5" ], [ 0.6666666666666666, "#256abf" ], [ 0.8333333333333334, "#184f95" ], [ 1, "#0d366b" ] ], "hoverinfo": "skip", "text": [ [ "1,200 XP
12 quests
avg 100", "9,875 XP
33 quests
avg 299", "11,765 XP
20 quests
avg 588", "18,245 XP
38 quests
avg 480", "", "", "" ], [ "1,615 XP
21 quests
avg 77", "8,960 XP
34 quests
avg 264", "12,720 XP
21 quests
avg 606", "19,445 XP
37 quests
avg 526", "", "", "" ], [ "1,390 XP
19 quests
avg 73", "9,720 XP
32 quests
avg 304", "10,015 XP
20 quests
avg 501", "17,170 XP
36 quests
avg 477", "5,930 XP
7 quests
avg 847", "2,715 XP
3 quests
avg 905", "" ] ], "textfont": { "size": 12 }, "texttemplate": "%{text}", "type": "heatmap", "x": [ "1-3", "4-6", "7-9", "10-12", "13-15", "16-18", "19-21" ], "xgap": 2, "y": [ "Teldrassil", "Elwynn Forest", "Dun Morogh" ], "ygap": 2, "z": { "bdata": "AAAAAADAkkAAAAAAgEnDQAAAAACA+sZAAAAAAEDR0UAAAAAAAAD4fwAAAAAAAPh/AAAAAAAA+H8AAAAAADyZQAAAAAAAgMFAAAAAAADYyEAAAAAAQP3SQAAAAAAAAPh/AAAAAAAA+H8AAAAAAAD4fwAAAAAAuJVAAAAAAAD8wkAAAAAAgI/DQAAAAACAxNBAAAAAAAAqt0AAAAAAADalQAAAAAAAAPh/", "dtype": "f8", "shape": "3, 7" } } ], "layout": { "font": { "color": "#0b0b0b", "family": "system-ui, -apple-system, Segoe UI, sans-serif", "size": 12 }, "height": 390, "margin": { "b": 40, "l": 10, "r": 10, "t": 60 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "white", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "white", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "#C8D4E3", "linecolor": "#C8D4E3", "minorgridcolor": "#C8D4E3", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "#C8D4E3", "linecolor": "#C8D4E3", "minorgridcolor": "#C8D4E3", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ], "sequentialminus": [ [ 0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "white", "showlakes": true, "showland": true, "subunitcolor": "#C8D4E3" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "paper_bgcolor": "white", "plot_bgcolor": "white", "polar": { "angularaxis": { "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "" }, "bgcolor": "white", "radialaxis": { "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" }, "yaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" }, "zaxis": { "backgroundcolor": "white", "gridcolor": "#DFE8F3", "gridwidth": 2, "linecolor": "#EBF0F8", "showbackground": true, "ticks": "", "zerolinecolor": "#EBF0F8" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" }, "baxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" }, "bgcolor": "white", "caxis": { "gridcolor": "#DFE8F3", "linecolor": "#A2B1C6", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "#EBF0F8", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "#EBF0F8", "linecolor": "#EBF0F8", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "#EBF0F8", "zerolinewidth": 2 } } }, "title": { "text": "Quest XP by zone and level bracket (Alliance)" }, "xaxis": { "showgrid": false, "side": "top", "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Chart the XP summary as a heatmap of total XP per zone + bracket, with each cell's stats printed on it.\n", "import plotly.graph_objects as go\n", "\n", "SURFACE = \"#fcfcfb\"\n", "INK = \"#0b0b0b\"\n", "MUTED = \"#898781\"\n", "# Single-hue sequential ramp, light = little XP, dark = lots\n", "XP_RAMP = [\"#cde2fb\", \"#9ec5f4\", \"#6da7ec\", \"#3987e5\", \"#256abf\", \"#184f95\", \"#0d366b\"]\n", "\n", "chart_layout = dict(\n", " template=\"plotly_white\",\n", " paper_bgcolor=SURFACE,\n", " plot_bgcolor=SURFACE,\n", " font=dict(family=\"system-ui, -apple-system, Segoe UI, sans-serif\", color=INK, size=12),\n", " margin=dict(l=10, r=10, t=60, b=40),\n", ")\n", "\n", "chart_df = xp_summary.copy()\n", "# Bracket keys look like \"1: 1-3\", the prefix only exists to sort them, so show just the level range\n", "bracket_order = sorted(chart_df[\"bracket\"].unique())\n", "bracket_labels = [strip_bracket_string(b) for b in bracket_order]\n", "\n", "target_zones = [\"Elwynn Forest\", \"Teldrassil\", \"Dun Morogh\"]\n", "chart_df = chart_df[chart_df[\"zone\"].isin(target_zones)].copy()\n", "# Ascending so the zone with the most XP ends up at the top of the chart\n", "zone_totals = chart_df.groupby(\"zone\")[\"total_xp\"].sum().sort_values()\n", "\n", "def zone_grid(column):\n", " return chart_df.pivot(index=\"zone\", columns=\"bracket\", values=column).reindex(index=zone_totals.index, columns=bracket_order)\n", "\n", "quest_grid = zone_grid(\"quests\")\n", "avg_grid = zone_grid(\"avg_xp\")\n", "# Brackets with no quests (e.g. the Zephras Isle placeholder) are left blank rather than shaded as 0 XP\n", "xp_grid = zone_grid(\"total_xp\").where(quest_grid > 0)\n", "\n", "def cell_label(xp, quests, avg):\n", " if pd.isna(quests) or quests == 0:\n", " return \"\"\n", " return f\"{xp:,.0f} XP
{quests:.0f} quests
avg {avg:,.0f}\"\n", "\n", "# Stats go on the cells themselves instead of a tooltip, so the chart reads in a screenshot\n", "cell_text = [\n", " [cell_label(xp, quests, avg) for xp, quests, avg in zip(xp_row, quest_row, avg_row)]\n", " for xp_row, quest_row, avg_row in zip(xp_grid.to_numpy(), quest_grid.to_numpy(), avg_grid.to_numpy())\n", "]\n", "\n", "heatmap = go.Figure(go.Heatmap(\n", " z=xp_grid.to_numpy(),\n", " x=bracket_labels,\n", " y=xp_grid.index,\n", " text=cell_text,\n", " texttemplate=\"%{text}\",\n", " textfont=dict(size=12),\n", " hoverinfo=\"skip\",\n", " colorscale=[[i / (len(XP_RAMP) - 1), c] for i, c in enumerate(XP_RAMP)],\n", " xgap=2, ygap=2,\n", " colorbar=dict(title=\"Total XP\", outlinewidth=0, thickness=12),\n", "))\n", "heatmap.update_layout(\n", " **chart_layout,\n", " title=\"Quest XP by zone and level bracket (Alliance)\",\n", " # Rows need room for three lines of text\n", " height=90 * len(xp_grid) + 120,\n", " # Without type=\"category\", plotly reads labels like \"1-3\" as dates\n", " xaxis=dict(type=\"category\", side=\"top\", showgrid=False, tickfont_color=MUTED),\n", " yaxis=dict(type=\"category\", showgrid=False, tickfont_color=MUTED),\n", ")\n", "heatmap.show()" ] } ], "metadata": { "kernelspec": { "display_name": ".venv (3.14.6)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.14.6" } }, "nbformat": 4, "nbformat_minor": 5 }