{"spec_id":"scatter-3d","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nscatter-3d: 3D Scatter Plot\nLibrary: letsplot 4.9.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-08\n\"\"\"\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_text,\n    geom_point,\n    geom_segment,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_color_viridis,\n    scale_size_identity,\n    theme,\n    theme_minimal,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Data - Simulated 3D spatial measurements (e.g., sensor readings in a volume)\nnp.random.seed(42)\n\n# Create 3 distinct clusters with varied characteristics\nn_points = 150\n\n# Cluster 1: Dense spherical cluster (tight sensor group) - 60 points\nn1 = 60\nc1_x = np.random.randn(n1) * 0.5 + 3\nc1_y = np.random.randn(n1) * 0.5 + 2\nc1_z = np.random.randn(n1) * 0.5 + 1\n\n# Cluster 2: Elongated ellipsoid (stretched along X) - 50 points\nn2 = 50\nc2_x = np.random.randn(n2) * 1.8 - 2\nc2_y = np.random.randn(n2) * 0.4 - 1\nc2_z = np.random.randn(n2) * 0.6 + 3.5\n\n# Cluster 3: Flat disk (spread in X-Y plane, thin in Z) - 40 points\nn3 = 40\nc3_x = np.random.randn(n3) * 1.2 + 0.5\nc3_y = np.random.randn(n3) * 1.2 - 2.5\nc3_z = np.random.randn(n3) * 0.2 - 1.5\n\nx = np.concatenate([c1_x, c2_x, c3_x])\ny = np.concatenate([c1_y, c2_y, c3_y])\nz = np.concatenate([c1_z, c2_z, c3_z])\n\n# 3D to 2D isometric projection with rotation angles for good viewing\nelev = np.radians(25)  # elevation angle\nazim = np.radians(-50)  # azimuth angle\n\n# Apply rotation: first around z-axis (azimuth), then x-axis (elevation)\ncos_azim, sin_azim = np.cos(azim), np.sin(azim)\ncos_elev, sin_elev = np.cos(elev), np.sin(elev)\n\nx_rot = x * cos_azim - y * sin_azim\ny_rot = x * sin_azim + y * cos_azim\n\n# Project to 2D\nx_proj = x_rot\ny_proj = y_rot * sin_elev + z * cos_elev\n\n# Depth for sorting (painter's algorithm - back to front)\ndepth = y_rot * cos_elev - z * sin_elev\ndepth_norm = (depth - depth.min()) / (depth.max() - depth.min())\npoint_sizes = 4 + depth_norm * 6  # perspective: closer points larger\n\n# Create DataFrame and sort by depth\ndf = pd.DataFrame({\"x_proj\": x_proj, \"y_proj\": y_proj, \"altitude\": z, \"depth\": depth, \"size\": point_sizes})\ndf = df.sort_values(\"depth\").reset_index(drop=True)\n\n# Project axis lines from origin - extended to span full data range\n# Calculate data bounds for axis sizing\ndata_range = max(x.max() - x.min(), y.max() - y.min(), z.max() - z.min())\naxis_length = data_range * 0.8  # 80% of data range for visible axes\n\n# Project origin to 2D\norigin_x_rot = 0.0\norigin_y_rot = 0.0\norigin_x_proj = origin_x_rot\norigin_y_proj = origin_y_rot * sin_elev + 0.0 * cos_elev\n\n# Project axis endpoints (X, Y, Z directions from origin)\n# X axis direction: (axis_length, 0, 0)\nx_end_rot_x = axis_length * cos_azim\nx_end_rot_y = axis_length * sin_azim\nx_end_proj_x = x_end_rot_x\nx_end_proj_y = x_end_rot_y * sin_elev\n\n# Y axis direction: (0, axis_length, 0)\ny_end_rot_x = -axis_length * sin_azim\ny_end_rot_y = axis_length * cos_azim\ny_end_proj_x = y_end_rot_x\ny_end_proj_y = y_end_rot_y * sin_elev\n\n# Z axis direction: (0, 0, axis_length)\nz_end_proj_x = 0.0\nz_end_proj_y = axis_length * cos_elev\n\naxes_df = pd.DataFrame(\n    {\n        \"x\": [origin_x_proj, origin_x_proj, origin_x_proj],\n        \"y\": [origin_y_proj, origin_y_proj, origin_y_proj],\n        \"xend\": [x_end_proj_x, y_end_proj_x, z_end_proj_x],\n        \"yend\": [x_end_proj_y, y_end_proj_y, z_end_proj_y],\n        \"axis\": [\"X\", \"Y\", \"Z\"],\n    }\n)\n\n# Axis label positions (slightly beyond axis endpoints)\nlabel_offset = 1.15\naxis_labels_df = pd.DataFrame(\n    {\n        \"x\": [x_end_proj_x * label_offset, y_end_proj_x * label_offset, z_end_proj_x],\n        \"y\": [x_end_proj_y * label_offset, y_end_proj_y * label_offset, z_end_proj_y * label_offset],\n        \"label\": [\"X\", \"Y\", \"Z\"],\n    }\n)\n\n# Create floor grid (XY plane at z_min) for depth perception\nz_floor = z.min() - 0.5\ngrid_size = 6\ngrid_step = 2.0\ngrid_lines = []\nfor i in range(-grid_size // 2, grid_size // 2 + 1):\n    # Lines parallel to X axis\n    start_x, start_y = i * grid_step, -grid_size // 2 * grid_step\n    end_x, end_y = i * grid_step, grid_size // 2 * grid_step\n    # Project start point\n    sx_rot = start_x * cos_azim - start_y * sin_azim\n    sy_rot = start_x * sin_azim + start_y * cos_azim\n    sx_proj = sx_rot\n    sy_proj = sy_rot * sin_elev + z_floor * cos_elev\n    # Project end point\n    ex_rot = end_x * cos_azim - end_y * sin_azim\n    ey_rot = end_x * sin_azim + end_y * cos_azim\n    ex_proj = ex_rot\n    ey_proj = ey_rot * sin_elev + z_floor * cos_elev\n    grid_lines.append({\"x\": sx_proj, \"y\": sy_proj, \"xend\": ex_proj, \"yend\": ey_proj})\n    # Lines parallel to Y axis\n    start_x, start_y = -grid_size // 2 * grid_step, i * grid_step\n    end_x, end_y = grid_size // 2 * grid_step, i * grid_step\n    sx_rot = start_x * cos_azim - start_y * sin_azim\n    sy_rot = start_x * sin_azim + start_y * cos_azim\n    sx_proj = sx_rot\n    sy_proj = sy_rot * sin_elev + z_floor * cos_elev\n    ex_rot = end_x * cos_azim - end_y * sin_azim\n    ey_rot = end_x * sin_azim + end_y * cos_azim\n    ex_proj = ex_rot\n    ey_proj = ey_rot * sin_elev + z_floor * cos_elev\n    grid_lines.append({\"x\": sx_proj, \"y\": sy_proj, \"xend\": ex_proj, \"yend\": ey_proj})\n\ngrid_df = pd.DataFrame(grid_lines)\n\n# Create the plot\nplot = (\n    ggplot()\n    # Floor grid first (behind everything)\n    + geom_segment(\n        data=grid_df, mapping=aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=\"#CCCCCC\", size=0.5, alpha=0.4\n    )\n    # Axis lines\n    + geom_segment(\n        data=axes_df, mapping=aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"), color=\"#555555\", size=2.0, alpha=0.8\n    )\n    # Axis labels (X, Y, Z)\n    + geom_text(\n        data=axis_labels_df, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), color=\"#333333\", size=14, fontface=\"bold\"\n    )\n    # Data points\n    + geom_point(\n        data=df,\n        mapping=aes(x=\"x_proj\", y=\"y_proj\", color=\"altitude\", size=\"size\"),\n        alpha=0.75,\n        tooltips=layer_tooltips().line(\"Altitude|@altitude\").line(\"Depth|@depth\"),\n    )\n    + scale_color_viridis(name=\"Altitude (m)\", option=\"viridis\")\n    + scale_size_identity()\n    + labs(x=\"Projected X (m)\", y=\"Projected Y (m)\", title=\"scatter-3d · letsplot · pyplots.ai\")\n    + theme_minimal()\n    + theme(\n        axis_title=element_text(size=22),\n        axis_text=element_text(size=18),\n        plot_title=element_text(size=32),  # Fixed: increased from 28 to 32 for optimal visibility\n        legend_text=element_text(size=16),\n        legend_title=element_text(size=18),\n        panel_grid=element_blank(),\n    )\n    + ggsize(1600, 900)\n)\n\n# Save PNG (scale=3 gives 4800x2700)\nggsave(plot, \"plot.png\", path=\".\", scale=3)\n\n# Save HTML for interactivity\nggsave(plot, \"plot.html\", path=\".\")\n"}