{"spec_id":"scatter-3d","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nscatter-3d: 3D Scatter Plot\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-08\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColorBar, ColumnDataSource, Label, LinearColorMapper, Range1d\nfrom bokeh.palettes import Viridis256\nfrom bokeh.plotting import figure\nfrom bokeh.resources import CDN\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (read from environment, default light)\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\"\n\n# Data - create 3D clustered data demonstrating spatial relationships\nnp.random.seed(42)\n\n# Generate 3 clusters in 3D space (150 points total)\nn_per_cluster = 50\n\n# Cluster 1: centered at (2, 2, 2)\nx1 = np.random.randn(n_per_cluster) * 0.8 + 2\ny1 = np.random.randn(n_per_cluster) * 0.8 + 2\nz1 = np.random.randn(n_per_cluster) * 0.8 + 2\n\n# Cluster 2: centered at (-2, -1, 3)\nx2 = np.random.randn(n_per_cluster) * 0.7 - 2\ny2 = np.random.randn(n_per_cluster) * 0.7 - 1\nz2 = np.random.randn(n_per_cluster) * 0.7 + 3\n\n# Cluster 3: centered at (0, -2, -1)\nx3 = np.random.randn(n_per_cluster) * 0.9\ny3 = np.random.randn(n_per_cluster) * 0.9 - 2\nz3 = np.random.randn(n_per_cluster) * 0.9 - 1\n\n# Combine all clusters\nx = np.concatenate([x1, x2, x3])\ny = np.concatenate([y1, y2, y3])\nz = np.concatenate([z1, z2, z3])\n\n# 3D to 2D isometric projection (elevation=25°, azimuth=45°)\nelev_rad = np.radians(25)\nazim_rad = np.radians(45)\n\n# Rotation around z-axis (azimuth)\nx_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad)\ny_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad)\n\n# Rotation around x-axis (elevation) and project to 2D\nx_proj = x_rot\nz_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad)\n\n# Calculate depth for size scaling and sorting (points further away are smaller)\ndepth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad)\ndepth_normalized = (depth - depth.min()) / (depth.max() - depth.min())\n\n# Sort by depth (back to front) so front points render on top\nsort_idx = np.argsort(-depth)  # Sort descending (back first)\nx_proj_sorted = x_proj[sort_idx]\nz_proj_sorted = z_proj[sort_idx]\nz_sorted = z[sort_idx]\ndepth_sorted = depth_normalized[sort_idx]\n\n# Scale marker size based on depth (25-50 for improved visibility at 4800x2700)\nsizes = 25 + (1 - depth_sorted) * 25\n\n# Color mapping using Z value (fourth dimension via color)\nz_min, z_max = z.min(), z.max()\ncolor_mapper = LinearColorMapper(palette=Viridis256, low=z_min, high=z_max)\n\n# Map z values to colors\ncolors = []\nfor z_val in z_sorted:\n    idx = int((z_val - z_min) / (z_max - z_min) * 255)\n    idx = max(0, min(255, idx))\n    colors.append(Viridis256[idx])\n\n# Create ColumnDataSource\nsource = ColumnDataSource(\n    data={\"x\": x_proj_sorted, \"y\": z_proj_sorted, \"size\": sizes, \"color\": colors, \"z_value\": z_sorted}\n)\n\n# Create Bokeh figure with interactive tools\np = figure(\n    width=4800,\n    height=2700,\n    title=\"scatter-3d · bokeh · pyplots.ai\",\n    toolbar_location=\"right\",\n    tools=\"pan,wheel_zoom,box_zoom,reset,save\",\n)\n\n# Draw scatter points with improved size for depth perception\np.scatter(x=\"x\", y=\"y\", size=\"size\", color=\"color\", alpha=0.8, line_color=INK_SOFT, line_width=1, source=source)\n\n# Set appropriate ranges with padding - balanced layout\nx_min, x_max = x_proj.min(), x_proj.max()\ny_min, y_max = z_proj.min(), z_proj.max()\nx_pad = (x_max - x_min) * 0.15\ny_pad = (y_max - y_min) * 0.15\n\n# Center the data in the plot area with balanced padding\np.x_range = Range1d(x_min - x_pad * 1.8, x_max + x_pad * 1.2)\np.y_range = Range1d(y_min - y_pad * 1.5, y_max + y_pad)\n\n# Hide default axes for cleaner 3D projection look\np.xaxis.visible = False\np.yaxis.visible = False\n\n# Custom 3D axis lines positioned at the projected origin\norigin_3d_x, origin_3d_y, origin_3d_z = 0, 0, 0\norigin_x_rot = origin_3d_x * np.cos(azim_rad) - origin_3d_y * np.sin(azim_rad)\norigin_y_rot = origin_3d_x * np.sin(azim_rad) + origin_3d_y * np.cos(azim_rad)\norigin_x = origin_x_rot\norigin_y = origin_y_rot * np.sin(elev_rad) + origin_3d_z * np.cos(elev_rad)\n\n# Axis styling - theme-adaptive\naxis_color = INK_SOFT\naxis_width = 5\naxis_length = 3.0\n\n# Project 3D axis endpoints to 2D\n# X-axis: point (axis_length, 0, 0)\nx_end_x_rot = axis_length * np.cos(azim_rad)\nx_end_y_rot = axis_length * np.sin(azim_rad)\nx_axis_end_x = x_end_x_rot\nx_axis_end_y = x_end_y_rot * np.sin(elev_rad)\n\n# Y-axis: point (0, axis_length, 0)\ny_end_x_rot = -axis_length * np.sin(azim_rad)\ny_end_y_rot = axis_length * np.cos(azim_rad)\ny_axis_end_x = y_end_x_rot\ny_axis_end_y = y_end_y_rot * np.sin(elev_rad)\n\n# Z-axis: point (0, 0, axis_length)\nz_axis_end_x = origin_x\nz_axis_end_y = origin_y + axis_length * np.cos(elev_rad)\n\n# Draw axis lines from projected origin\np.line(x=[origin_x, x_axis_end_x], y=[origin_y, x_axis_end_y], line_color=axis_color, line_width=axis_width)\np.line(x=[origin_x, y_axis_end_x], y=[origin_y, y_axis_end_y], line_color=axis_color, line_width=axis_width)\np.line(x=[origin_x, z_axis_end_x], y=[origin_y, z_axis_end_y], line_color=axis_color, line_width=axis_width)\n\n# Add axis arrows (small triangles at the end of each axis)\narrow_size = 0.2\n\n# X-axis arrow\nx_dir = np.array([x_axis_end_x - origin_x, x_axis_end_y - origin_y])\nx_dir = x_dir / np.linalg.norm(x_dir)\nx_perp = np.array([-x_dir[1], x_dir[0]])\np.patch(\n    x=[\n        x_axis_end_x,\n        x_axis_end_x - arrow_size * x_dir[0] + arrow_size * 0.5 * x_perp[0],\n        x_axis_end_x - arrow_size * x_dir[0] - arrow_size * 0.5 * x_perp[0],\n    ],\n    y=[\n        x_axis_end_y,\n        x_axis_end_y - arrow_size * x_dir[1] + arrow_size * 0.5 * x_perp[1],\n        x_axis_end_y - arrow_size * x_dir[1] - arrow_size * 0.5 * x_perp[1],\n    ],\n    fill_color=axis_color,\n    line_color=axis_color,\n)\n\n# Y-axis arrow\ny_dir = np.array([y_axis_end_x - origin_x, y_axis_end_y - origin_y])\ny_dir = y_dir / np.linalg.norm(y_dir)\ny_perp = np.array([-y_dir[1], y_dir[0]])\np.patch(\n    x=[\n        y_axis_end_x,\n        y_axis_end_x - arrow_size * y_dir[0] + arrow_size * 0.5 * y_perp[0],\n        y_axis_end_x - arrow_size * y_dir[0] - arrow_size * 0.5 * y_perp[0],\n    ],\n    y=[\n        y_axis_end_y,\n        y_axis_end_y - arrow_size * y_dir[1] + arrow_size * 0.5 * y_perp[1],\n        y_axis_end_y - arrow_size * y_dir[1] - arrow_size * 0.5 * y_perp[1],\n    ],\n    fill_color=axis_color,\n    line_color=axis_color,\n)\n\n# Z-axis arrow\nz_dir = np.array([z_axis_end_x - origin_x, z_axis_end_y - origin_y])\nz_dir = z_dir / np.linalg.norm(z_dir)\nz_perp = np.array([-z_dir[1], z_dir[0]])\np.patch(\n    x=[\n        z_axis_end_x,\n        z_axis_end_x - arrow_size * z_dir[0] + arrow_size * 0.5 * z_perp[0],\n        z_axis_end_x - arrow_size * z_dir[0] - arrow_size * 0.5 * z_perp[0],\n    ],\n    y=[\n        z_axis_end_y,\n        z_axis_end_y - arrow_size * z_dir[1] + arrow_size * 0.5 * z_perp[1],\n        z_axis_end_y - arrow_size * z_dir[1] - arrow_size * 0.5 * z_perp[1],\n    ],\n    fill_color=axis_color,\n    line_color=axis_color,\n)\n\n# Add descriptive axis labels with units - theme-adaptive text\nx_label = Label(\n    x=x_axis_end_x + 0.3,\n    y=x_axis_end_y - 0.2,\n    text=\"X-Axis (units)\",\n    text_font_size=\"44pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n)\np.add_layout(x_label)\n\ny_label = Label(\n    x=y_axis_end_x - 0.5,\n    y=y_axis_end_y - 0.5,\n    text=\"Y-Axis (units)\",\n    text_font_size=\"44pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n)\np.add_layout(y_label)\n\nz_label = Label(\n    x=z_axis_end_x + 0.25,\n    y=z_axis_end_y + 0.15,\n    text=\"Z-Axis (units)\",\n    text_font_size=\"44pt\",\n    text_color=INK,\n    text_font_style=\"bold\",\n)\np.add_layout(z_label)\n\n# Add color bar for Z-value scale (clarified title)\ncolor_bar = ColorBar(\n    color_mapper=color_mapper,\n    width=60,\n    location=(0, 0),\n    title=\"Z-Value\",\n    title_text_font_size=\"32pt\",\n    major_label_text_font_size=\"24pt\",\n    title_standoff=20,\n    margin=40,\n    padding=20,\n)\np.add_layout(color_bar, \"right\")\n\n# Title styling for large canvas - theme-adaptive\np.title.text_font_size = \"48pt\"\np.title.text_font_style = \"bold\"\np.title.text_color = INK\n\n# Grid styling - much more subtle to not conflict with 3D axes\np.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.05\np.ygrid.grid_line_alpha = 0.05\np.xgrid.grid_line_dash = [6, 4]\np.ygrid.grid_line_dash = [6, 4]\n\n# Background and border styling - theme-adaptive\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\np.min_border_right = 220\n\n# Save HTML output\noutput_file(f\"plot-{THEME}.html\")\nsave(p, resources=CDN, title=\"scatter-3d · bokeh · pyplots.ai\")\n\n# Screenshot with Selenium/Chrome for PNG export\nW, H = 4800, 2700\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # Let Bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}