{"spec_id":"stereonet-equal-area","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// stereonet-equal-area: Structural Geology Stereonet (Equal-Area Projection)\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-16\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// Theme-adaptive chrome (data colours stay constant; only chrome flips)\nconst PAGE = t.pageBg;\nconst ELEV = t.elevatedBg;\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\n\n// Imprint palette — bedding=brand green (first), then canonical order\nconst FEATURES = [\n  { type: \"Bedding\", color: t.palette[0] }, // #009E73\n  { type: \"Joint set\", color: t.palette[1] }, // #C475FD\n  { type: \"Fault\", color: t.palette[2] }, // #4467A3\n];\n\n// --- Deterministic in-memory data (no RNG in the browser) -------------------\nlet seed = 0x2f6b3c1;\nconst rand = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);\nconst gauss = (mean, sd) => {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return mean + sd * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n};\nconst wrap360 = (a) => ((a % 360) + 360) % 360;\nconst clampDip = (d) => Math.max(2, Math.min(88, d));\n\n// Each cluster mimics a field campaign: a preferred orientation plus scatter.\nconst CLUSTERS = [\n  { feature: 0, n: 20, dipDir: 118, dip: 24, sdDir: 13, sdDip: 6 },\n  { feature: 1, n: 16, dipDir: 207, dip: 79, sdDir: 9, sdDip: 6 },\n  { feature: 2, n: 12, dipDir: 43, dip: 58, sdDir: 15, sdDip: 8 },\n];\n\n// Lower-hemisphere equal-area projection of a line (trend, plunge) onto the net.\nconst projectTP = (trendDeg, plungeDeg, cx, cy, R) => {\n  const trend = (trendDeg * Math.PI) / 180;\n  const zeta = ((90 - plungeDeg) * Math.PI) / 180; // colatitude from the nadir\n  const r = R * Math.SQRT2 * Math.sin(zeta / 2);\n  return { x: cx + r * Math.sin(trend), y: cy - r * Math.cos(trend) };\n};\n\n// Great circle of a plane given its dip direction and dip, as (trend, plunge)s.\nconst planeGreatCircle = (dipDirDeg, dipDeg, n = 180) => {\n  const dd = (dipDirDeg * Math.PI) / 180;\n  const d = (dipDeg * Math.PI) / 180;\n  const sdir = ((dipDirDeg - 90) * Math.PI) / 180; // strike direction\n  const u = [Math.sin(sdir), Math.cos(sdir), 0]; // strike line (horizontal)\n  const v = [Math.cos(d) * Math.sin(dd), Math.cos(d) * Math.cos(dd), -Math.sin(d)]; // down-dip\n  const pts = [];\n  for (let i = 0; i <= n; i++) {\n    const a = (Math.PI * i) / n;\n    const ca = Math.cos(a);\n    const sa = Math.sin(a);\n    const east = ca * u[0] + sa * v[0];\n    const north = ca * u[1] + sa * v[1];\n    const up = ca * u[2] + sa * v[2];\n    pts.push({\n      trend: (Math.atan2(east, north) * 180) / Math.PI,\n      plunge: (Math.asin(Math.max(-1, Math.min(1, -up))) * 180) / Math.PI,\n    });\n  }\n  return pts;\n};\n\nconst measurements = [];\nCLUSTERS.forEach((c) => {\n  for (let i = 0; i < c.n; i++) {\n    const dipDir = wrap360(gauss(c.dipDir, c.sdDir));\n    const dip = clampDip(gauss(c.dip, c.sdDip));\n    measurements.push({\n      feature: c.feature,\n      color: FEATURES[c.feature].color,\n      greatCircle: planeGreatCircle(dipDir, dip),\n      poleTrend: wrap360(dipDir + 180), // pole is the plane normal\n      polePlunge: 90 - dip,\n    });\n  }\n});\n\n// Equatorial (Schmidt) reference net: meridians (N–S striking planes dipping\n// E/W) and parallels (small-circle cones about the N–S axis), every 10 degrees.\nconst netCurves = [];\nfor (let dip = 10; dip <= 80; dip += 10) {\n  netCurves.push(planeGreatCircle(90, dip)); // east-dipping meridians\n  netCurves.push(planeGreatCircle(270, dip)); // west-dipping meridians\n}\nnetCurves.push(planeGreatCircle(90, 90)); // central N–S meridian\nfor (let alpha = 10; alpha <= 170; alpha += 10) {\n  const a = (alpha * Math.PI) / 180;\n  const pts = [];\n  for (let i = 0; i <= 180; i++) {\n    const b = (Math.PI * i) / 180;\n    const east = Math.sin(a) * Math.cos(b);\n    const north = Math.cos(a);\n    const up = -Math.sin(a) * Math.sin(b);\n    pts.push({\n      trend: (Math.atan2(east, north) * 180) / Math.PI,\n      plunge: (Math.asin(Math.max(-1, Math.min(1, -up))) * 180) / Math.PI,\n    });\n  }\n  netCurves.push(pts);\n};\n\n// --- Kamb density field over the pole population ----------------------------\n// Count poles inside a small counting circle at each net node, expressed in\n// standard deviations above a uniform-fabric expectation (Kamb, 1959). The\n// equal-area projection preserves area, so this density is meaningful.\nconst poleVecs = measurements.map((m) => {\n  const T = (m.poleTrend * Math.PI) / 180;\n  const P = (m.polePlunge * Math.PI) / 180;\n  return [Math.cos(P) * Math.sin(T), Math.cos(P) * Math.cos(T), Math.sin(P)];\n});\nconst N = poleVecs.length;\nconst KAMB_A = 3 / (N + 3); // counting-circle area fraction (k = 3 sigma)\nconst COS_RC = 1 - KAMB_A; // cos of the counting-circle angular radius\nconst EXP = N * KAMB_A; // expected count under a uniform fabric\nconst SIG = Math.sqrt(N * KAMB_A * (1 - KAMB_A));\nconst GN = 64; // density-grid resolution\nconst gridPos = Array.from({ length: GN }, (_, k) => -1 + (2 * k) / (GN - 1));\nconst density = new Float64Array(GN * GN);\nfor (let j = 0; j < GN; j++) {\n  for (let i = 0; i < GN; i++) {\n    const px = gridPos[i];\n    const py = gridPos[j];\n    const r = Math.hypot(px, py);\n    if (r > 1) {\n      density[j * GN + i] = NaN;\n      continue;\n    }\n    const zeta = 2 * Math.asin(Math.min(1, r / Math.SQRT2)); // colatitude from nadir\n    const trend = Math.atan2(px, -py);\n    const sz = Math.sin(zeta);\n    const node = [sz * Math.sin(trend), sz * Math.cos(trend), Math.cos(zeta)];\n    let count = 0;\n    for (let p = 0; p < N; p++) {\n      const v = poleVecs[p];\n      if (node[0] * v[0] + node[1] * v[1] + node[2] * v[2] >= COS_RC) count++;\n    }\n    density[j * GN + i] = (count - EXP) / SIG;\n  }\n}\n\n// Marching squares: iso-density line segments (normalised net coords) per level.\nconst MS_EDGES = {\n  1: [[3, 0]], 2: [[0, 1]], 3: [[3, 1]], 4: [[1, 2]],\n  5: [[3, 0], [1, 2]], 6: [[0, 2]], 7: [[3, 2]], 8: [[2, 3]],\n  9: [[2, 0]], 10: [[0, 1], [2, 3]], 11: [[2, 1]], 12: [[1, 3]],\n  13: [[1, 0]], 14: [[0, 3]],\n};\nconst contourSegments = (level) => {\n  const segs = [];\n  const edgePt = (edge, i, j, a, b, c, d) => {\n    const x0 = gridPos[i];\n    const x1 = gridPos[i + 1];\n    const y0 = gridPos[j];\n    const y1 = gridPos[j + 1];\n    const lerp = (xa, ya, va, xb, yb, vb) => {\n      const tt = (level - va) / (vb - va);\n      return [xa + tt * (xb - xa), ya + tt * (yb - ya)];\n    };\n    if (edge === 0) return lerp(x0, y0, a, x1, y0, b); // top a-b\n    if (edge === 1) return lerp(x1, y0, b, x1, y1, c); // right b-c\n    if (edge === 2) return lerp(x1, y1, c, x0, y1, d); // bottom c-d\n    return lerp(x0, y1, d, x0, y0, a); // left d-a\n  };\n  for (let j = 0; j < GN - 1; j++) {\n    for (let i = 0; i < GN - 1; i++) {\n      const a = density[j * GN + i];\n      const b = density[j * GN + i + 1];\n      const c = density[(j + 1) * GN + i + 1];\n      const d = density[(j + 1) * GN + i];\n      if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c) || Number.isNaN(d)) continue;\n      const ci =\n        (a > level ? 1 : 0) | (b > level ? 2 : 0) | (c > level ? 4 : 0) | (d > level ? 8 : 0);\n      const pairs = MS_EDGES[ci];\n      if (!pairs) continue;\n      pairs.forEach(([e1, e2]) =>\n        segs.push([edgePt(e1, i, j, a, b, c, d), edgePt(e2, i, j, a, b, c, d)]),\n      );\n    }\n  }\n  return segs;\n};\n\n// --- Helpers ----------------------------------------------------------------\nconst hexA = (hex, a) => {\n  const n = parseInt(hex.slice(1), 16);\n  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;\n};\nconst strokePath = (ctx, pts, cx, cy, R) => {\n  ctx.beginPath();\n  pts.forEach((p, i) => {\n    const { x, y } = projectTP(p.trend, p.plunge, cx, cy, R);\n    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);\n  });\n  ctx.stroke();\n};\n\n// --- Stereonet plugin: draws net, great circles and poles in pixel space ----\nconst stereonet = {\n  id: \"stereonet\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const cx = (chartArea.left + chartArea.right) / 2;\n    const cy = (chartArea.top + chartArea.bottom) / 2;\n    const R =\n      (Math.min(chartArea.right - chartArea.left, chartArea.bottom - chartArea.top) / 2) * 0.86;\n\n    ctx.save();\n    ctx.lineJoin = \"round\";\n    ctx.lineCap = \"round\";\n\n    // Net \"card\" so the subtle grid reads against the page surface\n    ctx.beginPath();\n    ctx.arc(cx, cy, R, 0, 2 * Math.PI);\n    ctx.fillStyle = ELEV;\n    ctx.fill();\n\n    // Equal-area net grid (subtle)\n    ctx.strokeStyle = GRID;\n    ctx.lineWidth = 1;\n    netCurves.forEach((c) => strokePath(ctx, c, cx, cy, R));\n\n    // Kamb density contours — highlight preferred orientations (every 2 sigma)\n    ctx.save();\n    ctx.beginPath();\n    ctx.arc(cx, cy, R, 0, 2 * Math.PI);\n    ctx.clip();\n    [2, 4, 6, 8].forEach((lv, idx) => {\n      const segs = contourSegments(lv);\n      if (!segs.length) return;\n      ctx.strokeStyle = hexA(INK_SOFT, 0.28 + idx * 0.16);\n      ctx.lineWidth = 1 + idx * 0.25;\n      ctx.beginPath();\n      segs.forEach(([p0, p1]) => {\n        ctx.moveTo(cx + p0[0] * R, cy + p0[1] * R);\n        ctx.lineTo(cx + p1[0] * R, cy + p1[1] * R);\n      });\n      ctx.stroke();\n    });\n    ctx.restore();\n\n    // Primitive circle (the horizontal plane)\n    ctx.beginPath();\n    ctx.arc(cx, cy, R, 0, 2 * Math.PI);\n    ctx.strokeStyle = INK_SOFT;\n    ctx.lineWidth = 2.5;\n    ctx.stroke();\n\n    // Perimeter degree ticks (every 10 deg, major every 30 deg)\n    ctx.fillStyle = INK_SOFT;\n    ctx.font = '600 15px -apple-system, \"Segoe UI\", Roboto, sans-serif';\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    for (let az = 0; az < 360; az += 10) {\n      const a = (az * Math.PI) / 180;\n      const dx = Math.sin(a);\n      const dy = -Math.cos(a);\n      const major = az % 30 === 0;\n      const len = major ? 16 : 9;\n      ctx.beginPath();\n      ctx.moveTo(cx + R * dx, cy + R * dy);\n      ctx.lineTo(cx + (R + len) * dx, cy + (R + len) * dy);\n      ctx.strokeStyle = INK_SOFT;\n      ctx.lineWidth = major ? 2 : 1;\n      ctx.stroke();\n      if (major && az !== 0) {\n        ctx.fillText(String(az).padStart(3, \"0\"), cx + (R + len + 18) * dx, cy + (R + len + 18) * dy);\n      }\n    }\n\n    // Great circles for the measured planes (faint, coloured by feature type so\n    // the poles stay the focal point even where curves bunch up)\n    ctx.lineWidth = 1;\n    measurements.forEach((m) => {\n      ctx.strokeStyle = hexA(m.color, 0.22);\n      strokePath(ctx, m.greatCircle, cx, cy, R);\n    });\n\n    // Poles to planes (the primary data points)\n    measurements.forEach((m) => {\n      const p = projectTP(m.poleTrend, m.polePlunge, cx, cy, R);\n      ctx.beginPath();\n      ctx.arc(p.x, p.y, 5.5, 0, 2 * Math.PI);\n      ctx.fillStyle = m.color;\n      ctx.fill();\n      ctx.lineWidth = 1.2;\n      ctx.strokeStyle = PAGE;\n      ctx.stroke();\n    });\n\n    // North arrow + label\n    const ny = cy - R - 16;\n    ctx.beginPath();\n    ctx.moveTo(cx, ny - 13);\n    ctx.lineTo(cx - 8, ny + 4);\n    ctx.lineTo(cx + 8, ny + 4);\n    ctx.closePath();\n    ctx.fillStyle = INK;\n    ctx.fill();\n    ctx.font = '700 19px -apple-system, \"Segoe UI\", Roboto, sans-serif';\n    ctx.fillText(\"N\", cx, ny - 26);\n\n    ctx.restore();\n  },\n};\n\n// --- Mount ------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 10 },\n    scales: {\n      x: { display: false, min: -1, max: 1 },\n      y: { display: false, min: -1, max: 1 },\n    },\n    plugins: {\n      title: {\n        display: true,\n        text: \"stereonet-equal-area · javascript · chartjs · anyplot.ai\",\n        color: INK,\n        font: { size: 22, weight: \"600\" },\n        padding: { top: 4, bottom: 2 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Lower-hemisphere equal-area net · poles (points) and great circles by feature type\",\n        color: INK_SOFT,\n        font: { size: 14 },\n        padding: { bottom: 10 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: INK,\n          font: { size: 16 },\n          padding: 18,\n          boxWidth: 14,\n          boxHeight: 14,\n          usePointStyle: true,\n          pointStyle: \"circle\",\n          generateLabels: () =>\n            FEATURES.map((f) => ({\n              text: f.type,\n              fillStyle: f.color,\n              strokeStyle: PAGE,\n              lineWidth: 1.2,\n              fontColor: INK,\n            })),\n        },\n      },\n      tooltip: { enabled: false },\n    },\n  },\n  plugins: [stereonet],\n});\n"}