Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
21e2cc9
somewhat working
ShiZhongming Nov 7, 2025
1029e7e
greentick blinked fix
ShiZhongming Nov 7, 2025
6462f29
part 2 working for network selection
ShiZhongming Nov 9, 2025
343997e
some fix for warning
ShiZhongming Nov 9, 2025
786c767
map layer to show the most recent network
ShiZhongming Nov 9, 2025
67c520a
Remove backend validation documentation files
reyery Nov 11, 2025
75f0969
Refactor Parameter component to unify validation logic
reyery Nov 11, 2025
58ce9a6
Add validation feedback for NetworkLayoutNameParameter
reyery Nov 11, 2025
ac62945
Refactor parameter validation into custom hook
reyery Nov 11, 2025
747daf7
Refactor Parameter input handling and validation
reyery Nov 11, 2025
416e5fe
Refactor parameter validation hook usage
reyery Nov 11, 2025
d959680
Improve validation logic for parameter selection
reyery Nov 11, 2025
ce03682
Simplify parameter validation logic
reyery Nov 12, 2025
adcfbbc
Add feedback indicator to network name input
reyery Nov 12, 2025
3b6bc70
Refactor Tool and ToolForm to simplify input checks
reyery Nov 12, 2025
60af28d
Fix linting
reyery Nov 12, 2025
494998f
Add dependencies prop to Form.Item in Parameter
reyery Nov 12, 2025
6d79611
Add dynamic parameter metadata refetch in ToolForm
reyery Nov 12, 2025
da129d7
Show spinner during parameter metadata refetch
reyery Nov 12, 2025
4cb2676
Re-check missing inputs after parameter metadata update
reyery Nov 12, 2025
1f5d6ae
Add dashed line support to PathLayer in Map
reyery Nov 18, 2025
e6c2d13
Handle null peak_mass_flow in map components
reyery Nov 19, 2025
596f5cc
Render plant nodes with star icon and update tooltips
reyery Nov 19, 2025
e2ac1ed
Replace star icon with triangle icon for plant nodes
reyery Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/icons/triangle-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 88 additions & 22 deletions src/components/Parameter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ import {
Form,
} from 'antd';
import { checkExist } from 'utils/file';
import { forwardRef } from 'react';
import { forwardRef, useCallback } from 'react';

import { isElectron, openDialog } from 'utils/electron';
import { SelectWithFileDialog } from 'features/scenario/components/CreateScenarioForms/FormInput';
import { apiClient } from 'lib/api/axios';

// Helper component to standardize Form.Item props
export const FormField = ({ name, help, children, ...props }) => {
Expand All @@ -37,10 +38,54 @@ export const FormField = ({ name, help, children, ...props }) => {
);
};

const Parameter = ({ parameter, form }) => {
const { name, type, value, choices, nullable, help } = parameter;
const useParameterValidation = ({ needs_validation, toolName, name, form }) => {
// Create async validator for Ant Design Form.Item rules
const validator = useCallback(
async (_, fieldValue) => {
// Skip validation if not needed
if (!needs_validation || !toolName || !name) return Promise.resolve();

try {
const formValues = form.getFieldsValue();
const response = await apiClient.post(
`/api/tools/${toolName}/validate-field`,
{
parameter_name: name,
value: fieldValue,
form_values: formValues,
},
);

if (response.data.valid) {
return Promise.resolve();
} else {
return Promise.reject(new Error(response.data.error));
}
} catch (error) {
console.error('Validation error:', error);
const errorMessage =
error?.response?.data?.error || error?.message || 'Validation failed';
return Promise.reject(new Error(errorMessage));
}
},
[needs_validation, toolName, name, form],
);

return validator;
};

const Parameter = ({ parameter, form, toolName }) => {
const { name, type, value, choices, nullable, help, needs_validation } =
parameter;
const { setFieldsValue } = form;

const validator = useParameterValidation({
needs_validation,
toolName,
name,
form,
});

switch (type) {
case 'IntegerParameter':
case 'RealParameter': {
Expand Down Expand Up @@ -167,6 +212,7 @@ const Parameter = ({ parameter, form }) => {
</FormField>
);
}
case 'NetworkLayoutChoiceParameter':
case 'ChoiceParameter':
case 'PlantNodeParameter':
case 'ScenarioNameParameter':
Expand All @@ -179,35 +225,39 @@ const Parameter = ({ parameter, form }) => {
value: choice,
}));

const optionsValidator = (_, value) => {
if (choices.length < 1) {
if (type === 'GenerationParameter')
return Promise.reject(
'No generations found. Run optimization first.',
);
else
return Promise.reject('There are no valid choices for this input');
} else if (!nullable) {
if (!value) return Promise.reject('Select a choice');
if (!choices.includes(value))
return Promise.reject(`${value} is not a valid choice`);
}

return Promise.resolve();
};

return (
<FormField
name={name}
help={help}
rules={[
{
validator: (_, value) => {
if (choices.length < 1) {
if (type === 'GenerationParameter')
return Promise.reject(
'No generations found. Run optimization first.',
);
else
return Promise.reject(
'There are no valid choices for this input',
);
} else if (value == null) {
return Promise.reject('Select a choice');
} else if (!choices.includes(value)) {
return Promise.reject(`${value} is not a valid choice`);
} else {
return Promise.resolve();
}
},
validator: optionsValidator,
},
]}
initialValue={value}
>
<Select options={options} disabled={!choices.length} />
<Select
placeholder={nullable ? 'Nothing Selected' : 'Select a choice'}
options={options}
disabled={!choices.length}
/>
</FormField>
);
}
Expand Down Expand Up @@ -380,6 +430,22 @@ const Parameter = ({ parameter, form }) => {
);
}

case 'NetworkLayoutNameParameter': {
return (
<FormField
name={name}
help={help}
initialValue={value}
rules={[{ validator }]}
validateTrigger="onBlur"
dependencies={parameter.depends_on || []}
hasFeedback
>
<Input placeholder="Enter a name for the network" />
</FormField>
);
}

default:
return (
<FormField name={name} help={help} initialValue={value}>
Expand Down
2 changes: 1 addition & 1 deletion src/features/jobs/components/Jobs/JobInfoModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const JobOutputModal = ({ job, visible, setVisible }) => {
width={800}
footer={false}
onCancel={() => setVisible(false)}
destroyOnClose
destroyOnHidden
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{job.state == 1 && <Alert message="Job running..." type="info" />}
Expand Down
20 changes: 15 additions & 5 deletions src/features/map/components/Map/Layers/Selectors/Choice.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ const ChoiceSelector = ({
scenarioName,
mapLayerParameters ?? {},
);
setChoices(data);
// Backend can return either array (legacy) or {choices: [...], default: "..."} (new)
if (Array.isArray(data)) {
setChoices({ choices: data, default: data[0] });
} else {
setChoices(data);
}
Comment on lines +72 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential edge case: empty array response.

The backward compatibility logic converts legacy array responses to the new format, defaulting to data[0]. However, if the backend returns an empty array, this would set default: undefined.

Consider adding a guard:

 // Backend can return either array (legacy) or {choices: [...], default: "..."} (new)
 if (Array.isArray(data)) {
-  setChoices({ choices: data, default: data[0] });
+  setChoices({ choices: data, default: data.length > 0 ? data[0] : null });
 } else {
   setChoices(data);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Backend can return either array (legacy) or {choices: [...], default: "..."} (new)
if (Array.isArray(data)) {
setChoices({ choices: data, default: data[0] });
} else {
setChoices(data);
}
// Backend can return either array (legacy) or {choices: [...], default: "..."} (new)
if (Array.isArray(data)) {
setChoices({ choices: data, default: data.length > 0 ? data[0] : null });
} else {
setChoices(data);
}
🤖 Prompt for AI Agents
In src/features/map/components/Map/Layers/Selectors/Choice.jsx around lines 72
to 77, the compatibility branch converts a legacy array to {choices, default}
but uses data[0] without guarding against an empty array; update the logic to
detect an empty array and set a safe default (e.g., null or an empty string) and
ensure choices is still set to the array (possibly empty) so the component can
handle no-options gracefully.

} catch (error) {
console.error(error.response?.data);
setChoices(null);
Expand All @@ -79,21 +84,26 @@ const ChoiceSelector = ({
fetchChoices();
}, [dependsOnValues]);

// Set the first choice as the default value
// Set the default value from backend (or first choice as fallback)
useEffect(() => {
if (choices) {
handleChange(choices[0]);
const defaultValue = choices.default || choices.choices?.[0];
console.log(
`[Choice] ${parameterName}: Using default value:`,
defaultValue,
);
handleChange(defaultValue);
} else {
handleChange(null);
}
}, [choices]);

const options = choices?.map((choice) => ({
const options = choices?.choices?.map((choice) => ({
value: choice,
label: choice,
}));

if (!choices) return null;
if (!choices || !choices.choices) return null;

return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
Expand Down
135 changes: 117 additions & 18 deletions src/features/map/components/Map/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
import { DeckGL } from '@deck.gl/react';
import {
GeoJsonLayer,
IconLayer,
PointCloudLayer,
PolygonLayer,
TextLayer,
} from '@deck.gl/layers';
import { DataFilterExtension } from '@deck.gl/extensions';
import { DataFilterExtension, PathStyleExtension } from '@deck.gl/extensions';

import positron from 'constants/mapStyles/positron.json';
import no_label from 'constants/mapStyles/positron_nolabel.json';
// eslint-disable-next-line import/no-unresolved
import triangleFillIcon from 'assets/icons/triangle-fill.svg?url';

import * as turf from '@turf/turf';
import './Map.css';
Expand Down Expand Up @@ -220,13 +223,18 @@ const useMapLayers = (onHover = () => {}) => {
data: mapLayers[name]?.edges,
getLineWidth: (f) =>
normalizeLineWidth(
f.properties['peak_mass_flow'],
f.properties?.['peak_mass_flow'] ?? 0,
min,
max,
1,
7 * scale,
),
getLineColor: edgeColour,
getDashArray: (f) =>
f.properties?.['peak_mass_flow'] != null ? [0, 0] : [8, 4],
dashJustified: true,
dashGapPickable: true,
extensions: [new PathStyleExtension({ dash: true })],
updateTriggers: {
Comment on lines 224 to 238
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Guard against invalid/missing peak_mass_flow when computing line width

Right now getLineWidth passes f.properties?.['peak_mass_flow'] ?? 0 into normalizeLineWidth. If min > 0 (which is likely when you have only positive flows), edges with missing/invalid peak_mass_flow will use 0, which is outside [min, max]. Given the current normalizeLineWidth implementation, this can produce widths smaller than minWidth or even effectively invisible lines, which is probably not intended for “no data” edges.

You can keep the current behavior for valid values while falling back to a safe minimum width for missing/invalid ones by guarding the accessor:

-            getLineWidth: (f) =>
-              normalizeLineWidth(
-                f.properties?.['peak_mass_flow'] ?? 0,
-                min,
-                max,
-                1,
-                7 * scale,
-              ),
+            getLineWidth: (f) => {
+              const raw = f.properties?.['peak_mass_flow'];
+              const value = raw != null ? Number(raw) : null;
+
+              // Fall back to minimum width when value is missing/invalid
+              if (value == null || Number.isNaN(value)) {
+                return 1;
+              }
+
+              return normalizeLineWidth(value, min, max, 1, 7 * scale);
+            },

The dashed logic (getDashArray using a strict null/undefined check) correctly treats 0 as a present value, addressing the earlier zero-handling concern.


I'll help verify this review comment. Let me gather information about the code structure and the normalizeLineWidth function.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find the normalizeLineWidth function definition and understand its behavior
rg -n "normalizeLineWidth" --type=jsx --type=js -B2 -A10

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Look at the broader context of the Map.jsx file around the flagged lines
cat src/features/map/components/Map/Map.jsx | head -280 | tail -100

Length of output: 3309


🏁 Script executed:

#!/bin/bash
# Find where min and max are computed/set for this specific context
rg -n "min\|max" src/features/map/components/Map/Map.jsx -B3 -A3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if there are other uses of getLineWidth or normalizeLineWidth in the codebase
rg -n "getLineWidth|normalizeLineWidth" --type=jsx --type=js -C2

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Look for utility/helper files that might contain normalizeLineWidth
fd -e js -e jsx -e ts -e tsx | xargs rg -l "normalizeLineWidth" 2>/dev/null | head -20

Length of output: 39


I need to search more carefully for the function definitions. Let me adjust the approach:


---

🏁 Script executed:

```shell
#!/bin/bash
# Look at the beginning of Map.jsx for imports
head -50 src/features/map/components/Map/Map.jsx

Length of output: 1492


🏁 Script executed:

#!/bin/bash
# Search for normalizeLineWidth definition without file type restriction
rg -n "normalizeLineWidth" -B2 -A10

Length of output: 2037


🏁 Script executed:

#!/bin/bash
# Search for getPropertyRange definition
rg -n "getPropertyRange" -B2 -A15

Length of output: 2724


🏁 Script executed:

#!/bin/bash
# Look for all imports and exports in Map.jsx
grep -n "import\|export\|normalizeLineWidth" src/features/map/components/Map/Map.jsx | head -30

Length of output: 1200


🏁 Script executed:

#!/bin/bash
# Get the full context of the Map.jsx file - look at top section
head -100 src/features/map/components/Map/Map.jsx

Length of output: 2997


Guard against invalid/missing peak_mass_flow when computing line width

The issue is confirmed. When peak_mass_flow is missing, the code defaults to 0 via ?? 0. Since getPropertyRange filters out null/NaN values (keeping only valid numerics), if all actual flow values are positive (min > 0), passing 0 to normalizeLineWidth violates its expected range assumption and produces out-of-range line widths via the formula 1 + ((0 - min) / (max - min)) * (7*scale - 1), resulting in values below minWidth or effectively invisible lines.

The suggested fix is appropriate: return a safe minimum width (1) for missing/invalid values instead of normalizing with out-of-range input.

-            getLineWidth: (f) =>
-              normalizeLineWidth(
-                f.properties?.['peak_mass_flow'] ?? 0,
-                min,
-                max,
-                1,
-                7 * scale,
-              ),
+            getLineWidth: (f) => {
+              const raw = f.properties?.['peak_mass_flow'];
+              const value = raw != null ? Number(raw) : null;
+
+              // Fall back to minimum width when value is missing/invalid
+              if (value == null || Number.isNaN(value)) {
+                return 1;
+              }
+
+              return normalizeLineWidth(value, min, max, 1, 7 * scale);
+            },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getLineWidth: (f) =>
normalizeLineWidth(
f.properties['peak_mass_flow'],
f.properties?.['peak_mass_flow'] ?? 0,
min,
max,
1,
7 * scale,
),
getLineColor: edgeColour,
getDashArray: (f) =>
f.properties?.['peak_mass_flow'] != null ? [0, 0] : [8, 4],
dashJustified: true,
dashGapPickable: true,
extensions: [new PathStyleExtension({ dash: true })],
updateTriggers: {
getLineWidth: (f) => {
const raw = f.properties?.['peak_mass_flow'];
const value = raw != null ? Number(raw) : null;
// Fall back to minimum width when value is missing/invalid
if (value == null || Number.isNaN(value)) {
return 1;
}
return normalizeLineWidth(value, min, max, 1, 7 * scale);
},
getLineColor: edgeColour,
getDashArray: (f) =>
f.properties?.['peak_mass_flow'] != null ? [0, 0] : [8, 4],
dashJustified: true,
dashGapPickable: true,
extensions: [new PathStyleExtension({ dash: true })],
updateTriggers: {
🤖 Prompt for AI Agents
In src/features/map/components/Map/Map.jsx around lines 224 to 238, the current
logic passes 0 when peak_mass_flow is missing which breaks normalizeLineWidth
(producing out-of-range widths); change the getLineWidth handler to detect
missing/invalid values (null/undefined/NaN) and immediately return a safe
minimum width (e.g., 1) instead of calling normalizeLineWidth with 0, otherwise
pass the actual numeric peak_mass_flow into normalizeLineWidth as before.

getLineWidth: [scale, min, max],
},
Expand All @@ -239,23 +247,114 @@ const useMapLayers = (onHover = () => {}) => {
}),
);

_layers.push(
new GeoJsonLayer({
id: `${name}-nodes`,
data: mapLayers[name]?.nodes,
getFillColor: (f) => nodeFillColor(f.properties['type']),
getPointRadius: (f) => nodeRadius(f.properties['type']),
getLineColor: (f) => nodeLineColor(f.properties['type']),
getLineWidth: 1,
updateTriggers: {
getPointRadius: [scale],
},
onHover: onHover,
pickable: true,

parameters: { depthTest: false },
}),
// Partition nodes by type
const nodesData = mapLayers[name]?.nodes;
const { plantNodes, consumerNodes, noneNodes } = (
nodesData?.features ?? []
).reduce(
(acc, feature) => {
const type = feature.properties['type'];
if (type === 'PLANT') {
acc.plantNodes.push(feature);
} else if (type === 'CONSUMER') {
acc.consumerNodes.push(feature);
} else {
acc.noneNodes.push(feature);
}
return acc;
},
{ plantNodes: [], consumerNodes: [], noneNodes: [] },
);

// Add GeoJsonLayer for NONE nodes - rendered first (bottom layer)
if (noneNodes.length > 0) {
_layers.push(
new GeoJsonLayer({
id: `${name}-none-nodes`,
data: {
type: 'FeatureCollection',
features: noneNodes,
},
getFillColor: (f) => nodeFillColor(f.properties['type']),
getPointRadius: (f) => nodeRadius(f.properties['type']),
getLineColor: (f) => nodeLineColor(f.properties['type']),
getLineWidth: 1,
updateTriggers: {
getPointRadius: [scale],
},
onHover: onHover,
pickable: true,
parameters: { depthTest: false },
}),
);
}

// Add GeoJsonLayer for CONSUMER nodes - rendered second (above NONE nodes)
if (consumerNodes.length > 0) {
_layers.push(
new GeoJsonLayer({
id: `${name}-consumer-nodes`,
data: {
type: 'FeatureCollection',
features: consumerNodes,
},
getFillColor: (f) => nodeFillColor(f.properties['type']),
getPointRadius: (f) => nodeRadius(f.properties['type']),
getLineColor: (f) => nodeLineColor(f.properties['type']),
getLineWidth: 1,
updateTriggers: {
getPointRadius: [scale],
},
onHover: onHover,
pickable: true,
parameters: { depthTest: false },
}),
);
}

// Add IconLayer for plant nodes with triangle icon
// Rendered after other nodes to appear on top
if (plantNodes.length > 0) {
// Use bright yellow for high visibility and to complement blue/red edges
const plantColor = [255, 209, 29, 255]; // Bright yellow

_layers.push(
new IconLayer({
id: `${name}-plant-nodes`,
data: plantNodes,
getIcon: () => ({
url: triangleFillIcon,
width: 64,
height: 64,
anchorY: 32,
mask: true,
}),
getPosition: (f) => {
const coords = f.geometry.coordinates;
// Add z-elevation of 3 meters to lift icon above the map
return [coords[0], coords[1], 3];
},
getSize: 10 * scale,
getColor: plantColor,
sizeUnits: 'meters',
sizeMinPixels: 20,
billboard: true,
loadOptions: {
imagebitmap: {
resizeWidth: 64,
resizeHeight: 64,
resizeQuality: 'high',
},
},
onHover: onHover,
pickable: true,
updateTriggers: {
getSize: [scale],
},
parameters: { depthTest: false },
}),
);
}
}

if (name == DEMAND && mapLayers?.[name]) {
Expand Down
Loading