// 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
}