{"spec_id":"quiver-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nquiver-basic: Basic Quiver Plot\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 85/100 | Updated: 2026-07-24\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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# Canvas — square inner view; vl-convert pads title/axis/legend outside width/height\nVIEW_W, VIEW_H = 440, 460\nTARGET_W, TARGET_H = 2400, 2400\n\n# Data - 15x15 grid with circular rotation field (u = -y, v = x)\nnp.random.seed(42)\ngrid_size = 15\nx_range = np.linspace(-2, 2, grid_size)\ny_range = np.linspace(-2, 2, grid_size)\nX, Y = np.meshgrid(x_range, y_range)\nx_flat = X.flatten()\ny_flat = Y.flatten()\n\n# Circular rotation field: u = -y, v = x\nU = -y_flat\nV = x_flat\nmagnitude = np.sqrt(U**2 + V**2)\n\n# Unit direction (defaults to +x for the single zero-magnitude point at the origin)\nsafe_magnitude = np.where(magnitude > 0, magnitude, 1.0)\nunit_u = np.where(magnitude > 0, U / safe_magnitude, 1.0)\nunit_v = np.where(magnitude > 0, V / safe_magnitude, 0.0)\n\n# Rescale arrow length into [MIN_LEN, MAX_LEN] so low-magnitude arrows near the\n# origin stay visible instead of shrinking to an imperceptible dot\nMIN_LEN, MAX_LEN = 0.08, 0.22\nmagnitude_norm = magnitude / magnitude.max()\ndisplay_length = MIN_LEN + magnitude_norm * (MAX_LEN - MIN_LEN)\nU_scaled = unit_u * display_length\nV_scaled = unit_v * display_length\n\n# Arrow tip positions\nx2 = x_flat + U_scaled\ny2 = y_flat + V_scaled\n\n# Arrowhead geometry — size proportional to arrow length for visual coherence\nangle = np.arctan2(V_scaled, U_scaled)\nhead_size = np.maximum(display_length * 0.35, 0.02)\n\n# Vectorized construction of shaft + two arrowhead lines per arrow\nshaft = pd.DataFrame({\"x\": x_flat, \"y\": y_flat, \"x2\": x2, \"y2\": y2, \"magnitude\": magnitude})\nleft = pd.DataFrame(\n    {\n        \"x\": x2,\n        \"y\": y2,\n        \"x2\": x2 - head_size * np.cos(angle - 0.4),\n        \"y2\": y2 - head_size * np.sin(angle - 0.4),\n        \"magnitude\": magnitude,\n    }\n)\nright = pd.DataFrame(\n    {\n        \"x\": x2,\n        \"y\": y2,\n        \"x2\": x2 - head_size * np.cos(angle + 0.4),\n        \"y2\": y2 - head_size * np.sin(angle + 0.4),\n        \"magnitude\": magnitude,\n    }\n)\narrow_df = pd.concat([shaft, left, right], ignore_index=True)\n\n# Equal-scale square domain (matches VIEW_W:VIEW_H so the rotation field renders\n# as a true circle, not an ellipse) with margin for the longest arrow tip\nx_half = 2.6\ny_half = x_half * VIEW_H / VIEW_W\n\n# Chart — rule marks encode vectors; Imprint sequential scale encodes magnitude,\n# stroke width adds a secondary emphasis cue for the strongest vectors\nchart = (\n    alt.Chart(arrow_df)\n    .mark_rule()\n    .encode(\n        x=alt.X(\"x:Q\", title=\"X Position\", scale=alt.Scale(domain=[-x_half, x_half])),\n        y=alt.Y(\"y:Q\", title=\"Y Position\", scale=alt.Scale(domain=[-y_half, y_half])),\n        x2=\"x2:Q\",\n        y2=\"y2:Q\",\n        color=alt.Color(\n            \"magnitude:Q\",\n            scale=alt.Scale(range=[\"#009E73\", \"#4467A3\"]),\n            title=\"Magnitude\",\n            legend=alt.Legend(titleFontSize=10, labelFontSize=10),\n        ),\n        strokeWidth=alt.StrokeWidth(\"magnitude:Q\", scale=alt.Scale(range=[1.4, 3.2]), legend=None),\n        tooltip=[\n            alt.Tooltip(\"x:Q\", title=\"X\", format=\".2f\"),\n            alt.Tooltip(\"y:Q\", title=\"Y\", format=\".2f\"),\n            alt.Tooltip(\"magnitude:Q\", title=\"Magnitude\", format=\".2f\"),\n        ],\n    )\n    .properties(\n        width=VIEW_W,\n        height=VIEW_H,\n        background=PAGE_BG,\n        title=alt.Title(\"quiver-basic · altair · anyplot.ai\", fontSize=16, anchor=\"middle\"),\n    )\n    .configure_view(fill=PAGE_BG, stroke=None)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n    )\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# PAD-only to exact target canvas — vl-convert pads title/legend outside width/\n# height, so the saved PNG is never exactly (VIEW_W*scale, VIEW_H*scale)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TARGET_W or _h > TARGET_H:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TARGET_W}x{TARGET_H}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TARGET_W or _h < TARGET_H:\n    _canvas = Image.new(\"RGB\", (TARGET_W, TARGET_H), PAGE_BG)\n    _canvas.paste(_img, ((TARGET_W - _w) // 2, (TARGET_H - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}