Multi-hazard analytics platform
  • Single Hazards Analytics
  • Temporally compound
  • Spatially compound
  • About
d3 = require("d3@7")
d3Sankey = require("d3-sankey@0.12.3")

height_px = "25px"

orderedMonths = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]

// Transpose here all the variables needed that are returned by ojs_define as well
t_freq_summary = typeof filter_freq_summary !== "undefined" ? transpose(filter_freq_summary) : []
// Add some safety checks to avoid errors when data is not yet available
t_click_sea = typeof sea_plot_data_click !== "undefined" ? transpose(sea_plot_data_click) : []
t_click_freq = typeof click_freq_map !== "undefined" ? transpose(click_freq_map) : []
t_click_freq_yr = typeof freq_yr_plot_data_click !== "undefined" ? transpose(freq_yr_plot_data_click) : []

// To remove after correcting the code
t_freq_yr_cluster = []
t_nuts0_freq_yr = []

// TC reactive data from server
t_tc_sea      = typeof tc_sea_click      !== "undefined" ? transpose(tc_sea_click)      : []
t_tc_dur      = typeof tc_dur_click      !== "undefined" ? transpose(tc_dur_click)      : []
t_tc_sankey   = typeof tc_sankey_data    !== "undefined" ? transpose(tc_sankey_data)    : []
t_tc_freq_sum = typeof tc_freq_summary   !== "undefined" ? transpose(tc_freq_summary)   : []
t_tc_dur_hist = typeof tc_dur_hist_click !== "undefined" ? transpose(tc_dur_hist_click) : []

// Load pre-computed synthetic transition diagram data
td_data = FileAttachment("./data/transition_diagram_data.json").json();
current_params = {
  const mapping = {
    "E-HYPE - selected stations": { data_source: "subid_sel", agg_lvl: "subid" },
    "E-HYPE all - NUTS2":         { data_source: "subid_all", agg_lvl: "nuts2" },
    "E-HYPE all - NUTS3":         { data_source: "subid_all", agg_lvl: "nuts3" }
  };
  return mapping[view_mode];
}

// DEFINING THE EXPORTS
// These specific variable names will become input$names in R
agg_lvl = current_params.agg_lvl
data_source = current_params.data_source


q_type_use = {
  if (data_source === "subid_sel" || data_source === "subid_all") {
    return "q_sim_mm";
  } else if (data_source === "nuts") {
    return "q_sim_m3s";
  } else {
    throw new Error(`Not set q_type_use for data_source: ${data_source}, agg_lvl: ${agg_lvl}`);
  }
}
function plotLegend(
  legendData = [],
  {
    marginTop,
    marginLeft,
    marginRight,
    marginBottom,
    fontSize,
    fontFamily = "system-ui,sans-serif",
    legendItemHeight,
    width
  } = {}
) {
  // generate Plot marks based on legendData
  const lData = legendData.map((lg, i) => ({
    ...lg,
    y: i + 1
  }));

  const legendMarks = lData
    .map((lg, i) => {
      // dot and symbols
      if (lg.type == "dot") {
        return Plot.dot([lg], {
          x: 0,
          y: (d) => d.y,
          dx: "7",
          stroke: `${lg.stroke}`,
          fill: `${lg.fill}`,
          symbol: `${lg.symbol || "circle"}`
        });
      }
      // image
      if (lg.type == "image") {
        return Plot.image([lg], {
          x: 0,
          y: (d) => d.y,
          dx: "7",
          src: (d) => `${d.symbol}`,
          preserveAspectRatio: "xMidYMid slice"
        });
      }
      // default to horizontal line
      return Plot.ruleY([lg], {
        y: (d) => d.y,
        stroke: `${lg.stroke}`,
        strokeWidth: lg.strokeWidth || 1,
        strokeDasharray: `${lg.strokeDasharray}`
      });
    })
    .concat([
      // legend item text
      Plot.textY(lData, {
        x: 1,
        y: (d) => d.y,
        textAnchor: "start",
        dx: "15",
        text: (d) => d.text
      })
    ]);

  return Plot.plot({
    marginTop: marginTop || 0,
    marginLeft: marginLeft || 0,
    marginRight: marginRight || 170,
    marginBottom: marginBottom || 0,
    height: legendData.length * (legendItemHeight || 20),
    width: width || 200,
    style: {
      fontSize: `${fontSize || 10}px`,
      fontFamily
    },
    y: {
      type: "point",
      axis: null
    },
    x: {
      domain: [0, 1],
      type: "point",
      axis: null
    },
    marks: legendMarks
  });
}
function pointerMore(pointer) {
  return ({ atrest = null, selector = "point", render, ...options } = {}) => {
    const I = new WeakMap(); // Memoize the at rest index for each facet (for performance).

    return pointer({
      ...options,
      render(index, scales, values, dimensions, context, next) {
        const facet = context.getMarkState(this).facets[index.fi ?? 0]; // full index for the current facet
        const { x, x1, x2, y, y1, y2, z, stroke, fill } = values.channels;
        const X = x?.value ?? x2?.value ?? x1?.value;
        const Y = y?.value ?? y2?.value ?? y1?.value;
        const VX = values.x ?? values.x2 ?? values.x1;
        const VY = values.y ?? values.y2 ?? values.y1;
        const Z = z?.value ?? stroke?.value ?? fill?.value;

        // at rest
        if (!I.has(facet)) {
          let select;
          switch (String(atrest).toLowerCase()) {
            case "null":
              select = () => [];
              break;
            case "minx":
              if (!X) throw new Error("missing channel x");
              select = (index) => [d3.least(index, (i) => X[i])];
              break;
            case "maxx":
              if (!X) throw new Error("missing channel x");
              select = (index) => [d3.greatest(index, (i) => X[i])];
              break;
            case "miny":
              if (!Y) throw new Error("missing channel y");
              select = (index) => [d3.least(index, (i) => Y[i])];
              break;
            case "maxy":
              if (!Y) throw new Error("missing channel y");
              select = (index) => [d3.greatest(index, (i) => Y[i])];
              break;
            case "first":
              select = (index) => index.slice(0, 1);
              break;
            case "last":
              select = (index) => index.slice(-1);
              break;
            // TODO top, bottom, left, right… ?
          }
          if (!select) throw new Error(`unsupported atrest method ${atrest}`);
          I.set(facet, select(facet));
        }

        if (index.length === 0) index = I.get(facet);

        // selector
        switch (String(selector).toLowerCase()) {
          case "point":
            break;
          case "before":
          case "lte":
            if (!VX) throw new Error("missing channel x");
            index = facet.filter((i) => VX[i] <= VX[index[0]]);
            break;
          case "lt":
            if (!VX) throw new Error("missing channel x");
            index = facet.filter((i) => VX[i] < VX[index[0]]);
            break;
          case "after":
          case "gte":
            if (!VX) throw new Error("missing channel x");
            index = facet.filter((i) => VX[i] >= VX[index[0]]);
            break;
          case "gt":
            if (!VX) throw new Error("missing channel x");
            index = facet.filter((i) => VX[i] > VX[index[0]]);
            break;
          case "eq":
          case "x":
            if (!VX) throw new Error("missing channel x");
            index = facet.filter((i) => VX[i] == VX[index[0]]);
            break;
          case "y":
            if (!VY) throw new Error("missing channel y");
            index = facet.filter((i) => VY[i] == VY[index[0]]);
            break;
          case "z":
            if (!Z) throw new Error("missing channel z");
            index = facet.filter((i) => Z[i] == Z[index[0]]);
            break;
          default:
            throw new Error(`unsupported selector ${selector}`);
        }

        return render
          ? render(index, scales, values, dimensions, context, next)
          : next(index, scales, values, dimensions, context);
      }
    });
  };
}



pointerMY = pointerMore(Plot.pointerY)
pointerMX = pointerMore(Plot.pointerX)
pointerM = pointerMore(Plot.pointer)



// Filters to only indices where prop_sta > 0.
// Text format: "{hz_n_sta_st_year} | {prop_sta * 100}%"
// Renders a colored rect badge with the percentage, and "N: X" label next to it.
function renderMultipleTextHz({
  render: prerender,
  rectHeight = 20,
  padding = 5,
  maxPasses = 100,
  ...options
  } = {}) {
  const minDistance = rectHeight + padding;
  const multiple = (index, scales, values, dimensions, context, next) => {
    // 1. Compute band midpoints from y1/y2 (provided by Plot.stackY)
    const hasStack = values.y1 && values.y2;
    const Y = hasStack
      ? Float64Array.from(values.y1, (v, k) => (v + values.y2[k]) / 2)
      : Float64Array.from(values.y);

    // 2. Filter to prop_sta > 0 BEFORE overlap avoidance, so zero-width
    //    bands don't reserve space or skew label positions.
    const filteredIndex = index
      .filter((i) => {
        const txt = values.text[i];
        if (!txt) return false;
        const [, pctStr] = String(txt).split(" | ");
        return pctStr && parseFloat(pctStr) > 0;
      })
      .sort((a, b) => Y[a] - Y[b]); // top-to-bottom by band midpoint

    // 3. Apply overlap avoidance only on the filtered set
    if (filteredIndex.length >= 2) {
      const yMin = dimensions.marginTop + minDistance / 2;
      const yMax = dimensions.height - dimensions.marginBottom - minDistance / 2;
      for (let pass = 0; pass < maxPasses; pass++) {
        let moved = false;
        for (let k = 1; k < filteredIndex.length; k++) {
          const prev = filteredIndex[k - 1];
          const curr = filteredIndex[k];
          const gap = Y[curr] - Y[prev];
          if (gap < minDistance) {
            const shift = (minDistance - gap) / 2;
            Y[prev] -= shift;
            Y[curr] += shift;
            moved = true;
          }
        }
        for (const i of filteredIndex) {
          Y[i] = Math.max(yMin, Math.min(yMax, Y[i]));
        }
        if (!moved) break;
      }
    }

    // Override values.y with adjusted midpoints
    values = { ...values, y: Y };

    const g = next(filteredIndex, scales, values, dimensions, context);
    const textEls = g.querySelectorAll("text");
    const white = g.getAttribute("stroke");

    filteredIndex.forEach((i, j) => {
      if (!textEls[j]) return;
      textEls[j].setAttribute("stroke", "none");
      textEls[j].setAttribute("fill", white);

      const [nSpells, pct] = textEls[j].textContent.split(" | ");
      textEls[j].textContent = pct;

      g.append(
        svg`<rect x=${values.x[i] - 38} y=${values.y[i] - (rectHeight / 2)}
             width=43 rx=4 height=${rectHeight}
             fill="${values.fill[i]}"></rect>${textEls[j]}<text dx=10 x=${values.x[i]} y=${values.y[i]}
             text-anchor="start"
             fill="${values.fill[i]}"
             dy="0.38em"
             stroke="${white}"
             stroke-width=3
             paint-order="stroke">N:${nSpells}</text>`
      );
    });

    return g;
  };

  const render = prerender
    ? (i, s, v, d, c, next) =>
        prerender(i, s, v, d, c, (i, s, v, d, c) =>
          multiple(i, s, v, d, c, next)
        )
    : multiple;

  return { ...options, render };
}
function customRadioInput(
  optionsData,
  {
    defaultValue = null,
    height_px = "25px",
    formatFn,
    disabled,
    customStyles = {}
  } = {}
) {

  // Base configuration
  const config = {
    value: defaultValue,
    label: null,
  };

  if (formatFn) config.format = formatFn;
  if (disabled) config.disabled = disabled;

  const radioInput = Inputs.radio(optionsData, config);

  Object.keys(customStyles).forEach(styleProp => {
    radioInput.style[styleProp] = customStyles[styleProp];
  });

  const innerDiv = radioInput.querySelector("div");
  if (innerDiv) {
    innerDiv.style.display = "flex";
    innerDiv.style.flexDirection = "column";
    innerDiv.style.alignItems = "flex-start";
    innerDiv.style.gap = "0";
    innerDiv.style.width = "100%";

    innerDiv.querySelectorAll("label").forEach((label) => {
      label.style.height = height_px;
      label.style.display = "flex";
      label.style.alignItems = "center";
      label.style.margin = "0";
      label.style.width = "100%";
      label.style.whiteSpace = "nowrap";

      const inputElement = label.querySelector("input");
      if (inputElement && inputElement.disabled) {
        label.style.opacity = "0.4";
        label.style.cursor = "not-allowed";
      }
    });
  }

  return radioInput;
};
// TC-specific Sankey diagram.
// Data contract: { links: [{source, target, value}, ...] }
// target ids carry a trailing "2" so d3-sankey places them in the right
// column. Source/target base ids (stripped of "2") are one of
// HFS_S, HFS_M, HFS_L, LFS_S, LFS_M, LFS_L.
function TCSankeyDiagram(
  { links },
  { invalidation, colorMap, nodeOrder, clusterGap = 20 } = {}
) {
  // Responsive wrapper ========================================================
  // height: 100% propagates card-body height (SCSS #sankey-card fixes the
  // .observablehq chain); overflow:hidden clips gradient labels at the edges.
  const wrapper = document.createElement("div");
  wrapper.style.cssText =
    "position:relative;width:100%;height:100%;overflow:hidden;";

  function render(width, height) {
    wrapper.innerHTML = "";
    if (!links || links.length === 0) return;

    // 1. Data construction ====================================================
    // Build the node set from all link endpoints. Target ids carry a trailing
    // "2" so d3-sankey assigns them to the right column; we strip it only for
    // display and color lookup.
    const nodeIds = Array.from(
      new Set(links.flatMap(d => [d.source, d.target]))
    );
    const nodes = nodeIds.map(id => ({ id }));
    const linkObjs = links.map(d => ({
      source: d.source,
      target: d.target,
      value: d.value
    }));

    // 2. Sankey layout =========================================================
    // Margins are kept wide (48 px) to accommodate outside node labels.
    // The extent is squeezed by clusterGap so the post-layout LFS shift
    // never pushes nodes below the SVG boundary.
    const mg = { top: 8, right: 48, bottom: 8, left: 48 };
    const sankey = d3Sankey.sankey()
      .nodeId(d => d.id)
      .nodeAlign(d3Sankey.sankeyJustify)
      .nodeWidth(22)
      .nodePadding(10)
      .nodeSort((a, b) => {
        const ak = a.id.replace(/2$/, ""),
              bk = b.id.replace(/2$/, "");
        return nodeOrder.indexOf(ak) - nodeOrder.indexOf(bk);
      })
      .extent([
        [mg.left, mg.top],
        [width - mg.right, height - mg.bottom - clusterGap]
      ]);

    sankey({ nodes, links: linkObjs });

    // 3. Cluster gap shift ====================================================
    // d3-sankey lays out HFS and LFS nodes in one continuous column.
    // Shifting LFS nodes and their link attachment points down by clusterGap
    // creates the visual separation between the two hazard families.
    for (const n of nodes) {
      if (n.id.startsWith("LFS")) {
        n.y0 += clusterGap;
        n.y1 += clusterGap;
      }
    }
    for (const l of linkObjs) {
      if (l.source.id.startsWith("LFS")) l.y0 += clusterGap;
      if (l.target.id.startsWith("LFS")) l.y1 += clusterGap;
    }

    // 4. SVG + tooltip setup ==================================================
    // Explicit pixel dimensions keep the viewBox in 1:1 CSS-pixel space so
    // tooltip coordinates need no scale correction.
    const svg = d3.select(wrapper)
      .append("svg")
      .attr("width", width)
      .attr("height", height)
      .attr("viewBox", [0, 0, width, height])
      .style("display", "block");

    const container = d3.select(wrapper);
    const tooltip = container.append("div")
      .attr("class", "tc-sankey-tooltip")
      .style("position", "absolute")
      .style("display", "none")
      .style("z-index", 10)
      .style("background", "rgba(30,41,59,0.75)")
      .style("-webkit-backdrop-filter", "blur(8px)")
      .style("backdrop-filter", "blur(8px)")
      .style("color", "#ffffff")
      .style("padding", "8px 10px")
      .style("border-radius", "6px")
      .style("border", "1px solid rgba(255,255,255,0.15)")
      .style("font-size", "12px")
      .style("font-family", "system-ui, sans-serif")
      .style("pointer-events", "none")
      .style("box-shadow", "0 4px 12px rgba(0,0,0,0.25)")
      .style("max-width", "260px");

    const totalValue = d3.sum(linkObjs, d => d.value);

    // Colored square swatch used in both link and node tooltip rows.
    const swatch = c =>
      `<span style="display:inline-block;flex-shrink:0;width:11px;height:11px;` +
      `background-color:${c};border:1px solid rgba(255,255,255,0.6);` +
      `border-radius:2px;"></span>`;

    function linkTooltipHTML(d) {
      const src = d.source.id.replace(/2$/, "");
      const tgt = d.target.id.replace(/2$/, "");
      const pct = ((d.value / totalValue) * 100).toFixed(1);
      return `
        <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
          ${swatch(colorMap[src])}<span style="font-weight:600">${src}</span>
          <span style="color:#fbfbfb">➔</span>
          ${swatch(colorMap[tgt])}<span style="font-weight:600">${tgt}</span>
        </div>
        <div style="font-size:11px;color:#fbfbfb">
          Proportion: <b>${pct}%</b> (${d.value} ${d.value === 1 ? "transition" : "transitions"})
        </div>`;
    }

    function nodeTooltipHTML(n) {
      const id = n.id.replace(/2$/, "");
      const pct = ((n.value / totalValue) * 100).toFixed(1);
      // Nodes in the left column are sources; right column are targets.
      const isSource = n.x0 < width / 2;
      const label = isSource ? "Originates" : "Arrives";
      return `
        <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
          ${swatch(colorMap[id])}<span style="font-weight:600">${id}</span>
        </div>
        <div style="font-size:11px;color:#fbfbfb">
          ${label}: <b>${pct}%</b> (${n.value} ${n.value === 1 ? "transition" : "transitions"})
        </div>`;
    }

    function showTooltip(event, html) {
      tooltip.style("display", "block").html(html);
      moveTooltip(event);
    }

    function moveTooltip(event) {
      const [mx, my] = d3.pointer(event, wrapper);
      const ttNode = tooltip.node();
      const ttW = ttNode ? ttNode.offsetWidth : 150;
      const ttH = ttNode ? ttNode.offsetHeight : 40;
      // Flip to the left of the cursor if it would overflow the right edge.
      let left = mx + 12;
      if (left + ttW > width - 4) left = mx - ttW - 12;
      left = Math.max(4, Math.min(left, width - ttW - 4));
      let top = my - 10;
      if (top + ttH > height - 4) top = height - ttH - 4;
      if (top < 4) top = 4;
      tooltip.style("left", `${left}px`).style("top", `${top}px`);
    }

    function hideTooltip() {
      tooltip.style("display", "none");
    }

    // 5. Gradient definitions =================================================
    // One linearGradient per link: source-spell color → target-spell color.
    // gradientUnits="userSpaceOnUse" lets us use exact SVG x-coordinates so
    // the gradient always runs left-to-right along the ribbon regardless of size.
    const uid = `tc-sk-${Math.random().toString(16).slice(2)}`;
    const defs = svg.append("defs");

    defs.selectAll("linearGradient")
      .data(linkObjs)
      .join("linearGradient")
      .attr("id", (_, i) => `${uid}-g${i}`)
      .attr("gradientUnits", "userSpaceOnUse")
      .attr("x1", d => d.source.x1)
      .attr("x2", d => d.target.x0)
      .call(g => g.append("stop")
        .attr("offset", "0%")
        .attr("stop-color", d => colorMap[d.source.id.replace(/2$/, "")]))
      .call(g => g.append("stop")
        .attr("offset", "100%")
        .attr("stop-color", d => colorMap[d.target.id.replace(/2$/, "")]));

    // 6. Link paths ============================================================
    // Base opacity 0.6; brushing interactions animate it to 1.0 (focus) or
    // 0.1 (dimmed). stroke-width increase was intentionally omitted — on wide
    // links it overflows the node rectangle, breaking visual alignment.
    const linkSel = svg.append("g")
      .attr("fill", "none")
      .selectAll("path")
      .data(linkObjs)
      .join("path")
      .attr("d", d3Sankey.sankeyLinkHorizontal())
      .attr("stroke", (_, i) => `url(#${uid}-g${i})`)
      .attr("stroke-width", d => Math.max(1, d.width))
      .attr("stroke-opacity", 0.6);

    // 7. Node rectangles =======================================================
    // A thin semi-transparent white stroke improves edge definition where a
    // same-color ribbon meets the node.
    const nodeSel = svg.append("g")
      .selectAll("rect")
      .data(nodes)
      .join("rect")
      .attr("x", d => d.x0)
      .attr("y", d => d.y0)
      .attr("width", d => d.x1 - d.x0)
      .attr("height", d => d.y1 - d.y0)
      .attr("fill", d => colorMap[d.id.replace(/2$/, "")])
      .attr("stroke", "rgba(255,255,255,0.5)")
      .attr("stroke-width", 1);

    // 8. Node labels ===========================================================
    // Placed outside the nodes: left-column labels are right-aligned to the
    // left of the node; right-column labels are left-aligned to the right.
    // Color is slightly darkened from the node fill for legibility on white.
    svg.append("g")
      .attr("font-family", "system-ui, sans-serif")
      .attr("font-size", 12)
      .attr("font-weight", 600)
      .selectAll("text")
      .data(nodes)
      .join("text")
      .attr("x", d => d.x0 < width / 2 ? d.x0 - 6 : d.x1 + 6)
      .attr("y", d => (d.y0 + d.y1) / 2)
      .attr("dy", "0.35em")
      .attr("text-anchor", d => d.x0 < width / 2 ? "end" : "start")
      .attr("fill", d => d3.color(colorMap[d.id.replace(/2$/, "")]).darker(0.5))
      .text(d => d.id.replace(/2$/, ""));

    // 9. Brushing + interaction handlers ======================================
    // Hovering a link: that link → opacity 1.0; all others → 0.1.
    // Hovering a node: its connected links → 1.0; unconnected → 0.1;
    //                  unconnected nodes → opacity 0.25.
    function highlightLinks(predicate) {
      linkSel.transition().duration(150)
        .attr("stroke-opacity", d => predicate(d) ? 1 : 0.1);
      nodeSel.transition().duration(150)
        .attr("opacity", n => {
          return linkObjs.some(l =>
            predicate(l) && (l.source === n || l.target === n)
          ) ? 1 : 0.25;
        });
    }

    function resetHighlight() {
      linkSel.transition().duration(150)
        .attr("stroke-opacity", 0.6);
      nodeSel.transition().duration(150).attr("opacity", 1);
    }

    linkSel
      .on("pointerenter", function (event, d) {
        highlightLinks(l => l === d);
        showTooltip(event, linkTooltipHTML(d));
      })
      .on("pointermove", moveTooltip)
      .on("pointerleave", () => { resetHighlight(); hideTooltip(); });

    nodeSel
      .on("pointerenter", function (event, n) {
        highlightLinks(l => l.source === n || l.target === n);
        showTooltip(event, nodeTooltipHTML(n));
      })
      .on("pointermove", moveTooltip)
      .on("pointerleave", () => { resetHighlight(); hideTooltip(); });
  }

  // Initial render — pre-mount clientWidth/Height are 0; use fallback.
  render(
    wrapper.clientWidth || 460,
    wrapper.clientHeight || 300
  );

  // Re-render on any card resize (window resize, sidebar toggle, etc.).
  // The SCSS rule on #sankey-card propagates card-body height through the
  // .observablehq wrapper so contentRect.height is accurate.
  const ro = new ResizeObserver(entries => {
    const { width: w, height: h } = entries[0].contentRect;
    if (w > 0 && h > 0) render(w, h);
  });
  ro.observe(wrapper);

  if (typeof invalidation !== "undefined" && invalidation) {
    invalidation.then(() => ro.disconnect());
  }

  return wrapper;
}
// Map dashboard severity inputs to JSON threshold keys
function tdThresholdKey(lfsSev, hfsSev) {
  const lfs = lfsSev === "ls" ? "q20" : "q05";
  const hfs = hfsSev === "ls" ? "q95" : "q99";
  return `${lfs}_${hfs}`;
}
// Build SVG area path between streamflow and threshold for one spell.
// sp.start / sp.end are 0-based JS indices into Q.
function spellAreaPath(Q, sp, threshold, xS, yS) {
  const isHfs = sp.type === "hfs";
  const thY = yS(threshold);
  let d = `M${xS(sp.start)},${thY}`;
  for (let i = sp.start; i <= sp.end; i++) {
    const qFill = isHfs
      ? Math.max(Q[i], threshold)
      : Math.min(Q[i], threshold);

    d += ` L${xS(i)},${yS(qFill)}`;
  }
  d += ` L${xS(sp.end)},${thY} Z`;
  return d;
}
// Self-sizing chart: a ResizeObserver on the wrapper measures the actual card
// dimensions (both width AND height) so the chart adapts to window resize,
// sidebar toggle, or any layout change — no external width/height needed.
function TransitionDiagram(
  data,
  { lfsSev = "ls", hfsSev = "ls", durCls = "wam", focusTr = "HFS-HFS", showAll = false, showAllDur = false, invalidation } = {},
) {
  // Color constants
  const spellColors = {
    HFS_S: "#6BB2D6",
    HFS_M: "#1E75B1",
    HFS_L: "#26456E",
    LFS_S: "#F2B16E",
    LFS_M: "#F87D57",
    LFS_L: "#EE4D5A",
  };

  // Definition of variables
  const thKey = tdThresholdKey(lfsSev, hfsSev);
  const Q = data.streamflow;
  const th = data.thresholds;
  const lfsTh = lfsSev === "ls" ? th.q20 : th.q05;
  const hfsTh = hfsSev === "ls" ? th.q95 : th.q99;
  const spells = data.spells[thKey];
  let trans = data.transitions[thKey];

  // Filter transitions by duration class (skip when showing all durations)
  if (!showAllDur) {
    if (durCls === "wam") trans = trans.filter((t) => t.duration <= 30);
    else if (durCls === "sea")
      trans = trans.filter((t) => t.duration > 30 && t.duration <= 90);
    else if (durCls === "long") trans = trans.filter((t) => t.duration > 90);
  }

  // Filter by transition type (skip when showing all types)
  if (!showAll) trans = trans.filter((t) => t.key === focusTr);

  // Responsive wrapper ========================================================
  // height: 100% propagates card-body height (SCSS #tc-transition-diagram
  // fixes the .observablehq chain)
  const wrapper = document.createElement("div");
  wrapper.style.cssText = "width:100%;height:100%;overflow:hidden;";

  function render(w, h) {
    const width = w;
    const height = h;

    wrapper.innerHTML = "";

    // Spell class labels for tooltip
    const spellClassLabel = {
      HFS_S: "Short HFS",
      HFS_M: "Medium HFS",
      HFS_L: "Long HFS",
      LFS_S: "Short LFS",
      LFS_M: "Medium LFS",
      LFS_L: "Long LFS",
    };

    // Scales ==================================================================
    const mg = { top: 22, right: 24, bottom: 36, left: 30 };
    const xS = d3
      .scaleLinear()
      .domain([0, Q.length - 1])
      .range([mg.left, width - mg.right]);
    const yS = d3
      .scaleLinear()
      .domain([0, d3.max(Q) * 1.08])
      .range([height - mg.bottom - 6, mg.top]); // Add a bottom padding so the LFS label doesn't hit the x-axis

    // Container (relative for tooltip) ========================================
    // Explicit pixel dimensions keep viewBox in 1:1 CSS-pixel space so
    // tooltip coordinates need no scale correction.
    const container = d3.select(wrapper).style("position", "relative");
    const svg = container
      .append("svg")
      .attr("viewBox", [0, 0, width, height])
      .attr("width", width)
      .attr("height", height)
      .style("display", "block");

    // 1. Y-grid lines =========================================================
    const yTicks = yS.ticks(5);
    svg
      .selectAll(".y-grid")
      .data(yTicks)
      .join("line")
      .attr("x1", mg.left)
      .attr("x2", width - mg.right)
      .attr("y1", (d) => yS(d))
      .attr("y2", (d) => yS(d))
      .attr("stroke", "#f1f5f9");

    // 2. Clip path + per-arc gradient definitions =============================
    const clipId = `td-clip-${Math.random().toString(16).slice(2)}`;
    const defs = svg.append("defs");

    defs
      .append("clipPath")
      .attr("id", clipId)
      .append("rect")
      .attr("x", mg.left)
      .attr("y", mg.top)
      .attr("width", width - mg.left - mg.right)
      .attr("height", height - mg.top - mg.bottom);

    // One linearGradient per arc: source-spell color -> target-spell color.
    // gradientUnits="userSpaceOnUse" lets us use exact SVG x-coordinates so
    // the gradient always runs left-to-right along the arc regardless of size.
    trans.forEach((tr, i) => {
      const grad = defs
        .append("linearGradient")
        .attr("id", `${clipId}-g${i}`)
        .attr("gradientUnits", "userSpaceOnUse")
        .attr("x1", xS(tr.from_end))
        .attr("y1", 0)
        .attr("x2", xS(tr.to_start))
        .attr("y2", 0);
      grad.append("stop").attr("offset", "0%").attr("stop-color", spellColors[tr.from_cls]);
      grad.append("stop").attr("offset", "100%").attr("stop-color", spellColors[tr.to_cls]);
    });

    const chart = svg.append("g").attr("clip-path", `url(#${clipId})`);

    // 3. Spell area fills =====================================================
    chart
      .selectAll(".td-spell")
      .data(spells)
      .join("path")
      .attr("d", (sp) =>
        spellAreaPath(Q, sp, sp.type === "hfs" ? hfsTh : lfsTh, xS, yS),
      )
      .attr("fill", (sp) => spellColors[sp.cls])
      .attr("opacity", 0.6);

    // 4. Transition arc lines ================================================
    // Badges are drawn separately after the streamflow line (step 4b) so they
    // always render on top and are never occluded by the streamflow.
    chart
      .selectAll(".td-arc")
      .data(trans)
      .join("path")
      .attr("d", (tr) => {
        const x1 = xS(tr.from_end),
          x2 = xS(tr.to_start);
        const y1 = yS(Q[tr.from_end]),
          y2 = yS(Q[tr.to_start]);
        const cx = (x1 + x2) / 2,
          cy = Math.min(y1, y2) - 28;
        return `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
      })
      .attr("fill", "none")
      .attr("stroke", (tr, i) => `url(#${clipId}-g${i})`)
      .attr("stroke-width", 1.8)
      .attr("stroke-dasharray", "5,3")
      .attr("opacity", 0.8);

    // 5. Threshold lines ======================================================
    [
      { th: hfsTh, stroke: "#0096c7" },
      { th: lfsTh, stroke: "#e63946" },
    ].forEach(({ th: y, stroke }) =>
      chart
        .append("line")
        .attr("x1", mg.left)
        .attr("x2", width - mg.right)
        .attr("y1", yS(y))
        .attr("y2", yS(y))
        .attr("stroke", stroke)
        .attr("stroke-width", 1.4)
        .attr("stroke-dasharray", "6,3"),
    );

    // 6. Streamflow line ======================================================
    chart
      .append("path")
      .datum(Q)
      .attr(
        "d",
        d3
          .line()
          .x((d, i) => xS(i))
          .y((d) => yS(d))
          .curve(d3.curveCatmullRom.alpha(0.5)),
      )
      .attr("fill", "none")
      .attr("stroke", "#264653")
      .attr("stroke-width", 1.6)
      .attr("stroke-linejoin", "round");

    // 4b. Transition arc badges ===============================================
    // after streamflow so they render on top
    // badgePos() returns {bx, cy}: the ideal center is the arc Bezier midpoint.
    // For each of several horizontal offsets we nudge upward until clear of both
    // the streamflow and the arc; we then pick whichever candidate has the
    // smallest total displacement² from the ideal, so left/right shifts are only
    // chosen when they genuinely beat going straight up.
    const r = 12;

    const badgePos = (tr) => {
      const bx0 = (xS(tr.from_end) + xS(tr.to_start)) / 2;
      const x1 = xS(tr.from_end), x2 = xS(tr.to_start);
      const y1 = yS(Q[tr.from_end]), y2 = yS(Q[tr.to_start]);
      const cyCtrl = Math.min(y1, y2) - 28;
      const arcMidY = 0.25 * y1 + 0.5 * cyCtrl + 0.25 * y2;
      const cy0 = Math.max(mg.top + r, arcMidY - r - 4);

      const hasCollision = (bx, cy) => {
        const iMin = Math.max(0, Math.round(xS.invert(bx - r)));
        const iMax = Math.min(Q.length - 1, Math.round(xS.invert(bx + r)));
        for (let i = iMin; i <= iMax; i++) {
          if (Math.abs(yS(Q[i]) - cy) < r + 2) return true;
        }
        for (let t = 0; t <= 1; t += 0.04) {
          const ax = (1-t)*(1-t)*x1 + 2*t*(1-t)*bx0 + t*t*x2;
          if (ax < bx - r || ax > bx + r) continue;
          const ay = (1-t)*(1-t)*y1 + 2*t*(1-t)*cyCtrl + t*t*y2;
          if ((ax-bx)*(ax-bx) + (ay-cy)*(ay-cy) < (r+2)*(r+2)) return true;
        }
        return false;
      };

      let best = { bx: bx0, cy: mg.top + r, dist2: Infinity };

      for (const dxOff of [0, -5, -10, -15, 15, -30, 30]) {
        const bx = bx0 + dxOff;
        if (bx < mg.left + r || bx > width - mg.right - r) continue;
        let cy = cy0;
        for (let attempt = 0; attempt <= 20; attempt++) {
          if (!hasCollision(bx, cy)) {
            const dist2 = dxOff * dxOff + (cy - cy0) * (cy - cy0);
            if (dist2 < best.dist2) best = { bx, cy, dist2 };
            break;
          }
          cy -= 5;
          if (cy < mg.top + r) { cy = mg.top + r; break; }
        }
      }

      return best;
    };

    const badgePositions = trans.map(badgePos);
    const badgeGs = chart.selectAll(".td-badge").data(trans).join("g");

    badgeGs
      .append("circle")
      .attr("cx", (_, i) => badgePositions[i].bx)
      .attr("cy", (_, i) => badgePositions[i].cy)
      .attr("r", r)
      .attr("fill", "#334155")
      .attr("stroke", "#ffffff")
      .attr("stroke-width", 1.5)
      .attr("opacity", 0.9);

    badgeGs
      .append("text")
      .attr("x", (_, i) => badgePositions[i].bx)
      .attr("y", (_, i) => badgePositions[i].cy)
      .attr("text-anchor", "middle")
      .attr("dominant-baseline", "middle")
      .attr("fill", "#fff")
      .attr("font-size", 7.5)
      .attr("font-weight", 700)
      .text((tr) => `${tr.duration}d`);

    // 7. Threshold labels =====================================================
    // inside frame, left edge
    const hfsLabel = hfsSev === "ls" ? "q95" : "q99";
    const lfsLabel = lfsSev === "ls" ? "q20" : "q05";

    svg
      .append("text")
      .attr("x", width - mg.right - 2)
      .attr("y", yS(hfsTh) - 5)
      .attr("text-anchor", "end")
      .attr("font-size", 10)
      .attr("fill", "#0096c7")
      .attr("font-weight", 600)
      .text(`HFS (${hfsLabel})`);

    svg
      .append("text")
      .attr("x", mg.left + 4)
      .attr("y", yS(lfsTh) + 13)
      .attr("text-anchor", "start")
      .attr("font-size", 10)
      .attr("fill", "#e63946")
      .attr("font-weight", 600)
      .text(`LFS (${lfsLabel})`);

    // 8. Y-axis ticks + rotated label =========================================
    const cy = mg.top + (height - mg.top - mg.bottom) / 2;
    svg
      .append("text")
      .attr("transform", `rotate(-90,14,${cy})`)
      .attr("x", 14)
      .attr("y", cy)
      .attr("text-anchor", "middle")
      .attr("dominant-baseline", "middle")
      .attr("font-size", 11)
      .attr("fill", "#64748b")
      .attr("font-weight", 600)
      .text("Streamflow");

    // 9. X-axis month ticks ===================================================
    // Map indices to D3 time scale using origin 2000-10-01
    // Note: JavaScript uses 0-based month indices, so 9 is October.
    const xTimeScale = d3
      .scaleTime()
      .domain([new Date(2000, 9, 1), new Date(2000, 9, Q.length)])
      .range([mg.left, width - mg.right]);

    const xAxisG = svg
      .append("g")
      .attr("transform", `translate(0, ${height - mg.bottom})`)
      .call(
        d3
          .axisBottom(xTimeScale)
          .ticks(width < 400 ? d3.timeMonth.every(4) : d3.timeMonth.every(2))
          .tickFormat(d3.timeFormat("%b"))
          .tickSize(4)
          .tickPadding(6),
      );

    xAxisG.select(".domain").remove();
    xAxisG.selectAll(".tick line").attr("stroke", "#cbd5e1");
    xAxisG
      .selectAll(".tick text")
      .attr("font-size", "9px")
      .attr("fill", "#94a3b8");

    // 10. Chart border ========================================================
    svg
      .append("rect")
      .attr("x", mg.left)
      .attr("y", mg.top)
      .attr("width", width - mg.left - mg.right)
      .attr("height", height - mg.top - mg.bottom)
      .attr("fill", "none")
      .attr("stroke", "#e2e8f0");

    // 11. Tooltip =============================================================
    const tooltip = container
      .append("div")
      .style("position", "absolute")
      .style("display", "none")
      .style("background", "rgba(30,41,59,0.75)")
      .style("-webkit-backdrop-filter", "blur(8px)")
      .style("backdrop-filter", "blur(8px)")
      .style("color", "#f1f5f9")
      .style("padding", "6px 10px")
      .style("border-radius", "6px")
      .style("border", "1px solid rgba(255,255,255,0.15)")
      .style("font-size", "11px")
      .style("pointer-events", "none")
      .style("white-space", "nowrap")
      .style("box-shadow", "0 4px 12px rgba(0,0,0,0.2)")
      .style("z-index", 10);

    svg.on("pointermove", (event) => {
      const [mx, my] = d3.pointer(event);
      const dayIdx = Math.round(xS.invert(mx));
      if (dayIdx < 0 || dayIdx >= Q.length) {
        tooltip.style("display", "none");
        return;
      }

      const sp = spells.find((s) => dayIdx >= s.start && dayIdx <= s.end);
      const tr = trans.find((t) => dayIdx > t.from_end && dayIdx < t.to_start);

      if (!sp && !tr) {
        tooltip.style("display", "none");
        return;
      }

      const row = (content) =>
        `<div style="display:flex;align-items:center;gap:5px;">${content}</div>`;
      const sw = (color) =>
        `<span style="display:inline-block;flex-shrink:0;width:11px;height:11px;background-color:${color};border:1px solid rgba(255,255,255,0.6);border-radius:2px;"></span>`;

      let html = "";
      if (sp)
        html += row(
          sw(spellColors[sp.cls]) +
          `<span style="color:#fbfbfb;font-weight:600">${spellClassLabel[sp.cls]} · ${sp.end - sp.start + 1} days</span>`
        );
      if (tr) {
        const [fromLbl, toLbl] = tr.label.split("➔").map(s => s.trim());
        html +=
          row(
            sw(spellColors[tr.from_cls]) +
            `<span style="color:#fbfbfb;font-weight:600">${fromLbl} ➔</span>` +
            sw(spellColors[tr.to_cls]) +
            `<span style="color:#fbfbfb;font-weight:600">${toLbl}</span>`
          ) +
          row(`<span style="color:#fbfbfb;">${tr.duration} days</span>`);
      }

      tooltip.style("display", "block").html(html);

      const ttNode = tooltip.node();
      const ttWidth = ttNode ? ttNode.offsetWidth : 150;
      const ttHeight = ttNode ? ttNode.offsetHeight : 40;

      let left = mx + 12;
      // If the tooltip overflows the right edge, show it on the left of the pointer
      if (left + ttWidth > width - 4) {
        left = mx - ttWidth - 12;
      }
      // Clamp so the tooltip never exits the left or right edge of the container.
      left = Math.max(4, Math.min(left, width - ttWidth - 4));

      let top = my - 10;
      // If the tooltip overflows the bottom edge, nudge it up
      if (top + ttHeight > height - 4) {
        top = height - ttHeight - 4;
      }
      if (top < 4) top = 4;

      tooltip
        .style("left", `${left}px`)
        .style("top", `${top}px`);
    });

    svg.on("pointerleave", () => tooltip.style("display", "none"));
  }

  // Initial render — pre-mount clientWidth/Height are 0; use fallback.
  render(460, 280);

  // Re-render on any card resize (window resize, sidebar toggle, etc.).
  // The SCSS rule on #tc-transition-diagram propagates card-body height
  // through the .observablehq wrapper so contentRect.height is accurate.
  const ro = new ResizeObserver((entries) => {
    const { width: w, height: h } = entries[0].contentRect;
    if (w > 0) render(w, h);
  });
  ro.observe(wrapper);

  if (typeof invalidation !== "undefined" && invalidation) {
    invalidation.then(() => ro.disconnect());
  }

  return wrapper;
}
// Hover readout card: appears top-right only when a row is hovered.
// Returns an empty span when no hover is active (no placeholder text).
function renderPinnedCard({ hoverRow, selectedLabel, euRow, durCls, entityNameMidSentence = "location" }) {
  if (!hoverRow) return html`<span></span>`;

  const COL = { local: "#D95F02", pooled: "#2171b5" };
  const pct = v => {
    if (v == null) return "—";
    const formatted = (v * 100).toFixed(1);
    if (formatted === "100.0") return "100%";
    if (formatted === "0.0") return "0%";
    return `${formatted}%`;
  };

  const period   = hoverRow.cat_name ?? "";
  const nStr     = hoverRow.tr_n_cat != null
    ? `<span class="tc-sea-card__n">\u202f(n\u202f=\u202f${hoverRow.tr_n_cat})</span>`
    : "";
  const maxIdStr = euRow?.max_regional_id
    ? (entityNameMidSentence === "station"
        ? `(ID:\u202f${euRow.max_regional_id})`
        : `(${euRow.max_regional_id})`)
    : "";
  const maxLine  = euRow?.max_regional_id
    ? `<div class="tc-sea-card__row" style="margin-top:4px;font-size:11px;">
         <span style="display:inline-block;width:16px;text-align:center;color:#888;">ⓘ</span>
         <span style="color:#666;">Highest ${entityNameMidSentence}:</span>
         <b>${pct(euRow.max_regional_share)}</b>
         <span class="tc-sea-card__secondary">\u202f${maxIdStr}</span>
       </div>`
    : "";

  // Build the whole card as one HTML string so the parser sees <sup> in a
  // single pass, avoids the implicit whitespace that Observable's html tag
  // inserts at DOM-node interpolation boundaries.
  const card = `<div class="tc-sea-card">
    <div class="tc-sea-card__period">${period}</div>
    <div class="tc-sea-card__row">
      <span style="width:16px;height:10px;background:${COL.local}40;border:1.5px solid ${COL.local};display:inline-block;border-radius:2px;"></span>
      ${selectedLabel}: <b>${pct(hoverRow.prop_tr_cat_q50)}</b>${nStr}
    </div>
    <div class="tc-sea-card__row">
      <span style="width:16px;height:2px;background:${COL.pooled};display:inline-block;vertical-align:middle;margin-bottom:2px;"></span>
      EU Overall: <b>${pct(euRow ? euRow.prop_tr_cat_pooled : null)}</b>
    </div>
    ${maxLine}
  </div>`;

  return html([card]);
}
// 12-month linear chart: Local area+line, EU Overall solid
function renderWamChart(data, { width, height, marginLeft = 55 } = {}) {
  const COL = { local: "#D95F02", pooled: "#2171b5" };

  // Split once; ruleX / dot marks filter on "local" for selector binding.
  const local = data.filter(d => d.agg_id !== "EU");
  const eu    = data.filter(d => d.agg_id === "EU");

  const vals = [
    ...local.map(d => d.prop_tr_cat_q50 || 0),
    ...eu.map(d => d.prop_tr_cat_pooled || 0)
  ];
  const maxVal = Math.max(0.01, d3.max(vals) || 0) * 1.1;

  const plot = Plot.plot({
    width, height,
    style: { fontSize: "13px", fontFamily: "inherit" },
    insetTop: 8, insetLeft: 0, insetRight: 0,
    marginLeft: marginLeft, marginBottom: 25,
    x: {
      label: null, domain: [1, 12],
      tickFormat: d => new Date(2000, d - 1, 1).toLocaleString(
        "en", { month: "short" }
      )
    },
    y: {
      label: "Proportion",
      labelAnchor: "center",
      labelArrow: "none",
      grid: true, nice: true, domain: [0, maxVal],
      tickFormat: d => `${Math.round(d * 100)}%`
    },
    marks: [
      Plot.frame(),
      Plot.areaY(local, {
        x: "cat_num", y: "prop_tr_cat_q50",
        fill: COL.local, fillOpacity: 0.25,
        sort: "cat_num", curve: "monotone-x"
      }),
      Plot.line(local, {
        x: "cat_num", y: "prop_tr_cat_q50",
        stroke: COL.local, strokeWidth: 2.5,
        sort: "cat_num", curve: "monotone-x"
      }),
      Plot.line(eu, {
        x: "cat_num", y: "prop_tr_cat_pooled",
        stroke: COL.pooled, strokeWidth: 1.5,
        sort: "cat_num", curve: "monotone-x"
      }),
      Plot.dot(local, pointerMX({
        x: "cat_num", y: "prop_tr_cat_q50",
        fill: COL.local, stroke: "#fff", strokeWidth: 2, r: 5,
        selector: "x"
      }))
    ]
  });

  return plot;
}
// 4-season categorical chart: Local bars, EU reference ticks (no connecting lines).
function renderSeaChart(data, { width, height, marginLeft = 55 } = {}) {
  const COL = { local: "#D95F02", pooled: "#2171b5" };
  const seasonOrder = ["DJF", "MAM", "JJA", "SON"];
  const local = data.filter(d => d.agg_id !== "EU");
  const eu    = data.filter(d => d.agg_id === "EU");

  const vals = [
    ...local.map(d => d.prop_tr_cat_q50 || 0),
    ...eu.map(d => d.prop_tr_cat_pooled || 0)
  ];
  const maxVal = Math.max(0.01, d3.max(vals) || 0) * 1.1;

  const plot = Plot.plot({
    width, height,
    style: { fontSize: "13px", fontFamily: "inherit" },
    insetTop: 8, insetLeft: 0, insetRight: 0,
    marginLeft: marginLeft, marginBottom: 25,
    x: {
      type: "band",
      label: null,
      domain: seasonOrder,
      paddingOuter: 0.2,
      paddingInner: 0.2
    },
    y: {
      label: "Proportion of transitions",
      labelAnchor: "center",
      labelArrow: "none",
      grid: true, nice: true, domain: [0, maxVal],
      tickFormat: d => `${Math.round(d * 100)}%`
    },
    marks: [
      Plot.frame(),
      Plot.barY(local, {
        x: "cat_name", y: "prop_tr_cat_q50",
        fill: COL.local, fillOpacity: 0.25,
        stroke: COL.local,
        strokeWidth: 1.5
      }),
      Plot.tickY(eu, {
        x: "cat_name", y: "prop_tr_cat_pooled",
        stroke: COL.pooled, strokeWidth: 3
      }),
      Plot.ruleX(local, pointerMX({
        x: "cat_name", stroke: "none",
        atrest: null, selector: "x"
      }))
    ]
  });

  return plot;
}
// Public entry point. All sizing is handled internally via a ResizeObserver
// on the plot box - this guarantees the plot height equals the available
// height *after* the legend row has consumed its share, fixing the bottom
// clip-off that occurred when the full container height was passed directly.
function TcSeasonalityChart(
  data,
  {
    durCls = "wam",
    selectedLabel = "",
    clickId = null,
    entityNameMidSentence = "station",
    invalidation
  } = {}
) {
  // Empty / placeholder states ================================================
  if (!clickId) {
    return html`<div class="card-placeholder">
      <i>Click on a ${entityNameMidSentence} on the map<br>
         to view its seasonal pattern</i>
    </div>`;
  }
  const localRows = data.filter(d => d.agg_id !== "EU");
  const totalN = d3.sum(localRows, d => d.tr_n_cat || 0);
  if (localRows.length === 0 || totalN === 0) {
    return html`<div class="card-placeholder">
      <i>No transitions available for this ${entityNameMidSentence}<br>
         with the current selection</i>
    </div>`;
  }

  // EU lookup map keyed by cat_name (sea) or cat_num (wam)
  const euMap = new Map();
  for (const r of data.filter(d => d.agg_id === "EU")) {
    euMap.set(durCls === "sea" ? r.cat_name : r.cat_num, r);
  }

  // Pinned card slot — CSS positions this top-right of plotBox
  const cardSlot = document.createElement("div");
  cardSlot.className = "tc-sea-card-slot";

  // Legend — swatch shape matches the actual mark type
  const COL = { local: "#D95F02", pooled: "#2171b5" };
  const isBar = durCls === "sea";
  const localSwatchStyle = isBar
    ? `width:18px;height:10px;background:${COL.local}40;` +
      `border:1.5px solid ${COL.local};display:inline-block;`
    : `width:18px;height:10px;background:${COL.local}40;` +
      `border:1.5px solid ${COL.local};display:inline-block;`;

  const legend = html`<div style="display:flex;gap:14px;font-size:12px;
      padding:4px 0 6px;justify-content:center;flex-wrap:wrap;align-items:center;">
    <div style="display:flex;align-items:center;gap:5px;">
      <span style="${localSwatchStyle}"></span>
      <span>${selectedLabel}</span>
    </div>
    <div style="display:flex;align-items:center;gap:5px;">
      <span style="width:18px;height:2px;background:${COL.pooled};
        display:inline-block;"></span>
      <span>EU Overall</span>
    </div>
  </div>`;

  // plotBox is what the ResizeObserver measures — its height equals the
  // card body minus the legend, so the plot gets the correct pixel height.
  const plotBox = html`<div style="flex:1 1 0;min-height:0;position:relative;
    overflow:hidden;"></div>`;

  const wrap = html`<div style="position:relative;width:100%;height:100%;
      display:flex;flex-direction:column;min-height:0;">
    ${legend}
    ${plotBox}
  </div>`;

  let currentPlot = null;

  const ro = new ResizeObserver(([entry]) => {
    const { width, height } = entry.contentRect;
    if (width <= 0 || height <= 0) return;

    const chartMarginLeft = 55;
    const tooltipPadding = 1;

    const render = durCls === "sea" ? renderSeaChart : renderWamChart;
    const newPlot = render(data, { width, height, marginLeft: chartMarginLeft});

    newPlot.addEventListener("input", () => {
      const v = newPlot.value;
      if (v) {
        // Check if hovered point is on the right half
        const isRightHalf = durCls === "sea" ? ["JJA", "SON"].includes(v.cat_name) : v.cat_num > 6;

        // Flip position
        cardSlot.style.right = isRightHalf ? "auto" : "8px";
        cardSlot.style.left = isRightHalf ? `${chartMarginLeft + tooltipPadding}px` : "auto";
      }
      const key = v ? (durCls === "sea" ? v.cat_name : v.cat_num) : null;
      cardSlot.replaceChildren(renderPinnedCard({
        hoverRow: v,
        selectedLabel,
        euRow: key != null ? euMap.get(key) : null,
        durCls,
        entityNameMidSentence
      }));
    });

    if (currentPlot) {
      plotBox.replaceChild(newPlot, currentPlot);
    } else {
      plotBox.appendChild(newPlot);
      plotBox.appendChild(cardSlot);
    }
    currentPlot = newPlot;
  });

  ro.observe(plotBox);
  if (invalidation) invalidation.then(() => ro.disconnect());

  return wrap;
}
// Defines the input but does not display it here.
// We use it later on the card-toolbar
viewof selected_cluster = {
  // If the agg_lvl isn't correct, we don't need a complex input.
  // if (agg_lvl !== "hyd_cluster") {
  //   // Return a minimal input; its value will be used, but it won't be shown.
  //   // return Inputs.radio(["all"], { value: "all" });
  //   return null;
  // }

  // If agg_lvl is correct, create the full set of options.
  const clusterOptions = new Map([
    // ["All", "all"],
    ...Array.from({length: 11}, (_, i) => [`${i + 1}`, i + 1])
  ]);

  const radioInput = Inputs.radio(clusterOptions, {
    label: null, // The label is handled in the toolbar cell
    value: 1
  });

  const innerDiv = radioInput.querySelector('div');
  if (innerDiv) {
    innerDiv.style.display = "flex";
    innerDiv.style.flexDirection = "row";
    innerDiv.style.alignItems = "center";
    innerDiv.style.gap = "0.5rem";
    innerDiv.style.flexWrap = "wrap";

    // Ensure radio buttons are aligned with label
    innerDiv.querySelectorAll('label').forEach(label => {
      label.style.display = "flex";
      label.style.alignItems = "center";
      label.style.margin = "0";
    });
  }

  return radioInput;
}
Frequency map
// Display the toolbar conditionally if the selected agg_lvl is "hyd_cluster"
{
  if (agg_lvl === "hyd_cluster") {
    // This creates the toolbar's HTML and embeds the input element.
    return html`
      <div style="display: flex; align-items: center; gap: 0.5rem;">
        <label style="margin: 0; font-weight: 500;">
          <i class="bi bi-funnel-fill me-2"></i>Cluster:
        </label>
        ${viewof selected_cluster}
      </div>
    `;
  } else {
    // Return an empty HTML element if conditions are not met
    return html``;
  }
}
Frequency distribution
{

  // Define colors and domain for OJS plot
  const colors = df_cols_ojs[hz_type]; // Array of hex codes used for selected hazard type
  const fullDomain = df_cols_ojs.rp_level; // Classes of return period for histogram

  // Check potential interaction with clicked point
  const clickedItem = t_click_freq[0]?.hz_emp_rp_cl;

  // Define label for Y Axis
  const yLabel = agg_lvl.includes("nuts") ? "Proportion of NUTS (%)" :
                  (agg_lvl === "subid" && data_source === "subid_all") ? "Proportion of subbasins (%)" :
                  "Proportion of stations (%)";

 const pl = Plot.plot({
  insetTop: 10,
  insetLeft: 7,
  insetRight: 7,
  marginLeft: 60,
  // marginBottom: 80, // In the case we need to rotate labels
  // marginBottom: 40,
  marginBottom: 52, // When adding x-axis title
  height: 250,

  style: {
    fontSize: "14px",
    fontFamily: "sans-serif",
    // width: "100%",
    // height: "100%"
  },

  // padding: 0,

  // Map colors
  color: {
    domain: fullDomain,
    range: colors,
    legend: false
    // legend: true, // Show legend for each color
    // swatchSize: 15
  },

  x: {
    label: "Years",
    labelOffset: 50, // Move x-axis title down
    // labelOffset: 75, // Move x-axis title down rotated labels
    domain: fullDomain,
    tickFormat: null,
    tickSize: 0, // Remove tick lines
    // tickFormat: (d) => d,
    // tickRotate: -45,
  },

  y: {
    label: yLabel,
    grid: false,
    anchor: "bottom",
    labelAnchor: "center",
    labelArrow: "none",
    tickFormat: (d) => d * 100,
    nice: true
  },

  marks: [
    Plot.frame(),

    // // Vertical rule for tooltip alignment
    // Plot.ruleX(fullDomain, pointerMX({
    //   x: (d) => d,
    //   stroke: "#9b9b9b",
    //   strokeOpacity: 0.3,
    //   atrest: null
    //  })),

    Plot.barY(t_freq_summary, {
      x: "hz_emp_rp_cl",
      y: "prop",
      fill: "hz_emp_rp_cl",
      stroke: "#333",
      strokeWidth: 0.7,
      strokeOpacity: 0.8
      // domain: [0, 1]
      // sort: {y: "y"}
    }),

    // Color as label squares
    Plot.dot(fullDomain, {
      x: (d) => d,
      y: 0,
      fill: (d) => d,
      r: 6,
      dy: 15,
      symbol: "square",
      stroke: "#333",
      strokeWidth: 0.7,
      strokeOpacity: 0.8
    }),

    // // Text label for each bar showing the class of return period
    // Plot.text(fullDomain,
    //   pointerMX({
    //     x: (d) => d,
    //     atrest: null,
    //     text: (d) => d,
    //     fill: "currentColor",
    //     stroke: "var(--plot-background)",
    //     strokeWidth: 12,
    //     frameAnchor: "top",
    //     dy: 5
    //   })
    // ),

    // Text label for each legend bar showing the class of return period
    Plot.text(fullDomain,
      pointerMX({
        x: (d) => d,
        y: 0,
        dy: 30,
        atrest: null,
        text: (d) => d,
        fill: "currentColor",
        stroke: "var(--plot-background)",
        strokeWidth: 12,
        fontSize: "12px",
        // frameAnchor: "top",
      })
    ),

    clickedItem ? Plot.barY(t_freq_summary, {
      filter: (d) => d.hz_emp_rp_cl === clickedItem,
      x: "hz_emp_rp_cl",
      y: "prop",
      fill: "none",
      stroke: "black",
      strokeWidth: 3
    }) : null


  ]

  });

  // Add legend if item is clicked or not
  if (clickedItem) {
    let legendText = "Clicked item";
    if (agg_lvl.includes("nuts")) {
      legendText = "NUTS";
    } else if (agg_lvl === "subid") {
      legendText = "Station";
    }

    return html`
      <div class="html-fill-item html-fill-container" style="display: flex; flex-direction: column;">
        <div style="flex-grow: 1, overflow: hidden;">
          ${pl}
        </div>
        <!--Create the rectangle to mimic the highlighting border-->
        <div style="
          display: flex;
          align-items: center;
          gap: 8px;
          font-family: sans-serif;
          font-size: 14px;
          margin-top: 5px;
          margin-left: 60px; /* Align with the plot's left margin */
          color: #333;
          flex-shrink: 0;
        ">
          <div style="
            width: 16px;
            height: 16px;
            border: 2.5px solid black;
            background: none;
            box-sizing: border-box;
          "></div>
          <span>${legendText} <strong>${click_id_react}</strong></span>
        </div>

      </div>
    `;


  } else {
    return pl;
  }


}
Intra-annual variability
{

// Test these options for colors:
// ins <- c("#cccccc", "#d9d9d9", "#bdc3c7", "#d1d1d6", "#D5D5D5", "#AAB6CA", "#D1DBD3", "#bdc3c7", "#cccccc")
// out <- c("#e5e5e5", "#f2f2f2", "#ecf0f1", "#f2f2f7", "#EAEAEA", "#D2D5DB", "#E4E7E4", "#E4E7E4", "#e5e5e5")

  const eu_color_inside = "#bdc3c7"
  const eu_color_out = "#ecf0f1"
  const median_line_color = "#d55e00"
  // const curve_type = d3.curveCatmullRom.alpha(0.7)
  const curve_type = "monotone-x"

  const levelNames = {
    subid: "Station",
    nuts2: "NUTS2",
    nuts3: "NUTS3",
    default: "Station"
  };

  const entityName = levelNames[agg_lvl] || levelNames.default;
  const entityNameMidSentence = entityName === "Station" ? "station" : entityName;
  const selectedLabel = `${entityName} ${click_id_react}`;

  const legendDomain = [
    "EU Variability (10th-90th)",
    "EU Variability (25th-75th)",
    selectedLabel
  ];


  if (!click_id_react) {
    return htl.html`
      <div class="card-placeholder">
        <i>Click on a ${entityNameMidSentence} on the map to view its intra-annual variability</i>
      </div>`;
  }
  {
    const _sea = t_click_sea.filter(d => d.agg_id !== "EU");
    if (_sea.length === 0 || d3.sum(_sea, d => d.hz_n_st_month || 0) === 0) {
      return htl.html`
        <div class="card-placeholder">
          <i>No spells detected for this ${entityNameMidSentence}<br>with the current selection</i>
        </div>`;
    }
  }

  const legend = htl.html`
    <div style="display: flex; gap: 20px; font-family: sans-serif; font-size: 12px; padding: 5px 0; justify-content: center; flex-wrap: wrap;">
      <div style="display: flex; align-items: center; gap: 6px;">
        <span style="width: 12px; height: 12px; background: ${eu_color_out}; border: 2px solid #ddd;"></span>
        <span>EU Variability (10<sup>th</sup>-90<sup>th</sup>)</span>
      </div>
      <div style="display: flex; align-items: center; gap: 6px;">
        <span style="width: 12px; height: 12px; background: ${eu_color_inside};"></span>
        <span>EU Variability (25<sup>th</sup>-75<sup>th</sup>)</span>
      </div>
      <div style="display: flex; align-items: center; gap: 6px;">
        <span style="width: 18px; height: 4px; background: ${median_line_color};"></span>
        <span>${entityName} <strong>${click_id_react}</strong></span>
      </div>
    </div>`;


  // Find the absolute highest number in your data (check the q90 column!)
  // const maxVal = d3.max(t_click_sea, d => Math.max(d.prop_st_month_q90, d.prop_st_month_q50));
  const maxVal = d3.max(t_click_sea, d => Math.max(
    d.prop_st_month_q90 || 0, // If q90 is undefined, treat it as 0
    d.prop_st_month_q50 || 0  // If q50 is undefined, treat it as 0
  ));

  // Round up to the next 0.1 (10%)
  const yLimit = Math.ceil(maxVal * 10) / 10;

  // 3. Define the domain
  const yDomain = [0, yLimit];

  const pl = Plot.plot({
    style: {
      fontSize: "14px",
      fontFamily: "sans-serif",
      width: "100%",
      height: "auto"
    },
    insetTop: 2,
    insetLeft: 2,
    insetRight: 2,
    marginLeft: 55,
    marginBottom: 40,
    // width: 1000,  // Need to find a way to make this responsive to the container

    x: {
      label: null,
      tickFormat: (d) => new Date(2000, d - 1, 1).toLocaleString('en', {month: 'short'}),
      domain: [1, 12],
      labelAnchor: "center",
      labelArrow: "none"
    },

    y: {
      label: "Proportion of Spells (%)",
      grid: true,
      anchor: "left",
      labelAnchor: "center",
      labelArrow: "none",
      tickFormat: (d) => d * 100,
      ticks: 5,
      domain: yDomain
    },

    color: {
      domain: legendDomain,
      range: [eu_color_out, eu_color_inside, median_line_color],
      legend: false
    },

    marks: [
      Plot.frame(),

      // EU variability area (10th to 90th percentile)
      Plot.areaY(t_click_sea, {
        filter: (d) => d.agg_id === "EU",
        x: "month_num",
        y1: "prop_st_month_q10",
        y2: "prop_st_month_q90",
        sort: "month_num",
        fill: () => "EU Variability (10th-90th)",
        curve: curve_type
      }),

      // EU variability area (25th to 75th percentile)
      Plot.areaY(t_click_sea, {
        filter: (d) => d.agg_id === "EU",
        x: "month_num",
        y1: "prop_st_month_q25",
        y2: "prop_st_month_q75",
        sort: "month_num",
        fill: () => "EU Variability (25th-75th)",
        fillOpacity: 0.8,
        curve: curve_type
      }),

      // Clicked station/NUTS line
      Plot.line(t_click_sea, {
        filter: (d) => d.agg_id !== "EU",
        x: "month_num",
        y: "prop_st_month_q50",
        sort: "month_num",
        stroke: () => selectedLabel,
        strokeWidth: 3,
        curve: curve_type
      }),

      // Point that follows the mouse with tooltip
      Plot.dot(t_click_sea, pointerMX({
        filter: (d) => d.agg_id !== "EU",
        x: "month_num",
        y: "prop_st_month_q50",
        fill: () => selectedLabel,
        r: 6,
        stroke: "#fff",
        strokeWidth: 3,
        selector: "x"
      })),

      // Vertical rule for tooltip alignment
      Plot.ruleX(t_click_sea, pointerMX({
        filter: (d) => d.agg_id !== "EU",
        x: "month_num",
        stroke: "#616161",
        strokeOpacity: 0.8,
        atrest: null
      })),

      // Tooltip content
      Plot.tip(t_click_sea, pointerMX({
        filter: (d) => d.agg_id !== "EU",
        x: "month_num",
        selector: "eq",
        frameAnchor: "top",
        preferredAnchor: "top-left",
        pointerSize: 0,
        title: (d) => {
          const eu_d = t_click_sea.find(row => row.agg_id === "EU" && row.month_num === d.month_num);
          const monthName = `${d.st_month}\n`;
          const regionInfo = `${selectedLabel}: ${(d.prop_st_month_q50 * 100).toFixed(1)}% (n = ${d.hz_n_st_month})\n`;
          const euInfo = `EU Q1 to Q3: ${(eu_d.prop_st_month_q25 * 100).toFixed(1)} - ${(eu_d.prop_st_month_q75 * 100).toFixed(1)}%`;
          return monthName + regionInfo + euInfo;
        }
      }))
    ]
  });


  return html`
    <div style="display: flex; flex-direction: column; width: 100%; height: 100%;">
      ${legend}
      <div style="flex-grow: 1; min-height: 0;">
        ${pl}
      </div>
  </div>`;
}
Inter-annual variability
{

  const eu_color_inside = "#bdc3c7"
  const eu_color_out = "#ecf0f1"
  const median_line_color = "#d55e00"
  // const curve_type = d3.curveCatmullRom.alpha(0.7)
  const curve_type = "step"

  const levelNames = {
    subid: "Station",
    nuts2: "NUTS2",
    nuts3: "NUTS3",
    default: "Station"
  };

  const entityName = levelNames[agg_lvl] || levelNames.default;
  const entityNameMidSentence = entityName === "Station" ? "station" : entityName;
  const selectedLabel = `Selected ${entityName}`;

  const legendDomain = [
    "Variability (10th-90th)",
    "Normal conditions (25th-75th)",
    selectedLabel
  ];

  if (!click_id_react) {
    return htl.html`
      <div class="card-placeholder">
        <i>Click on a ${entityNameMidSentence} on the map to view its inter-annual variability</i>
      </div>`;
  }
  {
    const _freqYr = t_click_freq_yr.filter(d => d.agg_id !== "EU");
    if (_freqYr.length === 0 || d3.sum(_freqYr, d => d.hz_n_sta_st_year || 0) === 0) {
      return htl.html`
        <div class="card-placeholder">
          <i>No spells detected for this ${entityNameMidSentence}<br>with the current selection</i>
        </div>`;
    }
  }

  if (agg_lvl === "subid") {

    // Find the absolute highest value in data for y yDomain
    const maxVal = d3.max(t_click_freq_yr, d => d.hz_n_sta_st_year)

    // Define the legend
    const legend = htl.html`
    <div style="display: flex; gap: 20px; font-family: sans-serif; font-size: 12px; padding: 5px 0; justify-content: start; flex-wrap: wrap; margin-left: 55px;">
      <div style="display: flex; align-items: center; gap: 6px;">
        <span style="width: 18px; height: 4px; background: ${median_line_color};"></span>
        <span">Station <strong>${click_id_react}</strong></span>
      </div>
    </div>`;

    const pl = Plot.plot({
      style: {
        fontSize: "16px",
        fontFamily: "sans-serif",
      },

      insetTop: 30,
      insetLeft: 2,
      insetRight: 2,
      marginLeft: 55,
      marginBottom: 30,

      x: {
        label: null,
        tickFormat: "d",
        labelAnchor: "center",
        labelArrow: "none"
      },

      y: {
        label: "Spells in a year",
        grid: true,
        anchor: "left",
        labelAnchor: "center",
        labelArrow: "none",
        domain: [0, maxVal],
        // tickFormat: (d) => Number.isInteger(d) ? d : "",
        interval: 1,
        nice: true,
        tickFormat: (d) => d
        // tickFormat: "%",
        // ticks: 5,

      },

      marks: [
        Plot.frame(),

        // Clicked station line
        Plot.line(t_click_freq_yr, {
          x: "st_year",
          y: "hz_n_sta_st_year",
          sort: "st_year",
          stroke: median_line_color,
          strokeWidth: 2,
          strokeOpacity: 0.5,
          curve: curve_type
        }),

        Plot.line(t_click_freq_yr, pointerMX({
          x: "st_year",
          y: "hz_n_sta_st_year",
          sort: "st_year",
          stroke: median_line_color,
          strokeWidth: 2,
          curve: curve_type,
          selector: "before",
          atrest: "maxX"
        })),

        // Point that follows the mouse with tooltip
        Plot.dot(t_click_freq_yr, pointerMX({
          x: "st_year",
          y: "hz_n_sta_st_year",
          fill: median_line_color,
          r: 6,
          stroke: "#fff",
          strokeWidth: 3,
          selector: "x",
          atrest: null
        })),

        // Triangle following the mouse
        Plot.dot(
          t_click_freq_yr,
          pointerMX({
            symbol: "triangle",
            x: "st_year",
            frameAnchor: "top",
            atrest: null,
            fill: "#616161",
            rotate: 180,
            dy: 18
          })
        ),

        // Vertical rule for tooltip alignment
        Plot.ruleX(t_click_freq_yr, pointerMX({
          x: "st_year",
          stroke: "#616161",
          strokeOpacity: 0.8,
          atrest: null,
          // dy: 18,
          insetTop: 18
        })),

        // Text
        Plot.text(
          t_click_freq_yr,
          pointerMX({
            x: "st_year",
            atrest: null,
            text: (d) => String(d.st_year),
            fill: "currentColor",
            color: "#616161",
            frameAnchor: "top",
            dy: 5,
            fontSize: "11px",
            fontWeight: "bold"
        }))

      ]

    })

    return html`
        <div style="display: flex; flex-direction: column; width: 100%; height: 100%;">
            ${legend}
            <div style="flex-grow: 1; min-height: 0;">
              ${pl}
            </div>
        </div>
      `;


  } else if (["nuts2", "nuts3"].includes(agg_lvl)) {

  const maxVal = d3.max(
    d3.rollups(
      t_click_freq_yr,
      // Reducer: Sum the prop_sta for the current group
      (v) => d3.sum(v, (d) => d.prop_sta),
      // Key: Group by the year
      (d) => d.st_year
    ),
    // Accessor: d3.rollups returns an array of [key, value] pairs.
    // We want the max of the values (index 1).
    (d) => d[1]
  );

  const maxSpells = d3.max(t_click_freq_yr, d => d.hz_n_sta_st_year);
  const dynamicTicks = Array.from(new Set([
    1,
    Math.round(maxSpells / 2),
    maxSpells
  ]));

  // Option 1: Show the grouped tooltip ----------------------------------------
  const pl = Plot.plot({
    style: {
      fontSize: "16px",
      fontFamily: "sans-serif",
    },
    insetTop: 45,
    insetLeft: 2,
    insetRight: 2,
    marginLeft: 65,
    marginBottom: 30,
    marginTop: 0,
    // width: 1000,  // Need to find a way to make this responsive to the container

    x: {
      label: null,
      tickFormat: "d",
      labelAnchor: "center",
      labelArrow: "none"
    },

    y: {
      label: "Proportion of\nsub-basins (%)",
      grid: true,
      anchor: "left",
      labelAnchor: "center",
      labelArrow: "none",
      nice: true,
      tickFormat: (d) => d * 100,
      ticks: 5,
      domain: [0, maxVal]
    },

    color: {
      scheme: "Oranges",
      label: null,
      style: {
        marginLeft: "10px",
        marginRight: "10px",
        marginBottom: "10px",
        fontSize: "16px",
        justifyContent: "space-around"
      }
    },

    marks: [
      Plot.frame(),

      Plot.areaY(t_click_freq_yr, {
        x: "st_year",
        y: "prop_sta",
        sort: "st_year",

        z: "hz_n_sta_st_year",
        fill: "hz_n_sta_st_year",
        // order: "hz_n_sta_st_year",
        reverse: true,

        fillOpacity: 0.6,
        stroke: null,
        curve: curve_type
      }),

      Plot.areaY(t_click_freq_yr, pointerMX({
        x: "st_year",
        y: "prop_sta",
        sort: "st_year",

        z: "hz_n_sta_st_year",
        fill: "hz_n_sta_st_year",
        // order: "hz_n_sta_st_year",
        reverse: true,

        // fillOpacity: 0.4,
        stroke: null,
        curve: curve_type,
        atrest: "maxX",
        selector: "before"
      })),

      Plot.lineY(
        t_click_freq_yr,
        pointerMX(Plot.groupX({y: "sum"}, {
          // filter: (d) => d.agg_id !== "EU",
          x: "st_year",
          y: "prop_sta",
          // z: "agg_id",
          selector: "before",
          atrest: "maxX",
          stroke: () => "#646669",
          strokeWidth: 1.5,
          curve: curve_type
        }))
      ),

        // Vertical rule for tooltip alignment
        Plot.ruleX(t_click_freq_yr, pointerMX({
            x: "st_year",
            stroke: "#616161",
            strokeOpacity: 0.8,
            atrest: null,
            insetTop: 18
          })),

        // Grouped tooltip — appends a nested Plot.plot() to the SVG directly.
        // Must NOT use Plot.tip: it ignores a custom render entirely.
        // Pattern: invisible dot + Plot.pointerX(Plot.groupX(...)) + render appends to ownerSVGElement.
        Plot.dot(
          t_click_freq_yr,
          Plot.pointerX(
            Plot.groupX(
              { title: (v) => v },
              {
                x: "st_year",
                r: 0,
                opacity: 0,
                render(index, scales, values, dimensions, context, next) {
                  const base = next(index, scales, values, dimensions, context);

                  // Remove any previous tooltip before appending the new one
                  d3.select(context.ownerSVGElement)
                    .selectAll("g.hz-yr-tooltip")
                    .remove();

                  const [i] = index;
                  if (i === undefined) return base;

                  const yearData = values.title[i]
                    .filter((d) => d.prop_sta > 0)
                    .sort((a, b) => a.hz_n_sta_st_year - b.hz_n_sta_st_year);

                  if (yearData.length === 0) return base;

                  const fmtPct = (v) => v < 0.01
                    ? d3.format(".1%")(v)
                    : d3.format(".1%")(v);

                  const tooltipFontSize = 14;
                  const tooltipWidth = 130;
                  const rowHeight = tooltipFontSize + 2;
                  const tooltipHeight = yearData.length * rowHeight + 32;

                  const xPos = Math.min(
                    values.x[i],
                    dimensions.width - dimensions.marginRight - tooltipWidth
                  );

                  d3.select(context.ownerSVGElement)
                    .append("g")
                    .attr("class", "hz-yr-tooltip")
                    .attr("transform", `translate(${xPos}, ${dimensions.marginTop})`)
                    .append(() =>
                      Plot.plot({
                        marginTop: 18,
                        height: tooltipHeight,
                        width: tooltipWidth,
                        axis: null,

                        style: {
                          fontSize: `${tooltipFontSize}px`,
                          fontFamily: "sans-serif",
                        },

                        y: {
                          domain: yearData.map((d) => d.hz_n_sta_st_year),
                          type: "point",
                        },

                        marks: [
                          Plot.frame({ fill: "white", stroke: "#aaa", rx: 4, strokeWidth: 1.5 }),
                          Plot.dot(yearData, {
                            y: "hz_n_sta_st_year",
                            fill: (d) => scales.color(d.hz_n_sta_st_year),
                            r: 6,
                            frameAnchor: "left",
                            symbol: "square2",
                            dx: 8,
                          }),
                          Plot.text(yearData, {
                            y: "hz_n_sta_st_year",
                            text: (d) => +d.hz_n_sta_st_year === 1
                              ? "1 spell"
                              : `${d.hz_n_sta_st_year} spells`,
                            frameAnchor: "left",
                            textAnchor: "start",
                            dx: 20,
                          }),
                          Plot.text(yearData, {
                            y: "hz_n_sta_st_year",
                            text: (d) => fmtPct(d.prop_sta),
                            frameAnchor: "right",
                            textAnchor: "end",
                            dx: -4,
                            fontWeight: "bold",
                          }),
                          Plot.text([yearData[0]], {
                            frameAnchor: "top-left",
                            dy: -14,
                            dx: 6,
                            text: (d) => String(d.st_year),
                            fontWeight: "bold",
                            fontSize: `${tooltipFontSize + 1}px`,
                          }),
                        ],
                      })
                    );

                  return base;
                },
              }
            )
          )
        )
    ]
  });

 // Extract the legend as a standalone element (without the default Plot label)
  const colorLegend = pl.legend("color", {
    legend: "ramp",
    width: 100,
    marginTop: 0,
    height: 27,
    ticks: dynamicTicks,
    tickFormat: (d) => d,
    style: {
      fontSize: "9px"
    }
  });

  // Return the custom HTML structure composing everything together
  return html`
    <div style="display: flex; flex-direction: column; width: 100%; height: 100%;">

      <div style="display: flex; align-items: center; flex-wrap: nowrap; margin-bottom: 6px; font-size: 12px; color: #444; margin-left: 20px; flex-shrink: 0;">

        <span style="margin-right: 11px; flex-shrink: 0;">
          Proportion of sub-basins in NUTS <strong>${click_id_react}</strong> experiencing
        </span>

        <div class="responsive-legend" style="display: flex; flex-direction: column; align-items: center; flex: 1 1 auto; max-width: 150px; min-width: 50px; margin-right: 30px;">
          <span style="font-size: 10px; font-weight: bold; margin-bottom: 2px;">Number of spells</span>
          ${colorLegend}
        </div>

      </div>

        <div style="flex-grow: 1; min-height: 0; width: 100%;">
          ${pl}
        </div>
    </div>
  `;


  } // If agg_lvl is nuts2 or nuts3
}
html`<div class="mt-0 fw-bold">
  <i class="bi bi-database me-2"></i>Data source & scale:
</div>`
viewof view_mode = customRadioInput(
  [
    "E-HYPE - selected stations",
    "E-HYPE all - NUTS2",
    "E-HYPE all - NUTS3"
  ],
  {
    defaultValue: "E-HYPE - selected stations",
    height_px: height_px,
    customStyles: { marginLeft: "0.5rem", marginBottom: 0, fontSize: "0.9em" }
  }
);

html`<div class="mt-0 fw-bold">
  <i class="bi bi-${hz_type === "LFS" ? "droplet-half" : "droplet-fill"} me-2"></i>Hazard selection:
</div>`
viewof hz_type = customRadioInput(
  new Map([
    ["Low-flow spell", "LFS"],
    ["High-flow spell", "HFS"]
  ]),
  {
    defaultValue: "LFS",
    height_px: height_px,
    formatFn: ([name, value]) => `${name} (${value})`,
    customStyles: { marginLeft: "0.5rem", marginBottom: 0, fontSize: "0.9em" }
  }
);
html`<div class="mt-3 fw-bold">
  <i class="bi bi-bar-chart-steps me-2"></i>Hazard severity (threshold):
</div>`
viewof hz_th = customRadioInput(
  new Map([
    ["Less severe", "ls"],
    ["More severe", "ms"]
  ]),
  {
    defaultValue: "ls",
    height_px: height_px,
    customStyles: { marginLeft: "0.5rem", marginBottom: 0, fontSize: "0.9em" },
    formatFn: ([name, value]) => {

      // Dynamic dictionary that reacts to the current hazard type!
      const thresholdInfo = {
        LFS: { ls: "20<sup>th</sup>", ms: "5<sup>th</sup>" },
        HFS: { ls: "95<sup>th</sup>", ms: "99<sup>th</sup>" }
      };

      const currentPercentile = thresholdInfo[hz_type][value];

      return html`
        <span class="d-inline-block" style="width: 90px;">${name}</span>
        <span>(${currentPercentile} percentile)</span>
      `;
    }
  }
)
html`<div class="mt-3 fw-bold">
  <i class="bi bi-stopwatch me-2"></i>Hazard duration class:
</div>`
viewof hz_dur = customRadioInput(
  new Map([
    ["Short", "short"],
    ["Medium", "medium"],
    ["Long", "long"]
  ]),
  {
    defaultValue: "short",
    height_px: height_px,
    customStyles: { marginLeft: "0.5rem", fontSize: "0.9em" },
    formatFn: ([name, value]) => {

      const durationInfo = {
        LFS: {
          short: "[7, 30]",
          medium: "(30, 90]",
          long: "> 90"
        },
        HFS: {
          short: "[1, 3]",
          medium: "(3, 15]",
          long: "> 15"
        }
      };

      const condition = durationInfo[hz_type][value];

      // 3-Column layout: Name (70px) | Condition (70px, right-aligned) | "days"
      return html`
        <span class="d-inline-block" style="width: 70px;">${name}</span>
        <span class="d-inline-block" style="width: 70px; text-align: right; margin-right: 5px;">
          ${condition}
        </span>
        <span>days</span>
      `;
    }
  }
)
html`
  <div class="d-flex align-items-center gap-2">
    <div class="fw-medium text-nowrap text-dark me-2" style="font-size: 1em;">
      <i class="bi bi-funnel-fill me-1"></i>Transition focus:
    </div>
    ${viewof tc_tr_cl}
  </div>
`

Transition frequency

Transition duration

{
  const levelNames = {
    subid: "Station", nuts2: "NUTS2", nuts3: "NUTS3", default: "Station"
  };
  const entityName = levelNames[agg_lvl] || levelNames.default;
  const entityNameMidSentence = entityName === "Station" ? "station" : entityName;

  // Filter to clicked entity only (exclude EU reference)
  const staHist = t_tc_dur_hist.filter(d => d.agg_id !== "EU");

  if (!tc_click_id_react) {
    return html`
      <div class="card-placeholder">
        <i>Click on a ${entityNameMidSentence} on the map<br>to view its duration distribution</i>
      </div>`;
  }
  if (staHist.length === 0) {
    return html`
      <div class="card-placeholder">
        <i>No transitions available for this ${entityNameMidSentence}<br>with the current selection</i>
      </div>`;
  }

  // x-axis extent and warm sequential color scale
  const xMin = d3.min(staHist, d => d.bin_lo);
  const xMax = d3.max(staHist, d => d.bin_hi);
  const durColorScale = d3.scaleSequential(
    [xMax, xMin], d3.interpolateYlOrRd
  );

  const pl = Plot.plot({
    style: { fontSize: "12px", fontFamily: "sans-serif" },
    insetTop: 55,          // space for grouped tooltip
    marginLeft: 55,
    marginRight: 10,
    marginBottom: 38,
    marginTop: 0,
    x: {
      label: "Days",
      labelArrow: "none",
      labelAnchor: "center",
      domain: [xMin, xMax],
      ticks: staHist.map(d => d.bin_lo).concat([xMax])
    },
    y: {
      label: "Proportion",
      labelArrow: "none",
      anchor: "left",
      labelAnchor: "center",
      tickFormat: d => `${(d * 100).toFixed(0)}%`,
      grid: true
    },
    marks: [
      Plot.frame(),

      // Base bars
      Plot.rectY(staHist, {
        x1: "bin_lo", x2: "bin_hi", y: "prop",
        fill: d => durColorScale(d.bin_lo),
        stroke: "white", strokeWidth: 0.5
      }),

      // Highlighted bar on hover — opacity=0 for empty bins keeps pointer
      // synchronized with tooltip dot (same data, same snap target)
      Plot.rectY(staHist, pointerMX({
        x1: "bin_lo", x2: "bin_hi", y: "prop",
        x: d => (d.bin_lo + d.bin_hi) / 2,
        fill: d => durColorScale(d.bin_lo),
        stroke: "#333", strokeWidth: 2,
        strokeOpacity: d => d.count > 0 ? 1 : 0,
        atrest: null
      })),

      // Vertical rule — opacity=0 for empty bins, same pointer as tooltip
      Plot.ruleX(staHist, pointerMX({
        x: d => (d.bin_lo + d.bin_hi) / 2,
        stroke: "#616161",
        strokeOpacity: d => d.count > 0 ? 0.8 : 0,
        atrest: null, insetTop: 18
      })),

      // Grouped tooltip — nested Plot.plot() appended to ownerSVGElement
      Plot.dot(
        staHist,
        Plot.pointerX(
          Plot.groupX(
            { title: v => v },
            {
              x: d => (d.bin_lo + d.bin_hi) / 2,
              r: 0, opacity: 0,
              render(index, scales, values, dimensions, context, next) {
                const base = next(index, scales, values, dimensions, context);

                d3.select(context.ownerSVGElement)
                  .selectAll("g.tc-dur-tooltip")
                  .remove();

                const [i] = index;
                if (i === undefined) return base;

                const binArr = values.title[i];
                if (!binArr || binArr.length === 0) return base;
                const d = binArr[0];

                // Skip tooltip for empty bins
                if (d.count === 0) return base;

                const tooltipFontSize = 11;
                const tooltipWidth   = 70;
                const tooltipHeight  = 48;

                const xPos = Math.min(
                  values.x[i],
                  dimensions.width - dimensions.marginRight - tooltipWidth
                );

                d3.select(context.ownerSVGElement)
                  .append("g")
                  .attr("class", "tc-dur-tooltip")
                  .attr(
                    "transform",
                    `translate(${xPos}, ${dimensions.marginTop})`
                  )
                  .append(() =>
                    Plot.plot({
                      marginTop: 18,
                      height: tooltipHeight,
                      width: tooltipWidth,
                      axis: null,
                      style: {
                        fontSize: `${tooltipFontSize}px`,
                        fontFamily: "sans-serif"
                      },
                      marks: [
                        Plot.frame({
                          fill: "white", stroke: "#aaa",
                          rx: 4, strokeWidth: 1.5
                        }),
                        // Bin label as header (outside frame, above top edge)
                        Plot.text([d], {
                          frameAnchor: "top-left",
                          dy: -14, dx: 4,
                          text: row => row.bin_label,
                          fontWeight: "bold",
                          fontSize: `${tooltipFontSize}px`
                        }),
                        // Count
                        Plot.text([d], {
                          frameAnchor: "left",
                          textAnchor: "start",
                          dx: 6, dy: -7,
                          text: row => `Count: ${row.count}`
                        }),
                        // Proportion
                        Plot.text([d], {
                          frameAnchor: "left",
                          textAnchor: "start",
                          dx: 6, dy: 7,
                          text: row =>
                            `Prop: ${(row.prop * 100).toFixed(1)}%`
                        })
                      ]
                    })
                  );

                return base;
              }
            }
          )
        )
      )
    ]
  });

  return html`
    <div style="width:100%;height:100%;display:flex;flex-direction:column;
                justify-content:center;padding:4px 4px 0 4px;">
      ${pl}
    </div>`;
}
Transition seasonality
{
  const levelNames = {
    subid: "Station", nuts2: "NUTS2", nuts3: "NUTS3", default: "Station"
  };
  const entityName = levelNames[agg_lvl] || levelNames.default;
  const entityNameMidSentence =
    entityName === "Station" ? "station" : entityName;
  const selectedLabel = tc_click_id_react
    ? `${entityName} ${tc_click_id_react}`
    : "";

  return TcSeasonalityChart(t_tc_sea, {
    durCls: tc_transition_dur,
    selectedLabel,
    clickId: tc_click_id_react,
    entityNameMidSentence,
    invalidation
  });
}
{
  const el = document.getElementById('tc-sea-info-btn');
  if (!el) return;

  // One-time init guard =====================================================
  // Drop Bootstrap Popover entirely: Popper.js miscalculates position inside
  // Quarto Dashboard layouts, and Bootstrap's HTML sanitizer strips the inline
  // style attributes used for the coloured swatches. A fixed-position overlay
  // with manual getBoundingClientRect() positioning avoids both issues.
  if (!el.dataset.popoverInit) {
    el.dataset.popoverInit = '1';

    const overlay = document.createElement('div');
    overlay.className = 'tc-sea-info-popover';
    document.body.appendChild(overlay);
    el._infoOverlay = overlay;

    let open = false;
    const close = () => { overlay.style.display = 'none'; open = false; };

    el.addEventListener('click', e => {
      e.stopPropagation();
      open = !open;
      if (open) {
        overlay.innerHTML = el._infoContent || '';
        const rect = el.getBoundingClientRect();
        // Position above the button, right-edge aligned to button right-edge.
        overlay.style.right  = (window.innerWidth - rect.right) + 'px';
        overlay.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
        overlay.style.display = 'block';
      } else {
        close();
      }
    });

    document.addEventListener('click', () => { if (open) close(); });
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape' && open) close();
    });
  }

  // Content update ============================================================
  // re-runs whenever agg_lvl changes
  const entityLabel =
    ({ subid: "station", nuts2: "NUTS2 region", nuts3: "NUTS3 region" })[agg_lvl]
    ?? "location";

  const unitLabel = tc_transition_dur === "wam" ? "month" : "season";

  const COL = { local: "#D95F02", pooled: "#2171b5" };

  const content = `
    <div class="tc-sea-info-body">
      <p class="tc-sea-info-title">Timing of transitions throughout the year</p>
      <div class="tc-sea-info-row">
        <span class="tc-sea-info-swatch"
              style="background:${COL.local}40;border:1.5px solid ${COL.local};">
        </span>
        <span>
          <b>Selected ${entityLabel}:</b>
          the percentage of transitions occurring in each ${unitLabel} for the selected ${entityLabel}.
        </span>
      </div>
      <div class="tc-sea-info-row">
        <span class="tc-sea-info-line" style="border-top:2px solid ${COL.pooled};background:none;"></span>
        <span>
          <b>EU Overall:</b>
          the total proportion of transitions pooled across all of Europe for a given ${unitLabel}.
        </span>
      </div>
    </div>`;

  el._infoContent = content;
  // Refresh immediately if overlay is already visible (agg_lvl changed while open)
  if (el._infoOverlay?.style.display !== 'none') {
    el._infoOverlay.innerHTML = content;
  }
}
viewof tc_focus_only = {
  const toggle = Inputs.toggle({ label: "Focus only", value: true });

  // Apply flex layout to the parent form container to create the gap
  toggle.style.cssText = "display:flex;align-items:center;gap:0.5rem;border:none;background:transparent;padding:0;";

  const label = toggle.querySelector("label");
  if (label) {
    label.style.cssText =
      "margin:0;cursor:pointer;font-size:0.85em;font-weight:500;" +
      "white-space:nowrap;color:#475569;";
  }

  const input = toggle.querySelector("input");
  if (input) input.style.cssText = "margin:0;cursor:pointer;";

  return toggle;
}
viewof tc_all_dur = {
  const toggle = Inputs.toggle({ label: "All durations", value: false });

  toggle.style.cssText = "display:flex;align-items:center;gap:0.5rem;border:none;background:transparent;padding:0;";

  const label = toggle.querySelector("label");
  if (label) {
    label.style.cssText =
      "margin:0;cursor:pointer;font-size:0.85em;font-weight:500;" +
      "white-space:nowrap;color:#475569;";
  }

  const input = toggle.querySelector("input");
  if (input) input.style.cssText = "margin:0;cursor:pointer;";

  return toggle;
}
Transition diagram
html`<div style="display:flex;align-items:center;gap:1rem;">
  ${viewof tc_focus_only}
  ${viewof tc_all_dur}
</div>`
TransitionDiagram(td_data, {
  lfsSev: tc_sev_lfs,
  hfsSev: tc_sev_hfs,
  durCls: tc_transition_dur,
  focusTr: tc_tr_cl,
  showAll: !tc_focus_only,
  showAllDur: tc_all_dur,
  invalidation
})
Transition proportions
{
  const levelNames = {
    subid: "station", nuts2: "NUTS2 region", nuts3: "NUTS3 region", default: "station"
  };
  const entityName = levelNames[agg_lvl] || levelNames.default;

  const clickedId = tc_click_id_react;
  if (!clickedId) {
    return html`
      <div class="card-placeholder">
        <i>Click on a ${entityName} on the map<br>to view its transition proportions</i>
      </div>`;
  }

  const raw = t_tc_sankey.filter(
    d => d.agg_id === clickedId && d.agg_id !== "EU"
  );
  if (raw.length === 0) {
    return html`
      <div class="card-placeholder">
        <i>No transition data for this ${entityName}</i>
      </div>`;
  }

  const links = raw
    .filter(d => d.value > 0)
    .map(d => ({
      source: d.source_node,
      target: d.target_node + "2",
      value:  d.value
    }));

  const colorMap = {
    HFS_S: "#6BB2D6",
    HFS_M: "#1E75B1",
    HFS_L: "#26456E",
    LFS_S: "#F2B16E",
    LFS_M: "#F87D57",
    LFS_L: "#EE4D5A"
  };
  const nodeOrder = ["HFS_S","HFS_M","HFS_L","LFS_S","LFS_M","LFS_L"];

  return TCSankeyDiagram({ links }, {
    invalidation,
    colorMap,
    nodeOrder,
    clusterGap: 20
  });
}
html`<div class="mt-0 fw-bold">
  <i class="bi bi-database me-2"></i>Data source & scale:
</div>`
Inputs.bind(
  customRadioInput(
    [
      "E-HYPE - selected stations",
      "E-HYPE all - NUTS2",
      "E-HYPE all - NUTS3"
    ],
    {
      defaultValue: "E-HYPE - selected stations",
      height_px: height_px,
      customStyles: { marginLeft: "0.5rem", marginBottom: 0, fontSize: "0.9em" }
    }
  ),
  viewof view_mode
);

html`<div class="mt-0 fw-bold">
  <i class="bi bi-bar-chart-steps me-2"></i>Hazard severity (threshold):
</div>`
html`<div class="ms-2 mt-1" style="font-size: 0.9em;">
  <i class="bi bi-droplet-half me-1"></i>Low-flow spell (LFS):
</div>`
viewof tc_sev_lfs = customRadioInput(
  new Map([
    ["Less severe", "ls"],
    ["More severe", "ms"]
  ]),
  {
    defaultValue: "ls",
    height_px: height_px,
    customStyles: { marginLeft: "1.5rem", fontSize: "0.9em" },
    formatFn: ([name, value]) => {
      const percentiles = { ls: "20<sup>th</sup>", ms: "5<sup>th</sup>" };

      // We force the name to take up exactly 90px of space so the parentheses align
      return html`
        <span style="display: inline-block; width: 90px;">${name}</span>
        <span>(${percentiles[value]} percentile)</span>
      `;
    }
  }
)

// 4. HFS Sub-heading
html`<div class="ms-2 mt-1" style="font-size: 0.9em;">
  <i class="bi bi-droplet-fill me-1"></i>High-flow spell (HFS):
</div>`
viewof tc_sev_hfs = customRadioInput(
    new Map([
      ["Less severe", "ls"],
      ["More severe", "ms"]
    ]),
    {
      defaultValue: "ls",
      height_px: height_px,
      customStyles: { marginLeft: "1.5rem", fontSize: "0.9em" },
      formatFn: ([name, value]) => {
        const percentiles = { ls: "95<sup>th</sup>", ms: "99<sup>th</sup>" };

        return html`
          <span style="display: inline-block; width: 90px;">${name}</span>
          <span>(${percentiles[value]} percentile)</span>
        `;
      }
    }
);
html`<div class="mt-3 fw-bold">
  <i class="bi bi-hourglass-split me-2"></i>Transition duration class:
</div>`
html`<style>
  label:has(.transition-all-option) {
    margin-top: 0.4rem !important; /* Creates that subtle visual break */
  }
</style>`
viewof tc_transition_dur = customRadioInput(
  new Map([
    ["Within-a-month", "wam"],
    ["Seasonal", "sea"]
    // ["Longer", "lon"],
    // ["All", "all"]
  ]),
  {
    defaultValue: "wam", // Feel free to change this default!
    height_px: height_px,
    customStyles: { marginLeft: "0.5rem", marginRight: 0, fontSize: "0.9em" },
    formatFn: ([name, value]) => {

      // If it's the "All" option, just return the name wrapped in our special class
      if (value === "all") {
        return html`<span class="transition-all-option">${name}</span>`;
      }

      // Dictionary for the math conditions
      const conditions = {
        wam: "≤ 30",
        sea: "(30, 90]",
        // lon: "> 90"
      };

      // Create two fixed-width columns to ensure everything aligns like a table
      return html`
        <span style="display: inline-block; width: 110px;">${name}</span>
        <span style="display: inline-block; width: 55px; text-align: right; margin-right: 5px;">${conditions[value]}</span>
        <span>days</span>
      `;
    }
  }
)

html`
<div class="mt-4">
  <div class="fw-bold mb-2" style="font-size: 0.95em;">
    <i class="bi bi-info-circle me-2"></i>Hazard duration classes (days):
  </div>

  <table class="table table-sm table-borderless" style="font-size: 0.85em; margin-bottom: 0;">
    <thead>
      <tr style="border-bottom: 1px solid #dee2e6;">
        <th class="fw-normal pb-1" style="padding-left: 0;">Class</th>
        <th class="fw-normal pb-1">HFS</th>
        <th class="fw-normal pb-1">LFS</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td style="padding-left: 0;">Short</td>
        <td>[1, 3]</td>
        <td>[7, 30]</td>
      </tr>
      <tr>
        <td style="padding-left: 0;">Medium</td>
        <td>(3, 15]</td>
        <td>(30, 90]</td>
      </tr>
      <tr>
        <td style="padding-left: 0;">Long</td>
        <td>> 15</td>
        <td>> 90</td>
      </tr>
    </tbody>
  </table>
</div>
`
viewof tc_tr_cl = {
  const radio = Inputs.radio(
    new Map([
      // Swapped to the thicker Unicode Heavy Arrow (U+2794)
      ["HFS ➔ HFS", "HFS-HFS"],
      ["HFS ➔ LFS", "HFS-LFS"],
      ["LFS ➔ LFS", "LFS-LFS"],
      ["LFS ➔ HFS", "LFS-HFS"]
    ]),
    { value: "HFS-HFS" } // Default value
  );

  // Apply the Single-Line Flex Layout!
  const div = radio.querySelector("div");
  if (div) {
    div.style.display = "flex";
    div.style.flexDirection = "row"; // Forces all buttons into one horizontal line
    div.style.alignItems = "center";
    div.style.gap = "1.25rem";       // Adds nice breathing room between each option
    div.style.margin = "0";

    // Make sure the labels don't wrap and fit the toolbar nicely
    div.querySelectorAll("label").forEach(label => {
      label.style.whiteSpace = "nowrap";
      label.style.margin = "0";
      // Optional: Add a subtle transition effect for when users click
      label.style.cursor = "pointer";
    });
  }

  // Shrink the font slightly so it fits beautifully in the toolbar
  radio.style.fontSize = "0.85em";
  radio.style.border = "none";
  radio.style.background = "transparent"

  return radio;
}
// Map sidebar severity selections to the DB th_lb column
tc_th_lb = {
  return `${tc_sev_lfs}-${tc_sev_hfs}`;
}

  • European Union logo with text: Funded by the European Union.
  • MedEWSa project Logo
  • MedEWSa grant agreement n. 101121192
  • SMHI logo
  • INRAE logo
  • INRAE's HYDRO team logo
Disclaimer

The data displayed on this page are for testing purposes only.

This is a prototype version of the multi-hazard analytics platform.