{"spec_id":"horizon-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// horizon-basic: Horizon Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-20\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic mean-reverting random walk) ------------\nfunction lcg(seed) {\n  let state = seed;\n  return function next() {\n    state = (state * 1664525 + 1013904223) % 4294967296;\n    return state / 4294967296;\n  };\n}\n\nconst stations = [\n  \"Berlin\", \"Madrid\", \"Oslo\", \"Cairo\", \"Tokyo\",\n  \"Nairobi\", \"Lima\", \"Perth\", \"Toronto\", \"Mumbai\",\n];\nconst numPoints = 120;\nconst startDate = new Date(2025, 5, 1);\nconst dates = Array.from({ length: numPoints }, (_, i) => new Date(startDate.getTime() + i * 86400000));\n\nconst series = stations.map((name, si) => {\n  const rand = lcg(1000 + si * 37);\n  let anomaly = 0;\n  const values = dates.map(() => {\n    const noise = (rand() - 0.5) * 1.6;\n    anomaly = anomaly * 0.9 + noise;\n    return anomaly;\n  });\n  return { name, values };\n});\n\nconst maxAbs = d3.max(series, (s) => d3.max(s.values, (v) => Math.abs(v)));\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nconst margin = { top: 110, right: 50, bottom: 60, left: 150 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst laneGap = 6;\nconst laneHeight = (ih - laneGap * (series.length - 1)) / series.length;\nconst bands = 3;\n\nconst x = d3.scaleTime().domain(d3.extent(dates)).range([0, iw]);\n// magnitude -> pixel distance folded across `bands` lanes of height laneHeight\nconst yBand = d3.scaleLinear().domain([0, maxAbs]).range([0, bands * laneHeight]);\n\n// Temperature anomaly is signed/diverging continuous data, so it must use the\n// Imprint diverging colormap (t.div = [red, theme-adaptive midpoint, blue]),\n// not the categorical palette — and red maps to warm (positive), blue to cold\n// (negative), matching the standard NOAA/IPCC temperature-anomaly convention.\nconst positiveColor = t.div[0]; // \"#AE3030\" matte red — warm anomaly\nconst negativeColor = t.div[2]; // \"#4467A3\" blue — cold anomaly\n\nconst root = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Horizon lanes -----------------------------------------------------------\nconst areaFor = (accessor) =>\n  d3.area()\n    .x((d) => x(d.date))\n    .y0(laneHeight)\n    .y1((d) => laneHeight - yBand(accessor(d)));\n\nseries.forEach((s, si) => {\n  const rows = dates.map((date, di) => ({ date, value: s.values[di] }));\n  const lane = root.append(\"g\").attr(\"transform\", `translate(0,${si * (laneHeight + laneGap)})`);\n\n  lane.append(\"clipPath\")\n    .attr(\"id\", `horizon-clip-${si}`)\n    .append(\"rect\")\n    .attr(\"width\", iw)\n    .attr(\"height\", laneHeight);\n\n  // Fold the (band-shifted) path inside a clipped group whose own frame stays\n  // fixed to the lane window — the clip must NOT inherit the fold transform,\n  // otherwise the visible window would slide along with the data and nothing\n  // would ever be cropped into bands.\n  const clippedLane = lane.append(\"g\").attr(\"clip-path\", `url(#horizon-clip-${si})`);\n\n  const positiveArea = areaFor((d) => Math.max(d.value, 0));\n  const negativeArea = areaFor((d) => Math.max(-d.value, 0));\n\n  for (let i = 0; i < bands; i++) {\n    const opacity = (i + 1) / bands;\n    clippedLane.append(\"path\")\n      .attr(\"transform\", `translate(0,${i * laneHeight})`)\n      .datum(rows)\n      .attr(\"d\", positiveArea)\n      .attr(\"fill\", positiveColor)\n      .attr(\"fill-opacity\", opacity);\n\n    clippedLane.append(\"path\")\n      .attr(\"transform\", `translate(0,${i * laneHeight})`)\n      .datum(rows)\n      .attr(\"d\", negativeArea)\n      .attr(\"fill\", negativeColor)\n      .attr(\"fill-opacity\", opacity);\n  }\n\n  lane.append(\"line\")\n    .attr(\"x1\", 0).attr(\"x2\", iw)\n    .attr(\"y1\", laneHeight).attr(\"y2\", laneHeight)\n    .attr(\"stroke\", t.grid).attr(\"stroke-width\", 1);\n\n  lane.append(\"text\")\n    .attr(\"x\", -12).attr(\"y\", laneHeight / 2)\n    .attr(\"text-anchor\", \"end\").attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\")\n    .text(s.name);\n});\n\n// --- X axis (below the last lane) --------------------------------------------\nconst xAxisGroup = root.append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(6).tickFormat(d3.timeFormat(\"%b %d\")));\nxAxisGroup.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxisGroup.selectAll(\"line\").attr(\"stroke\", t.grid);\nxAxisGroup.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Intensity legend (top-right, explains the color-to-magnitude mapping) ---\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${width - margin.right - 300},34)`);\nconst swatchW = 34;\nconst swatchH = 16;\n\nlegend.append(\"text\")\n  .attr(\"x\", 0).attr(\"y\", -8)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\")\n  .text(`Anomaly (°C) · ${bands} bands per side`);\n\nfor (let i = 0; i < bands; i++) {\n  legend.append(\"rect\")\n    .attr(\"x\", i * swatchW).attr(\"y\", 0)\n    .attr(\"width\", swatchW - 2).attr(\"height\", swatchH)\n    .attr(\"fill\", negativeColor).attr(\"fill-opacity\", (bands - i) / bands);\n  legend.append(\"rect\")\n    .attr(\"x\", (bands + 1 + i) * swatchW).attr(\"y\", 0)\n    .attr(\"width\", swatchW - 2).attr(\"height\", swatchH)\n    .attr(\"fill\", positiveColor).attr(\"fill-opacity\", (i + 1) / bands);\n}\nlegend.append(\"text\")\n  .attr(\"x\", 0).attr(\"y\", swatchH + 16)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\")\n  .text(`-${maxAbs.toFixed(1)}`);\nlegend.append(\"text\")\n  .attr(\"x\", bands * swatchW).attr(\"y\", swatchH + 16)\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\")\n  .text(\"0\");\nlegend.append(\"text\")\n  .attr(\"x\", (2 * bands + 1) * swatchW).attr(\"y\", swatchH + 16)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft).style(\"font-size\", \"12px\")\n  .text(`+${maxAbs.toFixed(1)}`);\n\n// --- Title ---------------------------------------------------------------------\n// Title fontsize scales linearly off the 67-char mandated-title baseline\n// (default 26px @ 67 chars) so the descriptive prefix never overflows while\n// still filling a healthy share of the plot width.\nconst titleText = \"Regional Temperature Anomalies · horizon-basic · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.round(26 * Math.min(1, 67 / titleText.length));\nsvg.append(\"text\")\n  .attr(\"x\", margin.left).attr(\"y\", 44)\n  .attr(\"fill\", t.ink).style(\"font-size\", `${titleFontSize}px`).style(\"font-weight\", \"600\")\n  .text(titleText);\n"}