{"spec_id":"skewt-logp-atmospheric","library":"d3","language":"javascript","code":"// anyplot.ai\n// skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-26\n\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 120, right: 120, bottom: 110, left: 150 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: radiosonde sounding (in-memory, deterministic) ------------------\n// Classic \"dry mid-level\" convective sounding shape: dewpoint tracks the\n// temperature closely near the surface, then diverges sharply around 700 hPa\n// (a dry layer), and both cool through the troposphere toward the tropopause.\nconst sounding = [\n  { p: 1000, temp: 24.0, dew: 19.0 },\n  { p: 975, temp: 22.2, dew: 17.0 },\n  { p: 950, temp: 20.5, dew: 15.0 },\n  { p: 900, temp: 17.0, dew: 10.0 },\n  { p: 850, temp: 13.0, dew: 4.0 },\n  { p: 800, temp: 9.0, dew: -2.0 },\n  { p: 750, temp: 5.0, dew: -8.0 },\n  { p: 700, temp: 2.0, dew: -15.0 },\n  { p: 650, temp: -2.5, dew: -20.0 },\n  { p: 600, temp: -7.0, dew: -22.0 },\n  { p: 550, temp: -12.5, dew: -25.0 },\n  { p: 500, temp: -18.0, dew: -30.0 },\n  { p: 450, temp: -24.5, dew: -37.0 },\n  { p: 400, temp: -31.5, dew: -45.0 },\n  { p: 350, temp: -39.5, dew: -52.0 },\n  { p: 300, temp: -48.0, dew: -60.0 },\n  { p: 250, temp: -56.5, dew: -68.0 },\n  { p: 200, temp: -56.0, dew: -70.0 },\n  { p: 150, temp: -54.0, dew: -72.0 },\n  { p: 100, temp: -56.0, dew: -75.0 },\n];\n\n// --- Scales & skew transform -------------------------------------------------\n// Pressure: logarithmic, inverted (1000 hPa surface at bottom, 100 hPa at top)\nconst yScale = d3.scaleLog().domain([1000, 100]).range([ih, 0]);\n// Temperature: linear reference axis, evaluated at the surface (p = 1000 hPa)\nconst xScale = d3.scaleLinear().domain([-40, 45]).range([0, iw]);\nconst SKEW = 1; // 1 px right-shift per 1 px of height climbed -> 45-degree isotherms\nfunction toX(tempC, p) {\n  return xScale(tempC) + (ih - yScale(p)) * SKEW;\n}\nfunction toY(p) {\n  return yScale(p);\n}\nconst skewLine = d3.line().x((d) => toX(d[1], d[0])).y((d) => toY(d[0]));\n\n// --- Thermodynamic reference curves -----------------------------------------\n// Dry adiabat: constant potential temperature theta, T(p) = theta_K * (p/1000)^0.286\nfunction dryAdiabatPoints(thetaC) {\n  const thetaK = thetaC + 273.15;\n  const pts = [];\n  for (let p = 1000; p >= 100; p -= 20) pts.push([p, thetaK * Math.pow(p / 1000, 0.286) - 273.15]);\n  return pts;\n}\n\n// Moist (pseudo-)adiabat: integrate the saturated-adiabatic lapse rate upward\n// from a surface temperature, conserving equivalent potential temperature.\nconst RD = 287.05;\nconst CP = 1005.7;\nconst LV = 2.501e6;\nconst EPS = 0.622;\nfunction saturationVaporPressure(tempC) {\n  return 6.112 * Math.exp((17.67 * tempC) / (tempC + 243.5)); // Bolton (1980), hPa\n}\nfunction moistAdiabatPoints(startTempC) {\n  const pts = [[1000, startTempC]];\n  let tempK = startTempC + 273.15;\n  let p = 1000;\n  const dp = -4;\n  while (p > 100) {\n    const es = saturationVaporPressure(tempK - 273.15);\n    const ws = (EPS * es) / (p - es);\n    const numerator = RD * tempK + LV * ws;\n    const denominator = CP + (LV * LV * ws * EPS) / (RD * tempK * tempK);\n    tempK += (numerator / (denominator * p)) * dp;\n    p += dp;\n    pts.push([p, tempK - 273.15]);\n  }\n  return pts;\n}\n\n// Mixing ratio line: constant saturation mixing ratio w (g/kg) -> invert Bolton\nfunction mixingRatioT(w, p) {\n  const es = (w * p) / (622 + w);\n  const logRatio = Math.log(es / 6.112);\n  return (243.5 * logRatio) / (17.67 - logRatio);\n}\nfunction mixingRatioPoints(w) {\n  const pts = [];\n  for (let p = 1000; p >= 400; p -= 20) pts.push([p, mixingRatioT(w, p)]);\n  return pts;\n}\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\nsvg\n  .append(\"clipPath\")\n  .attr(\"id\", \"plot-clip\")\n  .append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 0)\n  .attr(\"width\", iw)\n  .attr(\"height\", ih);\nconst clipped = g.append(\"g\").attr(\"clip-path\", \"url(#plot-clip)\");\n\n// --- Isobars (horizontal pressure gridlines) ---------------------------------\nconst isobarLevels = [1000, 850, 700, 500, 400, 300, 250, 200, 150, 100];\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(isobarLevels)\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (p) => toY(p))\n  .attr(\"y2\", (p) => toY(p))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\ng.append(\"g\")\n  .selectAll(\"text\")\n  .data(isobarLevels)\n  .join(\"text\")\n  .attr(\"x\", -12)\n  .attr(\"y\", (p) => toY(p))\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((p) => `${p}`);\n\n// --- Isotherms (straight, skewed 45 degrees) ---------------------------------\nconst isotherms = d3.range(-110, 51, 10);\nclipped\n  .selectAll(\"path.isotherm\")\n  .data(isotherms)\n  .join(\"path\")\n  .attr(\"class\", \"isotherm\")\n  .attr(\"d\", (tempC) => skewLine([[1000, tempC], [100, tempC]]))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\nclipped\n  .selectAll(\"text.isotherm-label\")\n  .data(d3.range(-40, 41, 10))\n  .join(\"text\")\n  .attr(\"class\", \"isotherm-label\")\n  .attr(\"x\", (tempC) => toX(tempC, 1000) + 6)\n  .attr(\"y\", ih - 8)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((tempC) => `${tempC}`);\n\n// --- Dry adiabats --------------------------------------------------------------\nconst dryAdiabatThetas = d3.range(-20, 101, 10);\nclipped\n  .selectAll(\"path.dry-adiabat\")\n  .data(dryAdiabatThetas)\n  .join(\"path\")\n  .attr(\"class\", \"dry-adiabat\")\n  .attr(\"d\", (theta) => skewLine(dryAdiabatPoints(theta)))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[3]) // ochre — dry, earth-toned reference lines\n  .attr(\"stroke-width\", 1)\n  .attr(\"opacity\", 0.55);\n\n// --- Moist adiabats --------------------------------------------------------------\nconst moistAdiabatStarts = d3.range(-20, 31, 10);\nclipped\n  .selectAll(\"path.moist-adiabat\")\n  .data(moistAdiabatStarts)\n  .join(\"path\")\n  .attr(\"class\", \"moist-adiabat\")\n  .attr(\"d\", (startTempC) => skewLine(moistAdiabatPoints(startTempC)))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1]) // lavender — canonical next slot for abstract reference lines\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"2,4\")\n  .attr(\"opacity\", 0.7);\n\n// --- Mixing ratio lines --------------------------------------------------------------\nconst mixingRatios = [1, 2, 4, 7, 10, 16, 24, 32];\nclipped\n  .selectAll(\"path.mixing-ratio\")\n  .data(mixingRatios)\n  .join(\"path\")\n  .attr(\"class\", \"mixing-ratio\")\n  .attr(\"d\", (w) => skewLine(mixingRatioPoints(w)))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[5]) // cyan — canonical next slot for abstract reference lines\n  .attr(\"stroke-width\", 1.5)\n  .attr(\"stroke-dasharray\", \"6,4\")\n  .attr(\"opacity\", 0.7);\nclipped\n  .selectAll(\"text.mixing-ratio-label\")\n  .data(mixingRatios)\n  .join(\"text\")\n  .attr(\"class\", \"mixing-ratio-label\")\n  .attr(\"x\", (w) => toX(mixingRatioT(w, 400), 400))\n  .attr(\"y\", toY(400) - 6)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.palette[5])\n  .style(\"font-size\", \"13px\")\n  .text((w) => `${w}`);\n\n// --- Temperature & dewpoint profiles --------------------------------------------\nclipped\n  .append(\"path\")\n  .attr(\"d\", skewLine(sounding.map((d) => [d.p, d.temp])))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0]) // brand green — ALWAYS the first series\n  .attr(\"stroke-width\", 4)\n  .attr(\"stroke-linejoin\", \"round\");\n\nclipped\n  .append(\"path\")\n  .attr(\"d\", skewLine(sounding.map((d) => [d.p, d.dew])))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[2]) // blue — dewpoint tracks atmospheric moisture\n  .attr(\"stroke-width\", 4)\n  .attr(\"stroke-dasharray\", \"10,6\")\n  .attr(\"stroke-linejoin\", \"round\");\n\n// --- Plot frame ---------------------------------------------------------------\ng.append(\"rect\")\n  .attr(\"x\", 0)\n  .attr(\"y\", 0)\n  .attr(\"width\", iw)\n  .attr(\"height\", ih)\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1.5);\n\n// --- Axis titles ---------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 64)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Temperature (°C)\");\n\ng.append(\"text\")\n  .attr(\"transform\", `translate(${-104},${ih / 2}) rotate(-90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Pressure (hPa)\");\n\n// --- Legend ---------------------------------------------------------------\nconst legendItems = [\n  { label: \"Temperature\", color: t.palette[0], dash: null },\n  { label: \"Dewpoint\", color: t.palette[2], dash: \"10,6\" },\n  { label: \"Dry adiabat\", color: t.palette[3], dash: null },\n  { label: \"Moist adiabat\", color: t.palette[1], dash: \"2,4\" },\n  { label: \"Mixing ratio (g/kg)\", color: t.palette[5], dash: \"6,4\" },\n];\nconst legend = g.append(\"g\").attr(\"transform\", `translate(18,18)`);\nlegend\n  .append(\"rect\")\n  .attr(\"width\", 240)\n  .attr(\"height\", legendItems.length * 30 + 16)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"rx\", 6);\nconst legendRow = legend\n  .selectAll(\"g.legend-row\")\n  .data(legendItems)\n  .join(\"g\")\n  .attr(\"class\", \"legend-row\")\n  .attr(\"transform\", (_, i) => `translate(16,${16 + i * 30})`);\nlegendRow\n  .append(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", 28)\n  .attr(\"y1\", 0)\n  .attr(\"y2\", 0)\n  .attr(\"stroke\", (d) => d.color)\n  .attr(\"stroke-width\", 3)\n  .attr(\"stroke-dasharray\", (d) => d.dash);\nlegendRow\n  .append(\"text\")\n  .attr(\"x\", 38)\n  .attr(\"y\", 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d.label);\n\n// --- Title ---------------------------------------------------------------\n// Title fontsize scales linearly off the 67-char baseline (22px default)\n// because the descriptive prefix pushes this title past the mandated length.\nconst titleText = \"Radiosonde Sounding · skewt-logp-atmospheric · javascript · d3 · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * (67 / titleText.length)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(titleText);\n"}