{"spec_id":"skewt-logp-atmospheric","library":"echarts","language":"javascript","code":"// anyplot.ai\n// skewt-logp-atmospheric: Skew-T Log-P Atmospheric Diagram\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 95/100 | Created: 2026-08-26\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// ECharts has no native skew coordinate system. The pressure axis uses a real\n// log/inverse yAxis (correct log-spaced geometry for free); the 45-degree\n// temperature skew is applied manually in pixel space inside each custom\n// series' renderItem: for a point at pixel height yPix, the x pixel is\n// shifted right by SKEW * (bottomPixelY - yPix), so the shift is zero at the\n// surface and grows linearly with height (SKEW = 1 gives exactly 45deg).\nconst SKEW = 1;\nconst P_BOTTOM = 1000;\nconst P_TOP = 100;\nconst X_MIN = -40;\nconst X_MAX = 45;\n\n// --- Thermodynamics helpers (Bolton 1980 approximations) -------------------\nfunction satVaporPressure(tempC) {\n  return 6.112 * Math.exp((17.67 * tempC) / (tempC + 243.5)); // hPa\n}\nfunction satMixingRatio(tempC, pressureHpa) {\n  const es = satVaporPressure(tempC);\n  return (622 * es) / (pressureHpa - es); // g/kg\n}\nfunction tempFromVaporPressure(es) {\n  const logTerm = Math.log(es / 6.112);\n  return (243.5 * logTerm) / (17.67 - logTerm); // Celsius\n}\nfunction dryAdiabatTemp(thetaK, pressureHpa) {\n  return thetaK * Math.pow(pressureHpa / 1000, 0.286) - 273.15;\n}\nfunction moistAdiabatCurve(startTempC, pressures) {\n  const Rd = 287.0;\n  const Cp = 1004.0;\n  const Lv = 2.501e6;\n  const eps = 0.622;\n  const points = [[startTempC, P_BOTTOM]];\n  let tempK = startTempC + 273.15;\n  let pressure = P_BOTTOM;\n  for (let i = 1; i < pressures.length; i++) {\n    const targetPressure = pressures[i];\n    const substeps = 8;\n    const dP = (targetPressure - pressure) / substeps;\n    for (let s = 0; s < substeps; s++) {\n      const tempC = tempK - 273.15;\n      const ws = satMixingRatio(tempC, pressure) / 1000; // kg/kg\n      const numerator = Rd * tempK + Lv * ws;\n      const denominator = pressure * (Cp + (Lv * Lv * ws * eps) / (Rd * tempK * tempK));\n      tempK += (numerator / denominator) * dP;\n      pressure += dP;\n    }\n    points.push([tempK - 273.15, targetPressure]);\n  }\n  return points;\n}\n\n// --- Sounding data (deterministic synthetic radiosonde profile) ------------\n// Surface-based boundary layer, a shallow inversion near 850 hPa, then\n// near-moist-adiabatic cooling through the mid-troposphere into a cold,\n// dry stratospheric layer above the tropopause.\nconst pressureLevels = [\n  1000, 975, 950, 925, 900, 875, 850, 800, 750, 700, 650, 600, 550, 500, 450,\n  400, 350, 300, 250, 200, 150, 100,\n];\nconst temperature = [\n  18.0, 16.4, 14.8, 13.1, 11.3, 9.6, 9.8, 6.9, 3.4, 0.8, -3.1, -7.8, -12.9,\n  -18.6, -25.1, -32.4, -40.6, -49.9, -56.3, -56.8, -58.9, -60.2,\n];\nconst dewpoint = [\n  14.5, 13.0, 11.6, 9.8, 7.5, 4.2, -1.5, -3.8, -6.9, -10.4, -15.8, -20.6,\n  -25.9, -31.8, -38.4, -45.9, -53.2, -60.4, -64.8, -66.1, -68.5, -70.9,\n];\nconst windSpeed = [\n  8, 10, 12, 15, 17, 19, 22, 25, 28, 32, 36, 40, 45, 50, 55, 62, 68, 74, 70,\n  60, 45, 35,\n];\nconst windDirection = [\n  190, 200, 205, 210, 215, 220, 225, 230, 235, 240, 245, 250, 255, 260, 265,\n  270, 275, 280, 285, 290, 295, 300,\n];\n\n// --- Reference-line families -------------------------------------------------\nconst isotherms = [];\nfor (let tempC = -100; tempC <= 50; tempC += 10) {\n  isotherms.push([\n    [tempC, P_BOTTOM],\n    [tempC, P_TOP],\n  ]);\n}\n\nconst dryAdiabatPressures = [1000, 900, 800, 700, 600, 500, 400, 300, 200, 100];\nconst dryAdiabats = [];\nfor (let thetaC = -30; thetaC <= 100; thetaC += 10) {\n  const thetaK = thetaC + 273.15;\n  dryAdiabats.push(dryAdiabatPressures.map((p) => [dryAdiabatTemp(thetaK, p), p]));\n}\n\nconst moistAdiabatPressures = [1000, 900, 800, 700, 600, 500, 400, 300, 200, 100];\nconst moistAdiabats = [];\nfor (let startTempC = -20; startTempC <= 30; startTempC += 5) {\n  moistAdiabats.push(moistAdiabatCurve(startTempC, moistAdiabatPressures));\n}\n\nconst mixingRatioPressures = [1000, 900, 800, 700, 600, 500, 400];\nconst mixingRatios = [];\nfor (const w of [1, 2, 4, 7, 10, 16, 24, 32]) {\n  mixingRatios.push(\n    mixingRatioPressures.map((p) => {\n      const es = (w * p) / (622 + w);\n      return [tempFromVaporPressure(es), p];\n    })\n  );\n}\n\nconst temperaturePoints = pressureLevels.map((p, i) => [temperature[i], p]);\nconst dewpointPoints = pressureLevels.map((p, i) => [dewpoint[i], p]);\n\nconst pressureGridLevels = [1000, 850, 700, 500, 400, 300, 250, 200, 150, 100];\nconst barbIndices = [0, 3, 6, 9, 12, 15, 18, 21];\nconst WIND_LANE_T = X_MAX + 4;\nconst BARB_SHAFT_LEN = 40;\nconst BARB_TICK_LEN = 12;\nconst BARB_TICK_GAP = 6;\n\n// --- Skew transform + shape builders -----------------------------------------\nfunction skewPoint(api, tempC, pressureHpa, bottomPixelY) {\n  const coord = api.coord([tempC, pressureHpa]);\n  const shift = SKEW * (bottomPixelY - coord[1]);\n  return [coord[0] + shift, coord[1]];\n}\nfunction makeLineFamilyRenderer(lines, style) {\n  return function renderItem(params, api) {\n    const bottomPixelY = api.coord([0, P_BOTTOM])[1];\n    const points = lines[params.dataIndex].map((pt) => skewPoint(api, pt[0], pt[1], bottomPixelY));\n    const isFreezing = lines[params.dataIndex][0][0] === 0 && lines[params.dataIndex][1][0] === 0;\n    return {\n      type: \"polyline\",\n      shape: { points },\n      style: isFreezing ? { ...style, stroke: t.inkSoft, lineWidth: 2, opacity: 0.7 } : style,\n    };\n  };\n}\nfunction windBarbShapes(anchorX, anchorY, speedKt, directionDeg) {\n  const dirRad = (directionDeg * Math.PI) / 180;\n  const ux = Math.sin(dirRad);\n  const uy = -Math.cos(dirRad);\n  const px = -uy;\n  const py = ux;\n  const tipX = anchorX + ux * BARB_SHAFT_LEN;\n  const tipY = anchorY + uy * BARB_SHAFT_LEN;\n  const stepFrac = BARB_TICK_GAP / BARB_SHAFT_LEN;\n  const tickBase = (step) => {\n    const frac = 1 - step * stepFrac;\n    return [anchorX + ux * BARB_SHAFT_LEN * frac, anchorY + uy * BARB_SHAFT_LEN * frac];\n  };\n\n  const children = [\n    {\n      type: \"line\",\n      shape: { x1: anchorX, y1: anchorY, x2: tipX, y2: tipY },\n      style: { stroke: t.ink, lineWidth: 1.5 },\n    },\n  ];\n\n  let speed = Math.round(speedKt / 5) * 5;\n  const pennants = Math.floor(speed / 50);\n  speed -= pennants * 50;\n  const fullBarbs = Math.floor(speed / 10);\n  speed -= fullBarbs * 10;\n  const halfBarb = speed >= 5 ? 1 : 0;\n\n  let step = 0;\n  for (let i = 0; i < pennants; i++) {\n    const [bx, by] = tickBase(step);\n    const [ix, iy] = tickBase(step + 1);\n    children.push({\n      type: \"polygon\",\n      shape: {\n        points: [\n          [bx, by],\n          [ix, iy],\n          [ix + px * BARB_TICK_LEN, iy + py * BARB_TICK_LEN],\n        ],\n      },\n      style: { fill: t.ink, stroke: \"none\" },\n    });\n    step += 1;\n  }\n  for (let i = 0; i < fullBarbs; i++) {\n    const [bx, by] = tickBase(step);\n    children.push({\n      type: \"line\",\n      shape: { x1: bx, y1: by, x2: bx + px * BARB_TICK_LEN, y2: by + py * BARB_TICK_LEN },\n      style: { stroke: t.ink, lineWidth: 1.5 },\n    });\n    step += 1;\n  }\n  if (halfBarb) {\n    const [bx, by] = tickBase(step);\n    children.push({\n      type: \"line\",\n      shape: {\n        x1: bx,\n        y1: by,\n        x2: bx + (px * BARB_TICK_LEN) / 2,\n        y2: by + (py * BARB_TICK_LEN) / 2,\n      },\n      style: { stroke: t.ink, lineWidth: 1.5 },\n    });\n  }\n  return children;\n}\n\n// --- Chart ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"skewt-logp-atmospheric · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 16,\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  legend: {\n    top: 56,\n    left: \"center\",\n    orient: \"horizontal\",\n    itemGap: 28,\n    itemWidth: 22,\n    itemHeight: 3,\n    selectedMode: false,\n    textStyle: { color: t.ink, fontSize: 14 },\n    data: [\n      { name: \"Temperature\", itemStyle: { color: t.palette[0] } },\n      { name: \"Dewpoint\", itemStyle: { color: t.palette[1] } },\n      { name: \"Dry adiabat\", itemStyle: { color: t.palette[2] } },\n      { name: \"Moist adiabat\", itemStyle: { color: t.palette[3] } },\n      { name: \"Mixing ratio\", itemStyle: { color: t.palette[4] } },\n    ],\n  },\n  grid: { left: 90, right: 170, top: 140, bottom: 90 },\n  xAxis: {\n    type: \"value\",\n    min: X_MIN,\n    max: X_MAX,\n    name: \"Temperature (°C)\",\n    nameLocation: \"middle\",\n    nameGap: 36,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14, formatter: \"{value}°\" },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: \"log\",\n    logBase: 10,\n    inverse: true,\n    min: P_TOP,\n    max: P_BOTTOM,\n    axisLine: { show: false },\n    axisLabel: { show: false },\n    axisTick: { show: false },\n    minorTick: { show: false },\n    splitLine: { show: false },\n    minorSplitLine: { show: false },\n  },\n  graphic: [\n    {\n      type: \"text\",\n      left: 22,\n      top: \"middle\",\n      rotation: -Math.PI / 2,\n      style: { text: \"Pressure (hPa)\", fill: t.ink, fontSize: 16, align: \"center\" },\n    },\n  ],\n  series: [\n    {\n      name: \"Pressure grid\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      xAxisIndex: 0,\n      yAxisIndex: 0,\n      data: pressureGridLevels,\n      renderItem: (params, api) => {\n        const level = pressureGridLevels[params.dataIndex];\n        const left = api.coord([X_MIN, level]);\n        const right = api.coord([X_MAX, level]);\n        return {\n          type: \"group\",\n          silent: true,\n          children: [\n            {\n              type: \"line\",\n              shape: { x1: left[0], y1: left[1], x2: right[0], y2: right[1] },\n              style: { stroke: t.grid, lineWidth: 1 },\n            },\n            {\n              type: \"text\",\n              style: {\n                text: String(level),\n                x: left[0] - 12,\n                y: left[1],\n                fill: t.inkSoft,\n                fontSize: 14,\n                align: \"right\",\n                verticalAlign: \"middle\",\n              },\n            },\n          ],\n        };\n      },\n    },\n    {\n      name: \"Isotherms\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: isotherms.map((_, i) => i),\n      renderItem: makeLineFamilyRenderer(isotherms, { stroke: t.grid, lineWidth: 1, fill: \"none\" }),\n    },\n    {\n      name: \"Dry adiabat\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: dryAdiabats.map((_, i) => i),\n      renderItem: makeLineFamilyRenderer(dryAdiabats, {\n        stroke: t.palette[2],\n        lineWidth: 1.5,\n        lineDash: [8, 5],\n        opacity: 0.6,\n        fill: \"none\",\n      }),\n    },\n    {\n      name: \"Moist adiabat\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: moistAdiabats.map((_, i) => i),\n      renderItem: makeLineFamilyRenderer(moistAdiabats, {\n        stroke: t.palette[3],\n        lineWidth: 1.5,\n        lineDash: [3, 5],\n        opacity: 0.6,\n        fill: \"none\",\n      }),\n    },\n    {\n      name: \"Mixing ratio\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: mixingRatios.map((_, i) => i),\n      renderItem: makeLineFamilyRenderer(mixingRatios, {\n        stroke: t.palette[4],\n        lineWidth: 1.5,\n        lineDash: [1, 4],\n        opacity: 0.55,\n        fill: \"none\",\n      }),\n    },\n    {\n      name: \"Dewpoint\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: [0],\n      renderItem: (params, api) => {\n        const bottomPixelY = api.coord([0, P_BOTTOM])[1];\n        const points = dewpointPoints.map((pt) => skewPoint(api, pt[0], pt[1], bottomPixelY));\n        return {\n          type: \"polyline\",\n          shape: { points },\n          style: { stroke: t.palette[1], lineWidth: 3, lineDash: [10, 6], fill: \"none\" },\n        };\n      },\n    },\n    {\n      name: \"Temperature\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      clip: true,\n      data: [0],\n      renderItem: (params, api) => {\n        const bottomPixelY = api.coord([0, P_BOTTOM])[1];\n        const points = temperaturePoints.map((pt) => skewPoint(api, pt[0], pt[1], bottomPixelY));\n        return {\n          type: \"polyline\",\n          shape: { points },\n          style: { stroke: t.palette[0], lineWidth: 4, fill: \"none\" },\n        };\n      },\n    },\n    {\n      name: \"Wind barbs\",\n      type: \"custom\",\n      coordinateSystem: \"cartesian2d\",\n      data: barbIndices,\n      renderItem: (params, api) => {\n        const index = barbIndices[params.dataIndex];\n        const anchor = api.coord([WIND_LANE_T, pressureLevels[index]]);\n        return {\n          type: \"group\",\n          silent: true,\n          children: windBarbShapes(anchor[0], anchor[1], windSpeed[index], windDirection[index]),\n        };\n      },\n    },\n  ],\n});\n"}