From 5530be6924327e84ada87ef29db8b25c5a6deffc Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Fri, 23 Jan 2026 16:08:24 +0100 Subject: [PATCH 01/16] chore: multiple updates Signed-off-by: joshuaunity --- flexmeasures/ui/static/js/components.js | 203 +++ flexmeasures/ui/static/js/ui-utils.js | 47 +- .../ui/templates/assets/asset_context.html | 2 +- .../ui/templates/assets/asset_graph.html | 1083 ++++++++++------- 4 files changed, 855 insertions(+), 480 deletions(-) create mode 100644 flexmeasures/ui/static/js/components.js diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js new file mode 100644 index 0000000000..21b84b6a9f --- /dev/null +++ b/flexmeasures/ui/static/js/components.js @@ -0,0 +1,203 @@ +import { getAsset, getAccount, getSensor, apiBasePath } from "./ui-utils.js"; + +const addInfo = (label, value, infoDiv, Resource, isLink = false) => { + const b = document.createElement("b"); + b.textContent = `${label}: `; + infoDiv.appendChild(b); + const isSensor = Resource.hasOwnProperty("unit"); + + if (isLink) { + const a = document.createElement("a"); + a.href = `${apiBasePath}/${isSensor ? "sensors" : "assets"}/${Resource.id}`; + a.textContent = value; + infoDiv.appendChild(a); + } else { + infoDiv.appendChild(document.createTextNode(value)); + } +}; + +export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { + const Asset = await getAsset(assetPlot.asset); + let IsFlexContext = false; + let IsFlexModel = false; + let flexConfigValue = null; + + if ("flex-context" in assetPlot) { + IsFlexContext = true; + flexConfigValue = assetPlot["flex-context"]; + } + + if ("flex-model" in assetPlot) { + IsFlexModel = true; + flexConfigValue = assetPlot["flex-model"]; + } + + const container = document.createElement("div"); + container.className = "p-1 mb-3 border-bottom border-secondary"; + + const flexDiv = document.createElement("div"); + flexDiv.className = "d-flex justify-content-between"; + + const infoDiv = document.createElement("div"); + + addInfo("ID", Asset.id, infoDiv, Asset, true); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Name", Asset.name, infoDiv, Asset); + infoDiv.appendChild(document.createTextNode(", ")); + if (IsFlexContext) { + addInfo("Flex Context", flexConfigValue, infoDiv, Asset); + } else if (IsFlexModel) { + addInfo("Flex Model", flexConfigValue, infoDiv, Asset); + } + + const closeIcon = document.createElement("i"); + closeIcon.className = "fa fa-times"; + closeIcon.style.cursor = "pointer"; + closeIcon.setAttribute("data-bs-toggle", "tooltip"); + closeIcon.title = "Remove Asset Plot"; + + // Attach the actual function here + closeIcon.addEventListener("click", (e) => { + e.stopPropagation(); // Prevent card selection click + // removeAssetPlotFromGraph(graphIndex, plotIndex); + }); + + flexDiv.appendChild(infoDiv); + flexDiv.appendChild(closeIcon); + container.appendChild(flexDiv); + + return container; +} + +export async function renderSensorCard(sensorId, graphIndex, sensorIndex) { + const Sensor = await getSensor(sensorId); + const Asset = await getAsset(Sensor.generic_asset_id); + const Account = await getAccount(Asset.account_id); + + const container = document.createElement("div"); + container.className = "p-1 mb-3 border-bottom border-secondary"; + + const flexDiv = document.createElement("div"); + flexDiv.className = "d-flex justify-content-between"; + + const infoDiv = document.createElement("div"); + + addInfo("ID", Sensor.id, infoDiv, Sensor, true); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Unit", Sensor.unit, infoDiv, Sensor); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Name", Sensor.name, infoDiv, Sensor); + + const spacer = document.createElement("div"); + spacer.style.paddingTop = "1px"; + infoDiv.appendChild(spacer); + + addInfo("Asset", Asset.name, infoDiv, Asset); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Account", Account?.name ? Account.name : "PUBLIC", infoDiv, Account); + + const closeIcon = document.createElement("i"); + closeIcon.className = "fa fa-times"; + closeIcon.style.cursor = "pointer"; + closeIcon.setAttribute("data-bs-toggle", "tooltip"); + closeIcon.title = "Remove Sensor"; + + // Attach the actual function here + closeIcon.addEventListener("click", (e) => { + e.stopPropagation(); // Prevent card selection click + removeSensorFromGraph(graphIndex, sensorIndex); + }); + + flexDiv.appendChild(infoDiv); + flexDiv.appendChild(closeIcon); + container.appendChild(flexDiv); + + // Return both the element and the unit (so we can check for mixed units later) + return { element: container, unit: Sensor.unit }; +} + +export async function renderSensorsList(sensorIds, graphIndex) { + const listContainer = document.createElement("div"); + const units = []; + + if (sensorIds.length === 0) { + listContainer.innerHTML = `
No sensors added to this graph.
`; + return { element: listContainer, uniqueUnits: [] }; + } + + // Using Promise.all to maintain order and wait for all sensors + const results = await Promise.all( + sensorIds.map((id, sIdx) => renderSensorCard(id, graphIndex, sIdx)), + ); + + results.forEach((res) => { + listContainer.appendChild(res.element); + units.push(res.unit); + }); + + return { element: listContainer, uniqueUnits: [...new Set(units)] }; +} + +/** + * Renders the header for a graph card. + * @param {string} title - The current title of the graph. + * @param {number} index - The index of the graph in the list. + * @param {boolean} isEditing - Whether this specific graph is in edit mode. + * @param {Function} onSave - Function to call when "Save" is clicked or "Enter" is pressed. + * @param {Function} onEdit - Function to call when "Edit" is clicked. + */ +export function renderGraphHeader(title, index, isEditing, onSave, onEdit) { + const header = document.createElement("div"); + header.className = "d-flex align-items-center mb-2"; + + if (isEditing) { + // 1. Title Input + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control me-2"; + input.id = `editTitle_${index}`; + input.value = title; + + // Save on Enter key + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSave(index); + } + }); + + // 2. Save Button + const saveBtn = document.createElement("button"); + saveBtn.className = "btn btn-success btn-sm"; + saveBtn.textContent = "Save"; + saveBtn.onclick = (e) => { + e.stopPropagation(); // Prevent card selection + onSave(index); + }; + + header.appendChild(input); + header.appendChild(saveBtn); + + // Auto-focus the input + setTimeout(() => input.focus(), 0); + } else { + // 1. Display Title + const h5 = document.createElement("h5"); + h5.className = "card-title me-2 mb-0"; + h5.textContent = title; + + // 2. Edit Button + const editBtn = document.createElement("button"); + editBtn.className = "btn btn-warning btn-sm"; + editBtn.textContent = "Edit"; + editBtn.onclick = (e) => { + e.stopPropagation(); // Prevent card selection + onEdit(index); + }; + + header.appendChild(h5); + header.appendChild(editBtn); + } + + return header; +} diff --git a/flexmeasures/ui/static/js/ui-utils.js b/flexmeasures/ui/static/js/ui-utils.js index 0c849efdf0..a0f447cdd8 100644 --- a/flexmeasures/ui/static/js/ui-utils.js +++ b/flexmeasures/ui/static/js/ui-utils.js @@ -85,10 +85,10 @@ export function processResourceRawJSON(schema, rawJSON, allowExtra = false) { } export function getFlexFieldTitle(fieldName) { - return fieldName - // .split("-") - // .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - // .join(" "); + return fieldName; + // .split("-") + // .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + // .join(" "); } export function renderFlexFieldOptions(schema, options) { @@ -118,8 +118,8 @@ export async function renderSensor(sensorId) {
Sensor: ${sensorData.id}, + sensorData.id + }">${sensorData.id}, Unit: ${ sensorData.unit === "" ? 'dimensionless' @@ -175,7 +175,7 @@ export function createReactiveState(initialValue, renderFunction) { export function renderSensorSearchResults( sensors, resultContainer, - actionFunc + actionFunc, ) { if (!resultContainer) { console.error("Result container is not defined."); @@ -208,8 +208,8 @@ export function renderSensorSearchResults(
${sensor.name}

ID: ${sensor.id}, + sensor.id + }">${sensor.id}, Unit: ${ sensor.unit === "" ? 'dimensionless' @@ -295,3 +295,32 @@ export function setDefaultLegendPosition(checkbox) { console.error("Error during API call:", error); }); } + +/** + * Swaps an item in an array with its neighbor based on direction. + * @param {Array} array - The source array. + * @param {number} index - The index of the item to move. + * @param {'up' | 'down'} direction - The direction to move. + * @returns {Array} A new array with the items swapped. + */ +export function moveArrayItem(array, index, direction) { + // Create a shallow copy to avoid mutating the original array + const newArray = [...array]; + + const isUp = direction === "up"; + const targetIndex = isUp ? index - 1 : index + 1; + + // Boundary Checks: + // Don't move 'up' if at the start, or 'down' if at the end. + if (targetIndex < 0 || targetIndex >= newArray.length) { + return newArray; + } + + // Perform the swap using destructuring + [newArray[index], newArray[targetIndex]] = [ + newArray[targetIndex], + newArray[index], + ]; + + return newArray; +} diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index ff571ef500..83d0548042 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -571,7 +571,7 @@

const card = document.createElement("div"); card.className = `card m-1 p-2 card-highlight ${isActiveCard() ? "border-on-click" : ""}`; card.id = `${fieldName}-control`; - // set card element to disabled if it's an extrafield + // set card element to disabled if it's an extra field if (isExtraField) { card.setAttribute("data-disabled", "true"); card.style.pointerEvents = "none"; diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index d9105c3825..ea1a1c29cb 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -42,21 +42,18 @@
- +
@@ -163,33 +160,29 @@
{{ kpi.title }}
- +
-
+
{% block paginate_tables_script %} {{ super() }} {% endblock %} -{% endblock %} +{% endblock %} \ No newline at end of file From 21237460195e42257a3a83c9414ae50a79fceaca Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 26 Jan 2026 07:40:11 +0100 Subject: [PATCH 02/16] chore: stabilized leeft side of modal and kicked off work onteh right side, including dissabling logic Signed-off-by: joshuaunity --- .../ui/templates/assets/asset_graph.html | 198 ++++++++++-------- .../ui/templates/assets/asset_properties.html | 2 - .../ui/templates/includes/graphs.html | 1 + 3 files changed, 107 insertions(+), 94 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index ea1a1c29cb..08551fe684 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -305,7 +305,28 @@
- Some content +
+
+
Assets
+ +
+ +
+
Config Type
+ +
+ +
+
Field
+ +
+
@@ -335,12 +356,18 @@ import { renderAssetPlotCard, renderSensorCard, renderSensorsList, renderGraphHeader } from "{{ url_for('flexmeasures_ui.static', filename='js/components.js') }}?v={{ flexmeasures_version }}"; + // This variable is used to prevent reRenderForm from running on initial load + let hasInitialized = false; + let hasInitialized2 = false; + document.addEventListener('DOMContentLoaded', async function () { const senSearchResEle = document.getElementById("sensorsSearchResults") const formModal = document.getElementById('sensorsToShowModal'); const apiSensorsListElement = document.getElementById("apiSensorsList"); const spinnerElement = document.getElementById('spinnerElement'); + const configTypeSelect = document.getElementById("configTypeSelect"); + const flexConfigField = document.getElementById("flexConfigField"); let sensorsToShowRawJSON = "{{ asset.sensors_to_show | safe }}"; sensorsToShowRawJSON = sensorsToShowRawJSON.replace(/'/g, '"'); @@ -352,16 +379,35 @@ let savedGraphIndex; // keeps track of the graph that is currently selected let selectedGraphTitle; // keeps track of the graph title that is currently selected const sensorsApiUrl = `${apiBasePath}/api/v3_0/sensors?page=1&per_page=100&asset_id={{ asset.id }}&include_public_assets=true`; + const assetApiUrl = `${apiBasePath}/api/v3_0/assets?page=1&per_page=10&sort_by=id&sort_dir=asc`; - const [getSensorToShow, setSensorToShow] = createReactiveState(newFormat, renderGraphCards); + const [getSensorToShow, setSensorToShow] = createReactiveState(newFormat, reRenderForm); + const [editingIndex, setEditingIndex] = createReactiveState(null, reRenderFormV2); const [activeCard, setActiveCard] = createReactiveState(null, () => { }); - const [editingIndex, setEditingIndex] = createReactiveState(null, renderGraphCards); + const [configAsset, setConfigAsset] = createReactiveState(null, () => { }); const saveChangesBtn = document.getElementById("saveChangesBtn"); saveChangesBtn.onclick = async function () { await updateAssetSensorsToShow(); }; + function reRenderForm() { + if (!hasInitialized) { + hasInitialized = true; + } else { + renderGraphCards(); + } + } + + function reRenderFormV2() { + if (!hasInitialized2) { + hasInitialized2 = true; + } else { + renderGraphCards(); + } + } + + // This function is for backward compatibility with the previous format of sensors_to_show function sensorsToShowFormatter(graphs) { const newShape = []; @@ -394,6 +440,50 @@ return newShape; } + async function renderRootAssets() { + const assetTreeSelect = document.getElementById("assetTree"); + const response = await fetch(`${assetApiUrl}?all_accessible=true&per_page=1000`); + const resBody = await response.json(); + console.log("Fetched assets for asset tree:", resBody); + const assets = resBody.data; + + // Clear existing options + assetTreeSelect.innerHTML = ``; + + assets.forEach(asset => { + const option = document.createElement("option"); + option.value = asset.id; + option.textContent = `ID: ${asset.id}, Name: ${asset.name}`; + assetTreeSelect.appendChild(option); + option.onclick = () => { + setConfigAsset(asset); + configTypeSelect.disabled = false; + // check if asset has flex_context or flex_model set + if (asset.flex_context != "{}") { + flexConfigField.disabled = false; + // add flex-context option + const flexContextOption = document.createElement("option"); + flexContextOption.value = "flex-context"; + flexContextOption.textContent = "Flex Context"; + configTypeSelect.appendChild(flexContextOption); + } else { + flexConfigField.disabled = true; + } + + if (asset.flex_model != "{}") { + flexConfigField.disabled = false; + // add flex-model option + const flexModelOption = document.createElement("option"); + flexModelOption.value = "flex-model"; + flexModelOption.textContent = "Flex Model"; + configTypeSelect.appendChild(flexModelOption); + } else { + flexConfigField.disabled = true; + } + }; + }); + } + // highlight selected graph async function selectGraph(graphIndex) { if (graphIndex !== undefined) { @@ -408,7 +498,7 @@ } else { savedGraphIndex = undefined; selectedGraphTitle = undefined; - renderGraphCards(); + reRenderForm(); } } @@ -426,7 +516,7 @@ setActiveCard(null); } else { setActiveCard(card); - renderGraphCards() + reRenderForm() } }; @@ -437,7 +527,7 @@ const header = document.createElement("div"); header.className = "d-flex align-items-center mb-2"; - const isEditing = editingIndex() === index; + const isEditing = editingIndex() && editingIndex() === index; const headerElement = renderGraphHeader( item.title, @@ -556,83 +646,6 @@ graphList.appendChild(graphCard); } - // for (const [index, item] of sensorsToShow.entries()) { - // const col = document.createElement("div"); - // col.classList.add("col-12", "mb-1"); - - // const sensorsUnits = []; - // // the initializing of the newItem is to handle the case where the item is an array of ID's instead of an object - // const newItem = { - // title: item.title ?? "No Title", - // sensors: item.sensors ?? (Array.isArray(item) ? item : [item]), - // } - // newSensorsToShow.push(newItem); - - // const sensorsContent = await Promise.all( - // newItem.sensors.map(async (sensor, sensorIndex) => { - // const sensorData = await getSensor(sensor); - // const Asset = await getAsset(sensorData.generic_asset_id); - // const Account = await getAccount(Asset.account_id); - // sensorsUnits.push(sensorData.unit); - - // return ` - //
- //
- //
- // ID: ${sensorData.id}, - // Unit: ${sensorData.unit}, - // Name: ${sensorData.name}, - //
- // Asset: ${Asset.name}, - // Account: ${Account?.name ? Account.name : "PUBLIC"} - //
- // - //
- - //
`; - // })); - - // const uniqueUnits = [...new Set(sensorsUnits)]; - - // col.innerHTML = ` - //
- //
- //
- //
- // ${editingIndex === index - // ? `` - // : `
${newItem.title}
` - // } - //
- - //
- // ${editingIndex === index - // ? `` - // : `` - // } - //
- //
- //
Sensors:
- //
- // ${sensorsContent.length > 0 ? sensorsContent.join("") : ``} - // ${uniqueUnits.length > 1 ? `` : ""} - //
- - // - // - // - //
- //
`; - - // graphList.appendChild(col); - // } - sensorsToShow = newSensorsToShow; } @@ -739,15 +752,15 @@ sensorsToShow.splice(index, 1); savedGraphIndex = undefined; selectedGraphTitle = undefined; - editingIndex = undefined; - renderGraphCards(); + setEditingIndex(null) + reRenderForm(); renderApiSensors(cachedFilteredSensors); } async function swapItems(index1, index2) { if (index1 >= 0 && index2 >= 0 && index1 < sensorsToShow.length && index2 < sensorsToShow.length) { [sensorsToShow[index1], sensorsToShow[index2]] = [sensorsToShow[index2], sensorsToShow[index1]]; - renderGraphCards() + reRenderForm() renderApiSensors(cachedFilteredSensors, index2); } } @@ -768,10 +781,10 @@ async function saveGraphTitle(index) { const newTitle = document.getElementById(`editTitle_${index}`).value; - sensorsToShow[index].title = newTitle; - editingIndex = null; - selectedGraphTitle = newTitle; - renderGraphCards(); + const sensorsToShowOldState = getSensorToShow(); + sensorsToShowOldState[index].title = newTitle; + setSensorToShow(sensorsToShowOldState); + setEditingIndex(null); } // ============== Graph Cards Management ============== // @@ -854,7 +867,7 @@
${sensor.name}
const sensorChartDiv = document.getElementById('sensorchart'); const apiURL = apiBasePath + "/api/v3_0/assets/{{ asset.id }}"; - const requestBody = JSON.stringify({ sensors_to_show: JSON.stringify(sensorsToShow) }); + const requestBody = JSON.stringify({ sensors_to_show: JSON.stringify(getSensorToShow()) }); refreshSpinner.style.display = 'block'; sensorChartDiv.style.display = 'none'; @@ -942,8 +955,9 @@
${sensor.name}
formModal.addEventListener('shown.bs.modal', function () { // Initial renders - renderGraphCards(); // Initial render of graph cards + reRenderForm(); // Initial render of graph cards filterSensors(); // Initial render of sensors + renderRootAssets(); // Initial render of root assets }); document.addEventListener("click", function (event) { diff --git a/flexmeasures/ui/templates/assets/asset_properties.html b/flexmeasures/ui/templates/assets/asset_properties.html index c09b950b8e..a9eed9d8a3 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -484,8 +484,6 @@ } function reRenderForm() { - // Skip on first init. This code prevents the access to teh variable 'getFlexmodel' - // on initial run of the script as its not available at that point if (!hasInitialized) { hasInitialized = true; } else { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index b643dc5954..3fa0eee57d 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -23,6 +23,7 @@ // Import local js (the FM version is used for cache-busting, causing the browser to fetch the updated version from the server) import { convertToCSV } from "{{ url_for('flexmeasures_ui.static', filename='js/data-utils.js') }}?v={{ flexmeasures_version }}"; + import {apiBasePath} from "{{ url_for('flexmeasures_ui.static', filename='js/ui-utils.js') }}?v={{ flexmeasures_version }}"; import { subtract, computeSimulationRanges, lastNMonths, encodeUrlQuery, getOffsetBetweenTimezonesForDate, toIsoStringWithOffset } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}?v={{ flexmeasures_version }}"; import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout } from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}?v={{ flexmeasures_version }}"; import { decompressChartData, checkDSTTransitions, checkSourceMasking } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-utils.js') }}?v={{ flexmeasures_version }}"; From a8d722d3f2e22abc2f860b0c534b9684281ea6ff Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Tue, 27 Jan 2026 09:53:50 +0100 Subject: [PATCH 03/16] chore: completed left side of graph modal Signed-off-by: joshuaunity --- .../ui/templates/assets/asset_graph.html | 164 +++++++++++++++--- 1 file changed, 140 insertions(+), 24 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 08551fe684..ab9365ae6f 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -326,10 +326,18 @@
Field
+ +
+
Field Details
+ +
+ + - @@ -366,20 +374,21 @@
Field
const formModal = document.getElementById('sensorsToShowModal'); const apiSensorsListElement = document.getElementById("apiSensorsList"); const spinnerElement = document.getElementById('spinnerElement'); + const assetTree = document.getElementById("assetTree"); const configTypeSelect = document.getElementById("configTypeSelect"); const flexConfigField = document.getElementById("flexConfigField"); + const flexConfigFieldDetail = document.getElementById("flexConfigFieldDetail"); let sensorsToShowRawJSON = "{{ asset.sensors_to_show | safe }}"; sensorsToShowRawJSON = sensorsToShowRawJSON.replace(/'/g, '"'); let sensorsToShow = JSON.parse(sensorsToShowRawJSON); const newFormat = sensorsToShowFormatter(sensorsToShow); - let cachedFilteredSensors = []; // keeps track of the filtered sensors let savedGraphIndex; // keeps track of the graph that is currently selected let selectedGraphTitle; // keeps track of the graph title that is currently selected const sensorsApiUrl = `${apiBasePath}/api/v3_0/sensors?page=1&per_page=100&asset_id={{ asset.id }}&include_public_assets=true`; - const assetApiUrl = `${apiBasePath}/api/v3_0/assets?page=1&per_page=10&sort_by=id&sort_dir=asc`; + const [getSensorToShow, setSensorToShow] = createReactiveState(newFormat, reRenderForm); const [editingIndex, setEditingIndex] = createReactiveState(null, reRenderFormV2); @@ -387,9 +396,28 @@
Field
const [configAsset, setConfigAsset] = createReactiveState(null, () => { }); const saveChangesBtn = document.getElementById("saveChangesBtn"); + const addPlotBtn = document.getElementById("addPlotBtn"); saveChangesBtn.onclick = async function () { await updateAssetSensorsToShow(); }; + addPlotBtn.onclick = async function () { + if (activeCard()) { + const index = parseInt(activeCard().id.split("_")[1]); + + const selectedFlexConfig = configTypeSelect.value; + const selectedConfigField = flexConfigField.value; + const payload = { + "asset": configAsset().id, + [selectedFlexConfig]: selectedConfigField, + } + + const sensorsToShowOldState = getSensorToShow(); + sensorsToShowOldState[index].plots.push(payload); + setSensorToShow(sensorsToShowOldState); + } else { + showToast("Please select a graph card to add the plot to.", "warning"); + } + }; function reRenderForm() { if (!hasInitialized) { @@ -407,6 +435,67 @@
Field
} } + assetTree.onchange = async (e) => { + let hasOptions = false; + + const selectedValue = e.target.value; + if (selectedValue !== "null") { + configTypeSelect.disabled = false; + const selectedAsset = await getAsset(selectedValue); + + + + if (selectedAsset.flex_context !== "{}" && selectedAsset.flex_context !== '"{\"inflexible-device-sensors\": []}"') { // Some flex-context have this value set with an empty array + const flexContextOption = document.createElement("option"); + flexContextOption.value = "flex-context"; + flexContextOption.textContent = "Flex Context"; + configTypeSelect.appendChild(flexContextOption); + hasOptions = true; + } + + if (selectedAsset.flex_model !== "{}") { + const flexModelOption = document.createElement("option"); + flexModelOption.value = "flex-model"; + flexModelOption.textContent = "Flex Model"; + configTypeSelect.appendChild(flexModelOption); + hasOptions = true; + } + + if (hasOptions) { + flexConfigField.disabled = false; + } + setConfigAsset(selectedAsset); + } + }; + + configTypeSelect.onchange = (e) => { + const selectedConfig = e.target.value; + + if (selectedConfig === "flex-context" || selectedConfig === "flex-model") { + let flexConfig = selectedConfig === "flex-context" ? configAsset().flex_context : configAsset().flex_model; + const assetFlexModelSchema = {}; + const [assetFlexModelRawJSON, extraFields] = processResourceRawJSON({ ...assetFlexModelSchema }, flexConfig, true); + + for (const [key, value] of Object.entries(assetFlexModelRawJSON)) { + const FlexFieldOption = document.createElement("option"); + FlexFieldOption.value = key; + FlexFieldOption.textContent = getFlexFieldTitle(key); + flexConfigField.appendChild(FlexFieldOption); + } + } + }; + + flexConfigField.onchange = (e) => { + const selectedField = e.target.value; + const selectedConfigValue = configTypeSelect.value; + let flexConfig = selectedConfigValue === "flex-context" ? configAsset().flex_context : configAsset().flex_model; + + if (selectedField !== "null") { + const assetFlexConfigSchema = {}; + const [assetFlexConfigRawJSON, extraFields] = processResourceRawJSON({ ...assetFlexConfigSchema }, flexConfig, true); + flexConfigFieldDetail.value = JSON.stringify(assetFlexConfigRawJSON[selectedField], null, 2); + } + }; // This function is for backward compatibility with the previous format of sensors_to_show function sensorsToShowFormatter(graphs) { @@ -442,9 +531,8 @@
Field
async function renderRootAssets() { const assetTreeSelect = document.getElementById("assetTree"); - const response = await fetch(`${assetApiUrl}?all_accessible=true&per_page=1000`); + const response = await fetch(`${apiBasePath}/api/v3_0/assets?page=1&per_page=10&sort_by=id&sort_dir=asc`); const resBody = await response.json(); - console.log("Fetched assets for asset tree:", resBody); const assets = resBody.data; // Clear existing options @@ -453,32 +541,40 @@
Field
assets.forEach(asset => { const option = document.createElement("option"); option.value = asset.id; - option.textContent = `ID: ${asset.id}, Name: ${asset.name}`; + option.textContent = `ID: ${asset.id}, Name: ${asset.name}`; assetTreeSelect.appendChild(option); option.onclick = () => { - setConfigAsset(asset); - configTypeSelect.disabled = false; - // check if asset has flex_context or flex_model set - if (asset.flex_context != "{}") { - flexConfigField.disabled = false; - // add flex-context option + + // 1. Reset the Select State + configTypeSelect.innerHTML = ''; + configTypeSelect.disabled = true; + flexConfigField.disabled = true; + + let hasOptions = false; + + // 2. Check for Flex Context + if (asset.flex_context !== "{}") { const flexContextOption = document.createElement("option"); flexContextOption.value = "flex-context"; flexContextOption.textContent = "Flex Context"; configTypeSelect.appendChild(flexContextOption); - } else { - flexConfigField.disabled = true; + hasOptions = true; } - if (asset.flex_model != "{}") { - flexConfigField.disabled = false; - // add flex-model option + // 3. Check for Flex Model + if (asset.flex_model !== "{}") { const flexModelOption = document.createElement("option"); flexModelOption.value = "flex-model"; flexModelOption.textContent = "Flex Model"; configTypeSelect.appendChild(flexModelOption); - } else { - flexConfigField.disabled = true; + hasOptions = true; + } + + // 4. Enable if options were added + if (hasOptions) { + configTypeSelect.disabled = false; + // Note: You usually want to enable flexConfigField + // ONLY after they actually pick a config type. } }; }); @@ -749,12 +845,10 @@
Field
// ============== Graph Cards Management ============== // async function removeGraph(index) { - sensorsToShow.splice(index, 1); - savedGraphIndex = undefined; - selectedGraphTitle = undefined; - setEditingIndex(null) + const sensorsToShowOldState = getSensorToShow(); + sensorsToShowOldState.splice(index, 1); + setSensorToShow(sensorsToShowOldState); reRenderForm(); - renderApiSensors(cachedFilteredSensors); } async function swapItems(index1, index2) { @@ -924,6 +1018,28 @@
${sensor.name}
// sensorsToShow[graphIndex].sensors.splice(sensorIndex, 1); // renderGraphCards(); // renderApiSensors(cachedFilteredSensors); + const oldSensorsToShow = getSensorToShow(); + oldSensorsToShow[graphIndex].sensors.splice(sensorIndex, 1); + setSensorToShow(oldSensorsToShow); + } + + function removePlotFromGraph(graphIndex, sensorIndex) { + const oldSensorsToShow = getSensorToShow(); + oldSensorsToShow[graphIndex].plots.splice(sensorIndex, 1); + setSensorToShow(oldSensorsToShow); + } + + function findPlotindexBySensorId(graphIndex, sensorId) { + const plots = sensorsToShow[graphIndex].plots; + for (let i = 0; i < plots.length; i++) { + const plot = plots[i]; + if (plot.sensor === sensorId || (Array.isArray(plot.sensors) && plot.sensors.includes(sensorId))) { + return {plotIndex: i, isSensors: false}; + }else if (plot.sensors && plot.sensors.includes(sensorId)) { + return {plotIndex: i, isSensors: true}; + } + } + return {plotIndex: -1, isSensors: false}; // Return -1 if not found } function handleEnterKeyEventForTitleEditing(event, graphIndex) { From 79a1c8a7f4fa4f7fb1a1876baf94d7bc1352855f Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 2 Feb 2026 05:07:49 +0100 Subject: [PATCH 04/16] chore: work in progress Signed-off-by: joshuaunity --- flexmeasures/ui/templates/assets/asset_graph.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index ab9365ae6f..cc92bf28ad 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -985,14 +985,15 @@
${sensor.name}
} } - // Add a sensor as a new graph card + // Add a sensor as a new graph ca function addSensorAsGraph(id) { const newAsset = { title: "No Title", - sensors: [id], + plots: [{ sensor: id }], }; - sensorsToShow.push(newAsset); - renderGraphCards(); + const oldSensorsToShow = getSensorToShow(); + oldSensorsToShow.push(newAsset); + setSensorToShow(oldSensorsToShow); } // Add blank graph to the graph cards From 4b75e823f39de74cfd9da7e9391bbb7d6ca075c0 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 2 Feb 2026 19:36:13 +0100 Subject: [PATCH 05/16] refactor: more stabilization work as well as reactivation of broken features Signed-off-by: joshuaunity --- .../ui/templates/assets/asset_graph.html | 72 ++++++++----------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index cc92bf28ad..d76facbe68 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -391,7 +391,7 @@
Field Details
const [getSensorToShow, setSensorToShow] = createReactiveState(newFormat, reRenderForm); - const [editingIndex, setEditingIndex] = createReactiveState(null, reRenderFormV2); + const [editingIndex, setEditingIndex] = createReactiveState(null, () => { }); const [activeCard, setActiveCard] = createReactiveState(null, () => { }); const [configAsset, setConfigAsset] = createReactiveState(null, () => { }); @@ -427,14 +427,6 @@
Field Details
} } - function reRenderFormV2() { - if (!hasInitialized2) { - hasInitialized2 = true; - } else { - renderGraphCards(); - } - } - assetTree.onchange = async (e) => { let hasOptions = false; @@ -608,23 +600,18 @@
Field Details
card.id = `graph_${index}`; card.className = `card m-0 p-1 card-highlight ${activeCard() && activeCard().id === card.id ? " border-on-click" : ""}`; card.onclick = () => { - if (activeCard() && activeCard().id === card.id) { - setActiveCard(null); - } else { - setActiveCard(card); - reRenderForm() - } + setActiveCard(activeCard()?.id === card.id ? null : card); + setEditingIndex(editingIndex() === index ? null : index); }; const cardBody = document.createElement("div"); cardBody.className = "card-body"; - - const header = document.createElement("div"); header.className = "d-flex align-items-center mb-2"; - const isEditing = editingIndex() && editingIndex() === index; + const isEditing = editingIndex() === index; + // 4. Header const headerElement = renderGraphHeader( item.title, index, @@ -851,22 +838,20 @@
Field Details
reRenderForm(); } - async function swapItems(index1, index2) { - if (index1 >= 0 && index2 >= 0 && index1 < sensorsToShow.length && index2 < sensorsToShow.length) { - [sensorsToShow[index1], sensorsToShow[index2]] = [sensorsToShow[index2], sensorsToShow[index1]]; - reRenderForm() - renderApiSensors(cachedFilteredSensors, index2); - } - } - async function moveGraphUp(index) { const newArray = moveArrayItem(getSensorToShow(), index, "up"); + const card = document.getElementById(`graph_${index - 1}`); + setActiveCard(card); setSensorToShow(newArray); + setEditingIndex(index - 1); } async function moveGraphDown(index) { const newArray = moveArrayItem(getSensorToShow(), index, "down"); + const card = document.getElementById(`graph_${index + 1}`); + setActiveCard(card); setSensorToShow(newArray); + setEditingIndex(index + 1); } function editGraphTitle(index) { @@ -881,6 +866,9 @@
Field Details
setEditingIndex(null); } + window.addNewGraph = addNewGraph; + window.addSensorAsGraph = addSensorAsGraph; + // ============== Graph Cards Management ============== // // Render the available API sensors @@ -985,27 +973,27 @@
${sensor.name}
} } - // Add a sensor as a new graph ca + // Add a sensor as a new graph card function addSensorAsGraph(id) { - const newAsset = { - title: "No Title", - plots: [{ sensor: id }], - }; + if (!activeCard() || !editingIndex()) { + showToast("Please select a graph card to add the plot to.", "warning"); + return; + } + const oldSensorsToShow = getSensorToShow(); - oldSensorsToShow.push(newAsset); + oldSensorsToShow[editingIndex()]["plots"].push({ sensor: id }); setSensorToShow(oldSensorsToShow); } // Add blank graph to the graph cards function addNewGraph() { const newAsset = { - title: "No Title " + (sensorsToShow.length + 1), - sensors: [], + title: "No Title", + plots: [] }; - selectedGraphTitle = newAsset.title; - sensorsToShow.push(newAsset); - selectGraph(sensorsToShow.length - 1) - renderGraphCards(); + const oldSensorsToShow = getSensorToShow(); + oldSensorsToShow.push(newAsset); + setSensorToShow(oldSensorsToShow); } // Add Sensor to an existing graph card @@ -1035,12 +1023,12 @@
${sensor.name}
for (let i = 0; i < plots.length; i++) { const plot = plots[i]; if (plot.sensor === sensorId || (Array.isArray(plot.sensors) && plot.sensors.includes(sensorId))) { - return {plotIndex: i, isSensors: false}; - }else if (plot.sensors && plot.sensors.includes(sensorId)) { - return {plotIndex: i, isSensors: true}; + return { plotIndex: i, isSensors: false }; + } else if (plot.sensors && plot.sensors.includes(sensorId)) { + return { plotIndex: i, isSensors: true }; } } - return {plotIndex: -1, isSensors: false}; // Return -1 if not found + return { plotIndex: -1, isSensors: false }; // Return -1 if not found } function handleEnterKeyEventForTitleEditing(event, graphIndex) { From 9b08119da44619488cb2c94df91588047ee72e65 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Fri, 6 Feb 2026 13:22:15 +0100 Subject: [PATCH 06/16] chore: reorder tabs Signed-off-by: joshuaunity --- .../ui/templates/assets/asset_graph.html | 84 ++++++++++--------- 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index d76facbe68..dbb9dc9daf 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -258,52 +258,22 @@
- -
- + -
+
@@ -337,6 +307,40 @@
Field Details
+ + + +
From 502554db18454e7debd83647c598edc89dd9aa67 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 9 Feb 2026 07:17:12 +0100 Subject: [PATCH 07/16] fix: fixed bug whre options keep gettgin added teh configType dropdown. Also added some docstring Signed-off-by: joshuaunity --- flexmeasures/ui/static/js/components.js | 15 ++++++++++++--- flexmeasures/ui/templates/assets/asset_graph.html | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js index 21b84b6a9f..4f72886220 100644 --- a/flexmeasures/ui/static/js/components.js +++ b/flexmeasures/ui/static/js/components.js @@ -1,14 +1,23 @@ import { getAsset, getAccount, getSensor, apiBasePath } from "./ui-utils.js"; -const addInfo = (label, value, infoDiv, Resource, isLink = false) => { +/** + * Helper function to add key-value information to a container. + * + * @param {string} label - The label text (e.g., "ID"). + * @param {string|number} value - The value to display. + * @param {HTMLElement} infoDiv - The container element to append to. + * @param {Object} resource - The generic resource object (Asset or Sensor). + * @param {boolean} [isLink=false] - If true, renders the value as a hyperlink to the resource page. + */ +const addInfo = (label, value, infoDiv, resource, isLink = false) => { const b = document.createElement("b"); b.textContent = `${label}: `; infoDiv.appendChild(b); - const isSensor = Resource.hasOwnProperty("unit"); + const isSensor = resource.hasOwnProperty("unit"); if (isLink) { const a = document.createElement("a"); - a.href = `${apiBasePath}/${isSensor ? "sensors" : "assets"}/${Resource.id}`; + a.href = `${apiBasePath}/${isSensor ? "sensors" : "assets"}/${resource.id}`; a.textContent = value; infoDiv.appendChild(a); } else { diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index dbb9dc9daf..ec88bfb2ed 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -437,10 +437,11 @@
Field Details
const selectedValue = e.target.value; if (selectedValue !== "null") { configTypeSelect.disabled = false; + configTypeSelect.innerHTML = ''; + flexConfigField.innerHTML = ''; + flexConfigFieldDetail.value = ""; const selectedAsset = await getAsset(selectedValue); - - if (selectedAsset.flex_context !== "{}" && selectedAsset.flex_context !== '"{\"inflexible-device-sensors\": []}"') { // Some flex-context have this value set with an empty array const flexContextOption = document.createElement("option"); flexContextOption.value = "flex-context"; From ce0a98484e59d44f73475fafa891a60c221d45fb Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 9 Feb 2026 08:12:09 +0100 Subject: [PATCH 08/16] fix: fixed error where graph cant be removed Signed-off-by: joshuaunity --- flexmeasures/ui/templates/assets/asset_graph.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index ec88bfb2ed..972be2b912 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -838,9 +838,10 @@
Field Details
async function removeGraph(index) { const sensorsToShowOldState = getSensorToShow(); - sensorsToShowOldState.splice(index, 1); - setSensorToShow(sensorsToShowOldState); - reRenderForm(); + const newSensorsToShow = sensorsToShowOldState.filter((_, i) => i !== index); + setActiveCard(null); + setConfigAsset(null); + setSensorToShow(newSensorsToShow); } async function moveGraphUp(index) { From d6d75d89af254d1dcd06a2f65e07c78c5f87f671 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 9 Feb 2026 09:45:00 +0100 Subject: [PATCH 09/16] fix: ixed issue with graph titles not being editable Signed-off-by: joshuaunity --- flexmeasures/ui/static/js/components.js | 64 --------------- .../ui/templates/assets/asset_graph.html | 79 +++++++++++++++++-- 2 files changed, 73 insertions(+), 70 deletions(-) diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js index 4f72886220..47b9c0f6bd 100644 --- a/flexmeasures/ui/static/js/components.js +++ b/flexmeasures/ui/static/js/components.js @@ -146,67 +146,3 @@ export async function renderSensorsList(sensorIds, graphIndex) { return { element: listContainer, uniqueUnits: [...new Set(units)] }; } - -/** - * Renders the header for a graph card. - * @param {string} title - The current title of the graph. - * @param {number} index - The index of the graph in the list. - * @param {boolean} isEditing - Whether this specific graph is in edit mode. - * @param {Function} onSave - Function to call when "Save" is clicked or "Enter" is pressed. - * @param {Function} onEdit - Function to call when "Edit" is clicked. - */ -export function renderGraphHeader(title, index, isEditing, onSave, onEdit) { - const header = document.createElement("div"); - header.className = "d-flex align-items-center mb-2"; - - if (isEditing) { - // 1. Title Input - const input = document.createElement("input"); - input.type = "text"; - input.className = "form-control me-2"; - input.id = `editTitle_${index}`; - input.value = title; - - // Save on Enter key - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - onSave(index); - } - }); - - // 2. Save Button - const saveBtn = document.createElement("button"); - saveBtn.className = "btn btn-success btn-sm"; - saveBtn.textContent = "Save"; - saveBtn.onclick = (e) => { - e.stopPropagation(); // Prevent card selection - onSave(index); - }; - - header.appendChild(input); - header.appendChild(saveBtn); - - // Auto-focus the input - setTimeout(() => input.focus(), 0); - } else { - // 1. Display Title - const h5 = document.createElement("h5"); - h5.className = "card-title me-2 mb-0"; - h5.textContent = title; - - // 2. Edit Button - const editBtn = document.createElement("button"); - editBtn.className = "btn btn-warning btn-sm"; - editBtn.textContent = "Edit"; - editBtn.onclick = (e) => { - e.stopPropagation(); // Prevent card selection - onEdit(index); - }; - - header.appendChild(h5); - header.appendChild(editBtn); - } - - return header; -} diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 972be2b912..644411d802 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -366,7 +366,7 @@
Field Details
apiBasePath, processResourceRawJSON, convertHtmlToElement, moveArrayItem } from "{{ url_for('flexmeasures_ui.static', filename='js/ui-utils.js') }}?v={{ flexmeasures_version }}"; - import { renderAssetPlotCard, renderSensorCard, renderSensorsList, renderGraphHeader } from "{{ url_for('flexmeasures_ui.static', filename='js/components.js') }}?v={{ flexmeasures_version }}"; + import { renderAssetPlotCard, renderSensorCard, renderSensorsList } from "{{ url_for('flexmeasures_ui.static', filename='js/components.js') }}?v={{ flexmeasures_version }}"; // This variable is used to prevent reRenderForm from running on initial load let hasInitialized = false; @@ -595,6 +595,70 @@
Field Details
} } + /** + * Renders the header for a graph card. + * @param {string} title - The current title of the graph. + * @param {number} index - The index of the graph in the list. + */ + function renderGraphHeader(title, index) { + const header = document.createElement("div"); + header.className = "d-flex align-items-center mb-2"; + + if (editingIndex() === index) { + // 1. Title Input + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control me-2"; + input.id = `editTitle_${index}`; + input.value = title; + + // Save on Enter key + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + saveGraphTitle(index); + } + }); + + // 2. Save Button + const saveBtn = document.createElement("button"); + saveBtn.className = "btn btn-success btn-sm"; + saveBtn.textContent = "Save"; + saveBtn.onclick = (e) => { + e.stopPropagation(); // Prevent card selection + saveGraphTitle(index); + }; + + header.appendChild(input); + header.appendChild(saveBtn); + + // Auto-focus the input + setTimeout(() => input.focus(), 0); + } else { + // 1. Display Title + const h5 = document.createElement("h5"); + h5.className = "card-title me-2 mb-0"; + h5.textContent = title; + + // 2. Edit Button + const editBtn = document.createElement("button"); + editBtn.className = "btn btn-warning btn-sm"; + editBtn.textContent = "Edit"; + editBtn.onclick = (e) => { + e.stopPropagation(); // Prevent card selection + console.log("Edit button clicked for graph index:", index); + setEditingIndex(index); + reRenderForm(); + }; + + header.appendChild(h5); + header.appendChild(editBtn); + } + + return header; + } + + async function renderGraphCard(item, index, sensorsToShowLength) { // 2. Outer Column const col = document.createElement("div"); @@ -620,9 +684,6 @@
Field Details
const headerElement = renderGraphHeader( item.title, index, - isEditing, - saveGraphTitle, - setEditingIndex ); cardBody.appendChild(headerElement); @@ -867,9 +928,15 @@
Field Details
async function saveGraphTitle(index) { const newTitle = document.getElementById(`editTitle_${index}`).value; const sensorsToShowOldState = getSensorToShow(); - sensorsToShowOldState[index].title = newTitle; - setSensorToShow(sensorsToShowOldState); + // sensorsToShowOldState[index].title = newTitle; + const newSensorsToShow = sensorsToShowOldState.map((item, i) => + i === index ? { ...item, title: newTitle } : item + ); + setActiveCard(null); + setConfigAsset(null); setEditingIndex(null); + setSensorToShow(newSensorsToShow); + } window.addNewGraph = addNewGraph; From b5b9876cf5289c9a8e504133c59469ebb9e988e9 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Wed, 11 Feb 2026 12:29:57 +0100 Subject: [PATCH 10/16] fix: fixed broken units dropdown as well as some other refactoring to revive other broken features Signed-off-by: joshuaunity --- flexmeasures/ui/static/js/components.js | 48 +++++- .../ui/templates/assets/asset_graph.html | 162 ++++++++---------- flexmeasures/ui/views/assets/views.py | 1 + 3 files changed, 114 insertions(+), 97 deletions(-) diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js index 47b9c0f6bd..4c6b6ef48d 100644 --- a/flexmeasures/ui/static/js/components.js +++ b/flexmeasures/ui/static/js/components.js @@ -1,3 +1,12 @@ +/** + * UI Components + * ============= + * + * This file contains reusable UI components for the FlexMeasures frontend. + * Moving forward, new UI elements and sub-components (graphs, cards, lists) + * should be defined here to promote reusability and cleaner template files. + */ + import { getAsset, getAccount, getSensor, apiBasePath } from "./ui-utils.js"; /** @@ -25,6 +34,18 @@ const addInfo = (label, value, infoDiv, resource, isLink = false) => { } }; +/** + * Renders a card representing an Asset Plot configuration. + * + * Creates a visual element displaying the Asset ID, Name, and its associated + * Flex Context or Flex Model configuration. Includes a remove button. + * + * @param {Object} assetPlot - The configuration object for the asset plot. + * Expected structure: { asset: , "flex-context"?: , "flex-model"?: } + * @param {number} graphIndex - The index of the parent graph in the sensors_to_show array. + * @param {number} plotIndex - The index of this specific plot within the graph's plots array. + * @returns {Promise} The constructed HTML element representing the card. + */ export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { const Asset = await getAsset(assetPlot.asset); let IsFlexContext = false; @@ -68,7 +89,7 @@ export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { // Attach the actual function here closeIcon.addEventListener("click", (e) => { e.stopPropagation(); // Prevent card selection click - // removeAssetPlotFromGraph(graphIndex, plotIndex); + // removeAssetPlotFromGraph(graphIndex, plotIndex); // Note: Function reference needs to be available in scope }); flexDiv.appendChild(infoDiv); @@ -78,6 +99,17 @@ export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { return container; } +/** + * Renders a card representing a single Sensor. + * + * Creates a visual element displaying Sensor ID, Unit, Name, Asset Name, + * and Account Name. Used within the list of sensors for a graph. + * + * @param {number} sensorId - The ID of the sensor to display. + * @param {number} graphIndex - The index of the parent graph in the sensors_to_show array. + * @param {number} sensorIndex - The index of this sensor within the graph's sensor list. + * @returns {Promise<{element: HTMLElement, unit: string}>} An object containing the card element and the sensor's unit. + */ export async function renderSensorCard(sensorId, graphIndex, sensorIndex) { const Sensor = await getSensor(sensorId); const Asset = await getAsset(Sensor.generic_asset_id); @@ -125,6 +157,16 @@ export async function renderSensorCard(sensorId, graphIndex, sensorIndex) { return { element: container, unit: Sensor.unit }; } +/** + * Renders a list of sensors for a specific graph card. + * + * Iterates through a list of sensor IDs, creates cards for them, and + * aggregates their units to help detect unit mismatches. + * + * @param {number[]} sensorIds - Array of sensor IDs to render. + * @param {number} graphIndex - The index of the parent graph being rendered. + * @returns {Promise<{element: HTMLElement, uniqueUnits: string[]}>} An object containing the container element with all sensors and a list of unique units found. + */ export async function renderSensorsList(sensorIds, graphIndex) { const listContainer = document.createElement("div"); const units = []; @@ -144,5 +186,5 @@ export async function renderSensorsList(sensorIds, graphIndex) { units.push(res.unit); }); - return { element: listContainer, uniqueUnits: [...new Set(units)] }; -} + return { element: listContainer, uniqueUnits: [...new Set(units)] } +}; \ No newline at end of file diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 644411d802..325bfdabc4 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -317,7 +317,7 @@
Field Details
- {% for unit in available_units %} @@ -382,6 +382,7 @@
Field Details
const configTypeSelect = document.getElementById("configTypeSelect"); const flexConfigField = document.getElementById("flexConfigField"); const flexConfigFieldDetail = document.getElementById("flexConfigFieldDetail"); + const unitsSelect = document.getElementById("unitsSelect"); let sensorsToShowRawJSON = "{{ asset.sensors_to_show | safe }}"; sensorsToShowRawJSON = sensorsToShowRawJSON.replace(/'/g, '"'); @@ -423,6 +424,10 @@
Field Details
} }; + unitsSelect.onchange = function () { + filterSensors(); + }; + function reRenderForm() { if (!hasInitialized) { hasInitialized = true; @@ -528,7 +533,8 @@
Field Details
async function renderRootAssets() { const assetTreeSelect = document.getElementById("assetTree"); - const response = await fetch(`${apiBasePath}/api/v3_0/assets?page=1&per_page=10&sort_by=id&sort_dir=asc`); + const rootAssetId = "{{asset.id}}" + const response = await fetch(`${apiBasePath}/api/v3_0/assets?page=1&per_page=200&sort_by=id&sort_dir=asc&root=${rootAssetId}`); const resBody = await response.json(); const assets = resBody.data; @@ -577,24 +583,6 @@
Field Details
}); } - // highlight selected graph - async function selectGraph(graphIndex) { - if (graphIndex !== undefined) { - savedGraphIndex = graphIndex; - selectedGraphTitle = sensorsToShow[graphIndex]?.title; - // check if graphIndex still exists(This is because this function is called when the removed button is clicked as well) - if (sensorsToShow[graphIndex]) { - renderApiSensors(cachedFilteredSensors, graphIndex); - } else { - renderApiSensors(cachedFilteredSensors); - } - } else { - savedGraphIndex = undefined; - selectedGraphTitle = undefined; - reRenderForm(); - } - } - /** * Renders the header for a graph card. * @param {string} title - The current title of the graph. @@ -646,7 +634,6 @@
Field Details
editBtn.textContent = "Edit"; editBtn.onclick = (e) => { e.stopPropagation(); // Prevent card selection - console.log("Edit button clicked for graph index:", index); setEditingIndex(index); reRenderForm(); }; @@ -671,6 +658,8 @@
Field Details
card.onclick = () => { setActiveCard(activeCard()?.id === card.id ? null : card); setEditingIndex(editingIndex() === index ? null : index); + filterSensors(); + reRenderForm(); }; const cardBody = document.createElement("div"); @@ -805,18 +794,6 @@
Field Details
spinnerElement.style.display = 'flex'; apiSensorsListElement.style.display = 'none'; - // Due to the nature of async functions, the highlightedCard might not be available - // when the filterSensors function is called. So, we need to check if it exists - if (highlightedCard) { - const cardId = highlightedCard.id; - const index = cardId.split("_")[1]; - savedGraphIndex = index; - selectedGraphTitle = sensorsToShow[index].title; - } else { - savedGraphIndex = undefined; - selectedGraphTitle = undefined; - } - // check if apiSensorsList has been rendered if (apiSensorsListElement.innerHTML === "") { document.getElementById('apiSensorsList').style.display = 'block'; @@ -845,8 +822,8 @@
Field Details
const filteredSensors = responseData.data; // Render the fetched sensors - if (savedGraphIndex !== null && savedGraphIndex !== undefined) { - renderApiSensors(filteredSensors, savedGraphIndex); + if (activeCard() && activeCard().id) { + renderApiSensors(filteredSensors, activeCard().id.split("_")[1]); } else { renderApiSensors(filteredSensors); } @@ -946,9 +923,7 @@
Field Details
// Render the available API sensors function renderApiSensors(sensors, graphIndex) { - // graphIndex is undefined when the sensors are being added to the graph - // graphIndex is defined when the sensors are being added to the graph cards. In other words - // when more sensors are being added to single graph + console.log("Rendering API sensors..."); apiSensorsList.innerHTML = ""; // Clear the previous sensors if (sensors.length === 0) { @@ -962,6 +937,9 @@
Field Details
const col = document.createElement("div"); col.classList.add("col-12", "mb-1"); + const sensorsToShowOldState = getSensorToShow(); + let currentlySelectedGraphText = graphIndex !== undefined ? sensorsToShowOldState[graphIndex].title : ""; + col.innerHTML = `
@@ -972,8 +950,8 @@
${sensor.name}
Asset: ${Asset.name}, Account: ${Account?.name ? Account.name : "PUBLIC"}

- ${graphIndex !== undefined && savedGraphIndex !== undefined && selectedGraphTitle !== undefined - ? `` + ${activeCard() && activeCard().id === `graph_${graphIndex}` + ? `` : `` }
@@ -1048,13 +1026,17 @@
${sensor.name}
// Add a sensor as a new graph card function addSensorAsGraph(id) { - if (!activeCard() || !editingIndex()) { - showToast("Please select a graph card to add the plot to.", "warning"); - return; - } - const oldSensorsToShow = getSensorToShow(); - oldSensorsToShow[editingIndex()]["plots"].push({ sensor: id }); + + if (editingIndex()) { + oldSensorsToShow[editingIndex()]["plots"].push({ sensor: id }); + } else { + const newAsset = { + title: "No Title", + plots: [{ sensor: id }] + }; + oldSensorsToShow.push(newAsset); + } setSensorToShow(oldSensorsToShow); } @@ -1071,15 +1053,16 @@
${sensor.name}
// Add Sensor to an existing graph card function addSensorToExistingGraph(graphIndex, sensorId) { - sensorsToShow[graphIndex].sensors.push(sensorId); - renderGraphCards(); + // sensorsToShow[graphIndex].sensors.push(sensorId); + // renderGraphCards(); + + const oldSensorsToShow = getSensorToShow(); + oldSensorsToShow[graphIndex].plots.push({ sensor: sensorId }); + setSensorToShow(oldSensorsToShow); } // Remove sensor from the graph sensor list function removeSensorFromGraph(graphIndex, sensorIndex) { - // sensorsToShow[graphIndex].sensors.splice(sensorIndex, 1); - // renderGraphCards(); - // renderApiSensors(cachedFilteredSensors); const oldSensorsToShow = getSensorToShow(); oldSensorsToShow[graphIndex].sensors.splice(sensorIndex, 1); setSensorToShow(oldSensorsToShow); @@ -1104,13 +1087,6 @@
${sensor.name}
return { plotIndex: -1, isSensors: false }; // Return -1 if not found } - function handleEnterKeyEventForTitleEditing(event, graphIndex) { - if (event.key === "Enter") { - saveGraphTitle(graphIndex); - renderApiSensors(cachedFilteredSensors, graphIndex); - } - } - async function showAssetChartSelection() { let asset = await getAsset("{{ asset.id }}") for (let child of asset.child_assets) { @@ -1127,7 +1103,6 @@
${sensor.name}
const shouldShow = await showAssetChartSelection(); const chartTypeDropdown = document.getElementById('chart-type-picker'); if (shouldShow) { - console.log("Show chart selection!"); chartTypeDropdown.style.display = 'block'; } @@ -1138,41 +1113,40 @@
${sensor.name}
renderRootAssets(); // Initial render of root assets }); - document.addEventListener("click", function (event) { - /** - The logic in this block is majorly to remove the border on click of the card and add it to the selected card - but as this event is added to the document, it will be triggered on any click event on the page - so the if statements are used to check if the click event is on the card or not - */ - const card = event.target.closest(".card-highlight"); - const sensorCard = event.target.closest(".sensor-card"); - const searchInput = event.target.id === "searchInput"; - const cardBody = event.target.closest(".card-body"); - const editTitleBtn = event.target.id === "editTitleBtn"; - const saveTitleBtn = event.target.id === "saveTitleBtn"; - const unSetBtn = event.target.id === "unSetFlexField"; - - if (card) { - if (card.classList.contains("border-on-click")) { - if (editTitleBtn || saveTitleBtn) { - renderGraphCards(); - } - // Pass - } else { - renderGraphCards(); - } - } else if ( - cardBody !== null && cardBody !== undefined || - sensorCard !== null && sensorCard !== undefined || - searchInput !== null && searchInput !== undefined - ) { - // Pass - } else { - document.querySelectorAll(".card-highlight").forEach(el => el.classList.remove("border-on-click")); - renderApiSensors([], undefined); - } - }); - + // document.addEventListener("click", function (event) { + // /** + // The logic in this block is majorly to remove the border on click of the card and add it to the selected card + // but as this event is added to the document, it will be triggered on any click event on the page + // so the if statements are used to check if the click event is on the card or not + // */ + // const card = event.target.closest(".card-highlight"); + // const sensorCard = event.target.closest(".sensor-card"); + // const searchInput = event.target.id === "searchInput"; + // const cardBody = event.target.closest(".card-body"); + // const editTitleBtn = event.target.id === "editTitleBtn"; + // const saveTitleBtn = event.target.id === "saveTitleBtn"; + // const unSetBtn = event.target.id === "unSetFlexField"; + + // if (card) { + // if (card.classList.contains("border-on-click")) { + // if (editTitleBtn || saveTitleBtn) { + // renderGraphCards(); + // } + // // Pass + // } else { + // renderGraphCards(); + // } + // } else if ( + // cardBody !== null && cardBody !== undefined || + // sensorCard !== null && sensorCard !== undefined || + // searchInput !== null && searchInput !== undefined + // ) { + // // Pass + // } else { + // document.querySelectorAll(".card-highlight").forEach(el => el.classList.remove("border-on-click")); + // renderApiSensors([], undefined); + // } + // }); // ============== Page Events ============== // }); diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index daec0ed067..de2b0e9bcb 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -352,6 +352,7 @@ def graphs(self, id: str, start_time=None, end_time=None): asset=asset, has_kpis=has_kpis, asset_kpis=asset_kpis, + available_units=available_units(), current_page="Graphs", ) From f878460b451c741a1392e8b8ad017a38426d1a46 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 16 Feb 2026 19:49:14 +0100 Subject: [PATCH 11/16] refactor: Major refactor phase 1 Signed-off-by: joshuaunity --- flexmeasures/api/v3_0/assets.py | 34 ++++- flexmeasures/data/models/generic_assets.py | 141 +++++++++++++----- flexmeasures/data/schemas/generic_assets.py | 2 +- flexmeasures/data/schemas/utils.py | 81 +++++++++- flexmeasures/ui/static/js/components.js | 43 +++++- .../ui/templates/assets/asset_graph.html | 28 ++-- flexmeasures/utils/coding_utils.py | 2 +- flexmeasures/utils/unit_utils.py | 25 ++++ 8 files changed, 290 insertions(+), 66 deletions(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 61ca9fe8ed..17603e2f5c 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -48,6 +48,7 @@ GenericAssetIdField as AssetIdField, GenericAssetTypeSchema as AssetTypeSchema, ) +from flexmeasures.data.schemas.utils import generate_constant_time_series from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema from flexmeasures.data.schemas.scheduling import AssetTriggerSchema, FlexContextSchema from flexmeasures.data.services.scheduling import ( @@ -898,7 +899,38 @@ def get_chart_data(self, id: int, asset: GenericAsset, **kwargs): - Assets """ sensors = flatten_unique(asset.validate_sensors_to_show()) - return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs) + bdf = asset.search_beliefs(sensors=sensors, as_json=True, **kwargs) + raw = json.loads(bdf) + + new_data = { + "sensors": raw["sensors"], + "data": raw["data"], + "sources": raw["sources"], + } + + for sensor in sensors: + if sensor.id < 0: + new_data["sensors"][f"{sensor.id}"] = { + "name": sensor.name, + "unit": sensor.unit, + "description": f"{sensor.name} fdfd ", + "asset_id": sensor.generic_asset_id, + "asset_description": sensor.asset.name, + } + + start_datetime = kwargs.get("event_starts_after") + end_datetime = kwargs.get("event_ends_before") + + if start_datetime and end_datetime: + simulated_graph_data = generate_constant_time_series( + event_start=start_datetime, + event_end=end_datetime, + value=sensor.attributes["graph_value"], + sid=sensor.id, + ) + new_data["data"].extend(simulated_graph_data) + + return new_data, 200 @route("//auditlog") @use_kwargs( diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 46bb19383c..92dc4ab379 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta from typing import Any +import math +import random import json from flask import current_app @@ -275,19 +277,10 @@ def validate_sensors_to_show( """ # If not set, use defaults (show first 2 sensors) if not self.sensors_to_show and suggest_default_sensors: - sensors_to_show = self.sensors[:2] - if ( - len(sensors_to_show) == 2 - and sensors_to_show[0].unit == sensors_to_show[1].unit - ): - # Sensors are shown together (e.g. they can share the same y-axis) - return [{"title": None, "sensors": sensors_to_show}] - # Otherwise, show separately - return [{"title": None, "sensors": [sensor]} for sensor in sensors_to_show] + return self._suggest_default_sensors_to_show() sensor_ids_to_show = self.sensors_to_show # Import the schema for validation - from flexmeasures.data.schemas.utils import extract_sensors_from_flex_config from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema sensors_to_show_schema = SensorsToShowSchema() @@ -299,51 +292,41 @@ def validate_sensors_to_show( sensor_id_allowlist = SensorsToShowSchema.flatten(standardized_sensors_to_show) - # Only allow showing sensors from assets owned by the user's organization, - # except in play mode, where any sensor may be shown - accounts = [self.owner] if self.owner is not None else None - if current_app.config.get("FLEXMEASURES_MODE") == "play": - from flexmeasures.data.models.user import Account - - accounts = db.session.scalars(select(Account)).all() - - from flexmeasures.data.services.sensors import get_sensors - - accessible_sensor_map = { - sensor.id: sensor - for sensor in get_sensors( - account=accounts, - include_public_assets=True, - sensor_id_allowlist=sensor_id_allowlist, - ) - } + accessible_sensor_map = self._get_accessible_sensor_map(sensor_id_allowlist) # Build list of sensor objects that are accessible sensors_to_show = [] missed_sensor_ids = [] + asset_refs: list[dict] = [] - for entry in standardized_sensors_to_show: + from flexmeasures.data.schemas.utils import extract_sensors_from_flex_config + from flexmeasures.data.models.time_series import Sensor + for entry in standardized_sensors_to_show: title = entry.get("title") - sensors = [] + sensors_for_entry: list[int] = [] plots = entry.get("plots", []) - if len(plots) > 0: - for plot in plots: - if "sensor" in plot: - sensors.append(plot["sensor"]) - if "sensors" in plot: - sensors.extend(plot["sensors"]) - if "asset" in plot: - extracted_sensors = extract_sensors_from_flex_config(plot) - sensors.extend(extracted_sensors) + + for plot in plots: + self._process_plot_entry( + plot, + sensors_for_entry, + asset_refs, + extract_sensors_from_flex_config, + Sensor, + ) accessible_sensors = [ accessible_sensor_map.get(sid) - for sid in sensors + for sid in sensors_for_entry if sid in accessible_sensor_map ] - inaccessible = [sid for sid in sensors if sid not in accessible_sensor_map] + + inaccessible = [ + sid for sid in sensors_for_entry if sid not in accessible_sensor_map + ] missed_sensor_ids.extend(inaccessible) + if accessible_sensors: sensors_to_show.append( {"title": title, "plots": [{"sensors": accessible_sensors}]} @@ -353,8 +336,84 @@ def validate_sensors_to_show( current_app.logger.warning( f"Cannot include sensor(s) {missed_sensor_ids} in sensors_to_show on asset {self}, as it is not accessible to user {current_user}." ) + + if asset_refs: + self._add_temporary_asset_sensors(asset_refs, sensors_to_show, Sensor) + return sensors_to_show + def _suggest_default_sensors_to_show(self): + """Helper to return default sensors if none are configured.""" + sensors_to_show = self.sensors[:2] + if ( + len(sensors_to_show) == 2 + and sensors_to_show[0].unit == sensors_to_show[1].unit + ): + # Sensors are shown together (e.g. they can share the same y-axis) + return [{"title": None, "sensors": sensors_to_show}] + # Otherwise, show separately + return [{"title": None, "sensors": [sensor]} for sensor in sensors_to_show] + + def _get_accessible_sensor_map(self, sensor_id_allowlist: list[int]) -> dict: + """Helper to fetch and map accessible sensors.""" + from flexmeasures.data.services.sensors import get_sensors + + # Only allow showing sensors from assets owned by the user's organization, + # except in play mode, where any sensor may be shown + accounts = [self.owner] if self.owner is not None else None + if current_app.config.get("FLEXMEASURES_MODE") == "play": + from flexmeasures.data.models.user import Account + + accounts = db.session.scalars(select(Account)).all() + + return { + sensor.id: sensor + for sensor in get_sensors( + account=accounts, + include_public_assets=True, + sensor_id_allowlist=sensor_id_allowlist, + ) + } + + def _process_plot_entry( + self, plot, sensors_list, asset_refs_list, extract_utils, SensorClass + ): + """Helper to extract sensors and asset refs from a single plot configuration.""" + if "sensor" in plot: + sensors_list.append(plot["sensor"]) + if "sensors" in plot: + sensors_list.extend(plot["sensors"]) + if "asset" in plot: + extracted_sensors, refs = extract_utils(plot) + for sensor_id in extracted_sensors: + sensor = db.session.get(SensorClass, sensor_id) + flex_config_key = plot.get("flex-context") or plot.get("flex-model") + + # temporarily update sensor name for display context + sensor_name = f"{sensor.name} ({flex_config_key} for ({sensor.generic_asset.name}))" + sensor.name = sensor_name + + sensors_list.extend(extracted_sensors) + asset_refs_list.extend(refs) + + def _add_temporary_asset_sensors(self, asset_refs, sensors_to_show, SensorClass): + """Helper to create temporary sensor objects for asset references.""" + for ref in asset_refs: + parent_asset = db.session.get(GenericAsset, ref["id"]) + sensor_name = f"Temporary Sensor ({ref['field']} for ({parent_asset.name}))" + # create temporary sensor with negative ID + temporary = SensorClass( + name=sensor_name, + unit=ref["unit"], + generic_asset=parent_asset, + attributes={"graph_value": ref["value"]}, + ) + # random negative number between -1 and -10000 to avoid conflicts with real sensor ids + temporary.id = -1 * math.ceil(random.random() * 10000) + sensors_to_show.append( + {"title": "Temporary Sensor", "plots": [{"sensor": temporary}]} + ) + @property def asset_type(self) -> GenericAssetType: """This property prepares for dropping the "generic" prefix later""" diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index a6e2e0b8df..b1132f8a05 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -243,7 +243,7 @@ def flatten(cls, nested_list) -> list[int]: if "sensor" in plot: all_objects.append(plot["sensor"]) if "asset" in plot: - sensors = extract_sensors_from_flex_config(plot) + sensors, _ = extract_sensors_from_flex_config(plot) all_objects.extend(sensors) elif "sensors" in s: all_objects.extend(s["sensors"]) diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 235b3dd103..26cf50c07e 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -1,10 +1,12 @@ import click import marshmallow as ma +import pandas as pd +from datetime import datetime from click import get_current_context from flask.cli import with_appcontext as with_cli_appcontext from pint import DefinitionSyntaxError, DimensionalityError, UndefinedUnitError -from flexmeasures.utils.unit_utils import to_preferred, ur +from flexmeasures.utils.unit_utils import to_preferred, ur, extract_unit_from_string from flexmeasures.data.models.time_series import Sensor @@ -86,12 +88,13 @@ def convert_to_quantity(value: str, to_unit: str) -> ur.Quantity: ) -def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: +def extract_sensors_from_flex_config(plot: dict) -> tuple[list[Sensor], list[dict]]: """ Extracts a consolidated list of sensors from an asset based on flex-context or flex-model definitions provided in a plot dictionary. """ all_sensors = [] + asset_refs = [] from flexmeasures.data.schemas.generic_assets import ( GenericAssetIdField, @@ -99,6 +102,9 @@ def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: asset = GenericAssetIdField().deserialize(plot.get("asset")) + if asset is None: + raise FMValidationError("Asset not found for the provided plot configuration.") + fields_to_check = { "flex-context": asset.flex_context, "flex-model": asset.flex_model, @@ -115,5 +121,74 @@ def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: sensor = field_value.get("sensor") if sensor: all_sensors.append(sensor) + elif isinstance(field_value, str): + unit = None + # extract unit from the string value and add a dummy sensor with that unit + value, unit = extract_unit_from_string(field_value) + if unit is not None: + asset_refs.append( + { + "id": asset.id, + "field": field_key, + "value": value, + "unit": unit, + } + ) + else: + raise FMValidationError( + f"Value '{field_value}' for field '{field_key}' in '{plot_key}' is not a valid quantity string." + ) + + return all_sensors, asset_refs + + +def generate_constant_time_series( + event_start: str, + event_end: str, + value: float, + sid: int, + src: int = 1, + belief_time: datetime = None, +) -> list[dict]: + """ + Generates a list of data points with a 1-hour frequency. + + :param event_start: Start of the range + :param event_end: End of the range + :param value: The constant value for 'val' + :param sid: Sensor ID + :param src: Source ID + :param belief_time: The time the data was "generated". + If None, it defaults to the start of the events. + """ + if belief_time is None: + belief_time = event_start + + # Create hourly range + # We use inclusive='left' to ensure we don't exceed the end date + # if the end date represents the boundary of the last interval. + dr = pd.date_range(start=event_start, end=event_end, freq="1h", inclusive="left") + + data = [] + + # Convert belief_time to milliseconds for the 'bh' calculation + bt_ms = int(belief_time.timestamp() * 1000) + + for ts in dr: + ts_ms = int(ts.timestamp() * 1000) + + # In your data: ts - bh = bt => bh = ts - bt + belief_horizon = ts_ms - bt_ms + + data.append( + { + "ts": ts_ms, + "sid": sid, + "val": float(value), + "sf": 1.0, + "src": src, + "bh": belief_horizon, + } + ) - return all_sensors + return data diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js index 4c6b6ef48d..dd8bf6d925 100644 --- a/flexmeasures/ui/static/js/components.js +++ b/flexmeasures/ui/static/js/components.js @@ -46,7 +46,12 @@ const addInfo = (label, value, infoDiv, resource, isLink = false) => { * @param {number} plotIndex - The index of this specific plot within the graph's plots array. * @returns {Promise} The constructed HTML element representing the card. */ -export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { +export async function renderAssetPlotCard( + assetPlot, + removeAssetPlotFromGraph, + graphIndex, + plotIndex, +) { const Asset = await getAsset(assetPlot.asset); let IsFlexContext = false; let IsFlexModel = false; @@ -89,9 +94,23 @@ export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { // Attach the actual function here closeIcon.addEventListener("click", (e) => { e.stopPropagation(); // Prevent card selection click - // removeAssetPlotFromGraph(graphIndex, plotIndex); // Note: Function reference needs to be available in scope + removeAssetPlotFromGraph(plotIndex, graphIndex); }); + // Disabled input to show data + const disabledInput = document.createElement("input"); + disabledInput.type = "text"; + disabledInput.className = "form-control fst-italic col mt-2"; + disabledInput.disabled = true; + const valueToDisplay = assetPlot.flexValue; + if (typeof valueToDisplay === "object") { + disabledInput.value = JSON.stringify(valueToDisplay); + } else { + disabledInput.value = valueToDisplay || "No Flex Context/Model Configured"; + } + + infoDiv.appendChild(disabledInput); + flexDiv.appendChild(infoDiv); flexDiv.appendChild(closeIcon); container.appendChild(flexDiv); @@ -107,10 +126,16 @@ export async function renderAssetPlotCard(assetPlot, graphIndex, plotIndex) { * * @param {number} sensorId - The ID of the sensor to display. * @param {number} graphIndex - The index of the parent graph in the sensors_to_show array. - * @param {number} sensorIndex - The index of this sensor within the graph's sensor list. + * @param {function} [removeAssetPlotFromGraph=null] - Optional function to remove the sensor's plot from the graph when the close icon is clicked. + * @param {number} [plotIndex=null] - The index of this sensor's plot within the graph's plots array, required if removeAssetPlotFromGraph is provided. * @returns {Promise<{element: HTMLElement, unit: string}>} An object containing the card element and the sensor's unit. */ -export async function renderSensorCard(sensorId, graphIndex, sensorIndex) { +export async function renderSensorCard( + sensorId, + graphIndex, + removeAssetPlotFromGraph = null, + plotIndex = null, +) { const Sensor = await getSensor(sensorId); const Asset = await getAsset(Sensor.generic_asset_id); const Account = await getAccount(Asset.account_id); @@ -145,8 +170,10 @@ export async function renderSensorCard(sensorId, graphIndex, sensorIndex) { // Attach the actual function here closeIcon.addEventListener("click", (e) => { - e.stopPropagation(); // Prevent card selection click - removeSensorFromGraph(graphIndex, sensorIndex); + if (plotIndex !== null) { + e.stopPropagation(); // Prevent card selection click + removeAssetPlotFromGraph(plotIndex, graphIndex); + } }); flexDiv.appendChild(infoDiv); @@ -186,5 +213,5 @@ export async function renderSensorsList(sensorIds, graphIndex) { units.push(res.unit); }); - return { element: listContainer, uniqueUnits: [...new Set(units)] } -}; \ No newline at end of file + return { element: listContainer, uniqueUnits: [...new Set(units)] }; +} diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 325bfdabc4..337eb4e710 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -312,8 +312,7 @@
Field Details
- +
@@ -383,6 +382,7 @@
Field Details
const flexConfigField = document.getElementById("flexConfigField"); const flexConfigFieldDetail = document.getElementById("flexConfigFieldDetail"); const unitsSelect = document.getElementById("unitsSelect"); + const searchInput = document.getElementById("searchInput"); let sensorsToShowRawJSON = "{{ asset.sensors_to_show | safe }}"; sensorsToShowRawJSON = sensorsToShowRawJSON.replace(/'/g, '"'); @@ -414,6 +414,7 @@
Field Details
const payload = { "asset": configAsset().id, [selectedFlexConfig]: selectedConfigField, + "flexValue": JSON.parse(flexConfigFieldDetail.value) } const sensorsToShowOldState = getSensorToShow(); @@ -428,6 +429,10 @@
Field Details
filterSensors(); }; + searchInput.oninput = function () { + filterSensors(); + }; + function reRenderForm() { if (!hasInitialized) { hasInitialized = true; @@ -534,9 +539,9 @@
Field Details
async function renderRootAssets() { const assetTreeSelect = document.getElementById("assetTree"); const rootAssetId = "{{asset.id}}" - const response = await fetch(`${apiBasePath}/api/v3_0/assets?page=1&per_page=200&sort_by=id&sort_dir=asc&root=${rootAssetId}`); + const response = await fetch(`${apiBasePath}/api/v3_0/assets?sort_by=id&sort_dir=asc&root=${rootAssetId}`); const resBody = await response.json(); - const assets = resBody.data; + const assets = resBody; // Clear existing options assetTreeSelect.innerHTML = ``; @@ -547,7 +552,6 @@
Field Details
option.textContent = `ID: ${asset.id}, Name: ${asset.name}`; assetTreeSelect.appendChild(option); option.onclick = () => { - // 1. Reset the Select State configTypeSelect.innerHTML = ''; configTypeSelect.disabled = true; @@ -682,7 +686,7 @@
Field Details
const childComponents = []; - for (const plot of item.plots) { + for (const [plotIndex, plot] of item.plots.entries()) { if ("sensors" in plot) { const plotHeader = document.createElement("h5"); plotHeader.className = "card-title pt-2"; @@ -705,7 +709,7 @@
Field Details
const plotHeader = document.createElement("h5"); plotHeader.className = "card-title pt-2"; plotHeader.innerHTML = "Sensor:"; - const sensorContent = await renderSensorCard(plot.sensor, index, 0); + const sensorContent = await renderSensorCard(plot.sensor, index, removeAssetPlotFromGraph, plotIndex); plotContainer.appendChild(plotHeader); plotContainer.appendChild(sensorContent.element); childComponents.push({ @@ -716,7 +720,7 @@
Field Details
const plotHeader = document.createElement("h5"); plotHeader.className = "card-title pt-2"; plotHeader.innerHTML = "Asset:"; - const assetPlotContent = await renderAssetPlotCard(plot, index, 0); + const assetPlotContent = await renderAssetPlotCard(plot, removeAssetPlotFromGraph, index, plotIndex); plotContainer.appendChild(plotHeader); plotContainer.appendChild(assetPlotContent); childComponents.push({ element: plotContainer, uniqueUnits: [] }); @@ -762,10 +766,14 @@
Field Details
return col; } + async function removeAssetPlotFromGraph(plotIndex, graphIndex) { + const sensorsToShowOldState = getSensorToShow(); + sensorsToShowOldState[graphIndex].plots.splice(plotIndex, 1); + setSensorToShow(sensorsToShowOldState); + } // Function to render the graph cards async function renderGraphCards() { - console.log("Rendering graph cards..."); const graphList = document.getElementById("graphList"); graphList.innerHTML = ""; const oldSensorsToShow = getSensorToShow(); @@ -923,8 +931,6 @@
Field Details
// Render the available API sensors function renderApiSensors(sensors, graphIndex) { - console.log("Rendering API sensors..."); - apiSensorsList.innerHTML = ""; // Clear the previous sensors if (sensors.length === 0) { apiSensorsList.innerHTML = "

No sensors found

"; diff --git a/flexmeasures/utils/coding_utils.py b/flexmeasures/utils/coding_utils.py index 0c1607efc6..6b735c6510 100644 --- a/flexmeasures/utils/coding_utils.py +++ b/flexmeasures/utils/coding_utils.py @@ -111,7 +111,7 @@ def flatten_unique(nested_list_of_objects: list) -> list: if "sensor" in entry: all_objects.append(entry["sensor"]) if "asset" in entry: - sensors = extract_sensors_from_flex_config(entry) + sensors, _ = extract_sensors_from_flex_config(entry) all_objects.extend(sensors) else: all_objects.append(s) diff --git a/flexmeasures/utils/unit_utils.py b/flexmeasures/utils/unit_utils.py index 91a8b10087..a87b642cf8 100644 --- a/flexmeasures/utils/unit_utils.py +++ b/flexmeasures/utils/unit_utils.py @@ -409,6 +409,31 @@ def get_unit_dimension(unit: str) -> str: return "value" +def extract_unit_from_string(text: str) -> tuple[str | None, str | None]: + """Extract the unit part from a string representing a quantity, as well as the number value. + + For example: + >>> extract_unit_from_string("1000 kW") + ('1000', 'kW') + >>> extract_unit_from_string("350 EUR/MWh") + ('350', 'EUR/MWh') + >>> extract_unit_from_string("50") + ('50', 'dimensionless') + >>> extract_unit_from_string("kW") + (None, 'kW') + """ + try: + # ur.Quantity parses the number and unit automatically + qty = ur.Quantity(text) + value = str(qty.magnitude) if qty.magnitude != 1 else None + + # We return the units formatted with "~P" (short pretty format) + # to match the registry settings. + return value, f"{qty.units:~P}" + except Exception: + return None, None + + def _convert_time_units( data: tb.BeliefsSeries | pd.Series | list[int | float] | int | float, from_unit: str, From 6ff2570145aea52e1c9a47747048997887ee10f0 Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Thu, 19 Feb 2026 18:26:10 +0100 Subject: [PATCH 12/16] refactor: Major refactor phase 2 Signed-off-by: joshuaunity --- flexmeasures/api/v3_0/assets.py | 34 +---- flexmeasures/data/models/charts/__init__.py | 6 + .../data/models/charts/belief_charts.py | 96 +++++++++++++++ flexmeasures/data/schemas/utils.py | 54 -------- .../ui/templates/assets/asset_graph.html | 116 ++++++++++++------ flexmeasures/ui/views/assets/views.py | 5 + 6 files changed, 184 insertions(+), 127 deletions(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 17603e2f5c..61ca9fe8ed 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -48,7 +48,6 @@ GenericAssetIdField as AssetIdField, GenericAssetTypeSchema as AssetTypeSchema, ) -from flexmeasures.data.schemas.utils import generate_constant_time_series from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema from flexmeasures.data.schemas.scheduling import AssetTriggerSchema, FlexContextSchema from flexmeasures.data.services.scheduling import ( @@ -899,38 +898,7 @@ def get_chart_data(self, id: int, asset: GenericAsset, **kwargs): - Assets """ sensors = flatten_unique(asset.validate_sensors_to_show()) - bdf = asset.search_beliefs(sensors=sensors, as_json=True, **kwargs) - raw = json.loads(bdf) - - new_data = { - "sensors": raw["sensors"], - "data": raw["data"], - "sources": raw["sources"], - } - - for sensor in sensors: - if sensor.id < 0: - new_data["sensors"][f"{sensor.id}"] = { - "name": sensor.name, - "unit": sensor.unit, - "description": f"{sensor.name} fdfd ", - "asset_id": sensor.generic_asset_id, - "asset_description": sensor.asset.name, - } - - start_datetime = kwargs.get("event_starts_after") - end_datetime = kwargs.get("event_ends_before") - - if start_datetime and end_datetime: - simulated_graph_data = generate_constant_time_series( - event_start=start_datetime, - event_end=end_datetime, - value=sensor.attributes["graph_value"], - sid=sensor.id, - ) - new_data["data"].extend(simulated_graph_data) - - return new_data, 200 + return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs) @route("//auditlog") @use_kwargs( diff --git a/flexmeasures/data/models/charts/__init__.py b/flexmeasures/data/models/charts/__init__.py index a25bd3cf39..266948863b 100644 --- a/flexmeasures/data/models/charts/__init__.py +++ b/flexmeasures/data/models/charts/__init__.py @@ -2,6 +2,7 @@ from . import belief_charts from .defaults import apply_chart_defaults +from .belief_charts import chart_for_flex_config_reference def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict: @@ -19,6 +20,11 @@ def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict: for chart_type, chart_specs in getmembers(belief_charts) if isfunction(chart_specs) or isinstance(chart_specs, dict) } + + belief_charts_mapping["chart_for_flex_config_reference"] = apply_chart_defaults( + chart_for_flex_config_reference + ) + # Create chart specs chart_specs_or_fnc = belief_charts_mapping[chart_type] if isfunction(chart_specs_or_fnc): diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index 1b93173287..6edc7ecf0f 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -1258,3 +1258,99 @@ def chart_for_chargepoint_sessions( ] chart_specs["vconcat"].insert(0, cp_chart) return chart_specs + + +def chart_for_flex_config_reference( + sensors_to_show: list["Sensor" | list["Sensor"] | dict[str, "Sensor"]], # noqa F821 + event_starts_after: datetime | None = None, + event_ends_before: datetime | None = None, + combine_legend: bool = True, + **override_chart_specs: dict, +): + """ + Generate chart specifications for comparing sensors against reference Flex Config values. + + This function renders a vertically concatenated view of line charts for each provided sensor. + It features specific logic for temporary sensors (id < 0), treating them as configuration references: + instead of querying the database, it generates a constant horizontal line based on the + 'graph_value' attribute found in the sensor's metadata. + """ + all_sensors = flatten_unique(sensors_to_show) + + charts = [] + + # Determine legend position based on config + # If combine_legend is True (default in asset view), we usually want a shared legend or + # to let the frontend defaults handle it. Here we force 'bottom' if combined, else 'right'. + legend_orient = "bottom" if combine_legend else "right" + + for sensor in all_sensors: + + # Define the chart + sensor_chart = { + "width": "container", + "height": 150, + "mark": {"type": "line", "point": True, "tooltip": True}, + "encoding": { + "x": { + "field": "event_start", + "type": "temporal", + "scale": { + "domain": ( + [ + event_starts_after.timestamp() * 1000, + event_ends_before.timestamp() * 1000, + ] + if event_starts_after and event_ends_before + else None + ) + }, + "title": None, + "axis": {"labelOverlap": True}, + }, + "y": { + "field": "event_value", + "type": "quantitative", + "title": f"{sensor.name} ({sensor.unit})", + }, + "color": { + "field": "sensor.name", + "type": "nominal", + "legend": {"title": "Sensor", "orient": legend_orient}, + }, + }, + "transform": [{"filter": {"field": "sensor.id", "equal": sensor.id}}], + } + + # SPECIAL HANDLING FOR TEMPORARY SENSORS + if sensor.id < 0: + custom_value = sensor.get_attribute("graph_value", 10) + start_ts = event_starts_after.timestamp() * 1000 + end_ts = event_ends_before.timestamp() * 1000 + manual_data = [] + # loop and add 10 points between start and end + for i in range(11): + manual_data.append( + { + "event_start": start_ts + i * (end_ts - start_ts) / 10, + "event_value": custom_value, + "sensor": {"id": sensor.id, "name": sensor.name}, + "source": {"name": "Simulation"}, + } + ) + + sensor_chart["data"] = {"values": manual_data} + del sensor_chart["transform"] + + charts.append(sensor_chart) + + chart_specs = { + "description": "Custom Sensor View", + "vconcat": charts, + "resolve": {"scale": {"color": "shared", "x": "shared"}}, + } + + for k, v in override_chart_specs.items(): + chart_specs[k] = v + + return chart_specs diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 26cf50c07e..aedd314691 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -1,7 +1,5 @@ import click import marshmallow as ma -import pandas as pd -from datetime import datetime from click import get_current_context from flask.cli import with_appcontext as with_cli_appcontext from pint import DefinitionSyntaxError, DimensionalityError, UndefinedUnitError @@ -140,55 +138,3 @@ def extract_sensors_from_flex_config(plot: dict) -> tuple[list[Sensor], list[dic ) return all_sensors, asset_refs - - -def generate_constant_time_series( - event_start: str, - event_end: str, - value: float, - sid: int, - src: int = 1, - belief_time: datetime = None, -) -> list[dict]: - """ - Generates a list of data points with a 1-hour frequency. - - :param event_start: Start of the range - :param event_end: End of the range - :param value: The constant value for 'val' - :param sid: Sensor ID - :param src: Source ID - :param belief_time: The time the data was "generated". - If None, it defaults to the start of the events. - """ - if belief_time is None: - belief_time = event_start - - # Create hourly range - # We use inclusive='left' to ensure we don't exceed the end date - # if the end date represents the boundary of the last interval. - dr = pd.date_range(start=event_start, end=event_end, freq="1h", inclusive="left") - - data = [] - - # Convert belief_time to milliseconds for the 'bh' calculation - bt_ms = int(belief_time.timestamp() * 1000) - - for ts in dr: - ts_ms = int(ts.timestamp() * 1000) - - # In your data: ts - bh = bt => bh = ts - bt - belief_horizon = ts_ms - bt_ms - - data.append( - { - "ts": ts_ms, - "sid": sid, - "val": float(value), - "sf": 1.0, - "src": src, - "bh": belief_horizon, - } - ) - - return data diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index 337eb4e710..7860360b76 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -48,6 +48,9 @@