{"spec_id":"stereonet-equal-area","library":"d3","language":"javascript","code":"// anyplot.ai\n// stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-16\n//# anyplot-orientation: square\n// anyplot.ai\n// stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)\n// Library: d3 7.9.0 | JavaScript 22\n// Quality: pending | Created: 2026-06-16\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\nconst DEG = Math.PI / 180;\nconst cx = width / 2;\nconst cy = height / 2 + 18;\nconst R = 455; // radius of the primitive (horizontal) circle\n\n// --- Vector helpers (right-handed: x=North, y=East, z=Down) -----------------\n// A line is a downward unit vector; planes are handled through their pole.\nfunction vecTP(trendDeg, plungeDeg) {\n  const tr = trendDeg * DEG;\n  const pl = plungeDeg * DEG;\n  return [Math.cos(pl) * Math.cos(tr), Math.cos(pl) * Math.sin(tr), Math.sin(pl)];\n}\nfunction cross(a, b) {\n  return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];\n}\nfunction norm(a) {\n  const m = Math.hypot(a[0], a[1], a[2]) || 1;\n  return [a[0] / m, a[1] / m, a[2] / m];\n}\nfunction poleVec(strike, dip) {\n  // Pole (normal) plunges 90-dip opposite the dip direction (strike + 90 + 180).\n  return vecTP(strike + 270, 90 - dip);\n}\n\n// Lower-hemisphere equal-area (Schmidt) projection of a unit vector to screen px.\nfunction project(v) {\n  let [n, e, d] = v;\n  if (d < 0) {\n    n = -n;\n    e = -e;\n    d = -d;\n  }\n  const trend = Math.atan2(e, n);\n  const phi = Math.acos(Math.max(-1, Math.min(1, d))); // colatitude from down-vertical\n  const r = R * Math.SQRT2 * Math.sin(phi / 2);\n  return [cx + r * Math.sin(trend), cy - r * Math.cos(trend)];\n}\n\n// Sample a parametric curve t->unit vector; flag points on the lower hemisphere\n// so the polyline breaks where the great/small circle leaves the net.\nfunction sampleCurve(fn, steps) {\n  const pts = [];\n  for (let i = 0; i <= steps; i++) {\n    const v = fn((i / steps) * 2 * Math.PI);\n    const [x, y] = project(v);\n    pts.push({ x, y, def: v[2] >= -1e-9 });\n  }\n  return pts;\n}\nfunction greatCircle(pole) {\n  const P = norm(pole);\n  const ref = Math.abs(P[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];\n  const u1 = norm(cross(P, ref));\n  const u2 = cross(P, u1);\n  return sampleCurve(\n    (a) => [\n      u1[0] * Math.cos(a) + u2[0] * Math.sin(a),\n      u1[1] * Math.cos(a) + u2[1] * Math.sin(a),\n      u1[2] * Math.cos(a) + u2[2] * Math.sin(a),\n    ],\n    256,\n  );\n}\nfunction smallCircle(axis, alphaDeg) {\n  const A = norm(axis);\n  const al = alphaDeg * DEG;\n  const ref = Math.abs(A[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0];\n  const u1 = norm(cross(A, ref));\n  const u2 = cross(A, u1);\n  const c = Math.cos(al);\n  const s = Math.sin(al);\n  return sampleCurve(\n    (a) => [\n      c * A[0] + s * (Math.cos(a) * u1[0] + Math.sin(a) * u2[0]),\n      c * A[1] + s * (Math.cos(a) * u1[1] + Math.sin(a) * u2[1]),\n      c * A[2] + s * (Math.cos(a) * u1[2] + Math.sin(a) * u2[2]),\n    ],\n    256,\n  );\n}\n\nconst line = d3\n  .line()\n  .defined((d) => d.def)\n  .x((d) => d.x)\n  .y((d) => d.y);\n\n// --- Data: simulated field campaign, three structural sets ------------------\n// Deterministic LCG + Box-Muller for reproducible Gaussian scatter (no RNG seed\n// in the browser, no network loads).\nlet seed = 42 >>> 0;\nfunction rnd() {\n  seed = (seed * 1664525 + 1013904223) >>> 0;\n  return seed / 4294967296;\n}\nfunction gauss() {\n  const u = Math.max(rnd(), 1e-9);\n  const v = rnd();\n  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n}\n\nconst sets = [\n  { name: \"Bedding\", color: t.palette[0], strike: 42, dip: 28, sStrike: 12, sDip: 6, n: 44 },\n  { name: \"Joint set 1\", color: t.palette[1], strike: 122, dip: 74, sStrike: 9, sDip: 6, n: 36 },\n  { name: \"Joint set 2\", color: t.palette[2], strike: 340, dip: 56, sStrike: 13, sDip: 8, n: 30 },\n];\n\nconst poles = [];\nfor (const s of sets) {\n  for (let i = 0; i < s.n; i++) {\n    const strike = s.strike + gauss() * s.sStrike;\n    const dip = Math.max(2, Math.min(89, s.dip + gauss() * s.sDip));\n    const [x, y] = project(poleVec(strike, dip));\n    poles.push({ x, y, color: s.color });\n  }\n}\n\n// --- SVG mount --------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst defs = svg.append(\"defs\");\ndefs.append(\"clipPath\").attr(\"id\", \"primitive\").append(\"circle\").attr(\"cx\", cx).attr(\"cy\", cy).attr(\"r\", R);\n\n// --- Equal-area net grid (subtle): meridians + parallels --------------------\nconst netCurves = [];\nfor (const strike of [0, 180]) {\n  for (const dip of [10, 20, 30, 40, 50, 60, 70, 80]) netCurves.push(greatCircle(poleVec(strike, dip)));\n}\nnetCurves.push(greatCircle(poleVec(0, 90))); // vertical N–S meridian\nfor (const alpha of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170]) {\n  netCurves.push(smallCircle([1, 0, 0], alpha)); // parallels about the N–S axis\n}\nsvg\n  .append(\"g\")\n  .attr(\"clip-path\", \"url(#primitive)\")\n  .selectAll(\"path\")\n  .data(netCurves)\n  .join(\"path\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1)\n  .attr(\"d\", line);\n\n// --- Kamb-style density contours over all poles -----------------------------\nconst contours = d3\n  .contourDensity()\n  .x((d) => d.x)\n  .y((d) => d.y)\n  .size([width, height])\n  .cellSize(4)\n  .bandwidth(26)\n  .thresholds(9)(poles);\n// Graduated neutral shading: denser inner contours read darker so the three\n// \"preferred orientation\" focal points pop, while strokes stay crisp.\nconst densityMax = d3.max(contours, (c) => c.value) || 1;\nconst densityFill = d3.scaleLinear().domain([0, densityMax]).range([0.02, 0.24]);\nsvg\n  .append(\"g\")\n  .attr(\"clip-path\", \"url(#primitive)\")\n  .selectAll(\"path\")\n  .data(contours)\n  .join(\"path\")\n  .attr(\"d\", d3.geoPath())\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"fill-opacity\", (c) => densityFill(c.value))\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-opacity\", 0.6)\n  .attr(\"stroke-width\", 1.4);\n\n// --- Primitive circle + perimeter degree ticks (every 10°) ------------------\nsvg\n  .append(\"circle\")\n  .attr(\"cx\", cx)\n  .attr(\"cy\", cy)\n  .attr(\"r\", R)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 2.4);\n\nconst ticks = svg.append(\"g\");\nfor (let az = 0; az < 360; az += 10) {\n  const a = az * DEG;\n  const len = az % 90 === 0 ? 18 : az % 30 === 0 ? 12 : 7;\n  ticks\n    .append(\"line\")\n    .attr(\"x1\", cx + R * Math.sin(a))\n    .attr(\"y1\", cy - R * Math.cos(a))\n    .attr(\"x2\", cx + (R + len) * Math.sin(a))\n    .attr(\"y2\", cy - (R + len) * Math.cos(a))\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", az % 90 === 0 ? 2.2 : 1);\n}\n\n// --- Great circles of each set's mean plane ---------------------------------\nsvg\n  .append(\"g\")\n  .attr(\"clip-path\", \"url(#primitive)\")\n  .selectAll(\"path\")\n  .data(sets)\n  .join(\"path\")\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", (s) => s.color)\n  .attr(\"stroke-width\", 2.8)\n  .attr(\"opacity\", 0.85)\n  .attr(\"d\", (s) => line(greatCircle(poleVec(s.strike, s.dip))));\n\n// --- Poles to planes (points) -----------------------------------------------\nsvg\n  .append(\"g\")\n  .selectAll(\"circle\")\n  .data(poles)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => d.x)\n  .attr(\"cy\", (d) => d.y)\n  .attr(\"r\", 6)\n  .attr(\"fill\", (d) => d.color)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1.3);\n\n// --- North arrow + cardinal labels ------------------------------------------\nsvg\n  .append(\"path\")\n  .attr(\"d\", `M ${cx} ${cy - R - 30} L ${cx - 9} ${cy - R - 13} L ${cx + 9} ${cy - R - 13} Z`)\n  .attr(\"fill\", t.ink);\nconst cardinals = [\n  [\"N\", 0, R + 52],\n  [\"E\", 90, R + 40],\n  [\"S\", 180, R + 40],\n  [\"W\", 270, R + 40],\n];\nfor (const [label, az, rr] of cardinals) {\n  const a = az * DEG;\n  svg\n    .append(\"text\")\n    .attr(\"x\", cx + rr * Math.sin(a))\n    .attr(\"y\", cy - rr * Math.cos(a))\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"dominant-baseline\", \"central\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"20px\")\n    .style(\"font-weight\", \"600\")\n    .text(label);\n}\n\n// --- Legend (top-left corner, on an elevated panel) -------------------------\nconst legendItems = [\n  ...sets.map((s) => ({ label: s.name, color: s.color, kind: \"plane\" })),\n  { label: \"Pole density (Kamb)\", color: t.inkSoft, kind: \"contour\" },\n];\nconst legend = svg.append(\"g\").attr(\"transform\", \"translate(44, 110)\");\nlegend\n  .append(\"rect\")\n  .attr(\"x\", -20)\n  .attr(\"y\", -26)\n  .attr(\"width\", 304)\n  .attr(\"height\", legendItems.length * 40 + 8)\n  .attr(\"rx\", 10)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\nlegendItems.forEach((it, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(0, ${i * 40})`);\n  if (it.kind === \"plane\") {\n    row.append(\"line\").attr(\"x1\", 0).attr(\"x2\", 32).attr(\"y1\", 0).attr(\"y2\", 0).attr(\"stroke\", it.color).attr(\"stroke-width\", 2.8);\n    row\n      .append(\"circle\")\n      .attr(\"cx\", 16)\n      .attr(\"cy\", 0)\n      .attr(\"r\", 6)\n      .attr(\"fill\", it.color)\n      .attr(\"stroke\", t.elevatedBg)\n      .attr(\"stroke-width\", 1.3);\n  } else {\n    row.append(\"line\").attr(\"x1\", 0).attr(\"x2\", 32).attr(\"y1\", 0).attr(\"y2\", 0).attr(\"stroke\", it.color).attr(\"stroke-width\", 1).attr(\"opacity\", 0.6);\n  }\n  row\n    .append(\"text\")\n    .attr(\"x\", 44)\n    .attr(\"y\", 0)\n    .attr(\"dominant-baseline\", \"central\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"18px\")\n    .text(it.label);\n});\n\n// --- Title ------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"stereonet-equal-area · javascript · d3 · anyplot.ai\");\n"}