diff --git a/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py b/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py index f9a06218f..799ee6370 100755 --- a/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py +++ b/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py @@ -29,7 +29,7 @@ from importlib.metadata import version from pathlib import Path -from CSET._common import combine_dicts, sort_dict +from CSET._common import sort_dict logging.basicConfig( level=os.getenv("LOGLEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s" @@ -64,35 +64,33 @@ def install_website_skeleton(www_root_link: Path, www_content: Path): def construct_index(www_content: Path): """Construct the plot index.""" plots_dir = www_content / "plots" - index = {} - # Loop over all diagnostics and append to index. - for metadata_file in plots_dir.glob("**/*/meta.json"): - try: - with open(metadata_file, "rt", encoding="UTF-8") as fp: - plot_metadata = json.load(fp) - - category = plot_metadata["category"] - case_date = plot_metadata.get("case_date", "") - relative_url = str(metadata_file.parent.relative_to(plots_dir)) - - record = { - category: { - case_date if case_date else "Aggregation": { - relative_url: plot_metadata["title"].strip() - } - } - } - except (json.JSONDecodeError, KeyError, TypeError) as err: - logging.error("%s is invalid, skipping.\n%s", metadata_file, err) - continue - index = combine_dicts(index, record) - - # Sort index of diagnostics. - index = sort_dict(index) - - # Write out website index. - with open(plots_dir / "index.json", "wt", encoding="UTF-8") as fp: - json.dump(index, fp, indent=2) + with open(plots_dir / "index.jsonl", "wt", encoding="UTF-8") as index_fp: + # Loop over all diagnostics and append to index. The glob is sorted to + # ensure a consistent ordering. + for metadata_file in sorted(plots_dir.glob("**/*/meta.json")): + try: + with open(metadata_file, "rt", encoding="UTF-8") as plot_fp: + plot_metadata = json.load(plot_fp) + plot_metadata["path"] = str(metadata_file.parent.relative_to(plots_dir)) + # Remove keys that are not useful for the index. + removed_index_keys = [ + "description", + "plot_resolution", + "plots", + "skip_write", + "SUBAREA_EXTENT", + "SUBAREA_TYPE", + ] + for key in removed_index_keys: + plot_metadata.pop(key, None) + # Sort plot metadata. + plot_metadata = sort_dict(plot_metadata) + # Write metadata into website index. + json.dump(plot_metadata, index_fp, separators=(",", ":")) + index_fp.write("\n") + except (json.JSONDecodeError, KeyError, TypeError) as err: + logging.error("%s is invalid, skipping.\n%s", metadata_file, err) + continue def bust_cache(www_content: Path): diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/index.html b/src/CSET/cset_workflow/app/finish_website/file/html/index.html index b1b0a5de7..8dc4d481c 100644 --- a/src/CSET/cset_workflow/app/finish_website/file/html/index.html +++ b/src/CSET/cset_workflow/app/finish_website/file/html/index.html @@ -14,10 +14,64 @@

CSET

+ + +
+ Filter help +

+ The filter consists of a space separated list of conditions. + A diagnostic is shown if all conditions are true. + Each condition takes the form: +

+ + [ facet ] [ operator ] value +

+ Where the facet is the name of the attribute of the diagnostic to test, and the operator is one of: +

+
+
:
+
A colon tests whether the value is in (is a substring of) the facet.
+
=
+
An equals sign tests whether the value is exactly equal to the facet.
+
<, >, <=, >=
+
A less-than, greater-than, less-than-or-equal or greater-than-or-equal sign tests whether the value sorts before or after the facet, allowing for ranges of dates and numbers.
+
+

+ If no facet is specified it defaults to checking the value is in the title, equivalent to title:value. + Values may be quoted to include special characters, such as spaces. +

+

Conditions may be combined with AND or OR, or prefixed with NOT to get the inverse.

+

Examples

+
+
histogram
+
"histogram" in the title.
+
title:"air temperature"
+
"air temperature" in the title.
+
field:temperature
+
"temperature" in facet "field".
+
field=temperature
+
"temperature" exactly matches field.
+
NOT temperature
+
"temperature" is not in title.
+
field=x_wind OR field=y_wind
+
Field equals "x_wind" or "y_wind".
+
histogram AND field:temperature
+
"histogram" in title and "temperature in facet "field".
+
(histogram AND field:temperature) OR (time_series AND field:humidity)
+
Histograms of temperature and time series of humidity. Parenthesis indicate precedence.
+
+
+
+ Search facet dropdowns +
+ +
-
- +
diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/script.js b/src/CSET/cset_workflow/app/finish_website/file/html/script.js index 265bb2289..f18ad1e72 100644 --- a/src/CSET/cset_workflow/app/finish_website/file/html/script.js +++ b/src/CSET/cset_workflow/app/finish_website/file/html/script.js @@ -1,6 +1,383 @@ // JavaScript code that is used by the pages. Plots should not rely on this // file, as it will not be stable. +/** Search query lexer and parser. + * + * EBNF for filter. + * + * query = expression ; + * + * expression = condition + * | expression , combiner ? , expression + * | "NOT" , expression + * | "(" , expression , ")" ; + * + * combiner = "AND" + * | "OR" ; + * + * condition = facet ? , operator ? , value ; + * + * facet = LITERAL ; + * + * value = LITERAL ; + * + * operator = IN + * | EQUALS ; + * | LESS_THAN + * | GREATER_THAN + * | LESS_THAN_OR_EQUALS + * | GREATER_THAN_OR_EQUALS + */ + +class Literal { + constructor(value) { + this.value = value; + } +} + +const Parenthesis = { + BEGIN: "BEGIN", + END: "END", +}; + +const Operator = { + IN: "IN", + EQUALS: "EQUALS", + LESS_THAN: "LESS_THAN", + GREATER_THAN: "GREATER_THAN", + LESS_THAN_OR_EQUALS: "LESS_THAN_OR_EQUALS", + GREATER_THAN_OR_EQUALS: "GREATER_THAN_OR_EQUALS", +}; + +const Combiner = { + NOT: "NOT", + AND: "AND", + OR: "OR", +}; + +const TokenTypes = { + Literal: Literal, + Parenthesis: Parenthesis, + Operator: Operator, + Combiner: Combiner, +}; + +const TOKEN_SPEC = { + Parenthesis_BEGIN: "\\(", + Parenthesis_END: "\\)", + Operator_GREATER_THAN_OR_EQUALS: "<=", + Operator_GREATER_THAN: "<", + Operator_LESS_THAN_OR_EQUALS: ">=", + Operator_LESS_THAN: ">", + Operator_EQUALS: "=", + Operator_IN: ":", + Combiner_NOT: "\\bnot\\b", + Combiner_AND: "\\band\\b", + Combiner_OR: "\\bor\\b", + LexOnly_WHITESPACE: "\\s+", + LexOnly_LITERAL: `'[^']*'|"[^"]*"|[^\\s\\(\\):=<>]+`, +}; + +const TOKEN_REGEX = RegExp( + Array.from( + Object.entries(TOKEN_SPEC).map((pair) => { + return `(?<${pair[0]}>${pair[1]})`; + }), + ).join("|"), + "ig", +); + +// Lex input string into tokens. +function lexer(query) { + const tokens = []; + for (const match of query.matchAll(TOKEN_REGEX)) { + // Split into tokens by matching the capture group names from TOKEN_SPEC. + if (!match.groups) { + throw new SyntaxError("Query did not consist of valid tokens."); + } + let [kind, value] = Object.entries(match.groups).filter( + (pair) => pair[1] !== undefined, + )[0]; + switch (kind) { + case "LexOnly_WHITESPACE": + // Skip whitespace. + continue; + case "LexOnly_LITERAL": + // Unquote and store value for literals. + if (/^".*"$|^'.+'$/.test(value)) { + value = value.slice(1, -1); + } + tokens.push(new Literal(value)); + break; + default: + // Tokens for Operators and Combiners. + const kind_split_position = kind.indexOf("_"); + const kind_type = kind.slice(0, kind_split_position); + const kind_value = kind.slice(kind_split_position + 1); + tokens.push(TokenTypes[kind_type][kind_value]); + break; + } + } + return tokens; +} + +class Condition { + constructor(value, facet, operator) { + // Allow constructing a Condition from a Condition, e.g: (((Condition))) + if (typeof value === "function") { + this.func = value; + return; + } + const v = value.value.toLowerCase(); + const f = facet.value; + let cond_func; + switch (operator) { + case Operator.IN: + cond_func = function cond_in(d) { + return f in d && d[f].toLowerCase().includes(v); + }; + break; + case Operator.EQUALS: + cond_func = function cond_eq(d) { + return f in d && v === d[f].toLowerCase(); + }; + break; + case Operator.GREATER_THAN: + cond_func = function cond_gt(d) { + return f in d && v > d[f].toLowerCase(); + }; + break; + case Operator.GREATER_THAN_OR_EQUALS: + cond_func = function cond_gte(d) { + return f in d && v >= d[f].toLowerCase(); + }; + break; + case Operator.LESS_THAN: + cond_func = function cond_lt(d) { + return f in d && v < d[f].toLowerCase(); + }; + break; + case Operator.LESS_THAN_OR_EQUALS: + cond_func = function cond_lte(d) { + return f in d && v <= d[f].toLowerCase(); + }; + break; + default: + throw new Error(`Invalid operator: ${operator}`); + } + this.func = cond_func; + } + + test(d) { + return this.func(d); + } + + and(other) { + return new Condition((d) => this.test(d) && other.test(d)); + } + + or(other) { + return new Condition((d) => this.test(d) || other.test(d)); + } + + invert() { + return new Condition((d) => !this.test(d)); + } +} + +// Parse a grouped expression from a stream of tokens. +function parse_grouped_expression(tokens) { + if (tokens.length < 2 || tokens[0] !== Parenthesis.BEGIN) { + return [0, null]; + } + let offset = 1; + let depth = 1; + while (depth > 0 && offset < tokens.length) { + switch (tokens[offset]) { + case Parenthesis.BEGIN: + depth += 1; + break; + case Parenthesis.END: + depth -= 1; + break; + } + offset += 1; + } + if (depth !== 0) { + throw new Error("Unmatched parenthesis."); + } + // Recursively parse the grouped expression. + inner_expression = parse_expression(tokens.slice(1, offset - 1)); + return [offset, inner_expression]; +} + +// Parse a condition from a stream of tokens. +function parse_condition(tokens) { + if ( + tokens[0] instanceof Literal && + tokens[1] in Operator && + tokens[2] instanceof Literal + ) { + // Value to search for in facet with operator. + return [3, new Condition(tokens[2], (facet = tokens[0]), (operator = tokens[1]))]; + } else if (tokens[0] instanceof Literal) { + // Just a value to search for. + return [ + 1, + new Condition( + tokens[0], + (facet = new Literal("title")), + (operator = Operator.IN), + ), + ]; + } else { + // Not matched as a condition. + return [0, null]; + } +} + +// Collapse all NOTs in a list of conditions. +function evaluate_not(conditions) { + const negated_conditions = []; + let index = 0; + while (index < conditions.length) { + if (conditions[index] === Combiner.NOT && conditions[index + 1] === Combiner.NOT) { + // Skip double NOTs, as they negate each other. + index += 2; + } else if ( + conditions[index] === Combiner.NOT && + conditions[index + 1] instanceof Condition + ) { + const right = conditions[index + 1]; + negated_conditions.push(right.invert()); + index += 2; + } else if (conditions[index] !== Combiner.NOT) { + negated_conditions.push(conditions[index]); + index += 1; + } else { + throw new Error("Unprocessable NOT."); + } + } + return negated_conditions; +} + +// Collapse all explicit and implicit ANDs in a list of conditions. +function evaluate_and(conditions) { + const anded_conditions = []; + let index = 0; + while (index < conditions.length) { + let left = null; + if (anded_conditions.length) { + left = anded_conditions.pop(); + } + if ( + left instanceof Condition && + conditions[index] === Combiner.AND && + conditions[index + 1] instanceof Condition + ) { + const right = conditions[index + 1]; + anded_conditions.push(left.and(right)); + index += 2; + } else if (left instanceof Condition && conditions[index] instanceof Condition) { + const right = conditions[index]; + anded_conditions.push(left.and(right)); + index += 2; + } else if (conditions[index] !== Combiner.AND) { + if (left !== null) { + anded_conditions.push(left); + } + const right = conditions[index]; + anded_conditions.push(right); + index += 1; + } else { + throw new Error("Unprocessable AND."); + } + } + return anded_conditions; +} + +// Collapse all ORs in a list of conditions. +function evaluate_or(conditions) { + const ored_conditions = []; + let index = 0; + while (index < conditions.length) { + if ( + conditions[index] instanceof Condition && + conditions[index + 1] === Combiner.OR && + conditions[index + 2] + ) { + const left = conditions[index]; + const right = conditions[index + 2]; + ored_conditions.push(left.or(right)); + index += 3; + } else if (conditions[index] !== Combiner.OR) { + ored_conditions.push(conditions[index]); + index += 1; + } else { + throw new Error("Unprocessable OR."); + } + } + return ored_conditions; +} + +// Parse an expression into a single Condition function. +function parse_expression(tokens) { + let conditions = []; + let index = 0; + while (index < tokens.length) { + console.log("Conditions:", conditions); + console.log("Token index:", index); + // Accounts for AND/OR/NOT. + if (tokens[index] in Combiner) { + conditions.push(tokens[index]); + index += 1; + continue; + } + // Accounts for parentheses. + let [offset, condition] = parse_grouped_expression(tokens.slice(index)); + if (offset > 0 && condition !== null) { + conditions.push(condition); + index += offset; + continue; + } + // Accounts for Facets, Operators, and Literals. + [offset, condition] = parse_condition(tokens.slice(index)); + if (offset > 0 && condition !== null) { + conditions.push(condition); + index += offset; + continue; + } + console.error(tokens[index]); + throw new Error(`Unexpected token in expression: ${tokens[index]}`); + } + // Evaluate NOTs first, left to right. + conditions = evaluate_not(conditions); + // Evaluate ANDs second, left to right. + conditions = evaluate_and(conditions); + // Evaluate ORs third, left to right. + conditions = evaluate_or(conditions); + // Verify we have collapsed down to a single condition at this point. + if (conditions.length !== 1 || !(conditions[0] instanceof Condition)) { + throw new Error("Collapse should produce a single Condition."); + } + return conditions[0]; +} + +// Parse the query, returning a comparison function. +function query2condition(query) { + const tokens = lexer(query); + console.log("Tokens:", tokens); + if (tokens.length === 0) { + // If query is empty show everything. + return new Condition((_) => true); + } + return parse_expression(tokens); +} + +/** + * End of query parser. + */ + // Toggle display of the extended description for plots. Global variable so it // can be referenced at plot insertion time. let description_shown = true; @@ -14,7 +391,7 @@ function enforce_description_toggle() { } label: for (plot_frame of document.querySelectorAll("iframe")) { const description_container = plot_frame.contentDocument.getElementById( - "description-container" + "description-container", ); // Skip doing anything if plot not loaded. if (!description_container) { @@ -66,87 +443,138 @@ function ensure_dual_frame() { dual_frame.classList.remove("hidden"); } -function construct_sidebar_from_data(data) { - const sidebar = document.getElementById("plot-selector"); +function add_to_sidebar(record, facet_values) { + const diagnostics_list = document.getElementById("diagnostics"); + + // Add entry's display name. + const entry_title = document.createElement("h2"); + entry_title.textContent = record["title"]; + + // Create card for diagnostic. + const facets = document.createElement("dl"); + for (const facet in record) { + if (facet !== "title" && facet !== "path") { + const facet_node = document.createElement("div"); + const facet_name = document.createElement("dt"); + const facet_value = document.createElement("dd"); + facet_name.textContent = facet; + facet_value.textContent = record[facet]; + facet_node.append(facet_name, facet_value); + facets.append(facet_node); + // Record facet values. + if (!(facet in facet_values)) { + facet_values[facet] = new Set(); + } + facet_values[facet].add(record[facet]); + } + } + + // Container element for plot position chooser buttons. + const position_chooser = document.createElement("div"); + position_chooser.classList.add("plot-position-chooser"); + + // Bind path to name in this scope to ensure it sticks around for callbacks. + const path = record["path"]; // Button icons. - const icons = { left: "◧", full: "▣", right: "◨" }; - - for (const category in data) { - // Details element for category. - const category_details = document.createElement("details"); - - // Title for category (summary element). - const category_summary = document.createElement("summary"); - category_summary.textContent = category; - category_details.append(category_summary); - - // Add each case date into category. - for (const case_date in data[category]) { - // Details element for case_date. - const case_details = document.createElement("details"); - - // Title for case_date. - const case_summary = document.createElement("summary"); - case_summary.textContent = case_date; - case_details.append(case_summary); - - // Menu of plots for this category and case_date. - const case_menu = document.createElement("menu"); - - // Add each plot. - for (const plot in data[category][case_date]) { - // Menu entry for plot. - const list_item = document.createElement("li"); - list_item.textContent = data[category][case_date][plot]; - - // Container element for plot position chooser buttons. - const position_chooser = document.createElement("div"); - position_chooser.classList.add("plot-position-chooser"); - - // Add buttons for each position. - for (const position of ["left", "full", "right"]) { - // Create button. - const button = document.createElement("button"); - button.classList.add(position); - button.textContent = icons[position]; - - // Add a callback updating the iframe when the link is clicked. - button.addEventListener("click", (event) => { - event.preventDefault(); - // Set the appropriate frame layout. - position == "full" ? ensure_single_frame() : ensure_dual_frame(); - document.getElementById(`plot-frame-${position}`).src = `${PLOTS_PATH}/${plot}`; - }); - - // Add button to chooser. - position_chooser.append(button); - } + const icons = { left: "◧", full: "▣", right: "◨", popup: "↗" }; - // Add position chooser to entry. - list_item.append(position_chooser); + // Add buttons for each position. + for (const position of ["left", "full", "right", "popup"]) { + // Create button. + const button = document.createElement("button"); + button.classList.add(position); + button.textContent = icons[position]; - // Add entry to the menu. - case_menu.append(list_item); + // Add a callback updating the iframe when the link is clicked. + button.addEventListener("click", (event) => { + event.preventDefault(); + // Open new window for popup. + if (position === "popup") { + window.open(`${PLOTS_PATH}/${path}`, "_blank", "popup,width=800,height=600"); + return; } + // Set the appropriate frame layout. + position === "full" ? ensure_single_frame() : ensure_dual_frame(); + document.getElementById(`plot-frame-${position}`).src = `${PLOTS_PATH}/${path}`; + }); + + // Add button to chooser. + position_chooser.append(button); + } + + // Create entry. + const entry = document.createElement("li"); + + // Add name, facets, and position chooser to entry. + entry.append(entry_title, facets, position_chooser); + + // Join entry to the DOM. + diagnostics_list.append(entry); +} - // Finish constructing this case and add to its category. - case_details.append(case_menu); - category_details.append(case_details); +function add_facet_dropdowns(facet_values) { + const facets_container = document.getElementById("filter-facets"); + + for (const facet in facet_values) { + const label = document.createElement("label"); + label.setAttribute("for", `facet-${facet}`); + label.textContent = facet; + const select = document.createElement("select"); + select.id = `facet-${facet}`; + select.name = facet; + const null_option = document.createElement("option"); + null_option.value = ""; + null_option.defaultSelected = true; + null_option.textContent = "--- Any ---"; + select.append(null_option); + // Sort facet values. + const values = Array.from(facet_values[facet]); + values.sort(); + for (const value of values) { + const option = document.createElement("option"); + option.textContent = value; + select.append(option); } + select.addEventListener("change", updateFacetQuery); + // Put label and select in row. + const facet_row = document.createElement("div"); + facet_row.append(label, select); + // Add to DOM. + facets_container.append(facet_row); + } +} - // Join category to the DOM. - sidebar.append(category_details); +// Update query based on facet dropdown value. +function updateFacetQuery(e) { + const facet = e.target.name; + const value = e.target.value; + const queryElem = document.getElementById("filter-query"); + const query = queryElem.value; + let new_query; + // Construct regular expression matching facet condition. + const pattern = RegExp(`${facet}:\\s*('[^']*'|"[^"]*"|[^ \\t\\(\\)]+)`, "i"); + if (value === "") { + // Facet unselected, remove from query. + new_query = query.replace(pattern, ""); + } else if (pattern.test(query)) { + // Facet value selected, update the query. + new_query = query.replace(pattern, `${facet}:"${value}"`); + } else { + // Facet value selected, add the query. + new_query = query + ` ${facet}:"${value}"`; } + queryElem.value = new_query.trim(); + doSearch(); } -// Plot selection sidebar +// Plot selection sidebar. function setup_plots_sidebar() { // Skip if there is no sidebar on page. if (!document.getElementById("plot-selector")) { return; } // Loading of plot index file, and adding them to the sidebar. - fetch(`${PLOTS_PATH}/index.json`) + fetch(`${PLOTS_PATH}/index.jsonl`) .then((response) => { // Display a message and stop if the fetch fails. if (!response.ok) { @@ -155,7 +583,27 @@ function setup_plots_sidebar() { window.alert(message); return; } - response.json().then(construct_sidebar_from_data); + response.text().then((data) => { + const facet_values = {}; + // Remove throbber now download has finished. + document.querySelector("#diagnostics > loading-throbber").remove(); + for (let line of data.split("\n")) { + line = line.trim(); + // Skip blank lines. + if (line.length) { + add_to_sidebar(JSON.parse(line), facet_values); + } + } + add_facet_dropdowns(facet_values); + // Do search if we already have a query specified in the URL. + const search = document.getElementById("filter-query"); + const params = new URLSearchParams(document.location.search); + const initial_query = params.get("q"); + if (initial_query) { + search.value = initial_query; + doSearch(); + } + }); }) .catch((err) => { // Catch non-HTTP fetch errors. @@ -176,7 +624,84 @@ function setup_clear_view_button() { clear_view_button.addEventListener("click", clear_frames); } +function setup_clear_search_button() { + const clear_search_button = document.getElementById("clear-query"); + clear_search_button.addEventListener("click", () => { + document.getElementById("filter-query").value = ""; + for (const select of document.querySelectorAll("#filter-facets select")) { + select.value = ""; + } + doSearch(); + }); +} + +// Filter the displayed diagnostics by the query. +function doSearch() { + const queryElem = document.getElementById("filter-query"); + const query = queryElem.value; + // Update URL in address bar to match current query, deleting if blank. + const url = new URL(document.location.href); + query ? url.searchParams.set("q", query) : url.searchParams.delete("q"); + // Updates the URL without reloading the page. + history.pushState(history.state, "", url.href); + + console.log("Search query:", query); + let condition; + try { + condition = query2condition(query); + // Set to an empty string to mark as valid. + queryElem.setCustomValidity(""); + } catch (error) { + console.error("Query failed to parse.", error); + // Add :invalid pseudoclass to input, so user gets feedback. + queryElem.setCustomValidity("Query failed to parse."); + return; + } + + // Filter all entries. + for (const entryElem of document.querySelectorAll("#diagnostics > li")) { + const entry = {}; + entry["title"] = entryElem.querySelector("h2").textContent; + for (const facet_node of entryElem.querySelector("dl").children) { + const facet = facet_node.firstChild.textContent; + const value = facet_node.lastChild.textContent; + entry[facet] = value; + } + + // Show entries matching filter and hide entries that don't. + if (condition.test(entry)) { + entryElem.classList.remove("hidden"); + } else { + entryElem.classList.add("hidden"); + } + } +} + +// For performance don't search on every keystroke immediately. Instead wait +// until quarter of a second of no typing has elapsed. To maximised perceived +// responsiveness immediately perform the search if a space is typed, as that +// indicates a completed search term. +let searchTimeoutID = undefined; +function debounce(e) { + clearTimeout(searchTimeoutID); + if (e.data === " ") { + doSearch(); + } else { + searchTimeoutID = setTimeout(doSearch, 250); + } +} + +// Diagnostic filtering searchbar. +function setup_search() { + const search = document.getElementById("filter-query"); + search.addEventListener("input", debounce); + // Immediately search if input is unfocused. + search.addEventListener("change", doSearch); +} + // Run everything. setup_description_toggle_button(); setup_clear_view_button(); +setup_clear_search_button(); setup_plots_sidebar(); +setup_search(); diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/style.css b/src/CSET/cset_workflow/app/finish_website/file/html/style.css index f8e929844..051fb3041 100644 --- a/src/CSET/cset_workflow/app/finish_website/file/html/style.css +++ b/src/CSET/cset_workflow/app/finish_website/file/html/style.css @@ -1,3 +1,16 @@ +/* Colour definitions. */ +:root { + --primary-fg-colour: black; + --primary-bg-colour: white; + --secondary-fg-colour: oklab(42% 0 0); + --secondary-bg-colour: oklab(97% 0 0); + --alternate-bg-colour: oklab(90% 0 0); + --invalid-colour: red; + --hover-fg-colour: white; + --hover-bg-colour: oklab(22% 0 0); + --shadow-colour: darkgrey; +} + /* Inherit fonts for inputs and buttons */ input, button, @@ -20,83 +33,137 @@ a:hover { text-decoration-thickness: max(3px, 0.12em); } -nav > header { - margin: 8px; -} - -nav > header > h1 { - font-size: xx-large; - margin: 0; -} - -nav > header > button { - margin: 4px 0; -} - nav { display: inline; float: left; - width: 15em; + width: 35em; height: 100%; - overflow-x: hidden; overflow-y: scroll; - background-color: whitesmoke; - border-right: 1px solid black; -} + background-color: var(--secondary-bg-colour); + border-right: 1px solid var(--secondary-fg-colour); -nav summary { - font-weight: bold; -} + > header { + margin: 8px; -nav menu { - list-style: none; - padding: 0; - margin: 0; -} + > h1 { + font-size: xx-large; + margin: 0; + } -nav details { - margin: 8px; -} + > button { + margin: 4px 0; + } -nav menu > li { - background-color: lightgrey; - margin: 8px 0; - padding: 0 4px; - border: 1px solid black; - font-size: small; - overflow-wrap: break-word; -} + > search { + dt { + font-family: monospace; + } -.plot-position-chooser { - display: flex; - margin-bottom: 0.25em; - font-size: medium; -} + #filter-query { + width: 100%; + padding: 0.5em 1em; + margin: 8px 0; + border-radius: 3em; -.plot-position-chooser button { - margin: 0 4px; - cursor: pointer; - width: 33%; -} + &:invalid { + box-shadow: 0px 0px 4px 4px var(--invalid-colour); + } + } -.plot-position-chooser button.left { - background-color: #b8dcfd; - border-top-left-radius: 25% 50%; - border-bottom-left-radius: 25% 50%; -} -.plot-position-chooser button.full { - background-color: #c5e836; -} -.plot-position-chooser button.right { - background-color: #fdcabb; - border-top-right-radius: 25% 50%; - border-bottom-right-radius: 25% 50%; -} + #filter-facets div { + padding: 4px; + &:nth-child(even) { + background-color: var(--alternate-bg-colour); + } + label { + display: inline-block; + width: 50%; + } + select { + width: 50%; + } + } + } + } + + > ul { + list-style: none; + padding: 0; + margin-top: 8px; + + > li { + padding: 5px 4px 3px; + overflow-wrap: break-word; + box-shadow: inset 0 4px 4px var(--shadow-colour); -.plot-position-chooser button:focus, -.plot-position-chooser button:hover { - background-color: #1a1a1a; - color: white; + &:nth-child(odd) { + background-color: var(--alternate-bg-colour); + } + + > h2 { + margin: 0; + font-size: medium; + } + + > dl { + font-size: small; + margin: 4px 0; + columns: 16em auto; + + dt { + display: inline; + } + + dt:after { + content: ": "; + } + + dd { + display: inline; + margin-left: 0; + } + } + + .plot-position-chooser { + display: flex; + margin: 4px auto; + font-size: medium; + + button { + margin: 0 4px; + cursor: pointer; + width: 33%; + + &.left { + background-color: #b8dcfd; + border-top-left-radius: 3em; + border-bottom-left-radius: 3em; + } + + &.full { + background-color: #cae387; + } + + &.right { + background-color: #fdcabb; + border-top-right-radius: 3em; + border-bottom-right-radius: 3em; + } + + &.popup { + background-color: #e2dd92; + border-radius: 3em; + } + + &:focus, + &:hover { + background-color: var(--hover-bg-colour); + color: var(--hover-fg-colour); + } + } + } + } + } } .vsplit { @@ -118,7 +185,7 @@ nav menu > li { .websplit-container { display: flex; flex-direction: column; - outline: solid 1px black; + outline: solid 1px var(--primary-fg-colour); } main { @@ -139,3 +206,51 @@ main article > iframe { .hidden { display: none; } + +/* Loading throbber from https://cssloaders.github.io/ + +MIT License + +Copyright (c) 2020 Vineeth.TR + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ +loading-throbber { + display: block; + margin: 48px auto; + width: 48px; + height: 48px; + border: 5px solid var(--secondary-fg-colour); + border-bottom-color: transparent; + border-radius: 50%; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* End loading throbber. */ diff --git a/src/CSET/recipes/__init__.py b/src/CSET/recipes/__init__.py index 3c92d83fb..b459aa020 100644 --- a/src/CSET/recipes/__init__.py +++ b/src/CSET/recipes/__init__.py @@ -214,9 +214,15 @@ def parbake(self, ROSE_DATAC: Path, SHARE_DIR: Path) -> None: # Add input paths to recipe variables. self.variables["INPUT_PATHS"] = data_dirs - # Parbake this recipe, saving into recipe_dir. + # Parbake this recipe. recipe = parse_recipe(Path(self.recipe), self.variables) + # Add variables as extra metadata to filter on. + for key, value in self.variables.items(): + # Don't overwrite existing keys. + if key != "INPUT_PATHS" and key not in recipe: + recipe[key] = value + # Serialise into memory, as we use the serialised value twice. with StringIO() as s: with YAML(pure=True, output=s) as yaml: @@ -226,6 +232,7 @@ def parbake(self, ROSE_DATAC: Path, SHARE_DIR: Path) -> None: # with the same title. digest = hashlib.sha256(serialised_recipe).hexdigest() output_filename = recipe_dir / f"{slugify(recipe['title'])}_{digest[:12]}.yaml" + # Save into recipe_dir. with open(output_filename, "wb") as fp: fp.write(serialised_recipe) diff --git a/tests/workflow_utils/test_finish_website.py b/tests/workflow_utils/test_finish_website.py index eb2e29b5d..6187b24ed 100644 --- a/tests/workflow_utils/test_finish_website.py +++ b/tests/workflow_utils/test_finish_website.py @@ -79,9 +79,8 @@ def test_write_workflow_status(tmp_path): assert re.search(pattern, content) -def test_construct_index(monkeypatch, tmp_path): +def test_construct_index(tmp_path): """Test putting the index together.""" - monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path)) plots_dir = tmp_path / "web/plots" plots_dir.mkdir(parents=True) @@ -102,17 +101,19 @@ def test_construct_index(monkeypatch, tmp_path): finish_website.construct_index(plots_dir.parent) # Check index. - index_file = plots_dir / "index.json" + index_file = plots_dir / "index.jsonl" assert index_file.is_file() with open(index_file, "rt", encoding="UTF-8") as fp: - index = json.load(fp) - expected = {"Category": {"20250101": {"p1": "P1", "p2": "P2"}}} + index = fp.read() + expected = ( + '{"case_date":"20250101","category":"Category","path":"p1","title":"P1"}\n' + '{"case_date":"20250101","category":"Category","path":"p2","title":"P2"}\n' + ) assert index == expected -def test_construct_index_aggregation_case(monkeypatch, tmp_path): +def test_construct_index_aggregation_case(tmp_path): """Construct the index from a diagnostics without a case date.""" - monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path)) plots_dir = tmp_path / "web/plots" plots_dir.mkdir(parents=True) @@ -125,17 +126,40 @@ def test_construct_index_aggregation_case(monkeypatch, tmp_path): finish_website.construct_index(plots_dir.parent) # Check index. - index_file = plots_dir / "index.json" + index_file = plots_dir / "index.jsonl" assert index_file.is_file() with open(index_file, "rt", encoding="UTF-8") as fp: index = json.load(fp) - expected = {"Category": {"Aggregation": {"p1": "P1"}}} + expected = {"category": "Category", "path": "p1", "title": "P1"} assert index == expected -def test_construct_index_invalid(monkeypatch, tmp_path, caplog): +def test_construct_index_remove_keys(tmp_path): + """Unneeded keys are removed from the index.""" + plots_dir = tmp_path / "web/plots" + plots_dir.mkdir(parents=True) + + # Plot directories. + plot1 = plots_dir / "p1/meta.json" + plot1.parent.mkdir() + plot1.write_text( + '{"category": "Category", "title": "P1", "case_date": "20250101", "plots": ["a.png"], "description": "Foo"}' + ) + + # Construct index. + finish_website.construct_index(plots_dir.parent) + + # Check index. + index_file = plots_dir / "index.jsonl" + assert index_file.is_file() + with open(index_file, "rt", encoding="UTF-8") as fp: + index = json.loads(fp.readline()) + assert "plots" not in index + assert "description" not in index + + +def test_construct_index_invalid(tmp_path, caplog): """Test constructing index when metadata is invalid.""" - monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path)) plots_dir = tmp_path / "web/plots" plots_dir.mkdir(parents=True) @@ -152,12 +176,9 @@ def test_construct_index_invalid(monkeypatch, tmp_path, caplog): assert level == logging.ERROR assert "p1/meta.json is invalid, skipping." in message - index_file = plots_dir / "index.json" + index_file = plots_dir / "index.jsonl" assert index_file.is_file() - with open(index_file, "rt", encoding="UTF-8") as fp: - index = json.load(fp) - expected = {} - assert index == expected + assert index_file.stat().st_size == 0 def test_entrypoint(monkeypatch):