{"spec_id":"line-3d-trajectory","library":"echarts","language":"javascript","code":"// anyplot.ai\n// line-3d-trajectory: 3D Line Plot for Trajectory Visualization\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-10\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: Lorenz attractor, integrated with RK4 ----------------------------\n// Classic chaotic system (sigma, rho, beta) — a canonical example of a 3D\n// trajectory whose spatial structure (the two \"wings\") only reads correctly\n// with real depth cues, which is exactly what interactive rotation gives a\n// viewer and a flat projection has to approximate.\nconst SIGMA = 10;\nconst RHO = 28;\nconst BETA = 8 / 3;\nconst DT = 0.008;\nconst STEPS = 6000;\n\nfunction lorenzDerivative(x, y, z) {\n  return [SIGMA * (y - x), x * (RHO - z) - y, x * y - BETA * z];\n}\n\nfunction rk4Step(x, y, z) {\n  const [k1x, k1y, k1z] = lorenzDerivative(x, y, z);\n  const [k2x, k2y, k2z] = lorenzDerivative(\n    x + (DT / 2) * k1x,\n    y + (DT / 2) * k1y,\n    z + (DT / 2) * k1z\n  );\n  const [k3x, k3y, k3z] = lorenzDerivative(\n    x + (DT / 2) * k2x,\n    y + (DT / 2) * k2y,\n    z + (DT / 2) * k2z\n  );\n  const [k4x, k4y, k4z] = lorenzDerivative(x + DT * k3x, y + DT * k3y, z + DT * k3z);\n  return [\n    x + (DT / 6) * (k1x + 2 * k2x + 2 * k3x + k4x),\n    y + (DT / 6) * (k1y + 2 * k2y + 2 * k3y + k4y),\n    z + (DT / 6) * (k1z + 2 * k2z + 2 * k3z + k4z),\n  ];\n}\n\nconst rawPoints = [[0.1, 0, 0]];\nfor (let i = 1; i < STEPS; i += 1) {\n  const [px, py, pz] = rawPoints[i - 1];\n  rawPoints.push(rk4Step(px, py, pz));\n}\n// Downsample to a smooth-but-lighter point count (spec calls for 100-2000).\nconst DOWNSAMPLE = 4;\nconst points = rawPoints.filter((_, i) => i % DOWNSAMPLE === 0);\nconst N = points.length;\n\n// --- Camera: orthographic axonometric projection (elevation + azimuth) -----\n// -52/22 keeps the X/Y axis frame corner (xMin, CORNER_X) clear of the\n// trajectory's own coils — steeper azimuths (tried up to -75) square the\n// projected bounding box slightly better but swing that corner into the\n// data. Combined with fitting only to the tick tips (not the old, far larger\n// label-anchor points — see below), this angle already yields a\n// near-square box (~1.11 aspect), filling the canvas well under one\n// isotropic scale without risking a tick landing inside the data.\nconst INITIAL_AZIMUTH = (-52 * Math.PI) / 180;\nconst INITIAL_ELEVATION = (22 * Math.PI) / 180;\n\n// Data extents and normalization are camera-independent — computed once.\nconst xs = points.map((p) => p[0]);\nconst ys = points.map((p) => p[1]);\nconst zs = points.map((p) => p[2]);\nconst xMin = Math.min(...xs);\nconst xMax = Math.max(...xs);\nconst yMin = Math.min(...ys);\nconst yMax = Math.max(...ys);\nconst zMin = Math.min(...zs);\nconst zMax = Math.max(...zs);\nconst xCenter = (xMin + xMax) / 2;\nconst yCenter = (yMin + yMax) / 2;\nconst zCenter = (zMin + zMax) / 2;\nconst maxHalfRange = Math.max((xMax - xMin) / 2, (yMax - yMin) / 2, (zMax - zMin) / 2);\n\n// X/Y axis frame corner (data-only, camera-independent): X and Y axes meet\n// at the box edge farthest from the origin on the screen so their ticks stay\n// clear of the trajectory.\nconst CORNER_X = yMax;\n\n// Axis tick spacing (data-only, camera-independent).\nconst xTick = (xMax - xMin) * 0.35;\nconst yTick = (yMax - yMin) * 0.35;\nconst zTick = (zMax - zMin) * 0.35;\n\n// --- Trajectory color: continuous time takes the Imprint sequential ramp ---\nfunction lerpColor(hexA, hexB, frac) {\n  const a = [1, 3, 5].map((i) => parseInt(hexA.slice(i, i + 2), 16));\n  const b = [1, 3, 5].map((i) => parseInt(hexB.slice(i, i + 2), 16));\n  const c = a.map((v, i) => Math.round(v + (b[i] - v) * frac));\n  return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;\n}\nconst segmentColors = [];\nfor (let i = 0; i < N - 1; i += 1) {\n  segmentColors.push(lerpColor(t.seq[0], t.seq[1], i / (N - 2)));\n}\n\n// --- Time-progression color key (imprint_seq: t=0 -> t=STEPS*DT) -----------\n// Bottom-left, clear of the X/Y/Z axis frame which sits toward the right.\n// Pixel-space only, so it is identical for every camera angle.\n// Margins only need to cover the small fixed-pixel tick-label/axis-title\n// offsets beyond the fitted trajectory+frame box (see TICK_LABEL_OFFSET_PX\n// below) plus glyph size — not a large data-space allowance — so the box\n// itself can fill most of the canvas.\nconst TOP_MARGIN = 110;\nconst SIDE_MARGIN = 110;\nconst BOTTOM_MARGIN = 130;\nconst KEY_X = SIDE_MARGIN;\nconst KEY_Y = size.height - 56;\nconst KEY_W = 200;\nconst legendElements = [\n  {\n    type: \"text\",\n    style: { text: \"time\", x: KEY_X, y: KEY_Y - 20, fill: t.inkSoft, fontSize: 13, align: \"left\" },\n    silent: true,\n  },\n  {\n    type: \"rect\",\n    shape: { x: KEY_X, y: KEY_Y, width: KEY_W, height: 10 },\n    style: {\n      fill: {\n        type: \"linear\",\n        x: 0,\n        y: 0,\n        x2: 1,\n        y2: 0,\n        colorStops: [\n          { offset: 0, color: t.seq[0] },\n          { offset: 1, color: t.seq[1] },\n        ],\n      },\n    },\n    silent: true,\n  },\n  {\n    type: \"text\",\n    style: { text: \"t=0\", x: KEY_X, y: KEY_Y + 24, fill: t.inkSoft, fontSize: 12, align: \"left\" },\n    silent: true,\n  },\n  {\n    type: \"text\",\n    style: {\n      text: `t=${(STEPS * DT).toFixed(0)}`,\n      x: KEY_X + KEY_W,\n      y: KEY_Y + 24,\n      fill: t.inkSoft,\n      fontSize: 12,\n      align: \"right\",\n    },\n    silent: true,\n  },\n];\n\n// --- Build the full scene (trajectory + axis frame) for a given camera -----\n// Rebuilt on every drag-rotate frame, not just once, so the fit and the\n// Z-axis tick placement both stay correct as the viewer rotates the scene.\nfunction buildOption(azimuth, elevation) {\n  const sinAz = Math.sin(azimuth);\n  const cosAz = Math.cos(azimuth);\n  const sinEl = Math.sin(elevation);\n  const cosEl = Math.cos(elevation);\n\n  function projectRaw(x, y, z) {\n    const xn = (x - xCenter) / maxHalfRange;\n    const yn = (y - yCenter) / maxHalfRange;\n    const zn = (z - zCenter) / maxHalfRange;\n    const screenX = -xn * sinAz + yn * cosAz;\n    const screenY = -xn * cosAz * sinEl - yn * sinAz * sinEl + zn * cosEl;\n    const depth = xn * cosEl * cosAz + yn * cosEl * sinAz + zn * sinEl;\n    return { screenX, screenY, depth };\n  }\n\n  // Pick the Z-axis screen corner dynamically: of the 3 candidate (x, y)\n  // corners not already occupied by the X/Y axis frame (which meets at\n  // (xMax, CORNER_X)), use whichever projects farthest from the trajectory's\n  // own projected screen centroid, so its ticks/labels never land inside the\n  // data. A fixed corner assumption doesn't hold across all camera angles.\n  let centroidX = 0;\n  let centroidY = 0;\n  for (let i = 0; i < N; i += 1) {\n    const s = projectRaw(points[i][0], points[i][1], points[i][2]);\n    centroidX += s.screenX;\n    centroidY += s.screenY;\n  }\n  centroidX /= N;\n  centroidY /= N;\n  const zCornerCandidates = [\n    [xMin, yMin],\n    [xMin, yMax],\n    [xMax, yMin],\n  ];\n  let Z_CORNER_X = zCornerCandidates[0][0];\n  let Z_CORNER_Y = zCornerCandidates[0][1];\n  let bestDist = -Infinity;\n  zCornerCandidates.forEach(([cx, cy]) => {\n    const s = projectRaw(cx, cy, zMin);\n    const dist = Math.hypot(s.screenX - centroidX, s.screenY - centroidY);\n    if (dist > bestDist) {\n      bestDist = dist;\n      Z_CORNER_X = cx;\n      Z_CORNER_Y = cy;\n    }\n  });\n  // Ticks/labels step outward from the chosen corner, away from the box\n  // center, along whichever of +/-y clears the data (mirrors the fixed\n  // yMin-corner convention, generalized to whichever corner won above).\n  const zOutY = Z_CORNER_Y >= yCenter ? 1 : -1;\n\n  const frameCorners = [\n    [xMin, CORNER_X, zMin],\n    [xMax, CORNER_X, zMin],\n    [xMax, yMin, zMin],\n    [xMax, yMax, zMin],\n    [Z_CORNER_X, Z_CORNER_Y, zMin],\n    [Z_CORNER_X, Z_CORNER_Y, zMax],\n  ];\n\n  // Fit to the union of the trajectory's own footprint, the axis frame, and\n  // the tick marks' own outer tips (the *drawn* tick length, `xTick`/`yTick`/\n  // `zTick`) — NOT the tick-label text, which is placed afterwards as a\n  // small fixed-pixel offset (see TICK_LABEL_OFFSET_PX) rather than folded\n  // into the fit. Baking the label's text position into the fit extent made\n  // the fit (and therefore the whole scene) shrink to accommodate wherever\n  // that text projects, which for some camera angles is very far from the\n  // tick it labels — under-filling the canvas and detaching the label\n  // visually from its tick. A fixed pixel offset, applied after the fit, is\n  // camera-angle independent and keeps labels visually anchored to their tick.\n  const tickTipPoints = [\n    [xMin, CORNER_X + xTick, zMin],\n    [(xMin + xMax) / 2, CORNER_X + xTick, zMin],\n    [xMax, CORNER_X + xTick, zMin],\n    [xMax + yTick, yMin, zMin],\n    [xMax + yTick, (yMin + yMax) / 2, zMin],\n    [xMax + yTick, yMax, zMin],\n    [Z_CORNER_X, Z_CORNER_Y + zOutY * zTick, zMin],\n    [Z_CORNER_X, Z_CORNER_Y + zOutY * zTick, (zMin + zMax) / 2],\n    [Z_CORNER_X, Z_CORNER_Y + zOutY * zTick, zMax],\n  ];\n  const extentPoints = points.concat(frameCorners, tickTipPoints);\n  const extentProjected = extentPoints.map((p) => projectRaw(p[0], p[1], p[2]));\n  const sxs = extentProjected.map((c) => c.screenX);\n  const sys = extentProjected.map((c) => c.screenY);\n  const boxW = Math.max(...sxs) - Math.min(...sxs);\n  const boxH = Math.max(...sys) - Math.min(...sys);\n  const boxCx = (Math.max(...sxs) + Math.min(...sxs)) / 2;\n  const boxCy = (Math.max(...sys) + Math.min(...sys)) / 2;\n\n  const drawW = size.width - 2 * SIDE_MARGIN;\n  const drawH = size.height - TOP_MARGIN - BOTTOM_MARGIN;\n  const PAD = 1.03; // small headroom beyond the tick tips for glyph half-width\n  // A single shared scale (not independent X/Y factors) keeps every axis'\n  // true proportions intact; INITIAL_AZIMUTH/INITIAL_ELEVATION above are\n  // chosen so the projected box is already close to square, so this one\n  // scale fills both canvas dimensions without needing to stretch either.\n  const scale = Math.min(drawW / (boxW * PAD), drawH / (boxH * PAD));\n  const originX = size.width / 2 - boxCx * scale;\n  const originY = TOP_MARGIN + drawH / 2 + boxCy * scale;\n  const TICK_LABEL_OFFSET_PX = 50;\n  const AXIS_TITLE_OFFSET_PX = 82;\n\n  function toPixel(x, y, z) {\n    const { screenX, screenY, depth } = projectRaw(x, y, z);\n    return { px: originX + screenX * scale, py: originY - screenY * scale, depth };\n  }\n\n  // Places text a small, fixed pixel distance beyond a tick's own tip,\n  // continuing in the SAME screen-space direction the tick stub itself\n  // points (base -> tip). That direction is always \"away from the axis\n  // line,\" regardless of camera angle — unlike offsetting from the box's\n  // overall center, which for some corners points back toward the data.\n  function outwardPixel(basePos, tipPos, offsetPx) {\n    const a = toPixel(...basePos);\n    const b = toPixel(...tipPos);\n    const dx = b.px - a.px;\n    const dy = b.py - a.py;\n    const len = Math.hypot(dx, dy) || 1;\n    return { x: b.px + (dx / len) * offsetPx, y: b.py + (dy / len) * offsetPx };\n  }\n\n  // --- Trajectory: one segment per consecutive pair, colored by elapsed time\n  const screenPoints = points.map((p) => toPixel(p[0], p[1], p[2]));\n  const segments = [];\n  for (let i = 0; i < N - 1; i += 1) {\n    const a = screenPoints[i];\n    const b = screenPoints[i + 1];\n    segments.push({\n      x1: a.px,\n      y1: a.py,\n      x2: b.px,\n      y2: b.py,\n      depth: (a.depth + b.depth) / 2,\n      color: segmentColors[i],\n    });\n  }\n  // High point density (N ~ 1500) with heavy self-overlap on a chaotic curve:\n  // thin strokes + depth-faded alpha keep the wings readable instead of a\n  // solid smear. Back-to-front draw order approximates hidden-line depth.\n  segments.sort((s1, s2) => s1.depth - s2.depth);\n  const dMin = Math.min(...segments.map((s) => s.depth));\n  const dMax = Math.max(...segments.map((s) => s.depth));\n  const trajectoryElements = segments.map((s) => {\n    const tDepth = dMax > dMin ? (s.depth - dMin) / (dMax - dMin) : 1;\n    return {\n      type: \"line\",\n      shape: { x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 },\n      style: { stroke: s.color, lineWidth: 1.7, opacity: 0.45 + 0.5 * tDepth },\n      silent: true,\n    };\n  });\n\n  // --- Axis frame: three edges of the bounding box, ticks + labels ---------\n  const AXIS_COLOR = t.inkSoft;\n  const axisElements = [];\n\n  function axisLine(p1, p2) {\n    const a = toPixel(...p1);\n    const b = toPixel(...p2);\n    axisElements.push({\n      type: \"line\",\n      shape: { x1: a.px, y1: a.py, x2: b.px, y2: b.py },\n      style: { stroke: AXIS_COLOR, lineWidth: 2 },\n      silent: true,\n    });\n  }\n\n  function tickMark(base, outward) {\n    const a = toPixel(...base);\n    const b = toPixel(...outward);\n    axisElements.push({\n      type: \"line\",\n      shape: { x1: a.px, y1: a.py, x2: b.px, y2: b.py },\n      style: { stroke: AXIS_COLOR, lineWidth: 2 },\n      silent: true,\n    });\n  }\n\n  function tickLabel(base, tip, text) {\n    const p = outwardPixel(base, tip, TICK_LABEL_OFFSET_PX);\n    axisElements.push({\n      type: \"text\",\n      style: { text, x: p.x, y: p.y, fill: t.inkSoft, fontSize: 13, align: \"center\", verticalAlign: \"middle\" },\n      silent: true,\n    });\n  }\n\n  function axisTitle(base, tip, text) {\n    const p = outwardPixel(base, tip, AXIS_TITLE_OFFSET_PX);\n    axisElements.push({\n      type: \"text\",\n      style: {\n        text,\n        x: p.x,\n        y: p.y,\n        fill: t.ink,\n        fontSize: 17,\n        fontWeight: \"bold\",\n        align: \"center\",\n        verticalAlign: \"middle\",\n      },\n      silent: true,\n    });\n  }\n\n  axisLine([xMin, CORNER_X, zMin], [xMax, CORNER_X, zMin]);\n  [xMin, (xMin + xMax) / 2, xMax].forEach((v) => {\n    const base = [v, CORNER_X, zMin];\n    const tip = [v, CORNER_X + xTick, zMin];\n    tickMark(base, tip);\n    tickLabel(base, tip, v.toFixed(0));\n  });\n  axisTitle(\n    [(xMin + xMax) / 2, CORNER_X, zMin],\n    [(xMin + xMax) / 2, CORNER_X + xTick, zMin],\n    \"X\"\n  );\n\n  axisLine([xMax, yMin, zMin], [xMax, yMax, zMin]);\n  [yMin, (yMin + yMax) / 2, yMax].forEach((v) => {\n    const base = [xMax, v, zMin];\n    const tip = [xMax + yTick, v, zMin];\n    tickMark(base, tip);\n    tickLabel(base, tip, v.toFixed(0));\n  });\n  axisTitle([xMax, (yMin + yMax) / 2, zMin], [xMax + yTick, (yMin + yMax) / 2, zMin], \"Y\");\n\n  axisLine([Z_CORNER_X, Z_CORNER_Y, zMin], [Z_CORNER_X, Z_CORNER_Y, zMax]);\n  [zMin, (zMin + zMax) / 2, zMax].forEach((v) => {\n    const base = [Z_CORNER_X, Z_CORNER_Y, v];\n    const tip = [Z_CORNER_X, Z_CORNER_Y + zOutY * zTick, v];\n    tickMark(base, tip);\n    tickLabel(base, tip, v.toFixed(0));\n  });\n  axisTitle(\n    [Z_CORNER_X, Z_CORNER_Y, zMax],\n    [Z_CORNER_X, Z_CORNER_Y + zOutY * zTick, zMax],\n    \"Z\"\n  );\n\n  return {\n    animation: false,\n    backgroundColor: \"transparent\",\n    title: {\n      text: \"line-3d-trajectory · javascript · echarts · anyplot.ai\",\n      left: \"center\",\n      top: 24,\n      textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n    },\n    graphic: { elements: [...trajectoryElements, ...axisElements, ...legendElements] },\n  };\n}\n\n// --- Init + option -----------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.setOption(buildOption(INITIAL_AZIMUTH, INITIAL_ELEVATION));\nchart.on(\"finished\", () => {\n  window.__anyplotReady = true;\n});\n\n// --- Interactive drag-to-rotate ---------------------------------------------\n// echarts-gl is unavailable, so genuine rotation is implemented by hand: drag\n// deltas update azimuth/elevation and the whole projection is rebuilt and\n// re-rendered. Only affects the HTML output — the harness screenshots before\n// any mouse event fires, so the static PNG is unaffected.\nconst container = document.getElementById(\"container\");\nlet azimuth = INITIAL_AZIMUTH;\nlet elevation = INITIAL_ELEVATION;\nlet dragging = false;\nlet lastX = 0;\nlet lastY = 0;\nlet rafPending = false;\ncontainer.style.cursor = \"grab\";\n\nfunction scheduleRender() {\n  if (rafPending) return;\n  rafPending = true;\n  requestAnimationFrame(() => {\n    rafPending = false;\n    chart.setOption(buildOption(azimuth, elevation), { notMerge: true });\n  });\n}\n\ncontainer.addEventListener(\"mousedown\", (e) => {\n  dragging = true;\n  lastX = e.clientX;\n  lastY = e.clientY;\n  container.style.cursor = \"grabbing\";\n});\nwindow.addEventListener(\"mousemove\", (e) => {\n  if (!dragging) return;\n  const dx = e.clientX - lastX;\n  const dy = e.clientY - lastY;\n  lastX = e.clientX;\n  lastY = e.clientY;\n  azimuth += dx * 0.006;\n  elevation = Math.max(-1.45, Math.min(1.45, elevation - dy * 0.006));\n  scheduleRender();\n});\nwindow.addEventListener(\"mouseup\", () => {\n  if (!dragging) return;\n  dragging = false;\n  container.style.cursor = \"grab\";\n});\n"}