{"spec_id":"skewt-logp-atmospheric","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-08-26\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\nconst withAlpha = (hex, alpha) => {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n};\n\n// --- Skew-T log-P transform --------------------------------------------------\n// y is log-pressure (inverted so 1000 hPa sits at the bottom); x is temperature\n// skewed to the right by an amount proportional to ln(P0/P), so isotherms\n// render as ~45 deg diagonals instead of verticals while the x-axis ticks still\n// read as plain surface-level degrees Celsius (the classic skew-T convention).\nconst P0 = 1000;\nconst SKEW = 50;\nconst yOf = (pressureHpa) => -Math.log(pressureHpa);\nconst skewedX = (tempC, pressureHpa) => tempC + SKEW * Math.log(P0 / pressureHpa);\n\nconst P_BOTTOM = 1000;\nconst P_TOP = 100;\nconst Y_BOTTOM = yOf(P_BOTTOM);\nconst Y_TOP = yOf(P_TOP);\nconst X_MIN = -95;\nconst X_MAX = 160;\n\n// --- Sounding data (synthetic mid-latitude summer afternoon profile) --------\nconst pressures = [1000, 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150, 100];\nconst temperature = [28, 25.2, 22.3, 19.2, 16, 12.7, 9.2, 5.3, 1.4, -2.8, -7.5, -12.4, -17.8, -24, -30.8, -38.6, -56, -55, -52];\nconst dewpoint = [21, 19, 16, 12, 8, 2, -4, -10, -16, -22, -28, -35, -42, -50, -58, -64, -70, -75, -80];\n\n// --- Reference lines ---------------------------------------------------------\n// Isotherms: straight diagonals at constant temperature.\nconst isothermValues = [];\nfor (let temp = -90; temp <= 40; temp += 10) isothermValues.push(temp);\nconst isothermDatasets = isothermValues.map((temp, i) => ({\n  label: i === 0 ? \"Isotherm\" : \"\",\n  data: [\n    { x: skewedX(temp, P_BOTTOM), y: Y_BOTTOM },\n    { x: skewedX(temp, P_TOP), y: Y_TOP },\n  ],\n  showLine: true,\n  borderColor: t.grid,\n  borderWidth: 1,\n  pointRadius: 0,\n  tension: 0,\n}));\n\n// Dry adiabats: constant potential temperature, T(P) = theta*(P/P0)^(Rd/Cpd) - 273.15.\nconst RD_OVER_CPD = 0.2854;\nconst dryAdiabatThetas = [253, 273, 293, 313, 333, 353, 373, 393]; // Kelvin\nconst dryAdiabatDatasets = dryAdiabatThetas.map((theta, i) => {\n  const points = [];\n  for (let p = P_BOTTOM; p >= P_TOP - 1; p -= 50) {\n    const temp = theta * Math.pow(p / P0, RD_OVER_CPD) - 273.15;\n    points.push({ x: skewedX(temp, p), y: yOf(p) });\n  }\n  return {\n    label: i === 0 ? \"Dry adiabat\" : \"\",\n    data: points,\n    showLine: true,\n    borderColor: withAlpha(t.palette[3], 0.6),\n    borderDash: [6, 3],\n    borderWidth: 1.25,\n    pointRadius: 0,\n    tension: 0,\n  };\n});\n\n// Moist (pseudo-) adiabats: integrate the saturated adiabatic lapse rate\n// upward from a surface starting temperature, using Bolton's saturation\n// vapor pressure approximation for the mixing ratio term.\nconst saturationVaporPressure = (tempC) => 6.112 * Math.exp((17.67 * tempC) / (tempC + 243.5));\nconst saturationMixingRatio = (tempC, pressureHpa) => {\n  const es = saturationVaporPressure(tempC);\n  return (0.622 * es) / (pressureHpa - es);\n};\nconst moistLapseRate = (tempC, pressureHpa) => {\n  const tempK = tempC + 273.15;\n  const ws = saturationMixingRatio(tempC, pressureHpa);\n  const Lv = 2501000;\n  const Rd = 287;\n  const Cpd = 1004;\n  const epsilon = 0.622;\n  const numerator = Rd * tempK + Lv * ws;\n  const denominator = Cpd + (Lv * Lv * ws * epsilon) / (Rd * tempK * tempK);\n  return numerator / (denominator * pressureHpa); // dT/dP, K per hPa\n};\nconst moistAdiabatStarts = [-20, -10, 0, 10, 20, 30]; // deg C at 1000 hPa\nconst moistAdiabatDatasets = moistAdiabatStarts.map((startTemp, i) => {\n  const points = [{ x: skewedX(startTemp, P_BOTTOM), y: Y_BOTTOM }];\n  let temp = startTemp;\n  let p = P_BOTTOM;\n  const dp = -5;\n  while (p > P_TOP) {\n    temp += moistLapseRate(temp, p) * dp;\n    p += dp;\n    points.push({ x: skewedX(temp, p), y: yOf(p) });\n  }\n  return {\n    label: i === 0 ? \"Moist adiabat\" : \"\",\n    data: points,\n    showLine: true,\n    borderColor: withAlpha(t.palette[5], 0.6),\n    borderDash: [8, 3, 2, 3],\n    borderWidth: 1.25,\n    pointRadius: 0,\n    tension: 0,\n  };\n});\n\n// Mixing ratio lines: constant saturation mixing ratio, inverting Bolton's\n// formula to get the dewpoint that saturates at each pressure level.\nconst dewpointFromMixingRatio = (mixingRatioGkg, pressureHpa) => {\n  const vaporPressure = (mixingRatioGkg * pressureHpa) / (622 + mixingRatioGkg);\n  const lnRatio = Math.log(vaporPressure / 6.112);\n  return (243.5 * lnRatio) / (17.67 - lnRatio);\n};\nconst mixingRatioValues = [1, 2, 4, 7, 10, 16, 24, 32]; // g/kg\nconst mixingRatioDatasets = mixingRatioValues.map((ratio, i) => {\n  const points = [];\n  for (let p = P_BOTTOM; p >= 400; p -= 50) {\n    const temp = dewpointFromMixingRatio(ratio, p);\n    points.push({ x: skewedX(temp, p), y: yOf(p) });\n  }\n  return {\n    label: i === 0 ? \"Mixing ratio\" : \"\",\n    data: points,\n    showLine: true,\n    borderColor: withAlpha(t.palette[1], 0.6),\n    borderDash: [2, 3],\n    borderWidth: 1.25,\n    pointRadius: 0,\n    tension: 0,\n  };\n});\n\n// --- Observed profile (drawn last so it sits above the reference lines) ----\nconst temperatureDataset = {\n  label: \"Temperature\",\n  data: pressures.map((p, i) => ({ x: skewedX(temperature[i], p), y: yOf(p) })),\n  showLine: true,\n  borderColor: t.palette[0],\n  backgroundColor: t.palette[0],\n  borderWidth: 3.5,\n  pointRadius: 4,\n  pointBackgroundColor: t.palette[0],\n  pointBorderColor: t.pageBg,\n  pointBorderWidth: 1,\n  tension: 0,\n};\nconst dewpointDataset = {\n  label: \"Dewpoint\",\n  data: pressures.map((p, i) => ({ x: skewedX(dewpoint[i], p), y: yOf(p) })),\n  showLine: true,\n  borderColor: t.palette[2],\n  backgroundColor: t.palette[2],\n  borderDash: [8, 4],\n  borderWidth: 3,\n  pointRadius: 4,\n  pointBackgroundColor: t.palette[2],\n  pointBorderColor: t.pageBg,\n  pointBorderWidth: 1,\n  tension: 0,\n};\n\nconst datasets = [\n  ...isothermDatasets,\n  ...dryAdiabatDatasets,\n  ...moistAdiabatDatasets,\n  ...mixingRatioDatasets,\n  temperatureDataset,\n  dewpointDataset,\n];\n\n// --- Tooltip: recover real temperature/pressure from the skewed coordinates -\nconst realTempAt = (skewedXValue, pressureHpa) => skewedXValue - SKEW * Math.log(P0 / pressureHpa);\n\n// --- Axis ticks: pressure labels + surface-referenced temperature labels ---\nconst pressureTickValues = [1000, 850, 700, 500, 400, 300, 250, 200, 150, 100];\nconst temperatureTickValues = [];\nfor (let temp = -90; temp <= 40; temp += 10) temperatureTickValues.push(temp);\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Frame: chart.js only borders the axis edges, so draw the remaining two\n// sides of the enclosing rectangle that a Skew-T diagram conventionally has.\nconst framePlugin = {\n  id: \"skewtFrame\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    ctx.save();\n    ctx.strokeStyle = t.inkSoft;\n    ctx.lineWidth = 1.5;\n    ctx.strokeRect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n    ctx.restore();\n  },\n};\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  plugins: [framePlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 16, bottom: 8, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"skewt-logp-atmospheric · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 16 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          filter: (legendItem) => Boolean(legendItem.text),\n        },\n      },\n      tooltip: {\n        callbacks: {\n          title: () => \"\",\n          label: (ctx) => {\n            const pressureHpa = Math.exp(-ctx.parsed.y);\n            const tempC = realTempAt(ctx.parsed.x, pressureHpa);\n            const name = ctx.dataset.label || \"Reference line\";\n            return `${name}: ${tempC.toFixed(1)}°C @ ${pressureHpa.toFixed(0)} hPa`;\n          },\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: X_MIN,\n        max: X_MAX,\n        title: { display: true, text: \"Temperature (°C)\", color: t.ink, font: { size: 16 } },\n        grid: { display: false },\n        border: { display: true, color: t.inkSoft },\n        afterBuildTicks: (axis) => {\n          axis.ticks = temperatureTickValues.map((value) => ({ value }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${value}°`,\n        },\n      },\n      y: {\n        type: \"linear\",\n        min: Y_BOTTOM,\n        max: Y_TOP,\n        title: { display: true, text: \"Pressure (hPa)\", color: t.ink, font: { size: 16 } },\n        grid: { display: false },\n        border: { display: true, color: t.inkSoft },\n        afterBuildTicks: (axis) => {\n          axis.ticks = pressureTickValues.map((value) => ({ value: yOf(value) }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${Math.round(Math.exp(-value))} hPa`,\n        },\n      },\n    },\n  },\n});\n"}