Skip to content

Commit bcff04a

Browse files
authored
Merge pull request #205 from architecture-building-systems/validation-network-name
Re-org Nework to allow user-defined layout and network name: front-end
2 parents 9c87cd0 + e2ac1ed commit bcff04a

File tree

11 files changed

+589
-101
lines changed

11 files changed

+589
-101
lines changed

src/assets/icons/triangle-fill.svg

Lines changed: 3 additions & 0 deletions
Loading

src/components/Parameter.jsx

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ import {
1616
Form,
1717
} from 'antd';
1818
import { checkExist } from 'utils/file';
19-
import { forwardRef } from 'react';
19+
import { forwardRef, useCallback } from 'react';
2020

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

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

40-
const Parameter = ({ parameter, form }) => {
41-
const { name, type, value, choices, nullable, help } = parameter;
41+
const useParameterValidation = ({ needs_validation, toolName, name, form }) => {
42+
// Create async validator for Ant Design Form.Item rules
43+
const validator = useCallback(
44+
async (_, fieldValue) => {
45+
// Skip validation if not needed
46+
if (!needs_validation || !toolName || !name) return Promise.resolve();
47+
48+
try {
49+
const formValues = form.getFieldsValue();
50+
const response = await apiClient.post(
51+
`/api/tools/${toolName}/validate-field`,
52+
{
53+
parameter_name: name,
54+
value: fieldValue,
55+
form_values: formValues,
56+
},
57+
);
58+
59+
if (response.data.valid) {
60+
return Promise.resolve();
61+
} else {
62+
return Promise.reject(new Error(response.data.error));
63+
}
64+
} catch (error) {
65+
console.error('Validation error:', error);
66+
const errorMessage =
67+
error?.response?.data?.error || error?.message || 'Validation failed';
68+
return Promise.reject(new Error(errorMessage));
69+
}
70+
},
71+
[needs_validation, toolName, name, form],
72+
);
73+
74+
return validator;
75+
};
76+
77+
const Parameter = ({ parameter, form, toolName }) => {
78+
const { name, type, value, choices, nullable, help, needs_validation } =
79+
parameter;
4280
const { setFieldsValue } = form;
4381

82+
const validator = useParameterValidation({
83+
needs_validation,
84+
toolName,
85+
name,
86+
form,
87+
});
88+
4489
switch (type) {
4590
case 'IntegerParameter':
4691
case 'RealParameter': {
@@ -167,6 +212,7 @@ const Parameter = ({ parameter, form }) => {
167212
</FormField>
168213
);
169214
}
215+
case 'NetworkLayoutChoiceParameter':
170216
case 'ChoiceParameter':
171217
case 'PlantNodeParameter':
172218
case 'ScenarioNameParameter':
@@ -179,35 +225,39 @@ const Parameter = ({ parameter, form }) => {
179225
value: choice,
180226
}));
181227

228+
const optionsValidator = (_, value) => {
229+
if (choices.length < 1) {
230+
if (type === 'GenerationParameter')
231+
return Promise.reject(
232+
'No generations found. Run optimization first.',
233+
);
234+
else
235+
return Promise.reject('There are no valid choices for this input');
236+
} else if (!nullable) {
237+
if (!value) return Promise.reject('Select a choice');
238+
if (!choices.includes(value))
239+
return Promise.reject(`${value} is not a valid choice`);
240+
}
241+
242+
return Promise.resolve();
243+
};
244+
182245
return (
183246
<FormField
184247
name={name}
185248
help={help}
186249
rules={[
187250
{
188-
validator: (_, value) => {
189-
if (choices.length < 1) {
190-
if (type === 'GenerationParameter')
191-
return Promise.reject(
192-
'No generations found. Run optimization first.',
193-
);
194-
else
195-
return Promise.reject(
196-
'There are no valid choices for this input',
197-
);
198-
} else if (value == null) {
199-
return Promise.reject('Select a choice');
200-
} else if (!choices.includes(value)) {
201-
return Promise.reject(`${value} is not a valid choice`);
202-
} else {
203-
return Promise.resolve();
204-
}
205-
},
251+
validator: optionsValidator,
206252
},
207253
]}
208254
initialValue={value}
209255
>
210-
<Select options={options} disabled={!choices.length} />
256+
<Select
257+
placeholder={nullable ? 'Nothing Selected' : 'Select a choice'}
258+
options={options}
259+
disabled={!choices.length}
260+
/>
211261
</FormField>
212262
);
213263
}
@@ -380,6 +430,22 @@ const Parameter = ({ parameter, form }) => {
380430
);
381431
}
382432

433+
case 'NetworkLayoutNameParameter': {
434+
return (
435+
<FormField
436+
name={name}
437+
help={help}
438+
initialValue={value}
439+
rules={[{ validator }]}
440+
validateTrigger="onBlur"
441+
dependencies={parameter.depends_on || []}
442+
hasFeedback
443+
>
444+
<Input placeholder="Enter a name for the network" />
445+
</FormField>
446+
);
447+
}
448+
383449
default:
384450
return (
385451
<FormField name={name} help={help} initialValue={value}>

src/features/jobs/components/Jobs/JobInfoModal.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const JobOutputModal = ({ job, visible, setVisible }) => {
9292
width={800}
9393
footer={false}
9494
onCancel={() => setVisible(false)}
95-
destroyOnClose
95+
destroyOnHidden
9696
>
9797
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
9898
{job.state == 1 && <Alert message="Job running..." type="info" />}

src/features/map/components/Map/Layers/Selectors/Choice.jsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ const ChoiceSelector = ({
6969
scenarioName,
7070
mapLayerParameters ?? {},
7171
);
72-
setChoices(data);
72+
// Backend can return either array (legacy) or {choices: [...], default: "..."} (new)
73+
if (Array.isArray(data)) {
74+
setChoices({ choices: data, default: data[0] });
75+
} else {
76+
setChoices(data);
77+
}
7378
} catch (error) {
7479
console.error(error.response?.data);
7580
setChoices(null);
@@ -79,21 +84,26 @@ const ChoiceSelector = ({
7984
fetchChoices();
8085
}, [dependsOnValues]);
8186

82-
// Set the first choice as the default value
87+
// Set the default value from backend (or first choice as fallback)
8388
useEffect(() => {
8489
if (choices) {
85-
handleChange(choices[0]);
90+
const defaultValue = choices.default || choices.choices?.[0];
91+
console.log(
92+
`[Choice] ${parameterName}: Using default value:`,
93+
defaultValue,
94+
);
95+
handleChange(defaultValue);
8696
} else {
8797
handleChange(null);
8898
}
8999
}, [choices]);
90100

91-
const options = choices?.map((choice) => ({
101+
const options = choices?.choices?.map((choice) => ({
92102
value: choice,
93103
label: choice,
94104
}));
95105

96-
if (!choices) return null;
106+
if (!choices || !choices.choices) return null;
97107

98108
return (
99109
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>

src/features/map/components/Map/Map.jsx

Lines changed: 117 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
33
import { DeckGL } from '@deck.gl/react';
44
import {
55
GeoJsonLayer,
6+
IconLayer,
67
PointCloudLayer,
78
PolygonLayer,
89
TextLayer,
910
} from '@deck.gl/layers';
10-
import { DataFilterExtension } from '@deck.gl/extensions';
11+
import { DataFilterExtension, PathStyleExtension } from '@deck.gl/extensions';
1112

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

1518
import * as turf from '@turf/turf';
1619
import './Map.css';
@@ -220,13 +223,18 @@ const useMapLayers = (onHover = () => {}) => {
220223
data: mapLayers[name]?.edges,
221224
getLineWidth: (f) =>
222225
normalizeLineWidth(
223-
f.properties['peak_mass_flow'],
226+
f.properties?.['peak_mass_flow'] ?? 0,
224227
min,
225228
max,
226229
1,
227230
7 * scale,
228231
),
229232
getLineColor: edgeColour,
233+
getDashArray: (f) =>
234+
f.properties?.['peak_mass_flow'] != null ? [0, 0] : [8, 4],
235+
dashJustified: true,
236+
dashGapPickable: true,
237+
extensions: [new PathStyleExtension({ dash: true })],
230238
updateTriggers: {
231239
getLineWidth: [scale, min, max],
232240
},
@@ -239,23 +247,114 @@ const useMapLayers = (onHover = () => {}) => {
239247
}),
240248
);
241249

242-
_layers.push(
243-
new GeoJsonLayer({
244-
id: `${name}-nodes`,
245-
data: mapLayers[name]?.nodes,
246-
getFillColor: (f) => nodeFillColor(f.properties['type']),
247-
getPointRadius: (f) => nodeRadius(f.properties['type']),
248-
getLineColor: (f) => nodeLineColor(f.properties['type']),
249-
getLineWidth: 1,
250-
updateTriggers: {
251-
getPointRadius: [scale],
252-
},
253-
onHover: onHover,
254-
pickable: true,
255-
256-
parameters: { depthTest: false },
257-
}),
250+
// Partition nodes by type
251+
const nodesData = mapLayers[name]?.nodes;
252+
const { plantNodes, consumerNodes, noneNodes } = (
253+
nodesData?.features ?? []
254+
).reduce(
255+
(acc, feature) => {
256+
const type = feature.properties['type'];
257+
if (type === 'PLANT') {
258+
acc.plantNodes.push(feature);
259+
} else if (type === 'CONSUMER') {
260+
acc.consumerNodes.push(feature);
261+
} else {
262+
acc.noneNodes.push(feature);
263+
}
264+
return acc;
265+
},
266+
{ plantNodes: [], consumerNodes: [], noneNodes: [] },
258267
);
268+
269+
// Add GeoJsonLayer for NONE nodes - rendered first (bottom layer)
270+
if (noneNodes.length > 0) {
271+
_layers.push(
272+
new GeoJsonLayer({
273+
id: `${name}-none-nodes`,
274+
data: {
275+
type: 'FeatureCollection',
276+
features: noneNodes,
277+
},
278+
getFillColor: (f) => nodeFillColor(f.properties['type']),
279+
getPointRadius: (f) => nodeRadius(f.properties['type']),
280+
getLineColor: (f) => nodeLineColor(f.properties['type']),
281+
getLineWidth: 1,
282+
updateTriggers: {
283+
getPointRadius: [scale],
284+
},
285+
onHover: onHover,
286+
pickable: true,
287+
parameters: { depthTest: false },
288+
}),
289+
);
290+
}
291+
292+
// Add GeoJsonLayer for CONSUMER nodes - rendered second (above NONE nodes)
293+
if (consumerNodes.length > 0) {
294+
_layers.push(
295+
new GeoJsonLayer({
296+
id: `${name}-consumer-nodes`,
297+
data: {
298+
type: 'FeatureCollection',
299+
features: consumerNodes,
300+
},
301+
getFillColor: (f) => nodeFillColor(f.properties['type']),
302+
getPointRadius: (f) => nodeRadius(f.properties['type']),
303+
getLineColor: (f) => nodeLineColor(f.properties['type']),
304+
getLineWidth: 1,
305+
updateTriggers: {
306+
getPointRadius: [scale],
307+
},
308+
onHover: onHover,
309+
pickable: true,
310+
parameters: { depthTest: false },
311+
}),
312+
);
313+
}
314+
315+
// Add IconLayer for plant nodes with triangle icon
316+
// Rendered after other nodes to appear on top
317+
if (plantNodes.length > 0) {
318+
// Use bright yellow for high visibility and to complement blue/red edges
319+
const plantColor = [255, 209, 29, 255]; // Bright yellow
320+
321+
_layers.push(
322+
new IconLayer({
323+
id: `${name}-plant-nodes`,
324+
data: plantNodes,
325+
getIcon: () => ({
326+
url: triangleFillIcon,
327+
width: 64,
328+
height: 64,
329+
anchorY: 32,
330+
mask: true,
331+
}),
332+
getPosition: (f) => {
333+
const coords = f.geometry.coordinates;
334+
// Add z-elevation of 3 meters to lift icon above the map
335+
return [coords[0], coords[1], 3];
336+
},
337+
getSize: 10 * scale,
338+
getColor: plantColor,
339+
sizeUnits: 'meters',
340+
sizeMinPixels: 20,
341+
billboard: true,
342+
loadOptions: {
343+
imagebitmap: {
344+
resizeWidth: 64,
345+
resizeHeight: 64,
346+
resizeQuality: 'high',
347+
},
348+
},
349+
onHover: onHover,
350+
pickable: true,
351+
updateTriggers: {
352+
getSize: [scale],
353+
},
354+
parameters: { depthTest: false },
355+
}),
356+
);
357+
}
259358
}
260359

261360
if (name == DEMAND && mapLayers?.[name]) {

0 commit comments

Comments
 (0)