{"spec_id":"map-route-path","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// map-route-path: Route Path Map\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG) ------------------------------------------------\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n};\n\n// --- Data: Sierra ridge hiking-trail GPS track ------------------------------\n// Local km-scale offsets converted to lon/lat around a fixed trailhead so the\n// track sits on a real-looking small patch of the globe (~37.85N).\nconst LAT0 = 37.85;\nconst LON0 = -119.55;\nconst KM_PER_DEG_LAT = 111.0;\nconst KM_PER_DEG_LON = 111.0 * Math.cos((LAT0 * Math.PI) / 180);\n\nconst N = 96; // waypoints, within the spec's 50-1000 range\nconst rawPoints = [];\nfor (let i = 0; i < N; i++) {\n  const s = i / (N - 1);\n  // Switchback ascent profile in local km offsets (east, north of trailhead).\n  const east = 6.5 * s + 1.1 * Math.sin(s * 9.5) * (1 - 0.4 * s);\n  const north = 4.5 * s + 0.5 * Math.sin(s * 5.2 + 1);\n  rawPoints.push({ east: east + (rand() - 0.5) * 0.05, north: north + (rand() - 0.5) * 0.05 });\n}\n\n// 3-point moving-average smoothing — raw consumer GPS tracks are noisy, per\n// the spec's \"apply line smoothing for noisy GPS data\" note.\nconst points = rawPoints.map((p, i) => {\n  const prev = rawPoints[Math.max(0, i - 1)];\n  const next = rawPoints[Math.min(N - 1, i + 1)];\n  return { east: (prev.east + p.east + next.east) / 3, north: (prev.north + p.north + next.north) / 3 };\n});\n\n// Elevation profile: climbing ridge with two false summits, in meters.\nconst elevations = points.map((p, i) => {\n  const s = i / (N - 1);\n  return 2180 + 1050 * s - 90 * Math.sin(s * 9.5) + 60 * Math.sin(s * 3.1);\n});\n\n// Cumulative time + Naismith-like pace: uphill sections cost more minutes per\n// km than downhill ones, so pace naturally slows on the steep switchbacks.\nconst speeds = [null];\nlet cumMinutes = 0;\nfor (let i = 1; i < N; i++) {\n  const dEast = (points[i].east - points[i - 1].east) * 1000; // meters\n  const dNorth = (points[i].north - points[i - 1].north) * 1000;\n  const distM = Math.hypot(dEast, dNorth);\n  const climbM = elevations[i] - elevations[i - 1];\n  const speedKmh = Math.max(1.0, 4.2 - (climbM > 0 ? climbM * 0.09 : climbM * 0.03));\n  speeds.push(speedKmh);\n  cumMinutes += (distM / 1000 / speedKmh) * 60;\n}\nspeeds[0] = speeds[1];\n\nconst minSpeed = Math.min(...speeds);\nconst maxSpeed = Math.max(...speeds);\n\nconst waypoints = points.map((p, i) => ({\n  lon: LON0 + p.east / KM_PER_DEG_LON,\n  lat: LAT0 + p.north / KM_PER_DEG_LAT,\n  speed: speeds[i],\n}));\n\n// --- Color helpers -----------------------------------------------------------\nconst hexToRgb = (hex) => {\n  const h = hex.replace(\"#\", \"\");\n  return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16)];\n};\nconst [r0, g0, b0] = hexToRgb(t.seq[0]);\nconst [r1, g1, b1] = hexToRgb(t.seq[1]);\nconst paceColor = (speedKmh) => {\n  const f = (speedKmh - minSpeed) / (maxSpeed - minSpeed);\n  const r = Math.round(r0 + (r1 - r0) * f);\n  const g = Math.round(g0 + (g1 - g0) * f);\n  const b = Math.round(b0 + (b1 - b0) * f);\n  return `rgb(${r}, ${g}, ${b})`;\n};\n\nconst project = (chart, lon, lat) => ({\n  x: chart.scales.x.getPixelForValue(lon),\n  y: chart.scales.y.getPixelForValue(lat),\n});\n\n// --- Basemap: stylized terrain contours --------------------------------------\n// Chart.js has no native geo/terrain layer; nested rings around the trail's\n// high point stand in for elevation-band contour lines (a topographic-map\n// convention), giving spatial context without claiming survey accuracy.\nconst peakIdx = elevations.indexOf(Math.max(...elevations));\nconst peak = waypoints[peakIdx];\nconst CONTOUR_RINGS = [\n  { rx: 3.6, ry: 2.9 },\n  { rx: 2.6, ry: 2.1 },\n  { rx: 1.6, ry: 1.3 },\n  { rx: 0.7, ry: 0.6 },\n];\nconst RING_STEPS = 40;\n\nconst terrainPlugin = {\n  id: \"terrainContours\",\n  beforeDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.lineWidth = 1;\n    ctx.strokeStyle = t.grid;\n    CONTOUR_RINGS.forEach((ring, ringIdx) => {\n      ctx.beginPath();\n      for (let i = 0; i <= RING_STEPS; i++) {\n        const theta = (i / RING_STEPS) * Math.PI * 2;\n        const wobble = 1 + 0.06 * Math.sin(theta * 3 + ringIdx);\n        const east = (ring.rx * wobble * Math.cos(theta)) / KM_PER_DEG_LON;\n        const north = (ring.ry * wobble * Math.sin(theta)) / KM_PER_DEG_LAT;\n        const p = project(chart, peak.lon + east, peak.lat + north);\n        if (i === 0) ctx.moveTo(p.x, p.y);\n        else ctx.lineTo(p.x, p.y);\n      }\n      ctx.closePath();\n      ctx.fillStyle =\n        window.ANYPLOT_THEME === \"light\"\n          ? `rgba(26, 26, 23, ${0.03 + ringIdx * 0.02})`\n          : `rgba(240, 239, 232, ${0.03 + ringIdx * 0.02})`;\n      ctx.fill();\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Direction arrows ---------------------------------------------------------\n// A few evenly spaced heading markers along the route, per the spec's\n// \"optional direction arrows indicate travel direction\" note.\nconst ARROW_FRACTIONS = [0.18, 0.42, 0.66, 0.86];\nconst directionArrowsPlugin = {\n  id: \"directionArrows\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    ctx.save();\n    ctx.fillStyle = t.inkSoft;\n    ARROW_FRACTIONS.forEach((f) => {\n      const i = Math.round(f * (N - 1));\n      const prev = waypoints[Math.max(0, i - 2)];\n      const next = waypoints[Math.min(N - 1, i + 2)];\n      const p0 = project(chart, prev.lon, prev.lat);\n      const p1 = project(chart, next.lon, next.lat);\n      const mid = project(chart, waypoints[i].lon, waypoints[i].lat);\n      const angle = Math.atan2(p1.y - p0.y, p1.x - p0.x);\n      const size = 13;\n      ctx.save();\n      ctx.translate(mid.x, mid.y);\n      ctx.rotate(angle);\n      ctx.beginPath();\n      ctx.moveTo(size, 0);\n      ctx.lineTo(-size * 0.6, size * 0.6);\n      ctx.lineTo(-size * 0.6, -size * 0.6);\n      ctx.closePath();\n      ctx.fill();\n      ctx.restore();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Pace legend ---------------------------------------------------------------\n// Translates the sequential path color back into km/h, placed in the empty\n// lower-left corner so it never collides with the trail or the terrain rings.\nconst paceLegendPlugin = {\n  id: \"paceLegend\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.textBaseline = \"middle\";\n    ctx.font = \"13px sans-serif\";\n    const x0 = chartArea.left + 20;\n    const barW = 130;\n    const y = chartArea.bottom - 34;\n    ctx.fillStyle = t.inkSoft;\n    ctx.fillText(\"Pace (km/h)\", x0, y - 20);\n    const steps = 40;\n    for (let i = 0; i < steps; i++) {\n      const f = i / (steps - 1);\n      ctx.fillStyle = `rgb(${Math.round(r0 + (r1 - r0) * f)}, ${Math.round(g0 + (g1 - g0) * f)}, ${Math.round(b0 + (b1 - b0) * f)})`;\n      ctx.fillRect(x0 + (barW * i) / steps, y, barW / steps + 1, 8);\n    }\n    ctx.fillStyle = t.inkSoft;\n    ctx.fillText(minSpeed.toFixed(1), x0, y + 18);\n    ctx.fillText(maxSpeed.toFixed(1), x0 + barW - 14, y + 18);\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Title (scale fontsize to the rendered length, see plot-generator.md) ---\nconst title = \"Sierra Ridge Trail Pace · map-route-path · javascript · chartjs · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / title.length)));\n\n// --- Scale bounds (trail extent + padding) -----------------------------------\nconst lons = waypoints.map((w) => w.lon);\nconst lats = waypoints.map((w) => w.lat);\nconst lonPad = (Math.max(...lons) - Math.min(...lons)) * 0.22;\nconst latPad = (Math.max(...lats) - Math.min(...lats)) * 0.22;\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      {\n        label: \"GPS Track\",\n        data: waypoints.map((w) => ({ x: w.lon, y: w.lat })),\n        showLine: true,\n        fill: false,\n        borderWidth: 4,\n        pointRadius: 0,\n        tension: 0.2,\n        segment: {\n          borderColor: (ctx) => paceColor((waypoints[ctx.p0DataIndex].speed + waypoints[ctx.p1DataIndex].speed) / 2),\n        },\n      },\n      {\n        label: \"Start\",\n        data: [{ x: waypoints[0].lon, y: waypoints[0].lat }],\n        showLine: false,\n        pointStyle: \"circle\",\n        pointRadius: 11,\n        pointBackgroundColor: t.palette[0],\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 2,\n      },\n      {\n        label: \"Finish\",\n        data: [{ x: waypoints[N - 1].lon, y: waypoints[N - 1].lat }],\n        showLine: false,\n        pointStyle: \"rect\",\n        pointRadius: 10,\n        pointBackgroundColor: t.palette[4],\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 2,\n      },\n    ],\n  },\n  plugins: [terrainPlugin, directionArrowsPlugin, paceLegendPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 16 },\n    plugins: {\n      title: { display: true, text: title, color: t.ink, font: { size: titleFontSize, weight: \"500\" } },\n      legend: {\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          filter: (item) => item.text !== \"GPS Track\",\n        },\n      },\n      tooltip: { callbacks: { label: (ctx) => `${ctx.dataset.label} (${waypoints[ctx.dataIndex]?.speed.toFixed(1) ?? \"-\"} km/h)` } },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: Math.min(...lons) - lonPad,\n        max: Math.max(...lons) + lonPad,\n        title: { display: true, text: \"Longitude\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${v.toFixed(2)}°` },\n        grid: { color: t.grid },\n      },\n      y: {\n        type: \"linear\",\n        min: Math.min(...lats) - latPad,\n        max: Math.max(...lats) + latPad,\n        title: { display: true, text: \"Latitude\", color: t.ink, font: { size: 16 } },\n        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${v.toFixed(2)}°` },\n        grid: { color: t.grid },\n      },\n    },\n  },\n});\n"}