Lecture 15: Riemannian Manifolds — Foundations

Smooth manifolds, tangent spaces, metrics, and curvature

1 Learning Goals

By the end of this lecture, learners should be able to:

  • Define topological and smooth manifolds, charts, atlases, and transition maps, and visualize them on \(\mathbb{S}^2\) using stereographic charts.
  • Visualize the tangent space \(T_p\mathcal{M}\) as a vector space attached to a point, with a coordinate-derived tangent frame.
  • State the definitions of Riemannian metric, geodesic, exponential map, and logarithmic map.
  • Explain the inverse relationship \(\exp_p(\log_p(q)) = q\) and why it fails at the cut locus.
  • Compute the gradient of the squared-distance function on a Riemannian manifold.
  • Explain the Levi-Civita connection and the meaning of sectional curvature.
  • Contrast Hadamard manifolds with positively curved manifolds.
  • Describe the SPD cone and the sphere as Riemannian manifolds with explicit metrics.

2 Topological and Smooth Manifolds

Definition 1 A topological space \(\mathcal{M}\) is a topological manifold of dimension \(m\) if it is Hausdorff, second countable, and every point \(p \in \mathcal{M}\) has an open neighborhood \(U\) homeomorphic to an open subset of \(\mathbb{R}^m\).

Topology is the study of properties of spaces that are preserved under continuous deformations — stretching, bending, twisting — but not tearing or gluing. A topological space \((X, \tau)\) is a set \(X\) together with a collection \(\tau\) of subsets (called open sets) satisfying three axioms: (i) \(\emptyset\) and \(X\) are open, (ii) arbitrary unions of open sets are open, and (iii) finite intersections of open sets are open. The open sets define a notion of “nearness” without needing a distance function.

Three key properties appear in the definition of a topological manifold:

Hausdorff (\(T_2\)). Any two distinct points can be separated by disjoint open neighborhoods. This rules out pathological spaces where sequences can converge to two different limits at once.

  • Example: \(\mathbb{R}^n\) with the usual Euclidean topology is Hausdorff — two distinct points \(x \neq y\) can be enclosed in disjoint open balls of radius \(\|x-y\|/3\).

Second countable. There exists a countable basis for the topology — a countable collection \(\mathcal{B}\) of open sets such that every open set can be written as a union of members of \(\mathcal{B}\). This ensures the manifold is “not too big” and guarantees existence of partitions of unity (a crucial tool for constructing Riemannian metrics).

  • Example: \(\mathbb{R}^n\) is second countable — take \(\mathcal{B}\) as all open balls with rational centers and rational radii. There are only countably many such balls, and any open set is a union of them.

Homeomorphism. A continuous bijection with a continuous inverse. Two spaces are homeomorphic if one can be continuously deformed into the other without cutting or gluing.

  • Example: A coffee cup and a donut (torus) are homeomorphic — stretch the handle, deform the cup body, and they match.
  • Example: A sphere \(\mathbb{S}^2\) is not homeomorphic to a torus \(\mathbb{T}^2\) — you cannot remove the hole without tearing (they have different genus).

If \(U \subset \mathcal{M}\) is open and \(\phi : U \to \phi(U) \subset \mathbb{R}^m\) is such a homeomorphism, then \((U, \phi)\) is called a chart. A collection of charts covering \(\mathcal{M}\) is an atlas. On overlaps \((U_\alpha, \phi_\alpha)\) and \((U_\beta, \phi_\beta)\), the transition map \(\phi_\alpha \circ \phi_\beta^{-1}\) relates the two coordinate systems.

Two overlapping charts \((U_\alpha, \phi_\alpha)\) and \((U_\beta, \phi_\beta)\) on a manifold \(\mathcal{M}\). The transition map \(\phi_\alpha \circ \phi_\beta^{-1}\) maps coordinates from \(\phi_\beta(U_\alpha \cap U_\beta)\) to \(\phi_\alpha(U_\alpha \cap U_\beta)\), providing a consistent way to switch between local coordinate systems on the overlap region.

Definition 2 For \(k \ge 1\), a \(C^k\)-atlas has \(k\)-times continuously differentiable transition maps. A smooth manifold is a topological manifold with a maximal compatible \(C^\infty\)-atlas.

Two \(C^\infty\)-atlases are compatible if their union is still a \(C^\infty\)-atlas — equivalently, every chart from one atlas has smooth transition maps with every chart from the other. Compatibility is an equivalence relation on atlases; a smooth structure on \(\mathcal{M}\) is an equivalence class under this relation.

A maximal atlas is the union of all charts belonging to any atlas in a given equivalence class. It is the unique largest atlas in that class — throw in every compatible chart and you get the maximal one.

Why bother? Defining a smooth manifold as “a topological manifold together with a maximal \(C^\infty\)-atlas” makes the smooth structure unambiguous without privileging a particular set of charts. In practice, you only need to exhibit one \(C^\infty\)-atlas on \(\mathcal{M}\); it uniquely determines the maximal atlas, so the smooth structure is well-defined.

Example. The stereographic atlas on \(\mathbb{S}^2\) (the two charts \((U_N, \phi_N)\) and \((U_S, \phi_S)\) defined below) is not maximal — you could add, say, a rotated version of the north-pole chart. But the stereographic atlas already determines the smooth structure: the maximal atlas is simply every chart on \(\mathbb{S}^2\) whose transition maps with both stereographic charts are \(C^\infty\).

2.1 Examples

Example 1: The 2-sphere \(\mathbb{S}^2\). The unit sphere \(\mathbb{S}^2 = \{x \in \mathbb{R}^3 : \|x\|_2 = 1\}\) is a 2-dimensional smooth manifold. It cannot be covered by a single chart (it is compact, whereas a nonempty open subset of \(\mathbb{R}^2\) is not), and the following two stereographic charts form a minimal atlas.

North-pole chart \((U_N, \phi_N)\). Let \(N = (0, 0, 1)\) be the north pole. Define \(U_N = \mathbb{S}^2 \setminus \{N\}\) and \(\phi_N : U_N \to \mathbb{R}^2\) by

\[ \phi_N(x_1, x_2, x_3) = \left(\frac{x_1}{1 - x_3},\; \frac{x_2}{1 - x_3}\right). \]

Geometrically, \(\phi_N\) projects from \(N\) onto the equatorial plane \(\{x_3 = 0\}\). The inverse is

\[ \phi_N^{-1}(u, v) = \left(\frac{2u}{1 + u^2 + v^2},\; \frac{2v}{1 + u^2 + v^2},\; \frac{u^2 + v^2 - 1}{1 + u^2 + v^2}\right). \]

South-pole chart \((U_S, \phi_S)\). Let \(S = (0, 0, -1)\) be the south pole. Define \(U_S = \mathbb{S}^2 \setminus \{S\}\) and \(\phi_S : U_S \to \mathbb{R}^2\) by

\[ \phi_S(x_1, x_2, x_3) = \left(\frac{x_1}{1 + x_3},\; \frac{x_2}{1 + x_3}\right). \]

Transition map. On the overlap \(U_N \cap U_S = \mathbb{S}^2 \setminus \{N, S\}\), both charts are defined. The transition map \(\phi_S \circ \phi_N^{-1} : \mathbb{R}^2 \setminus \{(0,0)\} \to \mathbb{R}^2 \setminus \{(0,0)\}\) is

\[ (\phi_S \circ \phi_N^{-1})(u, v) = \left(\frac{u}{u^2 + v^2},\; \frac{v}{u^2 + v^2}\right). \]

This is \(C^\infty\) on \(\mathbb{R}^2 \setminus \{(0,0)\}\), confirming that \(\{(U_N, \phi_N), (U_S, \phi_S)\}\) is a smooth atlas. The point \((0,0)\) is not in the overlap because \(\phi_N^{-1}(0,0) = S\) and \(\phi_S^{-1}(0,0) = N\) — each pole is covered by exactly one chart.

Example 2: The SPD cone \(\mathcal{S}_{++}^m\). Let \(\operatorname{Sym}(m) = \{A \in \mathbb{R}^{m \times m} : A^\top = A\}\) be the vector space of symmetric matrices, of dimension \(m(m+1)/2\). The set

\[ \mathcal{S}_{++}^m = \{\Sigma \in \operatorname{Sym}(m) : x^\top \Sigma x > 0 \text{ for all } x \in \mathbb{R}^m \setminus \{0\}\} \]

is an open subset of \(\operatorname{Sym}(m)\) (the cone of positive-definite matrices). Being an open subset of a finite-dimensional vector space, \(\mathcal{S}_{++}^m\) is a smooth manifold of dimension \(m(m+1)/2\).

Since \(\mathcal{S}_{++}^m\) is an open subset of a vector space, a single chart (a global atlas) suffices. The most natural chart is the identity inclusion; a more useful one in practice is the matrix logarithm chart.

Let \(\operatorname{vech} : \operatorname{Sym}(m) \to \mathbb{R}^{m(m+1)/2}\) be the half-vectorization operator that stacks the \(m(m+1)/2\) unique entries of a symmetric matrix into a vector (e.g., column-wise, excluding the strict upper triangle). Define the global chart

\[ \phi : \mathcal{S}_{++}^m \to \mathbb{R}^{m(m+1)/2}, \qquad \phi(\Sigma) = \operatorname{vech}(\log \Sigma), \]

where \(\log\) is the principal matrix logarithm. This is a homeomorphism because \(\log : \mathcal{S}_{++}^m \to \operatorname{Sym}(m)\) is a diffeomorphism with smooth inverse \(\exp : \operatorname{Sym}(m) \to \mathcal{S}_{++}^m\) (the matrix exponential). The chart domain is all of \(\mathcal{S}_{++}^m\), so \(\{(\mathcal{S}_{++}^m, \phi)\}\) is a one-chart smooth atlas.

For \(m = 2\) (used in the portfolio demos in Lectures 6–10), \(\operatorname{Sym}(2)\) has dimension \(3\). Writing \(\Sigma = \begin{pmatrix} a & b \\ b & c \end{pmatrix}\),

\[ \phi(\Sigma) = \operatorname{vech}(\log \Sigma) = \begin{pmatrix} [\log \Sigma]_{11} \\ [\log \Sigma]_{12} \\ [\log \Sigma]_{22} \end{pmatrix} \in \mathbb{R}^3. \]

This is the coordinate representation used throughout the interactive demos in earlier lectures, where the compact storage [a, b, c] represents the three independent entries of the log-covariance matrix.

Other examples: Grassmann manifolds \(\mathrm{Gr}(r,m)\) (subspaces), \(\mathrm{SO}(m)\) (rotations), hyperbolic spaces (hierarchical data), Kendall shape spaces (landmark configurations).

2.2 Interactive: Charts, Atlas, and Transition Maps on \(\mathbb{S}^2\)

The abstract definition of charts and atlases becomes concrete when visualized on the sphere. The following demo shows two stereographic charts — one from the north pole, one from the south pole — that together form an atlas for \(\mathbb{S}^2\). Pick a point on the sphere to see its coordinates in each chart and the transition map between them.

Code
chart_th_control = Inputs.range([0, 180], {step: 1, value: 45, label: "Point θ (colatitude °)"})
chart_ph_control = Inputs.range([0, 360], {step: 5, value: 120, label: "Point φ (longitude °)"})
chart_rotY_control = Inputs.range([0, 360], {step: 10, value: 200, label: "Chart view azimuth °"})
chart_rotX_control = Inputs.range([-50, 50], {step: 5, value: 20, label: "Chart view elevation °"})
chart_th = Generators.input(chart_th_control)
chart_ph = Generators.input(chart_ph_control)
chart_rotY = Generators.input(chart_rotY_control)
chart_rotX = Generators.input(chart_rotX_control)

chart_controls_view = html`
<style>
  .chart-slider-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px 20px; max-width:700px; margin:0 auto 12px; font:0.85em system-ui,sans-serif; }
  .chart-slider-grid > * { min-width:0; margin:0; }
  .chart-slider-grid input[type="number"] { width:7.5rem !important; }
  .chart-atlas-layout { display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start; }
  .chart-s2-wrap { flex:0 1 380px; min-width:0; }
  .chart-s2-globe { display:block; width:100%; max-width:380px; height:auto; }
  .chart-atlas-details { flex:1 1 360px; min-width:min(100%,330px); }
  .chart-plane-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-bottom:12px; }
  .chart-plane-card { min-width:0; padding:8px; border:1px solid #dee2e6; border-radius:6px; background:#fff; }
  .chart-plane-card h5 { margin:0 0 4px; text-align:center; font-size:0.85em; }
  .chart-plane-card svg { display:block; width:100%; height:auto; }
  @media (max-width:500px) { .chart-slider-grid { grid-template-columns:1fr; } }
  @media (max-width:500px) { .chart-plane-grid { grid-template-columns:1fr; } }
</style>
<div class="chart-slider-grid">
  <div>${chart_th_control}</div>
  <div>${chart_ph_control}</div>
  <div>${chart_rotY_control}</div>
  <div>${chart_rotX_control}</div>
</div>`

function attachS2DragRotation(root, selector, azimuthLabel, elevationLabel, minElevation, maxElevation, data, kind) {
  var svg = root.querySelector(selector);
  if (!svg) return root;
  var scene = svg.querySelector('g[transform^="translate"]');
  if (!scene) return root;

  function findRange(labelText) {
    var labels = Array.from(document.querySelectorAll("label"));
    for (var i = 0; i < labels.length; i++) {
      if (labels[i].textContent.indexOf(labelText) >= 0) {
        return labels[i].querySelector('input[type="range"]') ||
          (labels[i].parentElement && labels[i].parentElement.querySelector('input[type="range"]'));
      }
    }
    return null;
  }

  var az = findRange(azimuthLabel);
  var el = findRange(elevationLabel);
  var state = {
    rotY: az ? Number(az.value) : 200,
    rotX: el ? Number(el.value) : 20
  };

  function clamp(x, lo, hi) {
    return Math.max(lo, Math.min(hi, x));
  }

  function makeProjector(rotY, rotX, R) {
    var ry = rotY * Math.PI / 180;
    var rx = rotX * Math.PI / 180;
    return function(v) {
      var x1 = v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
      var y1 = v[1];
      var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
      var x2 = x1;
      var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
      var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
      return {x: x2*R, y: -y2*R, z: z2};
    };
  }

  function renderWireframe(parts, proj, R, opacity) {
    for (var p = 0; p <= 18; p++) {
      var z = 1 - p*2/18;
      var r = Math.sqrt(Math.max(0, 1 - z*z));
      var d = '';
      for (var j = 0; j <= 120; j++) {
        var phi = j*2*Math.PI/120;
        var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
        d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
      }
      parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="'+opacity+'"/>');
    }
    for (var m = 0; m < 24; m++) {
      var mphi = m*Math.PI/12;
      var md = '';
      for (var mj = 0; mj <= 90; mj++) {
        var theta = mj*Math.PI/90;
        var mpt = proj([Math.sin(theta)*Math.cos(mphi), Math.sin(theta)*Math.sin(mphi), Math.cos(theta)]);
        md += (mj===0?'M':'L')+' '+mpt.x.toFixed(1)+' '+mpt.y.toFixed(1);
      }
      parts.push('<path d="'+md+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="'+opacity+'"/>');
    }
    parts.push('<circle cx="0" cy="0" r="'+R+'" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');
  }

  function renderChart(res, rotY, rotX) {
    var R = 160, parts = [];
    var proj = makeProjector(rotY, rotX, R);
    renderWireframe(parts, proj, R, 0.15);

    var nP = proj([0,0,1]);
    parts.push('<circle cx="'+nP.x.toFixed(1)+'" cy="'+nP.y.toFixed(1)+'" r="5" fill="#e03131"/>');
    parts.push('<line x1="'+(nP.x-4).toFixed(1)+'" y1="'+(nP.y-4).toFixed(1)+'" x2="'+(nP.x+4).toFixed(1)+'" y2="'+(nP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<line x1="'+(nP.x+4).toFixed(1)+'" y1="'+(nP.y-4).toFixed(1)+'" x2="'+(nP.x-4).toFixed(1)+'" y2="'+(nP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(nP.x+8).toFixed(0)+'" y="'+(nP.y-4).toFixed(0)+'" font-size="11" fill="#e03131" font-weight="bold">N</text>');
    var sP = proj([0,0,-1]);
    parts.push('<circle cx="'+sP.x.toFixed(1)+'" cy="'+sP.y.toFixed(1)+'" r="5" fill="#1971c2"/>');
    parts.push('<line x1="'+(sP.x-4).toFixed(1)+'" y1="'+(sP.y-4).toFixed(1)+'" x2="'+(sP.x+4).toFixed(1)+'" y2="'+(sP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<line x1="'+(sP.x+4).toFixed(1)+'" y1="'+(sP.y-4).toFixed(1)+'" x2="'+(sP.x-4).toFixed(1)+'" y2="'+(sP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(sP.x+8).toFixed(0)+'" y="'+(sP.y-4).toFixed(0)+'" font-size="11" fill="#1971c2" font-weight="bold">S</text>');
    var pP = proj(res.P);
    parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="6" fill="#2b8a3e" stroke="#fff" stroke-width="2"/>');
    parts.push('<text x="'+(pP.x+10).toFixed(0)+'" y="'+(pP.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">P</text>');
    return parts.join('\n');
  }

  function renderTangent(res, rotY, rotX) {
    var R = 175, parts = [];
    var proj = makeProjector(rotY, rotX, R);
    renderWireframe(parts, proj, R, 0.12);

    for (var i = 0; i < res.nGrid; i++) {
      var dRow = '';
      for (var j = 0; j < res.nGrid; j++) {
        var pt = proj(res.tgGrid[i*res.nGrid+j]);
        dRow += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
      }
      parts.push('<path d="'+dRow+'" fill="none" stroke="#f08c00" stroke-width="0.4" opacity="0.18"/>');
    }
    for (var cj = 0; cj < res.nGrid; cj++) {
      var dCol = '';
      for (var ci = 0; ci < res.nGrid; ci++) {
        var cpt = proj(res.tgGrid[ci*res.nGrid+cj]);
        dCol += (ci===0?'M':'L')+' '+cpt.x.toFixed(1)+' '+cpt.y.toFixed(1);
      }
      parts.push('<path d="'+dCol+'" fill="none" stroke="#f08c00" stroke-width="0.4" opacity="0.18"/>');
    }

    var cornerPts = [proj(res.tgGrid[0]), proj(res.tgGrid[res.nGrid-1]), proj(res.tgGrid[res.nGrid*res.nGrid-1]), proj(res.tgGrid[res.nGrid*(res.nGrid-1)])];
    var cStr = '';
    for (var ci2 = 0; ci2 < 4; ci2++) cStr += (ci2===0?'M':'L')+' '+cornerPts[ci2].x.toFixed(1)+' '+cornerPts[ci2].y.toFixed(1);
    parts.push('<path d="'+cStr+' Z" fill="none" stroke="#f08c00" stroke-width="1" opacity="0.3" stroke-dasharray="6,3"/>');

    var pP = proj(res.P);
    var etP = proj([res.P[0]+0.35*res.eTheta[0], res.P[1]+0.35*res.eTheta[1], res.P[2]+0.35*res.eTheta[2]]);
    var epP = proj([res.P[0]+0.35*res.ePhi[0], res.P[1]+0.35*res.ePhi[1], res.P[2]+0.35*res.ePhi[2]]);
    parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+etP.x.toFixed(1)+'" y2="'+etP.y.toFixed(1)+'" stroke="#e03131" stroke-width="2"/>');
    parts.push('<text x="'+(etP.x+3).toFixed(0)+'" y="'+(etP.y-3).toFixed(0)+'" font-size="10" fill="#e03131">eθ</text>');
    parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+epP.x.toFixed(1)+'" y2="'+epP.y.toFixed(1)+'" stroke="#1971c2" stroke-width="2"/>');
    parts.push('<text x="'+(epP.x+3).toFixed(0)+'" y="'+(epP.y-3).toFixed(0)+'" font-size="10" fill="#1971c2">eφ</text>');

    var vEnd = [res.P[0]+0.5*res.V[0], res.P[1]+0.5*res.V[1], res.P[2]+0.5*res.V[2]];
    var vdP = proj(vEnd);
    parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+vdP.x.toFixed(1)+'" y2="'+vdP.y.toFixed(1)+'" stroke="#2b8a3e" stroke-width="3" stroke-linecap="round"/>');
    if (res.Vnorm > 0.01) {
      var vDir = [res.V[0]/res.Vnorm, res.V[1]/res.Vnorm, res.V[2]/res.Vnorm];
      var ahBase = [res.P[0]+0.42*res.V[0], res.P[1]+0.42*res.V[1], res.P[2]+0.42*res.V[2]];
      var orth = [vDir[1]*res.P[2]-vDir[2]*res.P[1], vDir[2]*res.P[0]-vDir[0]*res.P[2], vDir[0]*res.P[1]-vDir[1]*res.P[0]];
      var orthN = Math.sqrt(orth[0]*orth[0]+orth[1]*orth[1]+orth[2]*orth[2]);
      if (orthN > 1e-10) {
        orth = [orth[0]/orthN*0.08, orth[1]/orthN*0.08, orth[2]/orthN*0.08];
        var wing1 = [ahBase[0]+orth[0]-vDir[0]*0.06, ahBase[1]+orth[1]-vDir[1]*0.06, ahBase[2]+orth[2]-vDir[2]*0.06];
        var wing2 = [ahBase[0]-orth[0]-vDir[0]*0.06, ahBase[1]-orth[1]-vDir[1]*0.06, ahBase[2]-orth[2]-vDir[2]*0.06];
        var w1 = proj(wing1), w2 = proj(wing2);
        parts.push('<polygon points="'+vdP.x.toFixed(1)+','+vdP.y.toFixed(1)+' '+w1.x.toFixed(1)+','+w1.y.toFixed(1)+' '+w2.x.toFixed(1)+','+w2.y.toFixed(1)+'" fill="#2b8a3e"/>');
      }
    }
    parts.push('<text x="'+(vdP.x+5).toFixed(0)+'" y="'+(vdP.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">v</text>');

    var geoPath = '';
    for (var gi = 0; gi < res.geodPts.length; gi++) {
      var gpt = proj(res.geodPts[gi]);
      geoPath += (gi===0?'M':'L')+' '+gpt.x.toFixed(1)+' '+gpt.y.toFixed(1);
    }
    parts.push('<path d="'+geoPath+'" fill="none" stroke="#7950f2" stroke-width="2.5" stroke-dasharray="8,4"/>');
    var expP = proj(res.expPt);
    parts.push('<circle cx="'+expP.x.toFixed(1)+'" cy="'+expP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(expP.x+7).toFixed(0)+'" y="'+(expP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">expₚ(v)</text>');
    parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(pP.x+8).toFixed(0)+'" y="'+(pP.y-8).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">p</text>');
    return parts.join('\n');
  }

  function renderExpLog(res, rotY, rotX) {
    var R = 180, parts = [];
    var proj = makeProjector(rotY, rotX, R);
    renderWireframe(parts, proj, R, 0.12);

    for (var i = 0; i < res.nGrid; i++) {
      var dRow = '';
      for (var j = 0; j < res.nGrid; j++) {
        var pt = proj(res.tgGrid[i*res.nGrid+j]);
        dRow += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
      }
      parts.push('<path d="'+dRow+'" fill="none" stroke="#f08c00" stroke-width="0.3" opacity="0.15"/>');
    }
    for (var cj = 0; cj < res.nGrid; cj++) {
      var dCol = '';
      for (var ci = 0; ci < res.nGrid; ci++) {
        var cpt = proj(res.tgGrid[ci*res.nGrid+cj]);
        dCol += (ci===0?'M':'L')+' '+cpt.x.toFixed(1)+' '+cpt.y.toFixed(1);
      }
      parts.push('<path d="'+dCol+'" fill="none" stroke="#f08c00" stroke-width="0.3" opacity="0.15"/>');
    }

    function pathFrom(points) {
      var d = '';
      for (var i = 0; i < points.length; i++) {
        var pt = proj(points[i]);
        d += (i===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
      }
      return d;
    }
    parts.push('<path d="'+pathFrom(res.geodExp)+'" fill="none" stroke="#7950f2" stroke-width="2.5" stroke-dasharray="8,4"/>');
    if (res.logDefined) parts.push('<path d="'+pathFrom(res.geodLog)+'" fill="none" stroke="#f08c00" stroke-width="2" stroke-dasharray="5,4"/>');

    var pP = proj(res.P);
    var vScale = 0.55/Math.max(1, res.Vnorm);
    var vdP = proj([res.P[0]+vScale*res.V[0], res.P[1]+vScale*res.V[1], res.P[2]+vScale*res.V[2]]);
    parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+vdP.x.toFixed(1)+'" y2="'+vdP.y.toFixed(1)+'" stroke="#2b8a3e" stroke-width="3" stroke-linecap="round"/>');
    parts.push('<text x="'+(vdP.x+5).toFixed(0)+'" y="'+(vdP.y-7).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">v</text>');
    if (res.logDefined) {
      var logScale = 0.55/Math.max(1, res.logNorm);
      var lqP = proj([res.P[0]+logScale*res.logQ[0], res.P[1]+logScale*res.logQ[1], res.P[2]+logScale*res.logQ[2]]);
      parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+lqP.x.toFixed(1)+'" y2="'+lqP.y.toFixed(1)+'" stroke="#f08c00" stroke-width="2.5" stroke-linecap="round"/>');
      parts.push('<text x="'+(lqP.x+5).toFixed(0)+'" y="'+(lqP.y-7).toFixed(0)+'" font-size="11" fill="#f08c00" font-weight="bold">logₚ(q)</text>');
    }
    var expVP = proj(res.expV);
    parts.push('<circle cx="'+expVP.x.toFixed(1)+'" cy="'+expVP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(expVP.x+6).toFixed(0)+'" y="'+(expVP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">expₚ(v)</text>');
    var qP = proj(res.Q);
    parts.push('<circle cx="'+qP.x.toFixed(1)+'" cy="'+qP.y.toFixed(1)+'" r="5" fill="#1971c2" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(qP.x+6).toFixed(0)+'" y="'+(qP.y-6).toFixed(0)+'" font-size="11" fill="#1971c2" font-weight="bold">q</text>');
    parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(pP.x+8).toFixed(0)+'" y="'+(pP.y-8).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">p</text>');
    return parts.join('\n');
  }

  function renderPT(res, rotY, rotX) {
    var R = 175, parts = [];
    var proj = makeProjector(rotY, rotX, R);
    renderWireframe(parts, proj, R, 0.12);

    function pathFrom(points) {
      var d = '';
      for (var i = 0; i < points.length; i++) {
        var pt = proj(points[i]);
        d += (i===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
      }
      return d;
    }

    parts.push('<path d="'+pathFrom(res.curve)+'" fill="none" stroke="#9775fa" stroke-width="1.1" stroke-dasharray="4,4" opacity="0.5"/>');
    if (res.trajPts.length > 1) {
      parts.push('<path d="'+pathFrom(res.trajPts)+'" fill="none" stroke="#7950f2" stroke-width="2.6"/>');
    }

    function drawArrow(base, vec, scl, color, w, op) {
      var tip = [base[0]+scl*vec[0], base[1]+scl*vec[1], base[2]+scl*vec[2]];
      var bP = proj(base), tP = proj(tip);
      var s = '<line x1="'+bP.x.toFixed(1)+'" y1="'+bP.y.toFixed(1)+'" x2="'+tP.x.toFixed(1)+'" y2="'+tP.y.toFixed(1)+'" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round" opacity="'+op+'"/>';
      var vn = Math.sqrt(vec[0]*vec[0]+vec[1]*vec[1]+vec[2]*vec[2]);
      if (vn > 1e-9) {
        var vd = [vec[0]/vn, vec[1]/vn, vec[2]/vn];
        var ox = vd[1]*base[2]-vd[2]*base[1], oy = vd[2]*base[0]-vd[0]*base[2], oz = vd[0]*base[1]-vd[1]*base[0];
        var on = Math.sqrt(ox*ox+oy*oy+oz*oz);
        if (on > 1e-9) {
          ox /= on; oy /= on; oz /= on;
          var hl = Math.min(0.3*vn, 0.15), hw = Math.min(0.18*vn, 0.08);
          var wb = [tip[0]-scl*hl*vd[0], tip[1]-scl*hl*vd[1], tip[2]-scl*hl*vd[2]];
          var w1 = proj([wb[0]+scl*hw*ox, wb[1]+scl*hw*oy, wb[2]+scl*hw*oz]);
          var w2 = proj([wb[0]-scl*hw*ox, wb[1]-scl*hw*oy, wb[2]-scl*hw*oz]);
          s += '<polygon points="'+tP.x.toFixed(1)+','+tP.y.toFixed(1)+' '+w1.x.toFixed(1)+','+w1.y.toFixed(1)+' '+w2.x.toFixed(1)+','+w2.y.toFixed(1)+'" fill="'+color+'" opacity="'+op+'"/>';
        }
      }
      return s;
    }

    for (var ai = 0; ai < res.arrowSamples.length; ai++) {
      parts.push(drawArrow(res.arrowSamples[ai].p, res.arrowSamples[ai].v, 0.45, '#0ca678', 1.6, 0.85));
    }
    parts.push(drawArrow(res.P0, res.V0, 0.45, '#2b8a3e', 2, 0.4));
    parts.push(drawArrow(res.curPt, res.curVec, 0.45, '#2b8a3e', 3, 1));

    var tipV = proj([res.curPt[0]+0.45*res.curVec[0], res.curPt[1]+0.45*res.curVec[1], res.curPt[2]+0.45*res.curVec[2]]);
    parts.push('<text x="'+(tipV.x+5).toFixed(0)+'" y="'+(tipV.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">V(t)</text>');

    var p0P = proj(res.P0);
    parts.push('<circle cx="'+p0P.x.toFixed(1)+'" cy="'+p0P.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(p0P.x+8).toFixed(0)+'" y="'+(p0P.y-7).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">γ(0)</text>');
    var pcP = proj(res.curPt);
    parts.push('<circle cx="'+pcP.x.toFixed(1)+'" cy="'+pcP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
    parts.push('<text x="'+(pcP.x+7).toFixed(0)+'" y="'+(pcP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">γ(t)</text>');
    return parts.join('\n');
  }

  function render() {
    if (kind === "chart") scene.innerHTML = renderChart(data, state.rotY, state.rotX);
    if (kind === "tangent") scene.innerHTML = renderTangent(data, state.rotY, state.rotX);
    if (kind === "explog") scene.innerHTML = renderExpLog(data, state.rotY, state.rotX);
    if (kind === "pt") scene.innerHTML = renderPT(data, state.rotY, state.rotX);
  }

  var dragging = false;
  var lastX = 0;
  var lastY = 0;

  svg.addEventListener("pointerdown", function(event) {
    dragging = true;
    lastX = event.clientX;
    lastY = event.clientY;
    svg.setPointerCapture(event.pointerId);
    svg.style.cursor = "grabbing";
  });

  svg.addEventListener("pointermove", function(event) {
    if (!dragging) return;
    var dx = event.clientX - lastX;
    var dy = event.clientY - lastY;
    lastX = event.clientX;
    lastY = event.clientY;
    state.rotY = (state.rotY + dx * 0.55 + 360) % 360;
    // Match vertical rotation to the pointer's direction.
    state.rotX = clamp(state.rotX + dy * 0.55, minElevation, maxElevation);
    render();
  });

  function stopDrag(event) {
    if (!dragging) return;
    dragging = false;
    svg.style.cursor = "grab";
    if (event.pointerId !== undefined) svg.releasePointerCapture(event.pointerId);
  }

  svg.addEventListener("pointerup", stopDrag);
  svg.addEventListener("pointercancel", stopDrag);
  svg.addEventListener("pointerleave", stopDrag);
  return root;
}

function runChartDemo(thDeg, phDeg, rotYdeg, rotXdeg) {
  var th = thDeg * Math.PI / 180;
  var ph = phDeg * Math.PI / 180;

  // Point on sphere
  var P = [Math.sin(th)*Math.cos(ph), Math.sin(th)*Math.sin(ph), Math.cos(th)];

  // Stereographic projection from N = (0,0,1)
  function chartN(x, y, z) {
    var denom = 1 - z;
    if (Math.abs(denom) < 1e-10) return null; // N pole excluded
    return [x / denom, y / denom];
  }
  // Stereographic projection from S = (0,0,-1)
  function chartS(x, y, z) {
    var denom = 1 + z;
    if (Math.abs(denom) < 1e-10) return null; // S pole excluded
    return [x / denom, y / denom];
  }
  var coordN = chartN(P[0], P[1], P[2]);
  var coordS = chartS(P[0], P[1], P[2]);

  // Transition map: φ_S ∘ φ_N^{-1}
  var transMap = null;
  if (coordN !== null) {
    var uv = coordN;
    var u2v2 = uv[0]*uv[0] + uv[1]*uv[1];
    if (u2v2 > 1e-10) {
      transMap = [uv[0]/u2v2, uv[1]/u2v2];
    }
  }

  // Rotation
  var ry = rotYdeg * Math.PI / 180;
  var rx = rotXdeg * Math.PI / 180;
  function rotate(v) {
    var x1 = v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
    var y1 = v[1];
    var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
    var x2 = x1;
    var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
    var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
    return [x2, y2, z2];
  }

  return {
    P: P, coordN: coordN, coordS: coordS, transMap: transMap,
    rotate: rotate
  };
}

chartData = runChartDemo(chart_th, chart_ph, chart_rotY, chart_rotX);

drawChartPlane = function(coord, color, excludedPole) {
  var parts = [];
  var cx = 125, cy = 102, scale = 22, limit = 4;
  parts.push('<rect x="0.5" y="0.5" width="249" height="199" rx="4" fill="#fbfcfd" stroke="#dee2e6"/>');
  parts.push('<line x1="15" y1="'+cy+'" x2="235" y2="'+cy+'" stroke="#868e96" stroke-width="1"/>');
  parts.push('<line x1="'+cx+'" y1="10" x2="'+cx+'" y2="190" stroke="#868e96" stroke-width="1"/>');
  parts.push('<text x="238" y="'+(cy-5)+'" font-size="10" fill="#495057">u</text>');
  parts.push('<text x="'+(cx+5)+'" y="13" font-size="10" fill="#495057">v</text>');
  for (var tick = -4; tick <= 4; tick += 2) {
    var tx = cx + tick*scale, ty = cy - tick*scale;
    parts.push('<line x1="'+tx+'" y1="'+(cy-3)+'" x2="'+tx+'" y2="'+(cy+3)+'" stroke="#868e96"/>');
    parts.push('<line x1="'+(cx-3)+'" y1="'+ty+'" x2="'+(cx+3)+'" y2="'+ty+'" stroke="#868e96"/>');
    if (tick !== 0) {
      parts.push('<text x="'+tx+'" y="'+(cy+14)+'" text-anchor="middle" font-size="8" fill="#868e96">'+tick+'</text>');
      parts.push('<text x="'+(cx-7)+'" y="'+(ty+3)+'" text-anchor="end" font-size="8" fill="#868e96">'+tick+'</text>');
    }
  }
  if (coord === null) {
    parts.push('<text x="'+cx+'" y="88" text-anchor="middle" font-size="13" fill="'+color+'" font-weight="bold">undefined</text>');
    parts.push('<text x="'+cx+'" y="108" text-anchor="middle" font-size="10" fill="#495057">P = '+excludedPole+' is excluded</text>');
    return parts.join('\n');
  }
  var clipped = Math.abs(coord[0]) > limit || Math.abs(coord[1]) > limit;
  var uPlot = Math.max(-limit, Math.min(limit, coord[0]));
  var vPlot = Math.max(-limit, Math.min(limit, coord[1]));
  var px = cx + uPlot*scale, py = cy - vPlot*scale;
  parts.push('<line x1="'+cx+'" y1="'+cy+'" x2="'+px.toFixed(1)+'" y2="'+py.toFixed(1)+'" stroke="'+color+'" stroke-width="1.2" stroke-dasharray="4,3"/>');
  parts.push('<circle cx="'+px.toFixed(1)+'" cy="'+py.toFixed(1)+'" r="5.5" fill="#2b8a3e" stroke="#fff" stroke-width="1.5"/>');
  parts.push('<text x="'+Math.max(8,Math.min(210,px+8)).toFixed(1)+'" y="'+Math.max(12,py-7).toFixed(1)+'" font-size="10" fill="#2b8a3e" font-weight="bold">φ(P)</text>');
  parts.push('<text x="'+cx+'" y="216" text-anchor="middle" font-size="10" fill="'+color+'" font-weight="bold">('+coord[0].toFixed(3)+', '+coord[1].toFixed(3)+')</text>');
  if (clipped) parts.push('<text x="'+cx+'" y="185" text-anchor="middle" font-size="9" fill="#c92a2a">marker clipped to ±4 window</text>');
  return parts.join('\n');
};

(() => {
const view = html`
<div style="font-family: system-ui, sans-serif; max-width: 950px;">

  <div class="chart-atlas-layout">

    <!-- Sphere and excluded projection poles -->
    <div class="chart-s2-wrap">
      <h4>Atlas on 𝕊²: Two Stereographic Charts</h4>
      <svg class="chart-s2-globe" viewBox="0 0 380 430" role="img" aria-label="Sphere showing the projection pole excluded by each stereographic chart" style="border: 1px solid #dee2e6; border-radius: 4px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g transform="translate(190, 190)">
          ${(() => {
            var R = 160, parts = [];
            var res = chartData;

            function proj(v) {
              var rv = res.rotate(v);
              return {x: rv[0]*R, y: -rv[1]*R, z: rv[2]};
            }

            // Sphere wireframe
            for (var p = 0; p <= 18; p++) {
              var z = 1 - p*2/18;
              var r = Math.sqrt(Math.max(0, 1 - z*z));
              var d = '';
              for (var j = 0; j <= 120; j++) {
                var phi = j * 2*Math.PI/120;
                var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.15"/>');
            }
            for (var m = 0; m < 24; m++) {
              var phi = m*Math.PI/12;
              var d = '';
              for (var j = 0; j <= 90; j++) {
                var theta = j*Math.PI/90;
                var pt = proj([Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.15"/>');
            }
            parts.push('<circle cx="0" cy="0" r="'+R+'" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

            // N is excluded from φ_N
            var nP = proj([0,0,1]);
            parts.push('<circle cx="'+nP.x.toFixed(1)+'" cy="'+nP.y.toFixed(1)+'" r="5" fill="#e03131"/>');
            parts.push('<line x1="'+(nP.x-4).toFixed(1)+'" y1="'+(nP.y-4).toFixed(1)+'" x2="'+(nP.x+4).toFixed(1)+'" y2="'+(nP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<line x1="'+(nP.x+4).toFixed(1)+'" y1="'+(nP.y-4).toFixed(1)+'" x2="'+(nP.x-4).toFixed(1)+'" y2="'+(nP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(nP.x+8).toFixed(0)+'" y="'+(nP.y-4).toFixed(0)+'" font-size="11" fill="#e03131" font-weight="bold">N</text>');

            // S is excluded from φ_S
            var sP = proj([0,0,-1]);
            parts.push('<circle cx="'+sP.x.toFixed(1)+'" cy="'+sP.y.toFixed(1)+'" r="5" fill="#1971c2"/>');
            parts.push('<line x1="'+(sP.x-4).toFixed(1)+'" y1="'+(sP.y-4).toFixed(1)+'" x2="'+(sP.x+4).toFixed(1)+'" y2="'+(sP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<line x1="'+(sP.x+4).toFixed(1)+'" y1="'+(sP.y-4).toFixed(1)+'" x2="'+(sP.x-4).toFixed(1)+'" y2="'+(sP.y+4).toFixed(1)+'" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(sP.x+8).toFixed(0)+'" y="'+(sP.y-4).toFixed(0)+'" font-size="11" fill="#1971c2" font-weight="bold">S</text>');

            // Selected point P
            var pP = proj(res.P);
            parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="6" fill="#2b8a3e" stroke="#fff" stroke-width="2"/>');
            parts.push('<text x="'+(pP.x+10).toFixed(0)+'" y="'+(pP.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">P</text>');

            return parts.join('\n');
          })()}
        </g>
        <g transform="translate(15, 370)">
          <circle cx="0" cy="0" r="5" fill="#e03131"/><path d="M-3,-3 L3,3 M3,-3 L-3,3" stroke="#fff" stroke-width="1.3"/><text x="10" y="4" font-size="10" fill="#495057">N excluded from φN; all other points included</text>
          <circle cx="0" cy="15" r="5" fill="#1971c2"/><path d="M-3,12 L3,18 M3,12 L-3,18" stroke="#fff" stroke-width="1.3"/><text x="10" y="19" font-size="10" fill="#495057">S excluded from φS; all other points included</text>
          <circle cx="0" cy="30" r="4.5" fill="#2b8a3e" stroke="#fff" stroke-width="1.5"/><text x="10" y="34" font-size="10" fill="#495057">Selected point P</text>
        </g>
        <text x="190" y="422" text-anchor="middle" font-size="10" fill="#6c757d">Drag the sphere to rotate and tilt</text>
      </svg>
    </div>

    <!-- Coordinate-plane charts and transition -->
    <div class="chart-atlas-details">
      <h4>Chart Coordinates</h4>
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 12px;">
        <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
          <tr><td style="padding: 3px 8px; font-weight: bold; color: #e03131;">φ_N(P) (N-chart)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => {
                var c = chartData.coordN;
                return c ? '('+c[0].toFixed(3)+', '+c[1].toFixed(3)+')' : 'undefined (P = N)';
              })()}</td></tr>
          <tr><td style="padding: 3px 8px; font-weight: bold; color: #1971c2;">φ_S(P) (S-chart)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => {
                var c = chartData.coordS;
                return c ? '('+c[0].toFixed(3)+', '+c[1].toFixed(3)+')' : 'undefined (P = S)';
              })()}</td></tr>
          <tr><td colspan="2"><hr style="margin: 4px 0;"></td></tr>
          <tr><td style="padding: 3px 8px; font-weight: bold;">Transition φ_S ∘ φ_N⁻¹(u,v)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => {
                var t = chartData.transMap;
                return t ? '('+t[0].toFixed(3)+', '+t[1].toFixed(3)+')' : '—';
              })()}</td></tr>
          <tr><td style="padding: 3px 8px; font-size: 0.8em; color: #868e96;">Formula</td>
              <td style="padding: 3px 8px; text-align: right; font-size: 0.8em; font-family: monospace; color: #868e96;">(u,v)/(u²+v²)</td></tr>
        </table>
      </div>

      <div class="chart-plane-grid">
        <div class="chart-plane-card" style="border-top:3px solid #e03131;">
          <h5 style="color:#e03131;">N-chart φ<sub>N</sub>(P) ∈ ℝ²</h5>
          <svg viewBox="0 0 250 225" role="img" aria-label="Selected point in the north-pole stereographic coordinate plane">
            ${drawChartPlane(chartData.coordN, "#e03131", "N")}
          </svg>
        </div>
        <div class="chart-plane-card" style="border-top:3px solid #1971c2;">
          <h5 style="color:#1971c2;">S-chart φ<sub>S</sub>(P) ∈ ℝ²</h5>
          <svg viewBox="0 0 250 225" role="img" aria-label="Selected point in the south-pole stereographic coordinate plane">
            ${drawChartPlane(chartData.coordS, "#1971c2", "S")}
          </svg>
        </div>
      </div>
    </div>
  </div>
</div>
`
return attachS2DragRotation(view, ".chart-s2-globe", "Chart view azimuth", "Chart view elevation", -50, 50, chartData, "chart");
})()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
Figure 1: Interactive: Charts, atlas, and transition maps on the 2-sphere
TipHow to read this visualization
  • The N-chart (red plane) maps all points except the north pole to the red coordinate plane.
  • The S-chart (blue plane) maps all points except the south pole to the blue coordinate plane.
  • Together they form an atlas — every point on the sphere is covered by at least one chart.
  • The transition map \(\phi_S \circ \phi_N^{-1}\) is smooth on the overlap \(\mathbb{R}^2 \setminus \{(0,0)\}\), confirming \(\mathbb{S}^2\) is a smooth manifold.
  • At \(\theta = 0^\circ\) or \(180^\circ\), one plane correctly reports “undefined” while the other gives \((0,0)\).

3 Tangent Spaces

Definition 3 Two smooth curves \(\gamma, \eta\) through \(p\) are equivalent if \((\phi \circ \gamma)'(0) = (\phi \circ \eta)'(0)\) in any chart. An equivalence class is a tangent vector. The set \(T_p\mathcal{M}\) of all such classes is the tangent space at \(p\), an \(m\)-dimensional vector space.

Informally, tangent vectors are velocities of smooth curves through \(p\). The tangent bundle \(T\mathcal{M} = \bigcup_{p \in \mathcal{M}} T_p\mathcal{M}\) collects all tangent spaces.

3.1 Interactive: Tangent Space and Tangent Vectors on \(\mathbb{S}^2\)

The tangent space \(T_p\mathcal{M}\) is the vector space of all possible velocities of curves passing through \(p\). On \(\mathbb{S}^2\), the tangent space is a 2D plane perpendicular to the position vector \(p\). The demo below visualizes \(T_p\mathbb{S}^2\), its basis vectors, and a user-defined tangent vector along with the geodesic it generates.

Code
ts_th_control = Inputs.range([1, 179], {step: 1, value: 40, label: "Base point θ (colatitude °)"})
ts_ph_control = Inputs.range([0, 360], {step: 5, value: 60, label: "Base point φ (longitude °)"})
ts_vang_control = Inputs.range([0, 360], {step: 10, value: 130, label: "Tangent direction in T_pM (°)"})
ts_vlen_control = Inputs.range([0.2, 2.5], {step: 0.1, value: 1.3, label: "Tangent vector length |v|"})
ts_rotY_control = Inputs.range([0, 360], {step: 10, value: 220, label: "Tangent view azimuth °"})
ts_rotX_control = Inputs.range([-50, 50], {step: 5, value: 25, label: "Tangent view elevation °"})
ts_th = Generators.input(ts_th_control)
ts_ph = Generators.input(ts_ph_control)
ts_vang = Generators.input(ts_vang_control)
ts_vlen = Generators.input(ts_vlen_control)
ts_rotY = Generators.input(ts_rotY_control)
ts_rotX = Generators.input(ts_rotX_control)

ts_controls_view = html`
<style>
  .ts-slider-grid { display:flex; flex-wrap:wrap; gap:6px 20px; width:100%; margin:0 0 12px; font:0.85em system-ui,sans-serif; container-type:inline-size; }
  .ts-slider-grid > * { flex:1 1 calc((100% - 40px)/3); min-width:0; margin:0; }
  .ts-slider-grid input[type="number"] { width:7.5rem !important; }
  .tangent-s2-globe { display:block; width:100%; max-width:420px; height:auto; }
  @container (max-width:700px) { .ts-slider-grid > * { flex-basis:calc((100% - 20px)/2); } }
  @container (max-width:480px) { .ts-slider-grid > * { flex-basis:100%; } }
</style>
<div class="ts-slider-grid">
  <div>${ts_th_control}</div>
  <div>${ts_ph_control}</div>
  <div>${ts_vang_control}</div>
  <div>${ts_vlen_control}</div>
  <div>${ts_rotY_control}</div>
  <div>${ts_rotX_control}</div>
</div>`

function runTangentDemo(thetaDeg, phiDeg, vAngDeg, vLen, rotYdeg, rotXdeg) {
  var th = thetaDeg * Math.PI / 180;
  var ph = phiDeg * Math.PI / 180;
  var vAng = vAngDeg * Math.PI / 180;

  // Base point on sphere
  var P = [Math.sin(th)*Math.cos(ph), Math.sin(th)*Math.sin(ph), Math.cos(th)];

  // Orthonormal tangent frame at P:
  // e_theta = ∂/∂θ = (cosθ cosφ, cosθ sinφ, -sinθ)
  // e_phi is the normalized longitude direction (1/sinθ)∂/∂φ
  var eTheta = [Math.cos(th)*Math.cos(ph), Math.cos(th)*Math.sin(ph), -Math.sin(th)];
  var ePhi = [-Math.sin(ph), Math.cos(ph), 0];

  // Tangent vector in the tangent plane
  var V = [
    vLen * (Math.cos(vAng)*eTheta[0] + Math.sin(vAng)*ePhi[0]),
    vLen * (Math.cos(vAng)*eTheta[1] + Math.sin(vAng)*ePhi[1]),
    vLen * (Math.cos(vAng)*eTheta[2] + Math.sin(vAng)*ePhi[2])
  ];

  var Vnorm = Math.sqrt(V[0]*V[0]+V[1]*V[1]+V[2]*V[2]);

  // exp_P(V): start geodesic from P with velocity V
  // On S²: exp_P(v) = cos(|v|)*P + sin(|v|)*(v/|v|)
  function expMap(base, vec) {
    var nv = Math.sqrt(vec[0]*vec[0]+vec[1]*vec[1]+vec[2]*vec[2]);
    if (nv < 1e-12) return [base[0], base[1], base[2]];
    return [
      Math.cos(nv)*base[0] + Math.sin(nv)*vec[0]/nv,
      Math.cos(nv)*base[1] + Math.sin(nv)*vec[1]/nv,
      Math.cos(nv)*base[2] + Math.sin(nv)*vec[2]/nv
    ];
  }

  var expPt = expMap(P, V);

  // Geodesic points
  var nPts = 40;
  var geodPts = [];
  for (var i = 0; i <= nPts; i++) {
    var t = i / nPts;
    var tv = [t*V[0], t*V[1], t*V[2]];
    geodPts.push(expMap(P, tv));
  }

  // Tangent plane visualization: grid points on the tangent plane
  var tgGrid = [];
  var gridExt = 0.8;
  var nGrid = 11;
  for (var i = 0; i < nGrid; i++) {
    for (var j = 0; j < nGrid; j++) {
      var a = (i/(nGrid-1) - 0.5) * 2 * gridExt;
      var b = (j/(nGrid-1) - 0.5) * 2 * gridExt;
      tgGrid.push([
        P[0] + a*eTheta[0] + b*ePhi[0],
        P[1] + a*eTheta[1] + b*ePhi[1],
        P[2] + a*eTheta[2] + b*ePhi[2]
      ]);
    }
  }

  // Rotation
  var ry = rotYdeg * Math.PI / 180;
  var rx = rotXdeg * Math.PI / 180;
  function rotate(v) {
    var x1 = v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
    var y1 = v[1];
    var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
    var x2 = x1;
    var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
    var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
    return [x2, y2, z2];
  }

  return {
    P: P, eTheta: eTheta, ePhi: ePhi, V: V, Vnorm: Vnorm,
    expPt: expPt, geodPts: geodPts,
    tgGrid: tgGrid, nGrid: nGrid,
    rotate: rotate
  };
}

tsData = runTangentDemo(ts_th, ts_ph, ts_vang, ts_vlen, ts_rotY, ts_rotX);

(() => {
const view = html`
<div style="font-family: system-ui, sans-serif; max-width: 950px;">

  <div style="display: flex; gap: 20px;">

    <!-- Sphere + tangent plane visualization -->
    <div style="flex: 1; min-width: 0;">
      <h4>Tangent Space T<sub>p</sub>𝕊² and Geodesic</h4>
      <svg class="tangent-s2-globe" viewBox="0 0 420 420" role="img" aria-label="Sphere, tangent plane, tangent frame, and generated geodesic" style="border: 1px solid #dee2e6; border-radius: 4px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g transform="translate(210, 210)">
          ${(() => {
            var R = 175, parts = [];
            var res = tsData;

            function proj(v) {
              var rv = res.rotate(v);
              return {x: rv[0]*R, y: -rv[1]*R, z: rv[2]};
            }

            // Sphere wireframe
            for (var p = 0; p <= 18; p++) {
              var z = 1 - p*2/18;
              var r = Math.sqrt(Math.max(0, 1 - z*z));
              var d = '';
              for (var j = 0; j <= 120; j++) {
                var phi = j*2*Math.PI/120;
                var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            for (var m = 0; m < 24; m++) {
              var phi = m*Math.PI/12;
              var d = '';
              for (var j = 0; j <= 90; j++) {
                var theta = j*Math.PI/90;
                var pt = proj([Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            parts.push('<circle cx="0" cy="0" r="'+R+'" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

            // Tangent plane grid (semi-transparent)
            for (var i = 0; i < res.nGrid; i++) {
              var dRow = '';
              for (var j = 0; j < res.nGrid; j++) {
                var idx = i*res.nGrid + j;
                var pt = proj(res.tgGrid[idx]);
                dRow += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+dRow+'" fill="none" stroke="#f08c00" stroke-width="0.4" opacity="0.18"/>');
            }
            for (var j = 0; j < res.nGrid; j++) {
              var dCol = '';
              for (var i = 0; i < res.nGrid; i++) {
                var idx = i*res.nGrid + j;
                var pt = proj(res.tgGrid[idx]);
                dCol += (i===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+dCol+'" fill="none" stroke="#f08c00" stroke-width="0.4" opacity="0.18"/>');
            }

            // Tangent plane border
            var cornerPts = [
              proj(res.tgGrid[0]),
              proj(res.tgGrid[res.nGrid-1]),
              proj(res.tgGrid[res.nGrid*res.nGrid-1]),
              proj(res.tgGrid[res.nGrid*(res.nGrid-1)])
            ];
            var cStr = '';
            for (var ci = 0; ci < 4; ci++) {
              cStr += (ci===0?'M':'L')+' '+cornerPts[ci].x.toFixed(1)+' '+cornerPts[ci].y.toFixed(1);
            }
            parts.push('<path d="'+cStr+' Z" fill="none" stroke="#f08c00" stroke-width="1" opacity="0.3" stroke-dasharray="6,3"/>');

            // Basis vectors e_theta (red), e_phi (blue)
            var eThetaEnd = [res.P[0]+0.35*res.eTheta[0], res.P[1]+0.35*res.eTheta[1], res.P[2]+0.35*res.eTheta[2]];
            var ePhiEnd = [res.P[0]+0.35*res.ePhi[0], res.P[1]+0.35*res.ePhi[1], res.P[2]+0.35*res.ePhi[2]];

            var pP = proj(res.P);
            var etP = proj(eThetaEnd);
            var epP = proj(ePhiEnd);

            parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+etP.x.toFixed(1)+'" y2="'+etP.y.toFixed(1)+'" stroke="#e03131" stroke-width="2"/>');
            parts.push('<text x="'+(etP.x+3).toFixed(0)+'" y="'+(etP.y-3).toFixed(0)+'" font-size="10" fill="#e03131">eθ</text>');
            parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+epP.x.toFixed(1)+'" y2="'+epP.y.toFixed(1)+'" stroke="#1971c2" stroke-width="2"/>');
            parts.push('<text x="'+(epP.x+3).toFixed(0)+'" y="'+(epP.y-3).toFixed(0)+'" font-size="10" fill="#1971c2">eφ</text>');

            // Tangent vector V (green arrow, thicker)
            var vEnd = [res.P[0]+0.5*res.V[0], res.P[1]+0.5*res.V[1], res.P[2]+0.5*res.V[2]];
            var vdP = proj(vEnd);
            parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+vdP.x.toFixed(1)+'" y2="'+vdP.y.toFixed(1)+'" stroke="#2b8a3e" stroke-width="3" stroke-linecap="round"/>');
            // Arrowhead
            if (res.Vnorm > 0.01) {
              var vDir = [res.V[0]/res.Vnorm, res.V[1]/res.Vnorm, res.V[2]/res.Vnorm];
              var ahBase = [res.P[0]+0.42*res.V[0], res.P[1]+0.42*res.V[1], res.P[2]+0.42*res.V[2]];
              // orthogonal direction in tangent plane
              var orth = [vDir[1]*res.P[2]-vDir[2]*res.P[1], vDir[2]*res.P[0]-vDir[0]*res.P[2], vDir[0]*res.P[1]-vDir[1]*res.P[0]];
              var orthN = Math.sqrt(orth[0]*orth[0]+orth[1]*orth[1]+orth[2]*orth[2]);
              if (orthN > 1e-10) {
                orth = [orth[0]/orthN*0.08, orth[1]/orthN*0.08, orth[2]/orthN*0.08];
                var wing1 = [ahBase[0]+orth[0]-vDir[0]*0.06, ahBase[1]+orth[1]-vDir[1]*0.06, ahBase[2]+orth[2]-vDir[2]*0.06];
                var wing2 = [ahBase[0]-orth[0]-vDir[0]*0.06, ahBase[1]-orth[1]-vDir[1]*0.06, ahBase[2]-orth[2]-vDir[2]*0.06];
                var w1 = proj(wing1), w2 = proj(wing2);
                parts.push('<polygon points="'+vdP.x.toFixed(1)+','+vdP.y.toFixed(1)+' '+w1.x.toFixed(1)+','+w1.y.toFixed(1)+' '+w2.x.toFixed(1)+','+w2.y.toFixed(1)+'" fill="#2b8a3e"/>');
              }
            }
            parts.push('<text x="'+(vdP.x+5).toFixed(0)+'" y="'+(vdP.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">v</text>');

            // Geodesic curve
            var geoPath = '';
            for (var gi = 0; gi < res.geodPts.length; gi++) {
              var gpt = proj(res.geodPts[gi]);
              geoPath += (gi===0?'M':'L')+' '+gpt.x.toFixed(1)+' '+gpt.y.toFixed(1);
            }
            parts.push('<path d="'+geoPath+'" fill="none" stroke="#7950f2" stroke-width="2.5" stroke-dasharray="8,4"/>');

            // exp point
            var expP = proj(res.expPt);
            parts.push('<circle cx="'+expP.x.toFixed(1)+'" cy="'+expP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(expP.x+7).toFixed(0)+'" y="'+(expP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">expₚ(v)</text>');

            // Base point p
            parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(pP.x+8).toFixed(0)+'" y="'+(pP.y-8).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">p</text>');

            return parts.join('\n');
          })()}
        </g>
        <g transform="translate(15, 385)">
          <circle cx="0" cy="0" r="4" fill="#e03131"/><text x="8" y="4" font-size="10" fill="#495057">Base point p</text>
          <line x1="90" y1="0" x2="105" y2="0" stroke="#f08c00" stroke-width="1"/><text x="109" y="4" font-size="10" fill="#495057">Tₚ𝕊² (tangent plane)</text>
          <line x1="260" y1="0" x2="275" y2="0" stroke="#2b8a3e" stroke-width="2.5"/><text x="279" y="4" font-size="10" fill="#495057">v ∈ Tₚ𝕊²</text>
          <line x1="260" y1="12" x2="275" y2="12" stroke="#7950f2" stroke-width="2" stroke-dasharray="6,3"/><text x="279" y="16" font-size="10" fill="#495057">γ(t) = expₚ(tv)</text>
        </g>
        <text x="210" y="412" text-anchor="middle" font-size="10" fill="#6c757d">Drag the sphere to rotate and tilt</text>
      </svg>
    </div>

    <!-- Right panel -->
    <div style="flex: 1; min-width: 0;">
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 12px;">
        <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
          <tr><td style="padding: 3px 8px;">Base point p</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">θ=${ts_th}°, φ=${ts_ph}°</td></tr>
          <tr><td style="padding: 3px 8px; color: #e03131;">Unit frame e<sub>θ</sub></td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">(${tsData.eTheta[0].toFixed(2)}, ${tsData.eTheta[1].toFixed(2)}, ${tsData.eTheta[2].toFixed(2)})</td></tr>
          <tr><td style="padding: 3px 8px; color: #1971c2;">Unit frame e<sub>φ</sub></td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">(${tsData.ePhi[0].toFixed(2)}, ${tsData.ePhi[1].toFixed(2)}, ${tsData.ePhi[2].toFixed(2)})</td></tr>
          <tr><td style="padding: 3px 8px; color: #2b8a3e; font-weight: bold;">Tangent vector v</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">|v| = ${tsData.Vnorm.toFixed(3)}</td></tr>
          <tr><td colspan="2"><hr style="margin: 4px 0;"></td></tr>
          <tr><td style="padding: 3px 8px;">v · p (orthogonality check)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => { var dot = tsData.V[0]*tsData.P[0]+tsData.V[1]*tsData.P[1]+tsData.V[2]*tsData.P[2]; return dot.toExponential(2); })()}</td></tr>
          <tr><td style="padding: 3px 8px;">dim(T<sub>p</sub>𝕊²)</td>
              <td style="padding: 3px 8px; text-align: right;">2</td></tr>
        </table>
      </div>
    </div>
  </div>
</div>
`
return attachS2DragRotation(view, ".tangent-s2-globe", "Tangent view azimuth", "Tangent view elevation", -50, 50, tsData, "tangent");
})()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
(o)
(p)
Figure 2: Interactive: Tangent space, basis vectors, and geodesics on the 2-sphere
TipKey concepts visualized
  • The orange plane is the tangent space \(T_p\mathbb{S}^2\) — a 2D vector space attached to \(p\).
  • \(\mathbf{e}_\theta\) and \(\mathbf{e}_\varphi\) form an orthonormal frame of \(T_p\mathbb{S}^2\); \(\mathbf{e}_\varphi\) is the normalized longitude direction, not the raw coordinate vector \(\partial/\partial\varphi\).
  • The green arrow \(\mathbf{v}\) is a tangent vector — any linear combination \(a\,\mathbf{e}_\theta + b\,\mathbf{e}_\varphi\).
  • The purple curve is the geodesic \(\gamma(t) = \exp_p(t\mathbf{v})\) starting at \(p\) with initial velocity \(\mathbf{v}\).
  • Drag the sphere to see that the orange tangent plane remains perpendicular to the radius through \(p\).

4 Differential and Covariant Derivative

So far we have treated manifolds as geometric objects — spaces with charts, tangent vectors, and eventually a metric. Before introducing the metric, we need the calculus that operates on manifolds: how to differentiate maps between manifolds, how to differentiate vector fields, and how to transport geometric data along curves.

4.1 Differentiable Maps Between Manifolds

A map between manifolds is smooth if, when expressed in local coordinates, it is smooth in the usual Euclidean sense.

Definition 4 Let \(\mathcal{M}\) and \(\mathcal{N}\) be smooth manifolds of dimensions \(m\) and \(n\), respectively. A map \(F: \mathcal{M} \to \mathcal{N}\) is smooth (or \(C^\infty\)) at \(p \in \mathcal{M}\) if there exist charts \((U, \phi)\) about \(p\) and \((V, \psi)\) about \(F(p)\) such that \(F(U) \subset V\) and the coordinate representation

\[ \widehat{F} = \psi \circ F \circ \phi^{-1}: \phi(U) \subset \mathbb{R}^m \to \psi(V) \subset \mathbb{R}^n \]

is \(C^\infty\) at \(\phi(p)\) in the usual Euclidean sense. \(F\) is smooth if it is smooth at every \(p \in \mathcal{M}\).

This definition is chart-independent: if \(\widehat{F}\) is smooth for one pair of charts, the smoothness of transition maps guarantees it is smooth for any compatible pair. If \(F\) is bijective and both \(F\) and \(F^{-1}\) are smooth, \(F\) is a diffeomorphism — two diffeomorphic manifolds are indistinguishable as smooth manifolds.

  • The inclusion \(\iota: \mathbb{S}^2 \hookrightarrow \mathbb{R}^3\) is smooth: using the stereographic north-pole chart, its coordinate representation \((u,v) \mapsto \phi_N^{-1}(u,v)\) is a rational map.
  • A rotation \(R \in \mathrm{SO}(3)\), restricted to \(\mathbb{S}^2\), is a diffeomorphism of the sphere onto itself.
  • The antipodal map \(A(p) = -p\) on \(\mathbb{S}^2\) is a diffeomorphism (it is its own inverse).
  • The matrix logarithm \(\log: \mathcal{S}_{++}^m \to \operatorname{Sym}(m)\) is a diffeomorphism, with smooth inverse \(\exp: \operatorname{Sym}(m) \to \mathcal{S}_{++}^m\).

4.2 Differential (Pushforward) of a Smooth Map

The derivative of a smooth map between manifolds is the differential, a linear map between tangent spaces that generalizes the Jacobian from multivariable calculus.

Definition 5 Let \(F: \mathcal{M} \to \mathcal{N}\) be smooth and \(p \in \mathcal{M}\). The differential of \(F\) at \(p\) is the linear map

\[ dF_p: T_p\mathcal{M} \to T_{F(p)}\mathcal{N} \]

defined as follows: for \(v \in T_p\mathcal{M}\), choose a smooth curve \(\gamma: (-\varepsilon, \varepsilon) \to \mathcal{M}\) with \(\gamma(0) = p\) and \(\dot{\gamma}(0) = v\). Then

\[ dF_p(v) = \left.\frac{d}{dt}\right|_{t=0} (F \circ \gamma)(t), \]

the velocity of the image curve \(F \circ \gamma\) at \(F(p)\). The vector \(dF_p(v)\) is also called the pushforward of \(v\) by \(F\), sometimes denoted \(F_*(v)\).

The definition is independent of the choice of curve \(\gamma\) representing \(v\). In local coordinates, if \((U, \phi = (x^1, \ldots, x^m))\) is a chart at \(p\) and \((V, \psi = (y^1, \ldots, y^n))\) at \(F(p)\), then \(dF_p\) acts on the coordinate basis as

\[ dF_p\!\left(\left.\frac{\partial}{\partial x^j}\right|_p\right) = \sum_{i=1}^n \frac{\partial \widehat{F}^i}{\partial x^j}\bigl(\phi(p)\bigr)\; \left.\frac{\partial}{\partial y^i}\right|_{F(p)}, \]

where \(\widehat{F} = \psi \circ F \circ \phi^{-1}\) are the component functions of the coordinate representation. Thus \(dF_p\) is represented by the \(n \times m\) Jacobian matrix \([\partial \widehat{F}^i / \partial x^j]\).

Key properties:

  • Linearity: \(dF_p(a v + b w) = a\,dF_p(v) + b\,dF_p(w)\) for all \(v, w \in T_p\mathcal{M}\), \(a,b \in \mathbb{R}\).
  • Chain rule: If \(F: \mathcal{M} \to \mathcal{N}\) and \(G: \mathcal{N} \to \mathcal{P}\) are smooth, then

\[ d(G \circ F)_p = dG_{F(p)} \circ dF_p. \]

  • Diffeomorphism criterion: A smooth bijection \(F\) is a diffeomorphism if \(dF_p\) is invertible at every \(p\). Conversely, every diffeomorphism has an invertible differential everywhere. The bijectivity assumption is essential: invertible differentials alone make \(F\) only a local diffeomorphism.

4.3 Interactive: Pushforward on the Sphere

The demo below visualizes the differential \(dF_p\) of a smooth map \(F: \mathbb{S}^2 \to \mathbb{S}^2\) defined by stretching along the \(x\)-axis and compressing along the \(z\)-axis, then normalizing back to the unit sphere:

\[ F(x,y,z) = \frac{(a x,\; b y,\; c z)}{\sqrt{a^2 x^2 + b^2 y^2 + c^2 z^2}}, \qquad a=1.6,\; b=1,\; c=0.6. \]

Writing \(A=\operatorname{diag}(a,b,c)\) and \(r=\|Ap\|\), differentiation gives \[ dF_p(v) =\frac{Av}{r}-\frac{Ap\,\langle Ap,Av\rangle}{r^3} =\frac{\{I-F(p)F(p)^\top\}Av}{\|Ap\|}. \] The projection \(I-F(p)F(p)^\top\) makes \(dF_p(v)\) orthogonal to \(F(p)\), as required for a vector in \(T_{F(p)}\mathbb S^2\).

  • Left panel (domain): The base point \(p\) (red) and a tangent vector \(v \in T_p\mathbb{S}^2\) (green).
  • Right panel (codomain): The image point \(F(p)\) (red) and the pushforward \(dF_p(v) \in T_{F(p)}\mathbb{S}^2\) (green).
  • The orange grids represent the tangent planes \(T_p\mathbb{S}^2\) and \(T_{F(p)}\mathbb{S}^2\).
  • The purple circle in \(T_p\mathbb S^2\) and purple ellipse in \(T_{F(p)}\mathbb S^2\) show the action of the full linear map, not only one selected vector.

Use the point sliders or click the visible part of the domain sphere to move \(p\); \(F(p)\) updates automatically. Drag either sphere to rotate it independently.

Code
pf_th_ctrl = Inputs.range([1, 179], {step: 1, value: 45, label: "Point θ (colatitude °)"})
pf_ph_ctrl = Inputs.range([0, 360], {step: 5, value: 60, label: "Point φ (longitude °)"})
pf_va_ctrl = Inputs.range([0, 360], {step: 10, value: 40, label: "Tangent direction in T_pM (°)"})
pf_vl_ctrl = Inputs.range([0.2, 2.2], {step: 0.1, value: 1.0, label: "Tangent vector |v|"})
pf_th = Generators.input(pf_th_ctrl)
pf_ph = Generators.input(pf_ph_ctrl)
pf_va = Generators.input(pf_va_ctrl)
pf_vl = Generators.input(pf_vl_ctrl)

pf_ctrls = html`
<style>
  #fig-pushforward .quarto-subfloat-caption { display:none; }
  .pf-ctrl-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px 20px; max-width:700px; margin:0 auto 12px; font:0.85em system-ui,sans-serif; }
  .pf-ctrl-grid > * { min-width:0; margin:0; }
  .pf-ctrl-grid input[type="number"] { width:7.5rem !important; }
  @media (max-width:500px) { .pf-ctrl-grid { grid-template-columns:1fr; } }
</style>
<div class="pf-ctrl-grid">
  <div>${pf_th_ctrl}</div>
  <div>${pf_ph_ctrl}</div>
  <div>${pf_va_ctrl}</div>
  <div>${pf_vl_ctrl}</div>
</div>`

function runPF(thDeg, phDeg, vaDeg, vLen) {
  var th = thDeg * Math.PI / 180, ph = phDeg * Math.PI / 180;
  var va = vaDeg * Math.PI / 180;

  // Base point on sphere
  var p = [Math.sin(th)*Math.cos(ph), Math.sin(th)*Math.sin(ph), Math.cos(th)];

  // Tangent basis at p
  var eTh = [Math.cos(th)*Math.cos(ph), Math.cos(th)*Math.sin(ph), -Math.sin(th)];
  var ePh = [-Math.sin(ph), Math.cos(ph), 0];

  // Tangent vector v at p
  var v = [
    vLen * (Math.cos(va)*eTh[0] + Math.sin(va)*ePh[0]),
    vLen * (Math.cos(va)*eTh[1] + Math.sin(va)*ePh[1]),
    vLen * (Math.cos(va)*eTh[2] + Math.sin(va)*ePh[2])
  ];
  var vNorm = Math.sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);

  // Smooth map F: diagonal stretch then normalize
  var a=1.6, b=1.0, c=0.6;
  var apx = a*p[0], bpy = b*p[1], cpz = c*p[2];
  var r = Math.sqrt(apx*apx + bpy*bpy + cpz*cpz);
  var Fp = [apx/r, bpy/r, cpz/r];

  // Differential dF_p applied to v
  var Av = [a*v[0], b*v[1], c*v[2]];
  var A2p_dot_v = a*a*p[0]*v[0] + b*b*p[1]*v[1] + c*c*p[2]*v[2];
  var dFv = [
    Av[0]/r - Fp[0]*A2p_dot_v/(r*r),
    Av[1]/r - Fp[1]*A2p_dot_v/(r*r),
    Av[2]/r - Fp[2]*A2p_dot_v/(r*r)
  ];
  var dFvNorm = Math.sqrt(dFv[0]*dFv[0]+dFv[1]*dFv[1]+dFv[2]*dFv[2]);

  // dF_p applied to basis vectors
  function applyDF(vec) {
    var Av2 = [a*vec[0], b*vec[1], c*vec[2]];
    var a2dv = a*a*p[0]*vec[0] + b*b*p[1]*vec[1] + c*c*p[2]*vec[2];
    return [
      Av2[0]/r - Fp[0]*a2dv/(r*r),
      Av2[1]/r - Fp[1]*a2dv/(r*r),
      Av2[2]/r - Fp[2]*a2dv/(r*r)
    ];
  }

  var dFeTh = applyDF(eTh);
  var dFePh = applyDF(ePh);

  // Project dFeTh and dFePh onto an orthonormal frame at F(p)
  // Use the standard spherical basis at F(p)
  var FpNorm = Math.sqrt(Fp[0]*Fp[0]+Fp[1]*Fp[1]+Fp[2]*Fp[2]);
  var FpTh = Math.acos(Math.max(-1,Math.min(1,Fp[2]/FpNorm)));
  var FpPh = Math.atan2(Fp[1], Fp[0]);
  if (FpPh < 0) FpPh += 2*Math.PI;

  var eThF = [Math.cos(FpTh)*Math.cos(FpPh), Math.cos(FpTh)*Math.sin(FpPh), -Math.sin(FpTh)];
  var ePhF = [-Math.sin(FpPh), Math.cos(FpPh), 0];

  // Jacobian matrix entries: dF(eθ) expressed in {eθF, eφF}
  var jThTh = dFeTh[0]*eThF[0]+dFeTh[1]*eThF[1]+dFeTh[2]*eThF[2];
  var jPhTh = dFeTh[0]*ePhF[0]+dFeTh[1]*ePhF[1]+dFeTh[2]*ePhF[2];
  var jThPh = dFePh[0]*eThF[0]+dFePh[1]*eThF[1]+dFePh[2]*eThF[2];
  var jPhPh = dFePh[0]*ePhF[0]+dFePh[1]*ePhF[1]+dFePh[2]*ePhF[2];
  var jDet = jThTh*jPhPh - jThPh*jPhTh;

  // Singular values of the 2x2 Jacobian: principal tangent stretch factors.
  var jtJ11 = jThTh*jThTh + jPhTh*jPhTh;
  var jtJ12 = jThTh*jThPh + jPhTh*jPhPh;
  var jtJ22 = jThPh*jThPh + jPhPh*jPhPh;
  var jtJTrace = jtJ11 + jtJ22;
  var jtJDisc = Math.sqrt(Math.max(0, (jtJ11-jtJ22)*(jtJ11-jtJ22) + 4*jtJ12*jtJ12));
  var sigmaMax = Math.sqrt(Math.max(0, 0.5*(jtJTrace+jtJDisc)));
  var sigmaMin = Math.sqrt(Math.max(0, 0.5*(jtJTrace-jtJDisc)));

  // Tangent plane grids
  var gridExt = 0.75, nG = 9;
  var tgGridP = [], tgGridF = [];
  for (var i = 0; i < nG; i++) {
    for (var j = 0; j < nG; j++) {
      var u = (i/(nG-1)-0.5)*2*gridExt;
      var w = (j/(nG-1)-0.5)*2*gridExt;
      tgGridP.push([p[0]+u*eTh[0]+w*ePh[0], p[1]+u*eTh[1]+w*ePh[1], p[2]+u*eTh[2]+w*ePh[2]]);
      tgGridF.push([Fp[0]+u*eThF[0]+w*ePhF[0], Fp[1]+u*eThF[1]+w*ePhF[1], Fp[2]+u*eThF[2]+w*ePhF[2]]);
    }
  }

  return {
    p: p, Fp: Fp, v: v, vNorm: vNorm, dFv: dFv, dFvNorm: dFvNorm,
    eTh: eTh, ePh: ePh, dFeTh: dFeTh, dFePh: dFePh,
    eThF: eThF, ePhF: ePhF,
    tgGridP: tgGridP, tgGridF: tgGridF, nG: nG,
    J11: jThTh, J12: jThPh, J21: jPhTh, J22: jPhPh,
    detJ: jDet, sigmaMin: sigmaMin, sigmaMax: sigmaMax,
    stretchRatio: vNorm > 0.001 ? dFvNorm/vNorm : 1
  };
}

pfData = runPF(pf_th, pf_ph, pf_va, pf_vl);

// Helper: projection onto 2D screen
function makeProj(rotY, rotX, R) {
  var ry = rotY*Math.PI/180, rx = rotX*Math.PI/180;
  return function(v) {
    var x1 = v[0]*Math.cos(ry)+v[2]*Math.sin(ry), y1 = v[1], z1 = -v[0]*Math.sin(ry)+v[2]*Math.cos(ry);
    var y2 = y1*Math.cos(rx)-z1*Math.sin(rx);
    var z2 = y1*Math.sin(rx)+z1*Math.cos(rx);
    return {x: x1*R, y: -y2*R, z: z2};
  };
}

function inversePFProjection(x2, y2, z2, rotY, rotX) {
  var ry = rotY*Math.PI/180, rx = rotX*Math.PI/180;
  var x1 = x2;
  var y1 = y2*Math.cos(rx)+z2*Math.sin(rx);
  var z1 = -y2*Math.sin(rx)+z2*Math.cos(rx);
  return [
    x1*Math.cos(ry)-z1*Math.sin(ry),
    y1,
    x1*Math.sin(ry)+z1*Math.cos(ry)
  ];
}

function pfVisiblePolyline(points, proj, attrs) {
  var runs = [], current = [];
  for (var i = 0; i < points.length; i++) {
    var q = proj(points[i]);
    if (q.z >= -1e-5) {
      current.push(q);
    } else {
      if (current.length > 1) runs.push(current);
      current = [];
    }
  }
  if (current.length > 1) runs.push(current);
  return runs.map(function(run) {
    var d = run.map(function(q, i) {
      return (i===0 ? "M" : "L")+" "+q.x.toFixed(1)+" "+q.y.toFixed(1);
    }).join(" ");
    return '<path d="'+d+'" fill="none" '+attrs+'/>';
  }).join("");
}

function renderPFGlobe(proj, R, side) {
  var parts = [];
  parts.push('<circle r="'+R+'" fill="url(#pf-glow-'+side+')" stroke="#9eb6c7" stroke-width="1.2"/>');

  for (var k = -8; k <= 8; k += 2) {
    var z = k/10, r = Math.sqrt(Math.max(0,1-z*z)), latitude = [];
    for (var j = 0; j <= 160; j++) {
      var phi = j*2*Math.PI/160;
      latitude.push([r*Math.cos(phi),r*Math.sin(phi),z]);
    }
    parts.push(pfVisiblePolyline(latitude,proj,'stroke="#74c0fc" stroke-width="0.65" opacity="0.55"'));
  }
  for (var m = 0; m < 18; m++) {
    var lon = m*Math.PI/9, meridian = [];
    for (var j = 0; j <= 120; j++) {
      var theta = j*Math.PI/120;
      meridian.push([Math.sin(theta)*Math.cos(lon),Math.sin(theta)*Math.sin(lon),Math.cos(theta)]);
    }
    parts.push(pfVisiblePolyline(meridian,proj,'stroke="#74c0fc" stroke-width="0.65" opacity="0.55"'));
  }
  return parts.join("");
}

function renderSphere(data, proj, R, side) {
  var parts = [renderPFGlobe(proj,R,side)];
  var isDomain = side === "domain";
  var pt = isDomain ? data.p : data.Fp;
  var tgGrid = isDomain ? data.tgGridP : data.tgGridF;
  var eThLoc = isDomain ? data.eTh : data.eThF;
  var ePhLoc = isDomain ? data.ePh : data.ePhF;
  var vec = isDomain ? data.v : data.dFv;
  var vecLabel = isDomain ? "v" : "dFₚ(v)";
  var nG = data.nG;

  // Tangent plane grid
  for (var i = 0; i < nG; i++) {
    var dRow = '';
    for (var j = 0; j < nG; j++) {
      var gp = proj(tgGrid[i*nG+j]);
      dRow += (j===0?'M':'L')+' '+gp.x.toFixed(1)+' '+gp.y.toFixed(1);
    }
    parts.push('<path d="'+dRow+'" fill="none" stroke="#f08c00" stroke-width="0.35" opacity="0.18"/>');
  }
  for (var j = 0; j < nG; j++) {
    var dCol = '';
    for (var i = 0; i < nG; i++) {
      var gp = proj(tgGrid[i*nG+j]);
      dCol += (i===0?'M':'L')+' '+gp.x.toFixed(1)+' '+gp.y.toFixed(1);
    }
    parts.push('<path d="'+dCol+'" fill="none" stroke="#f08c00" stroke-width="0.35" opacity="0.18"/>');
  }

  // A unit circle in T_pS² and its image ellipse under dF_p.
  var tangentCurve = [];
  for (var a = 0; a <= 120; a++) {
    var angle = a*2*Math.PI/120;
    var u = Math.cos(angle), w = Math.sin(angle), scale = 0.42;
    var dir = isDomain
      ? [u*data.eTh[0]+w*data.ePh[0],u*data.eTh[1]+w*data.ePh[1],u*data.eTh[2]+w*data.ePh[2]]
      : [u*data.dFeTh[0]+w*data.dFePh[0],u*data.dFeTh[1]+w*data.dFePh[1],u*data.dFeTh[2]+w*data.dFePh[2]];
    tangentCurve.push(proj([pt[0]+scale*dir[0],pt[1]+scale*dir[1],pt[2]+scale*dir[2]]));
  }
  var tangentPath = tangentCurve.map(function(q,i) {
    return (i===0?"M":"L")+" "+q.x.toFixed(1)+" "+q.y.toFixed(1);
  }).join(" ");
  parts.push('<path d="'+tangentPath+'" fill="rgba(121,80,242,0.06)" stroke="#7950f2" stroke-width="1.6" stroke-dasharray="5,3"/>');

  // Base point
  var pp = proj(pt);
  parts.push('<circle class="'+(isDomain?'pf-base-point':'pf-image-point')+'" cx="'+pp.x.toFixed(1)+'" cy="'+pp.y.toFixed(1)+'" r="6.5" fill="#e03131" stroke="#fff" stroke-width="1.5" style="cursor:'+(isDomain?'crosshair':'default')+';"/>');
  parts.push('<text x="'+(pp.x+9).toFixed(0)+'" y="'+(pp.y-7).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">'+(isDomain?'p':'F(p)')+'</text>');

  // Tangent basis arrows (thin)
  var eThEnd = [pt[0]+0.35*eThLoc[0], pt[1]+0.35*eThLoc[1], pt[2]+0.35*eThLoc[2]];
  var ePhEnd = [pt[0]+0.35*ePhLoc[0], pt[1]+0.35*ePhLoc[1], pt[2]+0.35*ePhLoc[2]];
  var eThP = proj(eThEnd), ePhP = proj(ePhEnd);
  parts.push('<line x1="'+pp.x.toFixed(1)+'" y1="'+pp.y.toFixed(1)+'" x2="'+eThP.x.toFixed(1)+'" y2="'+eThP.y.toFixed(1)+'" stroke="#e03131" stroke-width="1.2"/>');
  parts.push('<text x="'+(eThP.x+3).toFixed(0)+'" y="'+(eThP.y-3).toFixed(0)+'" font-size="10" fill="#e03131">eθ</text>');
  parts.push('<line x1="'+pp.x.toFixed(1)+'" y1="'+pp.y.toFixed(1)+'" x2="'+ePhP.x.toFixed(1)+'" y2="'+ePhP.y.toFixed(1)+'" stroke="#1971c2" stroke-width="1.2"/>');
  parts.push('<text x="'+(ePhP.x+3).toFixed(0)+'" y="'+(ePhP.y-3).toFixed(0)+'" font-size="10" fill="#1971c2">eφ</text>');

  // Tangent vector v / dF(v)
  var vecNorm = Math.sqrt(vec[0]*vec[0]+vec[1]*vec[1]+vec[2]*vec[2]);
  if (vecNorm > 0.001) {
    var vScale = 0.45 / Math.max(1, vecNorm);
    var vEnd3 = [pt[0]+vScale*vec[0], pt[1]+vScale*vec[1], pt[2]+vScale*vec[2]];
    var vEndP = proj(vEnd3);
    parts.push('<line x1="'+pp.x.toFixed(1)+'" y1="'+pp.y.toFixed(1)+'" x2="'+vEndP.x.toFixed(1)+'" y2="'+vEndP.y.toFixed(1)+'" stroke="#2b8a3e" stroke-width="3" stroke-linecap="round"/>');
    // Arrowhead
    var vDir = [vec[0]/vecNorm, vec[1]/vecNorm, vec[2]/vecNorm];
    var ahBase = [vEnd3[0]-0.09*vDir[0],vEnd3[1]-0.09*vDir[1],vEnd3[2]-0.09*vDir[2]];
    var orth = [vDir[1]*pt[2]-vDir[2]*pt[1], vDir[2]*pt[0]-vDir[0]*pt[2], vDir[0]*pt[1]-vDir[1]*pt[0]];
    var oN = Math.sqrt(orth[0]*orth[0]+orth[1]*orth[1]+orth[2]*orth[2]);
    if (oN > 1e-10) {
      orth = [orth[0]/oN*0.055, orth[1]/oN*0.055, orth[2]/oN*0.055];
      var w1 = [ahBase[0]+orth[0],ahBase[1]+orth[1],ahBase[2]+orth[2]];
      var w2 = [ahBase[0]-orth[0],ahBase[1]-orth[1],ahBase[2]-orth[2]];
      var w1p = proj(w1), w2p = proj(w2);
      parts.push('<polygon points="'+vEndP.x.toFixed(1)+','+vEndP.y.toFixed(1)+' '+w1p.x.toFixed(1)+','+w1p.y.toFixed(1)+' '+w2p.x.toFixed(1)+','+w2p.y.toFixed(1)+'" fill="#2b8a3e"/>');
    }
    parts.push('<text x="'+(vEndP.x+5).toFixed(0)+'" y="'+(vEndP.y-5).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">'+vecLabel+'</text>');
  }

  return parts.join('\n');
}

// --- Build view ---
function buildPFView() {
  var data = pfData;
  var R = 155;

  function makeRenderFn(svgIdSuffix, initRotY, initRotX) {
    var svg, scene, rotY = initRotY, rotX = initRotX;
    var dragging = false, moved = false, lastX = 0, lastY = 0, startX = 0, startY = 0;

    function render() {
      var proj = makeProj(rotY, rotX, R);
      scene.innerHTML = (svgIdSuffix === "dom")
        ? renderSphere(data, proj, R, "domain")
        : renderSphere(data, proj, R, "codomain");
    }

    function setControl(control, value) {
      var input = control.querySelector('input[type="range"]');
      if (!input) return;
      input.value = value;
      input.dispatchEvent(new Event("input",{bubbles:true}));
    }

    function setDomainPoint(e) {
      if (svgIdSuffix !== "dom") return;
      var rect = svg.getBoundingClientRect();
      var sx = (e.clientX-rect.left)*370/rect.width-185;
      var sy = (e.clientY-rect.top)*370/rect.height-185;
      var x2 = sx/R, y2 = -sy/R;
      var radial2 = x2*x2+y2*y2;
      if (radial2 > 1) return;
      var z2 = Math.sqrt(Math.max(0,1-radial2));
      var world = inversePFProjection(x2,y2,z2,rotY,rotX);
      var theta = Math.acos(Math.max(-1,Math.min(1,world[2])))*180/Math.PI;
      var phi = Math.atan2(world[1],world[0])*180/Math.PI;
      if (phi < 0) phi += 360;
      setControl(pf_th_ctrl,Math.max(1,Math.min(179,Math.round(theta))));
      setControl(pf_ph_ctrl,(Math.round(phi/5)*5)%360);
    }

    function attach(el) {
      svg = el;
      scene = svg.querySelector(".pf-scene");
      if (!scene) return;
      render();
      svg.addEventListener("pointerdown", function(e) {
        dragging = true; moved = false;
        startX = lastX = e.clientX; startY = lastY = e.clientY;
        if (svg.setPointerCapture) svg.setPointerCapture(e.pointerId);
        svg.style.cursor = "grabbing";
      });
      svg.addEventListener("pointermove", function(e) {
        if (!dragging) return;
        var dx = e.clientX - lastX, dy = e.clientY - lastY;
        lastX = e.clientX; lastY = e.clientY;
        if (Math.hypot(e.clientX-startX,e.clientY-startY) > 4) moved = true;
        if (!moved) return;
        rotY = (rotY + dx*0.55 + 360) % 360;
        // Keep the sphere under the pointer during vertical drags.
        rotX = Math.max(-60, Math.min(60, rotX + dy*0.55));
        render();
      });
      function stop(e,allowPointChange) {
        if (!dragging) return; dragging = false; svg.style.cursor = "grab";
        if (allowPointChange && !moved) setDomainPoint(e);
        if (e.pointerId !== undefined && svg.hasPointerCapture && svg.hasPointerCapture(e.pointerId)) {
          svg.releasePointerCapture(e.pointerId);
        }
      }
      svg.addEventListener("pointerup", function(e) { stop(e,true); });
      svg.addEventListener("pointercancel", function(e) { stop(e,false); });
    }
    return { attach: attach, render: render };
  }

  // Default cameras place the initial p and F(p) near the center of their globes.
  var domRenderer = makeRenderFn("dom", 333, 38);
  var codRenderer = makeRenderFn("cod", 307, 41);

  var tbl = data;
  var jDet = tbl.detJ;

  var container = html`<div style="font-family:system-ui,sans-serif;max-width:960px;">
    <div style="display:flex;gap:14px;flex-wrap:wrap;align-items:flex-start;">
      <!-- Domain sphere -->
      <div style="flex:1 1 300px;min-width:0;">
        <h5 style="text-align:center;margin:0 0 5px;color:#495057;line-height:1.2;">Domain 𝕊²<br><span style="font-size:0.82em;font-weight:400;">p and v ∈ T<sub>p</sub>𝕊²</span></h5>
        <svg viewBox="0 0 370 370" role="img" aria-label="Domain sphere with point p and tangent vector v"
          style="display:block;width:100%;max-width:370px;height:auto;border:1px solid #dee2e6;border-radius:4px;cursor:grab;touch-action:none;user-select:none;background:linear-gradient(180deg,#fbfdff 0%,#f5f8fb 100%);margin:0 auto;">
          <defs><radialGradient id="pf-glow-domain" cx="35%" cy="28%" r="72%"><stop offset="0%" stop-color="#fff"/><stop offset="58%" stop-color="#e7f5ff"/><stop offset="100%" stop-color="#d8edf7"/></radialGradient></defs>
          <g class="pf-scene" transform="translate(185,185)"></g>
          <text x="185" y="360" text-anchor="middle" font-size="10" fill="#6c757d">Click to move p · drag to rotate</text>
        </svg>
      </div>
      <!-- Codomain sphere -->
      <div style="flex:1 1 300px;min-width:0;">
        <h5 style="text-align:center;margin:0 0 5px;color:#495057;line-height:1.2;">Codomain 𝕊²<br><span style="font-size:0.82em;font-weight:400;">F(p) and dF<sub>p</sub>(v) ∈ T<sub>F(p)</sub>𝕊²</span></h5>
        <svg viewBox="0 0 370 370" role="img" aria-label="Codomain sphere with image point F(p) and pushforward dF(v)"
          style="display:block;width:100%;max-width:370px;height:auto;border:1px solid #dee2e6;border-radius:4px;cursor:grab;touch-action:none;user-select:none;background:linear-gradient(180deg,#fbfdff 0%,#f5f8fb 100%);margin:0 auto;">
          <defs><radialGradient id="pf-glow-codomain" cx="35%" cy="28%" r="72%"><stop offset="0%" stop-color="#fff"/><stop offset="58%" stop-color="#e7f5ff"/><stop offset="100%" stop-color="#d8edf7"/></radialGradient></defs>
          <g class="pf-scene" transform="translate(185,185)"></g>
          <text x="185" y="360" text-anchor="middle" font-size="10" fill="#6c757d">Drag to rotate</text>
        </svg>
      </div>
    </div>

    <!-- Numerical summary -->
    <div style="margin-top:14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px;">
      <div style="padding:10px 14px;background:#f8f9fa;border-radius:6px;font-size:0.85em;">
        <b>Domain</b>
        <table style="width:100%;border-collapse:collapse;margin-top:4px;">
          <tr><td>Point coordinates</td><td style="text-align:right;font-family:monospace;">θ=${pf_th}°, φ=${pf_ph}°</td></tr>
          <tr><td>p</td><td style="text-align:right;font-family:monospace;">(${tbl.p[0].toFixed(3)}, ${tbl.p[1].toFixed(3)}, ${tbl.p[2].toFixed(3)})</td></tr>
          <tr><td>|v|</td><td style="text-align:right;font-family:monospace;">${tbl.vNorm.toFixed(3)}</td></tr>
          <tr><td>⟨p,v⟩</td><td style="text-align:right;font-family:monospace;">${(tbl.p[0]*tbl.v[0]+tbl.p[1]*tbl.v[1]+tbl.p[2]*tbl.v[2]).toExponential(2)}</td></tr>
        </table>
      </div>
      <div style="padding:10px 14px;background:#f8f9fa;border-radius:6px;font-size:0.85em;">
        <b>Codomain</b>
        <table style="width:100%;border-collapse:collapse;margin-top:4px;">
          <tr><td>F(p)</td><td style="text-align:right;font-family:monospace;">(${tbl.Fp[0].toFixed(3)}, ${tbl.Fp[1].toFixed(3)}, ${tbl.Fp[2].toFixed(3)})</td></tr>
          <tr><td>|dF<sub>p</sub>(v)|</td><td style="text-align:right;font-family:monospace;">${tbl.dFvNorm.toFixed(3)}</td></tr>
          <tr><td>⟨F(p),dF<sub>p</sub>(v)⟩</td><td style="text-align:right;font-family:monospace;">${(tbl.Fp[0]*tbl.dFv[0]+tbl.Fp[1]*tbl.dFv[1]+tbl.Fp[2]*tbl.dFv[2]).toExponential(2)}</td></tr>
          <tr><td>Stretch ratio |dF<sub>p</sub>(v)|/|v|</td>
              <td style="text-align:right;font-family:monospace;font-weight:bold;color:${tbl.stretchRatio>1.05?'#c92a2a':tbl.stretchRatio<0.95?'#1971c2':'#212529'};">${tbl.stretchRatio.toFixed(3)}</td></tr>
        </table>
      </div>
    </div>

    <!-- Jacobian matrix -->
    <div style="margin-top:10px;padding:10px 14px;background:#f1f3f5;border-radius:6px;font-size:0.85em;">
      <b>Jacobian matrix of F at p</b> (orthonormal basis e<sub>θ</sub>, e<sub>φ</sub> at p → e<sub>θ′</sub>, e<sub>φ′</sub> at F(p)):
      <div style="text-align:center;margin:6px 0;font-family:monospace;font-size:1.05em;">
        dF<sub>p</sub> =
        <span style="display:inline-block;vertical-align:middle;border-left:2px solid #495057;border-right:2px solid #495057;padding:3px 8px;">
          ${tbl.J11.toFixed(3)} &nbsp; ${tbl.J12.toFixed(3)}<br>
          ${tbl.J21.toFixed(3)} &nbsp; ${tbl.J22.toFixed(3)}
        </span>
        &nbsp; det = ${jDet.toFixed(3)}
      </div>
      <p style="margin:4px 0 0;font-size:0.82em;color:#495057;">
        The principal tangent stretch factors are σ<sub>min</sub> = ${tbl.sigmaMin.toFixed(3)} and σ<sub>max</sub> = ${tbl.sigmaMax.toFixed(3)}.
        The local unsigned area factor is |det dF<sub>p</sub>| = ${Math.abs(jDet).toFixed(3)} = σ<sub>min</sub>σ<sub>max</sub>.
        The purple unit circle in the domain tangent plane is therefore mapped to the purple ellipse in the codomain tangent plane.
      </p>
    </div>
  </div>`;

  // Attach drag handlers after DOM insertion
  setTimeout(function() {
    var svgs = container.querySelectorAll('svg');
    if (svgs.length >= 2) {
      domRenderer.attach(svgs[0]);
      codRenderer.attach(svgs[1]);
    }
  }, 0);

  return container;
}

buildPFView()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
(o)
(p)
(q)
(r)
Figure 3: Interactive: Differential (pushforward) of a smooth map \(F: \mathbb{S}^2 \to \mathbb{S}^2\)
TipHow to explore the pushforward
  • Move the base point \(p\): Use the \(\theta,\varphi\) sliders or click a visible location on the domain globe. The red point \(p\), its image \(F(p)\), both tangent planes, and the Jacobian update together.
  • Compare the coordinate axes: At the positive \(x\)-axis the two tangent principal factors are \(b/a=0.625\) and \(c/a=0.375\), so all tangent directions contract. At the positive \(z\)-axis they are \(a/c\approx2.667\) and \(b/c\approx1.667\), so all tangent directions expand.
  • Rotate the tangent direction: At a fixed \(p\), sweep the tangent direction through 360° to see how the \(2 \times 2\) Jacobian transforms the unit circle in \(T_p\mathbb{S}^2\) into an ellipse in \(T_{F(p)}\mathbb{S}^2\).
  • Check linearity: Numerically, doubling \(|v|\) doubles \(|dF_p(v)|\) and leaves their ratio unchanged. Arrow lengths use an adaptive display scale so they remain inside the panels.
  • Read local area change correctly: The product of the singular values is \(|\det(dF_p)|\), the unsigned local area factor. The sign of the determinant additionally records orientation in the chosen oriented frames.

4.4 Differential of Real-Valued Functions

For a smooth function \(f: \mathcal{M} \to \mathbb{R}\), the target tangent space \(T_{f(p)}\mathbb{R}\) is canonically identified with \(\mathbb{R}\) itself. The differential becomes a linear functional on \(T_p\mathcal{M}\):

\[ df_p(v) = \left.\frac{d}{dt}\right|_{t=0} f(\gamma(t)) \;\in\; \mathbb{R}, \]

where \(\gamma(0) = p\), \(\dot{\gamma}(0) = v\). This is the directional derivative of \(f\) at \(p\) along \(v\).

In local coordinates \((U, \phi = (x^1, \ldots, x^m))\),

\[ df_p = \sum_{i=1}^m \frac{\partial (f \circ \phi^{-1})}{\partial x^i}\bigl(\phi(p)\bigr)\; dx^i|_p, \]

where \(\{dx^i|_p\}\) is the basis of the cotangent space \(T_p^*\mathcal{M}\) (the dual of \(T_p\mathcal{M}\)), satisfying \(dx^i|_p(\partial/\partial x^j|_p) = \delta^i_j\). The coefficients \(\partial(f \circ \phi^{-1})/\partial x^i\) are just the partial derivatives of the coordinate expression of \(f\).

NoteTangent vectors, covectors, and the differential

A tangent vector \(v \in T_p\mathcal{M}\) is a directional derivative operator: it eats a function and returns a number, \(v(f) = df_p(v)\). A covector \(\omega \in T_p^*\mathcal{M}\) is a linear functional on tangent vectors: \(\omega(v) \in \mathbb{R}\). The differential \(df_p\) is a covector — the coordinate-free version of the gradient row vector \(\nabla f(p)^\top\) from Euclidean calculus.

When a Riemannian metric \(g\) is available (see the next section), the covector \(df_p\) can be converted to a tangent vector \(\nabla f(p)\) — the gradient — via the metric duality \(df_p(v) = g_p(\nabla f(p), v)\). The gradient is the tangent vector pointing in the direction of steepest increase of \(f\), generalizing the Euclidean \(\nabla f\).

4.5 Vector Fields

Definition 6 A smooth vector field \(V\) on \(\mathcal{M}\) is a smooth map \(V: \mathcal{M} \to T\mathcal{M}\) such that \(V(p) \in T_p\mathcal{M}\) for every \(p \in \mathcal{M}\). The set of all smooth vector fields on \(\mathcal{M}\) is denoted \(\Gamma(T\mathcal{M})\).

The visualization below shows the vector field \(\partial/\partial\phi\) on \(\mathbb{S}^2\) — the generator of rotations about the \(z\)-axis (also called a Killing vector field). At each point \(p = (\sin\theta\cos\phi, \sin\theta\sin\phi, \cos\theta)\), the vector is

\[ V(p) = \frac{\partial}{\partial\phi}\bigg|_p = (-\sin\phi,\; \cos\phi,\; 0) = (-y, x, 0) \quad \text{in } \mathbb{R}^3. \]

This field is everywhere tangent to \(\mathbb{S}^2\) (since \((x,y,z)\cdot(-y,x,0)=0\)) and vanishes at the north and south poles \((\theta=0,\pi)\) where \(\sin\theta = 0\). This illustrates the Hairy Ball Theorem: every continuous tangent vector field on \(\mathbb{S}^2\) must vanish at least at one point.

Code
vf_rotY_ctrl = Inputs.range([0, 360], {step: 10, value: 210, label: "View azimuth °"})
vf_rotX_ctrl = Inputs.range([-50, 50], {step: 5, value: 20, label: "View elevation °"})
vf_scale_ctrl = Inputs.range([0.3, 2.5], {step: 0.1, value: 1.2, label: "Arrow scale"})

vf_rotY = Generators.input(vf_rotY_ctrl)
vf_rotX = Generators.input(vf_rotX_ctrl)
vf_scale = Generators.input(vf_scale_ctrl)

vf_ctrls = html`
<style>
  .vf-ctrl-grid { display:flex; flex-wrap:wrap; gap:6px 20px; width:100%; margin:0 0 12px; font:0.85em system-ui,sans-serif; container-type:inline-size; }
  .vf-ctrl-grid > * { flex:1 1 calc((100% - 40px)/3); min-width:0; margin:0; }
  .vf-ctrl-grid input[type="number"] { width:7.5rem !important; }
  .vf-s2-globe { display:block; width:100%; max-width:460px; height:auto; cursor:grab; touch-action:none; user-select:none; }
  @container (max-width:700px) { .vf-ctrl-grid > * { flex-basis:calc((100% - 20px)/2); } }
  @container (max-width:480px) { .vf-ctrl-grid > * { flex-basis:100%; } }
</style>
<div class="vf-ctrl-grid">
  <div>${vf_rotY_ctrl}</div>
  <div>${vf_rotX_ctrl}</div>
  <div>${vf_scale_ctrl}</div>
</div>`

function renderVectorField(rotYdeg, rotXdeg, arrowScale) {
  var W = 460, H = 460, R = 155;
  var ry = rotYdeg * Math.PI / 180;
  var rx = rotXdeg * Math.PI / 180;

  // Rotate a 3D point by (ry around Y, rx around X), return [x,y,z]
  function rotate(v) {
    var x1 =  v[0]*Math.cos(ry) + v[2]*Math.sin(ry);
    var y1 =  v[1];
    var z1 = -v[0]*Math.sin(ry) + v[2]*Math.cos(ry);
    var x2 = x1;
    var y2 = y1*Math.cos(rx) - z1*Math.sin(rx);
    var z2 = y1*Math.sin(rx) + z1*Math.cos(rx);
    return [x2, y2, z2];
  }

  // Orthographic project to 2D canvas
  function proj(v) {
    return {x: v[0]*R + W/2, y: -v[1]*R + H/2, z: v[2]};
  }

  var parts = [];

  // --- Wireframe: latitude circles ---
  for (var i = 1; i <= 12; i++) {
    var th = i * Math.PI / 12;
    var zCircle = Math.cos(th);
    var rCircle = Math.sin(th);
    var d = '';
    for (var j = 0; j <= 120; j++) {
      var ph = j * 2*Math.PI / 120;
      var pt = proj(rotate([rCircle*Math.cos(ph), rCircle*Math.sin(ph), zCircle]));
      d += (j===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(2) + ' ' + pt.y.toFixed(2);
    }
    parts.push('<path d="' + d + '" fill="none" stroke="#a0a0a0" stroke-width="0.6" opacity="0.55"/>');
  }

  // --- Wireframe: longitude (meridian) circles ---
  for (var j = 0; j < 24; j++) {
    var ph = j * 2*Math.PI / 24;
    var cosPh = Math.cos(ph), sinPh = Math.sin(ph);
    var d = '';
    for (var i = 0; i <= 120; i++) {
      var th = i * Math.PI / 120;
      var sinTh = Math.sin(th), cosTh = Math.cos(th);
      var pt = proj(rotate([sinTh*cosPh, sinTh*sinPh, cosTh]));
      d += (i===0 ? 'M' : 'L') + ' ' + pt.x.toFixed(2) + ' ' + pt.y.toFixed(2);
    }
    parts.push('<path d="' + d + '" fill="none" stroke="#a0a0a0" stroke-width="0.6" opacity="0.55"/>');
  }

  // --- Vector field: ∂/∂φ = (-y, x, 0) ---
  // Sample grid over the sphere (colatitude, longitude)
  var vectors = [];
  var dThGrid = 18, dPhGrid = 24;
  for (var thDeg = dThGrid; thDeg < 180; thDeg += dThGrid) {
    for (var phDeg = 0; phDeg < 360; phDeg += dPhGrid) {
      var thr = thDeg * Math.PI / 180;
      var phr = phDeg * Math.PI / 180;
      var sinTh = Math.sin(thr), cosTh = Math.cos(thr);
      var x = sinTh * Math.cos(phr);
      var y = sinTh * Math.sin(phr);
      var z = cosTh;

      // ∂/∂φ in ambient ℝ³: magnitude = sinθ
      var vx = -y, vy = x, vz = 0;
      var fieldNorm = sinTh;  // |∂/∂φ| = sin θ
      if (fieldNorm < 1e-10) continue;  // skip poles

      var displayLen = arrowScale * 0.22 * fieldNorm;
      var vnx = vx * displayLen / fieldNorm;
      var vny = vy * displayLen / fieldNorm;

      var base = proj(rotate([x, y, z]));
      var tip  = proj(rotate([x + vnx, y + vny, z]));
      vectors.push({
        bx: base.x, by: base.y, bz: base.z,
        tx: tip.x, ty: tip.y, tz: tip.z,
        depth: (base.z + tip.z) / 2,
        localNorm: fieldNorm
      });
    }
  }

  // Painter's algorithm: sort back-to-front
  vectors.sort(function(a,b) { return a.depth - b.depth; });

  // Draw vectors
  for (var vi = 0; vi < vectors.length; vi++) {
    var v = vectors[vi];
    var dx = v.tx - v.bx;
    var dy = v.ty - v.by;
    var len2d = Math.sqrt(dx*dx + dy*dy);
    if (len2d < 1.2) continue;

    var alpha = Math.min(1.0, v.localNorm + 0.2);
    var opacity = v.depth > 0 ? alpha : 0.22 * alpha;
    var arrowColor = '214,39,40'; // #d62728

    // Shaft
    parts.push('<line x1="' + v.bx.toFixed(2) + '" y1="' + v.by.toFixed(2) +
      '" x2="' + v.tx.toFixed(2) + '" y2="' + v.ty.toFixed(2) +
      '" stroke="rgb(' + arrowColor + ')" stroke-width="1.8" opacity="' +
      opacity.toFixed(2) + '" stroke-linecap="round"/>');

    // Arrowhead
    var ang = Math.atan2(dy, dx);
    var hLen = Math.min(7, len2d * 0.38);
    var hW = hLen * 0.55;
    var cosA1 = Math.cos(ang - 0.55), sinA1 = Math.sin(ang - 0.55);
    var cosA2 = Math.cos(ang + 0.55), sinA2 = Math.sin(ang + 0.55);
    parts.push('<polygon points="' +
      v.tx.toFixed(2) + ',' + v.ty.toFixed(2) + ' ' +
      (v.tx - hLen*cosA1).toFixed(2) + ',' + (v.ty - hLen*sinA1).toFixed(2) + ' ' +
      (v.tx - hLen*cosA2).toFixed(2) + ',' + (v.ty - hLen*sinA2).toFixed(2) +
      '" fill="rgb(' + arrowColor + ')" opacity="' + opacity.toFixed(2) + '"/>');
  }

  // --- Mark the poles where V = 0 ---
  var northP = proj(rotate([0, 0, 1]));
  var southP = proj(rotate([0, 0, -1]));
  var poleLabelStyle = 'font-size:11px; font-family:system-ui,sans-serif; fill:#333';

  function drawPoleMarker(pt, label) {
    if (pt.z > -0.05) {
      var rO = pt.z > 0 ? 5 : 3.5;
      var fillO = pt.z > 0 ? 1 : 0.35;
      parts.push('<circle cx="' + pt.x.toFixed(2) + '" cy="' + pt.y.toFixed(2) +
        '" r="' + rO + '" fill="#e67700" opacity="' + fillO + '" stroke="#555" stroke-width="1.2"/>');
      parts.push('<text x="' + (pt.x + 10).toFixed(2) + '" y="' + (pt.y + 4).toFixed(2) +
        '" style="' + poleLabelStyle + '">' + label + '</text>');
    }
  }
  drawPoleMarker(northP, 'N (V=0)');
  drawPoleMarker(southP, 'S (V=0)');

  // --- Equator arrow (emphasized) ---
  // Draw a thicker arrow on the front equator to emphasize rotation direction
  for (var ep = 0; ep < 8; ep++) {
    var eph = ep * Math.PI / 4;
    var ex = Math.cos(eph), ey = Math.sin(eph), ez = 0;
    var evx = -ey * arrowScale * 0.22, evy = ex * arrowScale * 0.22;
    var eBase = proj(rotate([ex, ey, ez]));
    var eTip  = proj(rotate([ex + evx, ey + evy, ez]));
    var eDepth = (eBase.z + eTip.z) / 2;
    if (eDepth < 0) continue;
    var edx = eTip.x - eBase.x, edy = eTip.y - eBase.y;
    var eLen = Math.sqrt(edx*edx + edy*edy);
    if (eLen < 2) continue;
    parts.push('<line x1="' + eBase.x.toFixed(2) + '" y1="' + eBase.y.toFixed(2) +
      '" x2="' + eTip.x.toFixed(2) + '" y2="' + eTip.y.toFixed(2) +
      '" stroke="#d62728" stroke-width="2.5" opacity="0.9" stroke-linecap="round"/>');
    var eAng = Math.atan2(edy, edx);
    var ehLen = Math.min(9, eLen * 0.35);
    var ehW = ehLen * 0.55;
    parts.push('<polygon points="' +
      eTip.x.toFixed(2) + ',' + eTip.y.toFixed(2) + ' ' +
      (eTip.x - ehLen*Math.cos(eAng-0.55)).toFixed(2) + ',' + (eTip.y - ehLen*Math.sin(eAng-0.55)).toFixed(2) + ' ' +
      (eTip.x - ehLen*Math.cos(eAng+0.55)).toFixed(2) + ',' + (eTip.y - ehLen*Math.sin(eAng+0.55)).toFixed(2) +
      '" fill="#d62728" opacity="0.9"/>');
  }

  return parts.join('\n');
}

// --- Build view ---
function buildVFView() {
  var container = html`
  <div style="font-family: system-ui, sans-serif; max-width: 500px; margin: 0 auto;">
    ${vf_ctrls}
    <div style="text-align: center;">
      <svg class="vf-s2-globe" viewBox="0 0 460 460" role="img"
        aria-label="Vector field ∂/∂φ on the 2-sphere — tangent vectors of rotation about the z-axis"
        style="border: 1px solid #dee2e6; border-radius: 4px; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g class="vf-scene"></g>
      </svg>
    </div>
    <p style="text-align:center; font-size:0.82em; color:#555; margin:6px 0 0;">
      <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#e67700;margin-right:4px;"></span> Poles (where <em>V</em> = 0) &nbsp;|&nbsp;
      <span style="display:inline-block;width:14px;height:3px;background:#d62728;margin-right:4px;vertical-align:middle;"></span> Tangent vector &part;/&part;&phi; &nbsp;|&nbsp;
      Drag sphere to rotate view
    </p>
  </div>`;

  var svg = container.querySelector(".vf-s2-globe");
  var scene = svg.querySelector(".vf-scene");
  var state = { rotY: vf_rotY, rotX: vf_rotX };

  function render() {
    scene.innerHTML = renderVectorField(state.rotY, state.rotX, vf_scale);
  }

  function findRange(labelText) {
    var labels = Array.from(container.querySelectorAll("label"));
    for (var i = 0; i < labels.length; i++) {
      if (labels[i].textContent.indexOf(labelText) >= 0) {
        return labels[i].querySelector('input[type="range"]') ||
          (labels[i].parentElement && labels[i].parentElement.querySelector('input[type="range"]'));
      }
    }
    return null;
  }

  var az = findRange("View azimuth");
  var el = findRange("View elevation");
  var dragging = false, lastX = 0, lastY = 0;

  function commitRotation() {
    if (az) { az.value = Math.round(state.rotY); az.dispatchEvent(new Event('input', {bubbles: true})); }
    if (el) { el.value = Math.round(state.rotX); el.dispatchEvent(new Event('input', {bubbles: true})); }
  }

  render();

  svg.addEventListener('pointerdown', function(e) {
    dragging = true; lastX = e.clientX; lastY = e.clientY;
    svg.style.cursor = 'grabbing';
    if (svg.setPointerCapture) svg.setPointerCapture(e.pointerId);
    e.preventDefault();
  });
  svg.addEventListener('pointermove', function(e) {
    if (!dragging) return;
    var dx = e.clientX - lastX, dy = e.clientY - lastY;
    lastX = e.clientX; lastY = e.clientY;
    state.rotY = (state.rotY + dx * 0.4 + 360) % 360;
    // Keep the sphere under the pointer during vertical drags.
    state.rotX = Math.max(-50, Math.min(50, state.rotX + dy * 0.4));
    render();
  });
  function stop(e, commit) {
    if (!dragging) return;
    dragging = false; svg.style.cursor = 'grab';
    if (e.pointerId !== undefined && svg.hasPointerCapture && svg.hasPointerCapture(e.pointerId)) {
      svg.releasePointerCapture(e.pointerId);
    }
    if (commit) commitRotation();
  }
  svg.addEventListener('pointerup', function(e) { stop(e, true); });
  svg.addEventListener('pointercancel', function(e) { stop(e, false); });

  return container;
}

buildVFView()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
Figure 4: Interactive: Vector field \(\partial/\partial\phi\) (rotation generator) on \(\mathbb{S}^2\) — vanishes at the poles, illustrating the Hairy Ball Theorem

A vector field acts on smooth functions via directional differentiation: for \(f \in C^\infty(\mathcal{M})\),

\[ V(f)(p) = df_p\bigl(V(p)\bigr) = \left.\frac{d}{dt}\right|_{t=0} f(\gamma(t)), \]

where \(\gamma(0) = p\) and \(\dot{\gamma}(0) = V(p)\). The result \(V(f)\) is again a smooth function on \(\mathcal{M}\). Intuitively, \(V(f)\) answers: “how fast is \(f\) changing in the direction \(V\)?”

Definition 7 For \(U, V \in \Gamma(T\mathcal{M})\), their Lie bracket (or commutator) is the vector field \([U, V]\) defined by its action on smooth functions:

\[ [U, V](f) = U\bigl(V(f)\bigr) - V\bigl(U(f)\bigr) \qquad \text{for all } f \in C^\infty(\mathcal{M}). \]

In local coordinates \((x^1, \ldots, x^m)\), with \(U = \sum_i U^i \partial_i\) and \(V = \sum_j V^j \partial_j\),

\[ [U, V]^i = \sum_{j=1}^m \bigl( U^j \partial_j V^i - V^j \partial_j U^i \bigr). \]

The Lie bracket measures the non-commutativity of directional derivatives along \(U\) and \(V\). When \([U,V]=0\), their local flows commute; where \(U\) and \(V\) are also linearly independent, their integral curves can form a local coordinate grid. In Euclidean space with Cartesian coordinates, \([\partial_i,\partial_j]=0\) for all \(i,j\), reflecting the commutativity of mixed partial derivatives.

4.6 Covariant Derivative and Affine Connection

In \(\mathbb{R}^m\), differentiating a vector field \(V\) along a direction \(U\) is straightforward: one computes the ordinary directional derivative \((D_U V)(p) = \frac{d}{dt}|_{t=0} V(p + t U(p))\), subtracting vectors that live in the same vector space. On a manifold, this recipe fails.

Consider a vector field on \(\mathbb{S}^2\) and two nearby points \(p\) and \(q\). The tangent spaces \(T_p\mathbb{S}^2\) and \(T_q\mathbb{S}^2\) are different 2-planes in \(\mathbb{R}^3\) — they are tilted relative to one another. A naive difference \(V(q) - V(p)\) in the ambient \(\mathbb{R}^3\) generally produces a vector that is not tangent to \(\mathbb{S}^2\) at either point. The result is geometrically meaningless as a tangent vector.

The problem is structural: \(V(p) \in T_p\mathcal{M}\) and \(V(q) \in T_q\mathcal{M}\) live in different fibers of the tangent bundle. There is no canonical identification between \(T_p\mathcal{M}\) and \(T_q\mathcal{M}\)—no intrinsic way to say whether a vector at \(q\) is “the same” as a vector at \(p\). A connection supplies a rule for differentiation and, equivalently, for parallel transport along a specified curve. The resulting identification can depend on the path; curvature measures part of that path dependence.

An affine connection \(\nabla\) is the structure that makes differentiation of vector fields possible.

Definition 8 An affine connection on \(\mathcal{M}\) is an \(\mathbb{R}\)-bilinear map

\[ \nabla: \Gamma(T\mathcal{M}) \times \Gamma(T\mathcal{M}) \to \Gamma(T\mathcal{M}), \qquad (U, V) \mapsto \nabla_U V, \]

satisfying for all \(U, V, W \in \Gamma(T\mathcal{M})\) and all smooth \(f, g, h: \mathcal{M} \to \mathbb{R}\):

  1. \(C^\infty\)-linearity in the direction (first argument): \(\nabla_{fU + gV} W = f\nabla_U W + g\nabla_V W\).

  2. Leibniz (product) rule: \(\nabla_U (h V) = U(h)\, V + h\,\nabla_U V\).

For fixed \(U\), the vector field \(\nabla_U V\) is the covariant derivative of \(V\) along \(U\).

The Leibniz rule is the defining feature: when the vector field \(V\) is scaled by a function \(h\), the derivative separates into a “function-derivative” term \(U(h)V\) and a “vector-derivative” term \(h\nabla_U V\), exactly as the product rule requires. Additivity and constant-scalar linearity in the second argument are already included in \(\mathbb R\)-bilinearity.

A crucial consequence of \(C^\infty\)-linearity in \(U\) is locality in the direction: \((\nabla_UV)(p)\) depends on \(U\) only through \(U(p)\), while it depends on the first-order behavior of \(V\) at \(p\). A connection also induces a covariant derivative along a curve \(\gamma\): if \(V(t)\in T_{\gamma(t)}\mathcal M\) is a smooth vector field along \(\gamma\), then \(D V/dt\) is well defined. If \(V\) is the restriction of an ambient vector field \(\widetilde V\), then \[ \frac{D V}{dt}=\nabla_{\dot\gamma}\widetilde V, \] and the result is independent of the chosen extension \(\widetilde V\).

In local coordinates \((x^1, \ldots, x^m)\), a connection is specified by its action on coordinate basis fields:

\[ \nabla_{\partial_i} \partial_j = \sum_{k=1}^m \Gamma_{ij}^k \,\partial_k, \qquad \partial_i = \frac{\partial}{\partial x^i}, \]

where the \(m^3\) functions \(\Gamma_{ij}^k\) are the Christoffel symbols of the connection. They are not the components of a tensor — under a change of coordinates, they transform with an additional inhomogeneous term involving second derivatives of the coordinate transition functions.

In \(\mathbb{R}^m\) with Cartesian coordinates, the ordinary directional derivative is a connection with \(\Gamma_{ij}^k \equiv 0\): \(\nabla_U V = D_U V\). In polar coordinates \((r, \theta)\) on \(\mathbb{R}^2\), the same flat connection acquires non-zero Christoffel symbols:

\[ \Gamma_{\theta\theta}^r = -r, \qquad \Gamma_{r\theta}^\theta = \Gamma_{\theta r}^\theta = \frac{1}{r}. \]

These encode how the coordinate basis \(\{\partial_r, \partial_\theta\}\) rotates as one moves — the connection is still flat (zero curvature), but the coordinate expression is non-trivial.

5 Riemannian Manifolds

5.1 Riemannian Metric

Definition 9 A Riemannian manifold is a smooth manifold \(\mathcal{M}\) with an inner product \(g_p : T_p\mathcal{M} \times T_p\mathcal{M} \to \mathbb{R}\) for each \(p\), varying smoothly with \(p\). The family \(g = \{g_p\}\) is called a Riemannian metric.

NoteAffine-invariant metric on SPD

For \(\Sigma \in \mathcal{S}_{++}^m\) and \(U, V \in T_\Sigma\mathcal{S}_{++}^m \cong \operatorname{Sym}(m)\):

\[ g^{\mathrm{AI}}_\Sigma(U, V) = \operatorname{tr}(\Sigma^{-1}U\,\Sigma^{-1}V). \]

This metric is invariant under simultaneous congruence of the base point and tangent vectors: \[ g^{\mathrm{AI}}_{G\Sigma G^\top}(GUG^\top,GVG^\top)=g^{\mathrm{AI}}_\Sigma(U,V) \] for every invertible \(G\). Its geodesic distance is \(d_{\mathrm{AI}}(\Sigma_0,\Sigma_1)=\|\log(\Sigma_0^{-1/2}\Sigma_1\Sigma_0^{-1/2})\|_F\), and its exponential map is \(\mathrm{Exp}^{\mathrm{AI}}_\Sigma(U)=\Sigma^{1/2}\exp(\Sigma^{-1/2}U\Sigma^{-1/2})\Sigma^{1/2}\) (Pennec et al. 2006; Moakher 2005).

NoteLog-Euclidean metric on SPD

The log-Euclidean metric is the pullback of the Frobenius inner product through the matrix logarithm: \(d_{\mathrm{LE}}(\Sigma_0, \Sigma_1) = \|\log\Sigma_0 - \log\Sigma_1\|_F\). Under this metric, \(\log:(\mathcal{S}_{++}^m, g^{\mathrm{LE}}) \to (\operatorname{Sym}(m), \langle\cdot,\cdot\rangle_F)\) is a global isometry, so the Fréchet mean is \(\exp(\frac{1}{n}\sum_i \log\Sigma_i)\) (Arsigny et al. 2007).

For a piecewise \(C^1\) curve \(\gamma\), the metric defines length: \(\ell_\gamma=\int_a^b\|\dot\gamma(t)\|_{\gamma(t)}\,dt\). On each connected component, the intrinsic distance is

\[ d_{\mathcal{M}}(p,q)=\inf\{\ell_\gamma:\gamma\text{ is piecewise }C^1,\ \gamma(a)=p,\ \gamma(b)=q\}. \]

5.2 Torsion and Metric Compatibility of Connection

Two additional geometric conditions single out the connection most natural for Riemannian geometry.

Definition 10 The torsion tensor of \(\nabla\) is

\[ T(U, V) = \nabla_U V - \nabla_V U - [U, V]. \]

A connection is torsion-free (or symmetric) if \(T(U, V) = 0\) for all \(U, V\). Equivalently, \(\Gamma_{ij}^k = \Gamma_{ji}^k\) in every coordinate system.

On a Riemannian manifold \((\mathcal{M}, g)\), a connection is compatible with the metric (written \(\nabla g = 0\)) if for all \(U, V, W \in \Gamma(T\mathcal{M})\),

\[ U\bigl(g(V, W)\bigr) = g(\nabla_U V, W) + g(V, \nabla_U W). \]

  • Torsion-free means there is no infinitesimal translational mismatch between differentiating in the \(U\)-then-\(V\) and \(V\)-then-\(U\) directions after accounting for their Lie bracket. This is often visualized by the closure of an infinitesimal geodesic parallelogram.
  • Metric compatibility means that parallel transport preserves lengths and angles. If two vectors at \(p\) are parallel transported along the same curve to \(q\), their inner product is unchanged. Equivalently, \(\nabla g = 0\) — the metric is “constant” from the perspective of the connection.

Theorem 1 Let \((\mathcal{M}, g)\) be a Riemannian manifold. There exists a unique affine connection \(\nabla\) on \(\mathcal{M}\) that is simultaneously torsion-free and compatible with the metric. It is called the Levi–Civita connection of \(g\), and its Christoffel symbols are

\[ \Gamma_{ij}^k = \frac{1}{2} \sum_{\ell=1}^m g^{k\ell} \bigl( \partial_i g_{j\ell} + \partial_j g_{i\ell} - \partial_\ell g_{ij} \bigr), \]

where \(g_{ij} = g(\partial_i, \partial_j)\) and \([g^{k\ell}] = [g_{ij}]^{-1}\).

NoteExample: Levi–Civita connection on \(\mathbb{S}^2\) with the round metric

The round metric on \(\mathbb{S}^2\) in spherical coordinates \((\theta, \varphi)\) is \(g = d\theta^2 + \sin^2\theta\,d\varphi^2\), with non-zero Christoffel symbols

\[ \Gamma_{\varphi\varphi}^\theta = -\sin\theta\cos\theta, \qquad \Gamma_{\theta\varphi}^\varphi = \Gamma_{\varphi\theta}^\varphi = \cot\theta. \]

These encode the geometry of the sphere:

  • \(\nabla_{\partial_\varphi} \partial_\varphi = -\sin\theta\cos\theta\,\partial_\theta\). In the northern hemisphere this points toward decreasing \(\theta\) (northward); in the southern hemisphere it points toward increasing \(\theta\) (southward). It vanishes at the equator, whose latitude circle is a geodesic.
  • \(\nabla_{\partial_\theta}\partial_\varphi=\cot\theta\,\partial_\varphi\) records how the longitude coordinate field changes while moving north–south. Torsion-freeness gives the equal mixed derivative \(\nabla_{\partial_\varphi}\partial_\theta=\cot\theta\,\partial_\varphi\).

At the equator (\(\theta=\pi/2\)), these Christoffel symbols vanish in this coordinate system. This does not mean that the sphere is flat there: Christoffel symbols are coordinate-dependent, whereas the round sphere still has sectional curvature \(+1\) at every point.

The Levi–Civita connection is the default connection in Riemannian geometry. It generalizes the ordinary directional derivative from Euclidean space (where \(\Gamma_{ij}^k = 0\) in Cartesian coordinates), and it underpins the fundamental geometric objects:

  • Geodesics are curves \(\gamma\) satisfying \(\nabla_{\dot{\gamma}} \dot{\gamma} = 0\) — zero covariant acceleration, generalizing straight lines. A geodesic has constant speed, and every sufficiently short segment is length-minimizing; a long geodesic segment need not be globally minimizing.
  • Parallel transport of a vector \(v\) along \(\gamma\) is the solution to \(\nabla_{\dot{\gamma}} V = 0\) with initial condition \(V(0) = v\).
  • Curvature quantifies how parallel transport along a closed loop differs from the identity.

Parallel transport of a tangent vector along a closed geodesic triangle on \(\mathbb{S}^2\). The vector is transported along the three edges of the triangle while kept parallel (covariant derivative zero) with respect to the Levi-Civita connection. After completing the loop and returning to the starting point, the transported vector differs from the initial vector by a rotation equal to the area enclosed by the triangle — a manifestation of the sphere’s constant positive sectional curvature \(K=+1\).

5.3 Interactive: Parallel Transport on \(\mathbb{S}^2\)

The figure above shows transport around one specific geodesic triangle; the demo below lets you explore parallel transport along several curves on \(\mathbb{S}^2\). Choose a curve from the dropdown, pick an initial tangent vector \(v_0 \in T_{\gamma(0)}\mathbb{S}^2\) (direction and length), and drag the progress slider to transport it along the curve. Along a geodesic (equator, meridian, triangle edges) the angle between \(V(t)\) and the velocity \(\dot\gamma(t)\) stays constant; along a latitude circle or a loxodrome it changes. For a closed curve, \(V\) generally does not return to \(v_0\): the rotation after a full loop — the holonomy — equals the solid angle enclosed by the loop.

Visual guide: - Dashed purple curve = the full curve \(\gamma\); solid purple segment = the portion traversed so far - Red dot = start point \(\gamma(0)\); purple dot = current point \(\gamma(t)\) - Faint green arrow = initial vector \(v_0\) kept at \(\gamma(0)\) for comparison - Bold green arrow = transported vector \(V(t)\) at \(\gamma(t)\); teal arrows = \(V\) at intermediate points

Code
pt_curve_control = Inputs.select(new Map([
  ["Latitude circle (colatitude θ₀ = 45°)", "latitude"],
  ["Equator (geodesic)", "equator"],
  ["Meridian arc (geodesic)", "meridian"],
  ["Geodesic triangle (closed loop)", "triangle"],
  ["Loxodrome (constant bearing 70°)", "loxodrome"]
]), {value: "latitude", label: "Curve γ"})
pt_t_control = Inputs.range([0, 100], {step: 1, value: 60, label: "Transport progress (%)"})
pt_vang_control = Inputs.range([0, 360], {step: 5, value: 60, label: "Initial direction of v₀ (°)"})
pt_vlen_control = Inputs.range([0.2, 1.5], {step: 0.1, value: 0.8, label: "Initial length |v₀|"})
pt_rotY_control = Inputs.range([0, 360], {step: 10, value: 210, label: "Transport view azimuth °"})
pt_rotX_control = Inputs.range([-50, 50], {step: 5, value: 20, label: "Transport view elevation °"})
pt_curve = Generators.input(pt_curve_control)
pt_t = Generators.input(pt_t_control)
pt_vang = Generators.input(pt_vang_control)
pt_vlen = Generators.input(pt_vlen_control)
pt_rotY = Generators.input(pt_rotY_control)
pt_rotX = Generators.input(pt_rotX_control)

pt_controls_view = html`
<style>
  .pt-slider-grid { display:flex; flex-wrap:wrap; gap:6px 20px; width:100%; margin:0 0 12px; font:0.85em system-ui,sans-serif; container-type:inline-size; }
  .pt-slider-grid > * { flex:1 1 calc((100% - 40px)/3); min-width:0; margin:0; }
  .pt-slider-grid input[type="number"] { width:7.5rem !important; }
  .pt-s2-globe { display:block; width:100%; height:auto; }
  @container (max-width:700px) { .pt-slider-grid > * { flex-basis:calc((100% - 20px)/2); } }
  @container (max-width:480px) { .pt-slider-grid > * { flex-basis:100%; } }
</style>
<div class="pt-slider-grid">
  <div>${pt_curve_control}</div>
  <div>${pt_t_control}</div>
  <div>${pt_vang_control}</div>
  <div>${pt_vlen_control}</div>
  <div>${pt_rotY_control}</div>
  <div>${pt_rotX_control}</div>
</div>`

function runParallelTransportDemo(curveName, tPct, vAngDeg, vLen, rotYdeg, rotXdeg) {
  var D2R = Math.PI / 180;
  function dot3(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
  function cross3(a, b) { 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]]; }
  function norm3(a) { return Math.sqrt(dot3(a, a)); }
  function clamp1(x) { return Math.max(-1, Math.min(1, x)); }

  // Curve definitions: t in [0,1] -> unit vector on S²
  var curveLabel, closed = false, curveFn;
  if (curveName === "equator") {
    curveLabel = "Equator (geodesic)";
    closed = true;
    curveFn = function(t) {
      var ph = 2*Math.PI*t;
      return [Math.cos(ph), Math.sin(ph), 0];
    };
  } else if (curveName === "meridian") {
    curveLabel = "Meridian arc (geodesic)";
    var ph0 = 30*D2R;
    curveFn = function(t) {
      var th = (15 + 150*t)*D2R;
      return [Math.sin(th)*Math.cos(ph0), Math.sin(th)*Math.sin(ph0), Math.cos(th)];
    };
  } else if (curveName === "triangle") {
    curveLabel = "Geodesic triangle (closed loop)";
    closed = true;
    var Nv = [0, 0, 1], Av = [1, 0, 0], Bv = [0, 1, 0];
    function slerp(a, b, s) {
      var om = Math.acos(clamp1(dot3(a, b)));
      if (om < 1e-12) return a.slice();
      var wa = Math.sin((1-s)*om)/Math.sin(om), wb = Math.sin(s*om)/Math.sin(om);
      return [wa*a[0]+wb*b[0], wa*a[1]+wb*b[1], wa*a[2]+wb*b[2]];
    }
    curveFn = function(t) {
      if (t < 1/3) return slerp(Nv, Av, 3*t);
      if (t < 2/3) return slerp(Av, Bv, 3*t-1);
      return slerp(Bv, Nv, 3*t-2);
    };
  } else if (curveName === "loxodrome") {
    curveLabel = "Loxodrome (constant bearing 70°)";
    var tanB = Math.tan(70*D2R);
    var thMin = 25*D2R, thMax = 155*D2R;
    curveFn = function(t) {
      var th = thMax + (thMin-thMax)*t;
      var ph = tanB*Math.log(Math.tan(th/2)/Math.tan(thMax/2));
      return [Math.sin(th)*Math.cos(ph), Math.sin(th)*Math.sin(ph), Math.cos(th)];
    };
  } else {
    curveLabel = "Latitude circle (colatitude θ₀ = 45°)";
    closed = true;
    var th0 = 45*D2R;
    curveFn = function(t) {
      var ph = 2*Math.PI*t;
      return [Math.sin(th0)*Math.cos(ph), Math.sin(th0)*Math.sin(ph), Math.cos(th0)];
    };
  }

  // Sample the curve
  var N = 600, pts = [];
  for (var i = 0; i <= N; i++) pts.push(curveFn(i/N));

  // Initial vector in the local (eθ, eφ) frame at γ(0)
  var P0 = pts[0];
  var thP = Math.acos(clamp1(P0[2]));
  var phP = Math.atan2(P0[1], P0[0]);
  var eTh = [Math.cos(thP)*Math.cos(phP), Math.cos(thP)*Math.sin(phP), -Math.sin(thP)];
  var ePh = [-Math.sin(phP), Math.cos(phP), 0];
  var vAng = vAngDeg*D2R;
  var V0 = [
    vLen*(Math.cos(vAng)*eTh[0] + Math.sin(vAng)*ePh[0]),
    vLen*(Math.cos(vAng)*eTh[1] + Math.sin(vAng)*ePh[1]),
    vLen*(Math.cos(vAng)*eTh[2] + Math.sin(vAng)*ePh[2])
  ];

  // Parallel transport: march along the sampled points; each step rotates v
  // by the rotation taking pts[i-1] to pts[i] (Rodrigues' formula). This is
  // the exact transport along the joining geodesic arc, and converges to the
  // Levi-Civita parallel transport along the smooth curve as N grows.
  var vecs = new Array(N+1);
  vecs[0] = V0;
  var v = V0;
  for (var i = 1; i <= N; i++) {
    var a = pts[i-1], b = pts[i];
    var ax = cross3(a, b);
    var axn = norm3(ax);
    var nv = v;
    if (axn > 1e-12) {
      var ang = Math.atan2(axn, clamp1(dot3(a, b)));
      var k = [ax[0]/axn, ax[1]/axn, ax[2]/axn];
      var cA = Math.cos(ang), sA = Math.sin(ang);
      var kv = cross3(k, v);
      var kdv = dot3(k, v);
      nv = [
        v[0]*cA + kv[0]*sA + k[0]*kdv*(1-cA),
        v[1]*cA + kv[1]*sA + k[1]*kdv*(1-cA),
        v[2]*cA + kv[2]*sA + k[2]*kdv*(1-cA)
      ];
    }
    // numerical hygiene: re-project onto T_b S² and restore the length
    var dvb = dot3(nv, b);
    nv = [nv[0]-dvb*b[0], nv[1]-dvb*b[1], nv[2]-dvb*b[2]];
    var nvn = norm3(nv);
    if (nvn > 1e-12) nv = [nv[0]*vLen/nvn, nv[1]*vLen/nvn, nv[2]*vLen/nvn];
    vecs[i] = nv;
    v = nv;
  }

  // Current state
  var iT = Math.round(N*tPct/100);
  var curPt = pts[iT], curVec = vecs[iT];
  var curTh = Math.acos(clamp1(curPt[2]))/D2R;
  var curPh = ((Math.atan2(curPt[1], curPt[0])/D2R) + 360) % 360;

  // Angle between V(t) and the velocity γ̇(t)
  var iA = Math.max(0, iT-1), iB = Math.min(N, iT+1);
  var vel = [pts[iB][0]-pts[iA][0], pts[iB][1]-pts[iA][1], pts[iB][2]-pts[iA][2]];
  var dvl = dot3(vel, curPt);
  vel = [vel[0]-dvl*curPt[0], vel[1]-dvl*curPt[1], vel[2]-dvl*curPt[2]];
  var veln = norm3(vel);
  var angleVelDeg = null;
  if (veln > 1e-9) angleVelDeg = Math.acos(clamp1(dot3(vel, curVec)/(veln*vLen)))/D2R;

  // Intermediate arrow samples along the traversed portion
  var arrowSamples = [];
  if (iT > 1) {
    var step = Math.max(1, Math.floor(iT/14));
    for (var j = step; j < iT; j += step) arrowSamples.push({p: pts[j], v: vecs[j]});
  }

  // Rotation relative to the initial vector (holonomy when the loop closes)
  var rotVsInitDeg = null;
  if (closed && iT > 0) {
    var a0 = Math.atan2(dot3(V0, ePh), dot3(V0, eTh));
    var a1 = Math.atan2(dot3(curVec, ePh), dot3(curVec, eTh));
    rotVsInitDeg = (((a1-a0)/D2R) % 360 + 360) % 360;
  }

  // View rotation
  var ry = rotYdeg*D2R, rx = rotXdeg*D2R;
  function rotate(vv) {
    var x1 = vv[0]*Math.cos(ry) + vv[2]*Math.sin(ry), y1 = vv[1], z1 = -vv[0]*Math.sin(ry) + vv[2]*Math.cos(ry);
    return [x1, y1*Math.cos(rx) - z1*Math.sin(rx), y1*Math.sin(rx) + z1*Math.cos(rx)];
  }

  return {
    curve: pts, trajPts: pts.slice(0, iT+1),
    P0: P0, V0: V0, curPt: curPt, curVec: curVec,
    arrowSamples: arrowSamples,
    curveLabel: curveLabel, closed: closed,
    vLen: vLen, curNorm: norm3(curVec),
    angleVelDeg: angleVelDeg, rotVsInitDeg: rotVsInitDeg,
    curThetaDeg: curTh, curPhiDeg: curPh,
    rotate: rotate
  };
}

ptData = runParallelTransportDemo(pt_curve, pt_t, pt_vang, pt_vlen, pt_rotY, pt_rotX);

(() => {
const view = html`
<div style="font-family: system-ui, sans-serif;">

  <div style="display: flex; gap: 20px; flex-wrap: wrap;">

    <!-- Sphere + transported vector visualization -->
    <div style="flex: 0 0 calc(50% - 10px); min-width: 0;">
      <h4>Parallel Transport along γ on 𝕊²</h4>
      <svg class="pt-s2-globe" viewBox="0 0 420 420" role="img" aria-label="Sphere with a curve and a parallel transported tangent vector" style="border: 1px solid #dee2e6; border-radius: 4px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g transform="translate(210, 210)">
          ${(() => {
            var R = 175, parts = [];
            var res = ptData;

            function proj(v) {
              var rv = res.rotate(v);
              return {x: rv[0]*R, y: -rv[1]*R, z: rv[2]};
            }

            // Sphere wireframe
            for (var p = 0; p <= 18; p++) {
              var z = 1 - p*2/18;
              var r = Math.sqrt(Math.max(0, 1 - z*z));
              var d = '';
              for (var j = 0; j <= 120; j++) {
                var phi = j*2*Math.PI/120;
                var pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            for (var m = 0; m < 24; m++) {
              var phi = m*Math.PI/12;
              var d = '';
              for (var j = 0; j <= 90; j++) {
                var theta = j*Math.PI/90;
                var pt = proj([Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            parts.push('<circle cx="0" cy="0" r="'+R+'" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

            function pathFrom(points) {
              var d = '';
              for (var i = 0; i < points.length; i++) {
                var pt = proj(points[i]);
                d += (i===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              return d;
            }

            // Full curve (dashed) and traversed portion (solid)
            parts.push('<path d="'+pathFrom(res.curve)+'" fill="none" stroke="#9775fa" stroke-width="1.1" stroke-dasharray="4,4" opacity="0.5"/>');
            if (res.trajPts.length > 1) {
              parts.push('<path d="'+pathFrom(res.trajPts)+'" fill="none" stroke="#7950f2" stroke-width="2.6"/>');
            }

            function drawArrow(base, vec, scl, color, w, op) {
              var tip = [base[0]+scl*vec[0], base[1]+scl*vec[1], base[2]+scl*vec[2]];
              var bP = proj(base), tP = proj(tip);
              var s = '<line x1="'+bP.x.toFixed(1)+'" y1="'+bP.y.toFixed(1)+'" x2="'+tP.x.toFixed(1)+'" y2="'+tP.y.toFixed(1)+'" stroke="'+color+'" stroke-width="'+w+'" stroke-linecap="round" opacity="'+op+'"/>';
              var vn = Math.sqrt(vec[0]*vec[0]+vec[1]*vec[1]+vec[2]*vec[2]);
              if (vn > 1e-9) {
                var vd = [vec[0]/vn, vec[1]/vn, vec[2]/vn];
                var ox = vd[1]*base[2]-vd[2]*base[1], oy = vd[2]*base[0]-vd[0]*base[2], oz = vd[0]*base[1]-vd[1]*base[0];
                var on = Math.sqrt(ox*ox+oy*oy+oz*oz);
                if (on > 1e-9) {
                  ox /= on; oy /= on; oz /= on;
                  var hl = Math.min(0.3*vn, 0.15), hw = Math.min(0.18*vn, 0.08);
                  var wb = [tip[0]-scl*hl*vd[0], tip[1]-scl*hl*vd[1], tip[2]-scl*hl*vd[2]];
                  var w1 = proj([wb[0]+scl*hw*ox, wb[1]+scl*hw*oy, wb[2]+scl*hw*oz]);
                  var w2 = proj([wb[0]-scl*hw*ox, wb[1]-scl*hw*oy, wb[2]-scl*hw*oz]);
                  s += '<polygon points="'+tP.x.toFixed(1)+','+tP.y.toFixed(1)+' '+w1.x.toFixed(1)+','+w1.y.toFixed(1)+' '+w2.x.toFixed(1)+','+w2.y.toFixed(1)+'" fill="'+color+'" opacity="'+op+'"/>';
                }
              }
              return s;
            }

            // Transported vector at intermediate points (teal)
            for (var ai = 0; ai < res.arrowSamples.length; ai++) {
              parts.push(drawArrow(res.arrowSamples[ai].p, res.arrowSamples[ai].v, 0.45, '#0ca678', 1.6, 0.85));
            }
            // Initial vector kept at the start (faint green) and current vector (bold green)
            parts.push(drawArrow(res.P0, res.V0, 0.45, '#2b8a3e', 2, 0.4));
            parts.push(drawArrow(res.curPt, res.curVec, 0.45, '#2b8a3e', 3, 1));

            var tipV = proj([res.curPt[0]+0.45*res.curVec[0], res.curPt[1]+0.45*res.curVec[1], res.curPt[2]+0.45*res.curVec[2]]);
            parts.push('<text x="'+(tipV.x+5).toFixed(0)+'" y="'+(tipV.y-6).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">V(t)</text>');

            // Start and current points
            var p0P = proj(res.P0);
            parts.push('<circle cx="'+p0P.x.toFixed(1)+'" cy="'+p0P.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(p0P.x+8).toFixed(0)+'" y="'+(p0P.y-7).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">γ(0)</text>');
            var pcP = proj(res.curPt);
            parts.push('<circle cx="'+pcP.x.toFixed(1)+'" cy="'+pcP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(pcP.x+7).toFixed(0)+'" y="'+(pcP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">γ(t)</text>');

            return parts.join('\n');
          })()}
        </g>
        <g transform="translate(15, 385)">
          <circle cx="0" cy="0" r="4" fill="#e03131"/><text x="8" y="4" font-size="10" fill="#495057">γ(0) start</text>
          <line x1="85" y1="0" x2="100" y2="0" stroke="#7950f2" stroke-width="2.5"/><text x="104" y="4" font-size="10" fill="#495057">traversed γ</text>
          <line x1="180" y1="0" x2="195" y2="0" stroke="#0ca678" stroke-width="2"/><text x="199" y="4" font-size="10" fill="#495057">V along curve</text>
          <line x1="290" y1="0" x2="305" y2="0" stroke="#2b8a3e" stroke-width="2.5"/><text x="309" y="4" font-size="10" fill="#495057">V(t)</text>
        </g>
        <text x="210" y="412" text-anchor="middle" font-size="10" fill="#6c757d">Drag the sphere to rotate and tilt</text>
      </svg>
    </div>

    <!-- Right panel -->
    <div style="flex: 0 0 calc(50% - 10px); min-width: 0;">
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 12px;">
        <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
          <tr><td style="padding: 3px 8px;">Curve γ</td>
              <td style="padding: 3px 8px; text-align: right;">${ptData.curveLabel}</td></tr>
          <tr><td style="padding: 3px 8px;">Progress</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${pt_t}%</td></tr>
          <tr><td style="padding: 3px 8px;">Current point γ(t)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">θ=${ptData.curThetaDeg.toFixed(1)}°, φ=${ptData.curPhiDeg.toFixed(1)}°</td></tr>
          <tr><td style="padding: 3px 8px; color: #2b8a3e;">|v₀| at γ(0)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${ptData.vLen.toFixed(2)}</td></tr>
          <tr><td style="padding: 3px 8px; color: #2b8a3e; font-weight: bold;">|V(t)| transported</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${ptData.curNorm.toFixed(4)}</td></tr>
          <tr><td colspan="2"><hr style="margin: 4px 0;"></td></tr>
          <tr><td style="padding: 3px 8px;">∠(V(t), γ̇(t))</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => { return ptData.angleVelDeg === null ? "—" : ptData.angleVelDeg.toFixed(1)+"°"; })()}</td></tr>
          ${(() => {
            if (ptData.rotVsInitDeg === null) return "";
            var suffix = (pt_t >= 99.5 && ptData.closed) ? " (holonomy)" : "";
            return '<tr><td style="padding: 3px 8px; color: #e8590c; font-weight: bold;">Rotation vs v₀' + suffix + '</td>' +
              '<td style="padding: 3px 8px; text-align: right; font-family: monospace;">' + ptData.rotVsInitDeg.toFixed(1) + '°</td></tr>';
          })()}
        </table>
      </div>
    </div>
  </div>
</div>
`
return attachS2DragRotation(view, ".pt-s2-globe", "Transport view azimuth", "Transport view elevation", -50, 50, ptData, "pt");
})()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
(o)
(p)
Figure 5: Interactive: Parallel transport of a tangent vector along a curve on the 2-sphere
TipKey concepts visualized
  • Metric compatibility: \(|V(t)|\) stays equal to \(|v_0|\) — parallel transport preserves lengths (and, for several vectors transported together, their mutual angles).
  • Geodesics: along the equator, the meridian, and each edge of the triangle, the velocity \(\dot\gamma\) is itself parallel, so \(\angle\bigl(V(t), \dot\gamma(t)\bigr)\) stays constant along the curve.
  • Holonomy = curvature: for the geodesic triangle at \(t = 100\%\), \(V\) returns rotated by exactly the enclosed area \(\pi/2 = 90^\circ\) (the unit sphere has \(K = +1\)); for the latitude circle at \(\theta_0 = 45^\circ\) the rotation equals the enclosed solid angle \(2\pi(1 - \cos\theta_0) \approx 105.4^\circ\).
  • Drag the sphere to view the transport from different angles.

The notion of a geodesic exists in two guises — one differential-geometric, one metric — and understanding their relationship and differences is important.

Riemannian (connection-based) definition. A smooth curve \(\gamma\) is a geodesic if it satisfies \(\nabla_{\dot\gamma}\dot\gamma = 0\), i.e., its velocity is covariantly constant. This is a second-order differential condition that guarantees constant speed (\(\frac{d}{dt}\|\dot\gamma\|^2 = 0\)) and, via the first variation formula, local length-minimization. The exponential map \(\exp_p(v) = \gamma_v(1)\) is built from this definition.

Metric-space definition. In a general metric space \((\mathcal{M},d)\), a curve \(\gamma:I\to\mathcal M\) is a constant-speed local geodesic if there is a constant \(c\ge0\) such that every \(t\in I\) has a neighborhood on which

\[ d\bigl(\gamma(s),\gamma(s')\bigr)=c|s-s'|. \]

This is a metric condition—no derivatives or connection are required—and it applies to nonsmooth spaces such as Wasserstein and \(\operatorname{CAT}(\kappa)\) spaces. A globally minimizing geodesic segment satisfies the same equality for every pair \(s,s'\in I\). A locally geodesic curve need not minimize between distant endpoints.

How they relate.

Aspect Riemannian geodesic (\(\nabla_{\dot\gamma}\dot\gamma=0\)) Metric geodesic (local length-minimizer)
Requires Smooth manifold + connection Only a metric \(d\)
Form Second-order ODE Metric/variational condition
Constant speed Automatic By parameterization convention
Local minimizer Yes Yes (definition)
Uniqueness from point + initial velocity Unique maximal solution for a smooth connection No velocity datum is intrinsic; endpoint geodesics need not be unique
Existence between two points Connected complete manifolds (Hopf–Rinow) Geodesic metric spaces

Local equivalence on Riemannian manifolds. On a Riemannian manifold \((\mathcal{M}, g)\) with the Levi–Civita connection, the two definitions coincide: every solution to \(\nabla_{\dot\gamma}\dot\gamma = 0\) is locally length-minimizing, and every locally length-minimizing curve parameterized proportionally to arc length is a solution to \(\nabla_{\dot\gamma}\dot\gamma = 0\). However, globally, a geodesic on manifolds may fail to minimize length between its endpoints (e.g., a great circle on the sphere).

Theorem 2 On a connected Riemannian manifold, the following are equivalent: (i) metric completeness, (ii) geodesic completeness, (iii) for every \(p\), \(\exp_p\) is defined on all of \(T_p\mathcal{M}\), and (iv) every closed and bounded subset is compact. Any of these implies that any two points can be joined by a minimizing geodesic (Lee 2018).

5.4 Curvature

The Riemann curvature tensor is

\[ R(U, V)W = \nabla_U\nabla_V W - \nabla_V\nabla_U W - \nabla_{[U,V]}W. \]

For linearly independent \(u, v \in T_p\mathcal{M}\), the sectional curvature of \(\Pi = \operatorname{span}\{u, v\}\) is

\[ \mathfrak{K}_p(u, v) = \frac{g_p(R(u, v)v, u)}{g_p(u, u)g_p(v, v) - g_p(u, v)^2}. \]

With this convention, the unit sphere has sectional curvature \(+1\), Euclidean space has curvature \(0\), and unit hyperbolic space has curvature \(-1\).

Theorem 3  

  • Sectional curvature \(\le \kappa\) is equivalent to being locally \(\mathrm{CAT}(\kappa)\).
  • Sectional curvature \(\ge \kappa\) is equivalent to having local Alexandrov curvature bounded below by \(\kappa\).
  • Complete, simply connected, sectional curvature \(\le 0\) implies global \(\mathrm{CAT}(0)\) (a Hadamard manifold).

A Hadamard manifold is complete, simply connected, with nonpositive sectional curvature. On such a manifold, any two points have a unique minimizing geodesic and \(x\mapsto d^2(x,y)\) is strongly geodesically convex. Consequently, every probability distribution with finite second moment has a unique Fréchet mean (Bridson and Haefliger 1999; Sturm 2003).

6 Exponential and Logarithmic Maps

Definition 11 For \(p\in\mathcal M\) and \(v\in T_p\mathcal M\), let \(\gamma_v\) be the maximal geodesic with \(\gamma_v(0)=p\) and \(\dot\gamma_v(0)=v\). Whenever \(\gamma_v\) is defined at time \(1\), the exponential map is \(\exp_p(v)=\gamma_v(1)\). Thus \(\exp_p\) is defined on an open subset of \(T_p\mathcal M\) containing \(0\); if \(\mathcal M\) is geodesically complete, its domain is all of \(T_p\mathcal M\). On a normal neighborhood of \(p\), the local inverse of \(\exp_p\) is the logarithmic map \(\log_p\).

Assume \(\mathcal{M}\) is complete. Along a unit-speed geodesic from \(p\), the cut time is the supremum of the times up to which the segment starting at \(p\) remains minimizing; when finite, the endpoint at that time is a cut point. The union of all such endpoints is \(\operatorname{Cut}(p)\). There is an open, star-shaped injectivity domain \(\mathcal{I}_p\subset T_p\mathcal{M}\) containing \(0\) such that

\[ \exp_p\big|_{\mathcal{I}_p}:\mathcal{I}_p\longrightarrow \mathcal{M}\setminus\operatorname{Cut}(p) \]

is a diffeomorphism. Its inverse is the single-valued map \(\log_p\) on \(\mathcal{M}\setminus\operatorname{Cut}(p)\). On the unit sphere, \(\operatorname{Cut}(p)=\{-p\}\).

NoteExample: \(\exp\) and \(\log\) in Euclidean space

Let \(\mathcal{M} = \mathbb{R}^d\) with the standard Euclidean metric \(g_p(u,v) = u^\top v\). Geodesics are straight lines \(\gamma(t) = p + tv\), so the exponential map is simply addition:

\[ \exp_p(v) = \gamma_v(1) = p + v, \qquad v \in T_p\mathbb{R}^d \cong \mathbb{R}^d. \]

The logarithmic map is the difference vector:

\[ \log_p(q) = q - p, \qquad q \in \mathbb{R}^d. \]

Because geodesics are globally minimizing, the cut locus is empty (\(\operatorname{Cut}(p) = \varnothing\)), and \(\exp_p\), \(\log_p\) are global diffeomorphisms — a stark contrast to curved manifolds like \(\mathbb{S}^2\), where the cut locus forces these maps to be only local inverses. This Euclidean baseline is what general Riemannian geometry reduces to when curvature vanishes.

Interactive Exploration: Exponential and Logarithmic Maps on \(\mathbb{S}^2\). The exponential map \(\exp_p(v)\) sends a tangent vector \(v \in T_p\mathcal{M}\) to the point reached by following the geodesic with initial velocity \(v\) for unit time. The logarithmic map \(\log_p(q)\) is its local inverse: it returns the tangent vector at \(p\) whose geodesic reaches \(q\). This demo visualizes both maps simultaneously, along with the fundamental identity \(\exp_p(\log_p(q)) = q\).

Visual guide: - Red dot = base point \(p\) - Green arrow = tangent vector \(v\) (exp map input) - Purple dot = \(\exp_p(v)\) (exp map output) - Blue dot = target point \(q\) (log map input) - Orange arrow = \(\log_p(q)\) (log map output) - Dashed curves = geodesics from \(p\) - Orange plane = tangent space \(T_p\mathbb{S}^2\)

The tangent arrows are rescaled when necessary so they remain inside the display; the tables report their true norms.

Code
el_th_control = Inputs.range([1, 179], {step: 1, value: 35, label: "Base point θ (colatitude °)"})
el_ph_control = Inputs.range([0, 360], {step: 5, value: 70, label: "Base point φ (longitude °)"})
el_vang_control = Inputs.range([0, 360], {step: 10, value: 60, label: "Tangent direction (exp input) °"})
el_vlen_control = Inputs.range([0.2, 4.2], {step: 0.1, value: 1.6, label: "Tangent vector |v| (exp input)"})
el_qth_control = Inputs.range([0, 180], {step: 1, value: 110, label: "Target q θ (log input) °"})
el_qph_control = Inputs.range([0, 360], {step: 5, value: 200, label: "Target q φ (log input) °"})
el_rotY_control = Inputs.range([0, 360], {step: 10, value: 200, label: "Exp/log view azimuth °"})
el_rotX_control = Inputs.range([-50, 50], {step: 5, value: 20, label: "Exp/log view elevation °"})
el_th = Generators.input(el_th_control)
el_ph = Generators.input(el_ph_control)
el_vang = Generators.input(el_vang_control)
el_vlen = Generators.input(el_vlen_control)
el_qth = Generators.input(el_qth_control)
el_qph = Generators.input(el_qph_control)
el_rotY = Generators.input(el_rotY_control)
el_rotX = Generators.input(el_rotX_control)

el_controls_view = html`
<style>
  .el-slider-grid { display:flex; flex-wrap:wrap; gap:6px 20px; width:100%; margin:0 0 12px; font:0.85em system-ui,sans-serif; container-type:inline-size; }
  .el-slider-grid > * { flex:1 1 calc((100% - 40px)/3); min-width:0; margin:0; }
  .el-slider-grid input[type="number"] { width:7.5rem !important; }
  .explog-s2-globe { display:block; width:100%; height:auto; }
  @container (max-width:700px) { .el-slider-grid > * { flex-basis:calc((100% - 20px)/2); } }
  @container (max-width:480px) { .el-slider-grid > * { flex-basis:100%; } }
</style>
<div class="el-slider-grid">
  <div>${el_th_control}</div>
  <div>${el_ph_control}</div>
  <div>${el_vang_control}</div>
  <div>${el_vlen_control}</div>
  <div>${el_qth_control}</div>
  <div>${el_qph_control}</div>
  <div>${el_rotY_control}</div>
  <div>${el_rotX_control}</div>
</div>`

function runExpLogDemo(pThDeg, pPhDeg, vAngDeg, vLen, qThDeg, qPhDeg, rotYdeg, rotXdeg) {
  var pTh = pThDeg * Math.PI / 180;
  var pPh = pPhDeg * Math.PI / 180;
  var vAng = vAngDeg * Math.PI / 180;
  var qTh = qThDeg * Math.PI / 180;
  var qPh = qPhDeg * Math.PI / 180;

  // Base point
  var P = [Math.sin(pTh)*Math.cos(pPh), Math.sin(pTh)*Math.sin(pPh), Math.cos(pTh)];
  // Target point q
  var Q = [Math.sin(qTh)*Math.cos(qPh), Math.sin(qTh)*Math.sin(qPh), Math.cos(qTh)];

  // Tangent basis at P
  var eTheta = [Math.cos(pTh)*Math.cos(pPh), Math.cos(pTh)*Math.sin(pPh), -Math.sin(pTh)];
  var ePhi = [-Math.sin(pPh), Math.cos(pPh), 0];

  // Tangent vector V from user input
  var V = [
    vLen * (Math.cos(vAng)*eTheta[0] + Math.sin(vAng)*ePhi[0]),
    vLen * (Math.cos(vAng)*eTheta[1] + Math.sin(vAng)*ePhi[1]),
    vLen * (Math.cos(vAng)*eTheta[2] + Math.sin(vAng)*ePhi[2])
  ];
  var Vnorm = Math.sqrt(V[0]*V[0]+V[1]*V[1]+V[2]*V[2]);

  // Helper: exp map on S²
  function expS2(base, vec) {
    var nv = Math.sqrt(vec[0]*vec[0]+vec[1]*vec[1]+vec[2]*vec[2]);
    if (nv < 1e-12) return [base[0], base[1], base[2]];
    var c = Math.cos(nv), s = Math.sin(nv);
    return [c*base[0]+s*vec[0]/nv, c*base[1]+s*vec[1]/nv, c*base[2]+s*vec[2]/nv];
  }

  // Helper: log map on S²
  function logS2(base, target) {
    var dot = base[0]*target[0]+base[1]*target[1]+base[2]*target[2];
    dot = Math.max(-1, Math.min(1, dot));
    var dist = Math.acos(dot);
    if (dist < 1e-12) return [0, 0, 0];
    if (Math.PI - dist < 1e-7) return null; // antipode: no single-valued log
    // Compute tangent direction: project target onto tangent plane
    var proj = [target[0]-dot*base[0], target[1]-dot*base[1], target[2]-dot*base[2]];
    var nProj = Math.sqrt(proj[0]*proj[0]+proj[1]*proj[1]+proj[2]*proj[2]);
    if (nProj < 1e-12) return [0, 0, 0];
    return [dist*proj[0]/nProj, dist*proj[1]/nProj, dist*proj[2]/nProj];
  }

  // exp_P(V)
  var expV = expS2(P, V);
  // log_P(Q)
  var logQ = logS2(P, Q);
  var logDefined = logQ !== null;
  var logNorm = logDefined ? Math.sqrt(logQ[0]*logQ[0]+logQ[1]*logQ[1]+logQ[2]*logQ[2]) : null;
  // exp_P(log_P(Q)) recovers Q only where the single-valued log exists
  var expLogQ = logDefined ? expS2(P, logQ) : null;
  var expDot = Math.max(-1, Math.min(1, P[0]*expV[0]+P[1]*expV[1]+P[2]*expV[2]));
  var expDistance = Math.acos(expDot);

  // Geodesic for exp (P -> expV)
  var nExpPts = 30;
  var geodExp = [];
  for (var i = 0; i <= nExpPts; i++) {
    var t = i/nExpPts;
    geodExp.push(expS2(P, [t*V[0], t*V[1], t*V[2]]));
  }
  // Geodesic for log (P -> Q)
  var nLogPts = 30;
  var geodLog = [];
  if (logDefined) {
    for (var i = 0; i <= nLogPts; i++) {
      geodLog.push(expS2(P, [i/nLogPts*logQ[0], i/nLogPts*logQ[1], i/nLogPts*logQ[2]]));
    }
  }

  // Tangent plane grid at P
  var tgGrid = [];
  var gridExt = 0.7, nGrid = 9;
  for (var i = 0; i < nGrid; i++) {
    for (var j = 0; j < nGrid; j++) {
      var a = (i/(nGrid-1) - 0.5) * 2 * gridExt;
      var b = (j/(nGrid-1) - 0.5) * 2 * gridExt;
      tgGrid.push([P[0]+a*eTheta[0]+b*ePhi[0], P[1]+a*eTheta[1]+b*ePhi[1], P[2]+a*eTheta[2]+b*ePhi[2]]);
    }
  }

  // Rotation
  var ry = rotYdeg*Math.PI/180, rx = rotXdeg*Math.PI/180;
  function rotate(v) {
    var x1 = v[0]*Math.cos(ry)+v[2]*Math.sin(ry), y1 = v[1], z1 = -v[0]*Math.sin(ry)+v[2]*Math.cos(ry);
    return [x1, y1*Math.cos(rx)-z1*Math.sin(rx), y1*Math.sin(rx)+z1*Math.cos(rx)];
  }

  return {
    P: P, Q: Q, V: V, Vnorm: Vnorm, expV: expV, expDistance: expDistance,
    logQ: logQ, logNorm: logNorm, logDefined: logDefined, expLogQ: expLogQ,
    geodExp: geodExp, geodLog: geodLog,
    tgGrid: tgGrid, nGrid: nGrid, eTheta: eTheta, ePhi: ePhi,
    rotate: rotate
  };
}

elData = runExpLogDemo(el_th, el_ph, el_vang, el_vlen, el_qth, el_qph, el_rotY, el_rotX);

(() => {
const view = html`
<div style="font-family: system-ui, sans-serif; width: 100%;">

  <div style="display: flex; gap: 20px; flex-wrap: wrap;">

    <!-- Sphere visualization -->
    <div style="flex: 1 1 0; min-width: 0;">
      <h4>exp<sub>p</sub> and log<sub>p</sub> on 𝕊²</h4>
      <svg class="explog-s2-globe" viewBox="0 0 430 430" role="img" aria-label="Exponential and logarithmic maps on the sphere" style="border: 1px solid #dee2e6; border-radius: 4px; cursor: grab; touch-action: none; user-select: none; background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%);">
        <g transform="translate(215, 215)">
          ${(() => {
            var R = 180, parts = [];
            var res = elData;

            function proj(v) {
              var rv = res.rotate(v);
              return {x: rv[0]*R, y: -rv[1]*R, z: rv[2]};
            }

            // Sphere wireframe
            for (var p = 0; p <= 18; p++) {
              var z = 1 - p*2/18, r = Math.sqrt(Math.max(0, 1 - z*z));
              var d = '';
              for (var j = 0; j <= 120; j++) {
                var phi = j*2*Math.PI/120, pt = proj([r*Math.cos(phi), r*Math.sin(phi), z]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            for (var m = 0; m < 24; m++) {
              var phi = m*Math.PI/12, d = '';
              for (var j = 0; j <= 90; j++) {
                var theta = j*Math.PI/90, pt = proj([Math.sin(theta)*Math.cos(phi), Math.sin(theta)*Math.sin(phi), Math.cos(theta)]);
                d += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+d+'" fill="none" stroke="#adb5bd" stroke-width="0.5" opacity="0.12"/>');
            }
            parts.push('<circle cx="0" cy="0" r="'+R+'" fill="none" stroke="#dee2e6" stroke-width="1.2"/>');

            // Tangent plane grid
            for (var i = 0; i < res.nGrid; i++) {
              var dRow = '';
              for (var j = 0; j < res.nGrid; j++) {
                var pt = proj(res.tgGrid[i*res.nGrid+j]);
                dRow += (j===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+dRow+'" fill="none" stroke="#f08c00" stroke-width="0.3" opacity="0.15"/>');
            }
            for (var j = 0; j < res.nGrid; j++) {
              var dCol = '';
              for (var i = 0; i < res.nGrid; i++) {
                var pt = proj(res.tgGrid[i*res.nGrid+j]);
                dCol += (i===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+dCol+'" fill="none" stroke="#f08c00" stroke-width="0.3" opacity="0.15"/>');
            }

            // Geodesic from P toward expV (exp map: purple)
            var gExpPath = '';
            for (var gi = 0; gi < res.geodExp.length; gi++) {
              var pt = proj(res.geodExp[gi]);
              gExpPath += (gi===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
            }
            parts.push('<path d="'+gExpPath+'" fill="none" stroke="#7950f2" stroke-width="2.5" stroke-dasharray="8,4"/>');

            // Minimizing geodesic from P to Q where the single-valued log exists
            if (res.logDefined) {
              var gLogPath = '';
              for (var gi = 0; gi < res.geodLog.length; gi++) {
                var pt = proj(res.geodLog[gi]);
                gLogPath += (gi===0?'M':'L')+' '+pt.x.toFixed(1)+' '+pt.y.toFixed(1);
              }
              parts.push('<path d="'+gLogPath+'" fill="none" stroke="#f08c00" stroke-width="2" stroke-dasharray="5,4"/>');
            }

            // Tangent vector V (green arrow from P)
            var pP = proj(res.P);
            var vScale = 0.55/Math.max(1, res.Vnorm);
            var vEnd = [res.P[0]+vScale*res.V[0], res.P[1]+vScale*res.V[1], res.P[2]+vScale*res.V[2]];
            var vdP = proj(vEnd);
            parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+vdP.x.toFixed(1)+'" y2="'+vdP.y.toFixed(1)+'" stroke="#2b8a3e" stroke-width="3" stroke-linecap="round"/>');
            parts.push('<text x="'+(vdP.x+5).toFixed(0)+'" y="'+(vdP.y-7).toFixed(0)+'" font-size="11" fill="#2b8a3e" font-weight="bold">v</text>');

            // Log_P(Q) vector (orange arrow from P), absent at the antipode
            if (res.logDefined) {
              var logScale = 0.55/Math.max(1, res.logNorm);
              var logQend = [res.P[0]+logScale*res.logQ[0], res.P[1]+logScale*res.logQ[1], res.P[2]+logScale*res.logQ[2]];
              var lqP = proj(logQend);
              parts.push('<line x1="'+pP.x.toFixed(1)+'" y1="'+pP.y.toFixed(1)+'" x2="'+lqP.x.toFixed(1)+'" y2="'+lqP.y.toFixed(1)+'" stroke="#f08c00" stroke-width="2.5" stroke-linecap="round"/>');
              parts.push('<text x="'+(lqP.x+5).toFixed(0)+'" y="'+(lqP.y-7).toFixed(0)+'" font-size="11" fill="#f08c00" font-weight="bold">logₚ(q)</text>');
            }

            // expV point
            var expVP = proj(res.expV);
            parts.push('<circle cx="'+expVP.x.toFixed(1)+'" cy="'+expVP.y.toFixed(1)+'" r="5" fill="#7950f2" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(expVP.x+6).toFixed(0)+'" y="'+(expVP.y-6).toFixed(0)+'" font-size="11" fill="#7950f2" font-weight="bold">expₚ(v)</text>');

            // Q point
            var qP = proj(res.Q);
            parts.push('<circle cx="'+qP.x.toFixed(1)+'" cy="'+qP.y.toFixed(1)+'" r="5" fill="#1971c2" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(qP.x+6).toFixed(0)+'" y="'+(qP.y-6).toFixed(0)+'" font-size="11" fill="#1971c2" font-weight="bold">q</text>');

            // Base point P
            parts.push('<circle cx="'+pP.x.toFixed(1)+'" cy="'+pP.y.toFixed(1)+'" r="5.5" fill="#e03131" stroke="#fff" stroke-width="1.5"/>');
            parts.push('<text x="'+(pP.x+8).toFixed(0)+'" y="'+(pP.y-8).toFixed(0)+'" font-size="12" fill="#e03131" font-weight="bold">p</text>');

            return parts.join('\n');
          })()}
        </g>
        <g transform="translate(15, 395)">
          <circle cx="0" cy="0" r="4" fill="#e03131"/><text x="8" y="4" font-size="9" fill="#495057">p (base)</text>
          <line x1="68" y1="0" x2="80" y2="0" stroke="#2b8a3e" stroke-width="2.5"/><text x="84" y="4" font-size="9" fill="#495057">v (exp input)</text>
          <circle cx="188" cy="0" r="4" fill="#7950f2"/><text x="196" y="4" font-size="9" fill="#495057">expₚ(v)</text>
          <circle cx="298" cy="0" r="4" fill="#1971c2"/><text x="306" y="4" font-size="9" fill="#495057">q (log input)</text>
          <line x1="68" y1="12" x2="80" y2="12" stroke="#f08c00" stroke-width="2"/><text x="84" y="16" font-size="9" fill="#495057">logₚ(q)</text>
        </g>
        <text x="215" y="422" text-anchor="middle" font-size="10" fill="#6c757d">Drag the sphere to rotate and tilt</text>
      </svg>
    </div>

    <!-- Right panel: stats -->
    <div style="flex: 1 1 0; min-width: 0;">
      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 12px;">
        <h4 style="margin-top: 0;">Exponential Map</h4>
        <table style="width: 100%; border-collapse: collapse; font-size: 0.85em;">
          <tr><td style="padding: 3px 8px;">v = tangent vector at p</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">|v| = ${elData.Vnorm.toFixed(3)}</td></tr>
          <tr><td style="padding: 3px 8px; color: #7950f2;">exp<sub>p</sub>(v)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">(${elData.expV[0].toFixed(3)}, ${elData.expV[1].toFixed(3)}, ${elData.expV[2].toFixed(3)})</td></tr>
          <tr><td style="padding: 3px 8px;">geodesic segment length</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${elData.Vnorm.toFixed(3)}</td></tr>
          <tr><td style="padding: 3px 8px;">d(p, exp<sub>p</sub>(v))</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${elData.expDistance.toFixed(3)}${elData.Vnorm <= Math.PI ? " = |v|" : " < |v| (wrapped)"}</td></tr>
        </table>
      </div>

      <div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 12px;">
        <h4 style="margin-top: 0;">Logarithmic Map</h4>
        <table style="width: 100%; border-collapse: collapse; font-size: 0.85em;">
          <tr><td style="padding: 3px 8px; color: #f08c00;">log<sub>p</sub>(q)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace; color:${elData.logDefined ? "#212529" : "#c92a2a"};">${elData.logDefined ? "|w| = "+elData.logNorm.toFixed(3) : "undefined at q = −p"}</td></tr>
          <tr><td style="padding: 3px 8px;">d(p, q)</td>
              <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${(() => { var dot=elData.P[0]*elData.Q[0]+elData.P[1]*elData.Q[1]+elData.P[2]*elData.Q[2]; dot=Math.max(-1,Math.min(1,dot)); return (Math.acos(dot)*180/Math.PI).toFixed(2); })()}° = ${(() => { var dot=elData.P[0]*elData.Q[0]+elData.P[1]*elData.Q[1]+elData.P[2]*elData.Q[2]; dot=Math.max(-1,Math.min(1,dot)); return Math.acos(dot).toFixed(3); })()} rad</td></tr>
        </table>
      </div>
    </div>
  </div>
</div>
`
return attachS2DragRotation(view, ".explog-s2-globe", "Exp/log view azimuth", "Exp/log view elevation", -50, 50, elData, "explog");
})()
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
(j)
(k)
(l)
(m)
(n)
(o)
(p)
(q)
(r)
(s)
(t)
Figure 6: Interactive: Exponential and logarithmic maps on the 2-sphere
Code
html`
<div class="callout callout-style-default callout-note callout-titled">
  <div class="callout-header d-flex align-content-center">
    <div class="callout-icon-container">
      <i class="callout-icon"></i>
    </div>
    <div class="callout-title-container flex-fill">
      Inverse relationship: exp∘log = id (away from the cut locus)
    </div>
  </div>
  <div class="callout-body-container callout-body">
    <table style="width: 100%; border-collapse: collapse; font-size: 0.85em; margin-top: 4px;">
      <tr><td style="padding: 3px 8px;">exp<sub>p</sub>(log<sub>p</sub>(q))</td>
          <td style="padding: 3px 8px; text-align: right; font-family: monospace;">${elData.logDefined ? "("+elData.expLogQ[0].toFixed(3)+", "+elData.expLogQ[1].toFixed(3)+", "+elData.expLogQ[2].toFixed(3)+")" : "undefined"}</td></tr>
      <tr><td style="padding: 3px 8px;">Q (original target)</td>
          <td style="padding: 3px 8px; text-align: right; font-family: monospace;">(${elData.Q[0].toFixed(3)}, ${elData.Q[1].toFixed(3)}, ${elData.Q[2].toFixed(3)})</td></tr>
      <tr><td style="padding: 3px 8px; font-weight: bold;">‖exp<sub>p</sub>(log<sub>p</sub>(q)) − q‖</td>
          <td style="padding: 3px 8px; text-align: right; font-family: monospace; font-weight: bold;">${(() => { if (!elData.logDefined) return "undefined"; var d0=elData.expLogQ[0]-elData.Q[0], d1=elData.expLogQ[1]-elData.Q[1], d2=elData.expLogQ[2]-elData.Q[2]; return Math.sqrt(d0*d0+d1*d1+d2*d2).toExponential(2); })()}</td></tr>
    </table>
    <p style="margin: 8px 0 0 0; font-size: 0.85em; color:${elData.logDefined ? "#495057" : "#c92a2a"};">
      ${elData.logDefined
        ? "The near-zero residual confirms expₚ(logₚ(q)) = q away from the cut locus. This identity underlies gradₓ d²(x,y) = −2 logₓ(y)."
        : "Here q = −p is the cut point. Infinitely many length-π tangent vectors exponentiate to q, so no single-valued logₚ(q) exists and the inverse expression is undefined."}
    </p>
  </div>
</div>
`
TipTry these experiments
  • Move q to coincide with \(\exp_p(v)\): When \(|v|<\pi\), set q’s coordinates to match \(\exp_p(v)\) and observe that \(\log_p(q)\) recovers \(v\). For \(|v|>\pi\), the displayed log is instead the shorter tangent vector to the same endpoint.
  • Move q to the antipodal point of p: Set \(\theta_q = 180° - \theta_p\), \(\phi_q = \phi_p + 180°\). This is the cut locus of \(p\) on \(\mathbb{S}^2\), where \(\log_p(q)\) is undefined (any direction gives the same distance \(\pi\)).
  • Vary the tangent vector length: As \(|v|\) approaches \(\pi\), \(\exp_p(v)\) approaches the antipodal point. For \(|v| > \pi\), the geodesic segment wraps around and is no longer minimizing; compare its length with the intrinsic distance in the table.
  • Compare exp and log directions: The green arrow (exp input) and orange arrow (log output) trace different geodesics — one toward \(\exp_p(v)\) and one toward \(q\). When they align, \(\exp_p(v)\) and \(q\) lie on the same geodesic ray from \(p\).

7 Key Takeaways

  • Smooth manifolds are spaces locally modeled on \(\mathbb{R}^m\) with smooth coordinate transitions. An atlas is a collection of charts covering the manifold; stereographic projections from the north and south poles form an atlas for \(\mathbb{S}^2\).
  • The tangent space \(T_p\mathcal{M}\) is a vector space of dimension \(\dim\mathcal{M}\) attached to each point \(p\), with a basis induced by any coordinate chart. Tangent vectors are velocities of curves through \(p\).
  • Riemannian metrics endow each tangent space with an inner product, defining length, distance, and geodesics.
  • The exponential map \(\exp_p(v)\) sends tangent vectors to manifold points along geodesics; its local inverse is the logarithmic map \(\log_p(q)\). On \(\mathbb{S}^2\), \(\exp_p(v) = \cos(|v|)p + \sin(|v|)v/|v|\).
  • Sectional curvature controls geodesic comparison: nonpositive curvature (Hadamard) guarantees convexity and unique Fréchet means; positive curvature (spheres) can produce multiple means.
  • The SPD cone with affine-invariant or log-Euclidean metric and the sphere are two recurring Riemannian manifolds in data analysis.

8 Exercises

  1. Stereographic transition. Compute the transition map between the north-pole and south-pole stereographic charts on \(\mathbb{S}^2\) and verify it is smooth. 📝 Show Solution

  2. SPD exponential. Verify that \(\mathrm{Exp}^{\mathrm{AI}}_\Sigma(U) = \Sigma^{1/2}\exp(\Sigma^{-1/2}U\Sigma^{-1/2})\Sigma^{1/2}\) satisfies \(\mathrm{Exp}^{\mathrm{AI}}_\Sigma(0) = \Sigma\) and \(\frac{d}{dt}|_{t=0}\mathrm{Exp}^{\mathrm{AI}}_\Sigma(tU) = U\). 📝 Show Solution

  3. Gradient formula on the circle. For \(\mathbb{S}^1\) with the arc-length metric, compute \(\nabla d^2(\theta, \phi)\) explicitly and verify \(\nabla d^2(\theta,\phi) = -2\log_\theta(\phi)\). 📝 Show Solution

  4. Hadamard vs. sphere. Explain why a probability distribution with finite second moment has a unique Fréchet mean on a Hadamard manifold but may have multiple means on a sphere. Give a concrete counterexample on \(\mathbb{S}^2\). 📝 Show Solution

Exercise 1

Exercise: Compute the stereographic transition map and verify smoothness.
Solution: North pole chart: \(\phi_N(x_1,x_2,x_3) = (\frac{x_1}{1-x_3}, \frac{x_2}{1-x_3})\) for \(x_3 \neq 1\). South pole chart: \(\phi_S(x_1,x_2,x_3) = (\frac{x_1}{1+x_3}, \frac{x_2}{1+x_3})\) for \(x_3 \neq -1\). For a point with coordinates \((u,v) = \phi_N(x)\), we have \(x = (\frac{2u}{1+u^2+v^2}, \frac{2v}{1+u^2+v^2}, \frac{u^2+v^2-1}{1+u^2+v^2})\). Then \(\phi_S \circ \phi_N^{-1}(u,v) = (\frac{u}{u^2+v^2}, \frac{v}{u^2+v^2})\), which is smooth on \(\mathbb{R}^2 \setminus \{(0,0)\}\) — the overlap of the two charts.

Exercise 2

Exercise: Verify the affine-invariant exponential map properties.
Solution: At \(t=0\): \(\mathrm{Exp}^{\mathrm{AI}}_\Sigma(0) = \Sigma^{1/2}\exp(0)\Sigma^{1/2} = \Sigma^{1/2}I\Sigma^{1/2} = \Sigma\). For the derivative: let \(A(t) = \Sigma^{-1/2}(tU)\Sigma^{-1/2} = t\Sigma^{-1/2}U\Sigma^{-1/2}\). Then \(\frac{d}{dt}|_{t=0}\exp(A(t)) = \Sigma^{-1/2}U\Sigma^{-1/2}\). By the product rule, \(\frac{d}{dt}|_{t=0}\mathrm{Exp}^{\mathrm{AI}}_\Sigma(tU) = \Sigma^{1/2}(\Sigma^{-1/2}U\Sigma^{-1/2})\Sigma^{1/2} = U\).

Exercise 3

Exercise: Compute \(\nabla d^2(\theta,\phi)\) on \(\mathbb{S}^1\).
Solution: On \(\mathbb{S}^1\) with arc-length distance \(d(\theta,\phi) = \min\{|\theta-\phi|, 2\pi-|\theta-\phi|\}\). For a fixed \(\phi\), \(f(\theta) = d^2(\theta,\phi)\). When \(\phi \notin \operatorname{Cut}(\theta)\) (i.e., \(\phi \neq \theta+\pi\) mod \(2\pi\)), the signed log is \(\log_\theta(\phi) = (\phi-\theta)\) reduced to \((-\pi,\pi)\). Then \(f'(\theta) = -2(\phi-\theta) = -2\log_\theta(\phi)\). Since the metric on \(\mathbb{S}^1\) is \(g_\theta = 1\) (unit speed parameterization), \(\nabla f(\theta) = g^{\theta\theta} f'(\theta) = -2\log_\theta(\phi)\), confirming the general formula.

Exercise 4

Exercise: Explain uniqueness of Fréchet means on Hadamard vs. sphere.
Solution: Hadamard manifolds: The CAT(0) inequality makes \(d^2(\cdot,y)\) strongly convex along geodesics. Thus, for a probability distribution with finite second moment, the Fréchet function \(F(x)=\mathbb{E}d^2(x,X)\) has a unique global minimizer. Sphere counterexample: Put equal point masses at the north and south poles of the unit sphere. At colatitude \(\theta\), \(F(\theta)=\tfrac12\{\theta^2+(\pi-\theta)^2\}\), which is minimized exactly when \(\theta=\pi/2\). Hence every point on the equator is a Fréchet mean. Afsari’s sufficient uniqueness condition is that the support lie in a ball of radius \(r<\tfrac12\min\{\operatorname{inj}(\mathcal M),\pi/\sqrt{\kappa}\}\) when \(\kappa>0\) bounds sectional curvature above; on the unit sphere this becomes \(r<\pi/2\) (Afsari 2011).

9 Further Reading

  • Lee (2018) — Comprehensive introduction to Riemannian manifolds.
  • Bridson and Haefliger (1999) — Metric spaces of non-positive curvature (CAT(0) spaces).
  • Pennec et al. (2006); Moakher (2005) — Affine-invariant geometry of SPD matrices.
  • Arsigny et al. (2007) — Log-Euclidean metrics for SPD matrices.
  • Krioukov et al. (2010); Nickel and Kiela (2017) — Hyperbolic spaces for hierarchical data.

10 Self-Assessment Quiz

Test your understanding of this lecture with the interactive MCQ quiz:

👉 Lecture 15 Quiz — 10 Multiple-Choice Questions

References

Afsari, Bijan. 2011. “Riemannian \(L^p\) Center of Mass: Existence, Uniqueness, and Convexity.” Proceedings of the American Mathematical Society 139 (2): 655–73. https://doi.org/10.1090/S0002-9939-2010-10541-5.
Arsigny, V., P. Fillard, X. Pennec, and N. Ayache. 2007. “Geometric Means in a Novel Vector Space Structure on Symmetric Positive-Definite Matrices.” SIAM Journal on Matrix Analysis and Applications 29 (1): 328–47. https://doi.org/10.1137/050637996.
Bridson, Martin R., and André Haefliger. 1999. Metric Spaces of Non-Positive Curvature. Vol. 319. Grundlehren Der Mathematischen Wissenschaften. Springer.
Krioukov, Dmitri, Fragkiskos Papadopoulos, Maksim Kitsak, Amin Vahdat, and Marián Boguñá. 2010. “Hyperbolic Geometry of Complex Networks.” Physical Review E 82 (3): 036106. https://doi.org/10.1103/PhysRevE.82.036106.
Lee, John M. 2018. Introduction to Riemannian Manifolds. 2nd ed. Vol. 176. Graduate Texts in Mathematics. Springer. https://doi.org/10.1007/978-3-319-91755-9.
Moakher, M. 2005. “A Differential Geometric Approach to the Geometric Mean of Symmetric Positive-Definite Matrices.” SIAM Journal on Matrix Analysis and Applications 26 (3): 735–47. https://doi.org/10.1137/S0895479803436937.
Nickel, Maximilian, and Douwe Kiela. 2017. “Poincaré Embeddings for Learning Hierarchical Representations.” Advances in Neural Information Processing Systems 30, 6338–47.
Pennec, X., P. Fillard, and N. Ayache. 2006. “A Riemannian Framework for Tensor Computing.” International Journal of Computer Vision 66 (1): 41–66. https://doi.org/10.1007/s11263-005-3222-z.
Sturm, Karl-Theodor. 2003. “Probability Measures on Metric Spaces of Nonpositive Curvature.” In Heat Kernels and Analysis on Manifolds, Graphs, and Metric Spaces, vol. 338. Contemporary Mathematics. American Mathematical Society. https://doi.org/10.1090/conm/338/06080.