feat: Introduce Simulation Dashboard with element management, data binding, and analysis capabilities.

This commit is contained in:
2025-12-23 11:06:54 +05:30
parent 17776679f2
commit 5558a6d4a1
7 changed files with 164 additions and 101 deletions

View File

@@ -32,7 +32,7 @@ const AnalyzerManager: React.FC = () => {
useEffect(() => {
blocksRef.current.forEach((block) => {
block.elements.forEach((element) => {
if (element.type === "graph" && element.dataBinding?.dataType === "single-machine") {
if (element.type === "graph" && (element.dataBinding?.dataType === "single-machine" || element.dataBinding?.dataType === "global")) {
// Check for default data (ElementContent uses Jan, Feb etc as fallback)
// If graphData is undefined, ElementContent shows defaults. We explicitly set to [] to show empty.
// If graphData has "Jan" data, we also clear it.
@@ -154,12 +154,7 @@ const AnalyzerManager: React.FC = () => {
const rawDataValue = element.dataBinding.dataValue;
const dataKeys = Array.isArray(rawDataValue) ? rawDataValue : [rawDataValue as string];
let dataObject: any = null;
if (assetId === "global") {
dataObject = getSystemMetrics();
} else {
dataObject = getAssetAnalysis(assetId);
}
const dataObject = getAssetAnalysis(assetId);
if (dataObject) {
let newGraphData: GraphDataPoint[] = [];
@@ -171,7 +166,51 @@ const AnalyzerManager: React.FC = () => {
const dataPoint: GraphDataPoint = { name: timeStr, value: 0 };
dataKeys.forEach((key) => {
const path = assetId === "global" && key.startsWith("global.") ? key.replace("global.", "") : key;
const val = resolvePath(dataObject, key);
dataPoint[key] = typeof val === "number" ? val : 0;
});
history.push(dataPoint);
if (history.length > 20) history.shift();
newGraphData = [...history];
lineChartHistory.current.set(element.elementUuid, history);
} else {
newGraphData = dataKeys.map((key) => {
const val = resolvePath(dataObject, key);
return {
// Make the key readable or just use it as name
name: key.split(".").pop() || key,
value: typeof val === "number" ? val : 0,
};
});
}
// Deep check to avoid unnecessary updates
if (JSON.stringify(newGraphData) !== JSON.stringify(element.graphData)) {
updateGraphData(block.blockUuid, element.elementUuid, newGraphData);
}
}
} else if (element.dataBinding?.dataType === "global") {
if (!element.dataBinding.dataSource || !element.dataBinding.dataValue) return;
// Ensure dataValue is an array
const rawDataValue = element.dataBinding.dataValue;
const dataKeys = Array.isArray(rawDataValue) ? rawDataValue : [rawDataValue as string];
const dataObject = getSystemMetrics();
if (dataObject) {
let newGraphData: GraphDataPoint[] = [];
if (element.graphType === "line") {
const history: GraphDataPoint[] = lineChartHistory.current.get(element.elementUuid) || [];
const now = new Date();
const timeStr = now.toLocaleTimeString([], { hour12: false });
const dataPoint: GraphDataPoint = { name: timeStr, value: 0 };
dataKeys.forEach((key) => {
const path = key.startsWith("global.") ? key.replace("global.", "") : key;
const val = resolvePath(dataObject, path);
dataPoint[key] = typeof val === "number" ? val : 0;
});
@@ -183,7 +222,7 @@ const AnalyzerManager: React.FC = () => {
lineChartHistory.current.set(element.elementUuid, history);
} else {
newGraphData = dataKeys.map((key) => {
const path = assetId === "global" && key.startsWith("global.") ? key.replace("global.", "") : key;
const path = key.startsWith("global.") ? key.replace("global.", "") : key;
const val = resolvePath(dataObject, path);
return {
// Make the key readable or just use it as name

View File

@@ -44,7 +44,7 @@ const ElementContent: React.FC<ElementContentProps> = ({ element, resolvedData }
{ name: "Jun", value: 900 },
];
if (element.dataBinding?.dataType === "single-machine" && element.dataBinding.dataValue) {
if ((element.dataBinding?.dataType === "single-machine" || element.dataBinding?.dataType === "global") && element.dataBinding.dataValue) {
const keys = Array.isArray(element.dataBinding.dataValue) ? element.dataBinding.dataValue : [element.dataBinding.dataValue as string];
return defaultData.map((item, i) => {
const newItem: any = { ...item };
@@ -145,12 +145,12 @@ const ElementContent: React.FC<ElementContentProps> = ({ element, resolvedData }
<XAxis dataKey="name" stroke="rgba(255,255,255,0.6)" fontSize={12} />
<YAxis stroke="rgba(255,255,255,0.6)" fontSize={12} />
<Tooltip {...tooltipStyle} />
{element.dataBinding?.dataType === "single-machine" &&
{(element.dataBinding?.dataType === "single-machine" || element.dataBinding?.dataType === "global") &&
element.dataBinding.dataValue &&
(!Array.isArray(element.dataBinding.dataValue) || element.dataBinding.dataValue.length > 0) ? (
(Array.isArray(element.dataBinding.dataValue) ? element.dataBinding.dataValue : [element.dataBinding.dataValue as string]).map((key, index) => (
<Line
key={key}
key={`${key}-${index}`}
type="monotone"
dataKey={key}
stroke={COLORS[index % COLORS.length]}

View File

@@ -16,10 +16,11 @@ interface ElementDataProps {
getLableValueDropdownItems: (assetId: string | undefined) => Array<{ title: string; items: Array<{ id: string; label: string; icon: JSX.Element }> }>;
singleSourceFields: Array<{ id: string; label: string; showEyeDropper: boolean; options: Array<{ id: string; label: string }> }>;
singleValueFields: Array<{ id: string; label: string; showEyeDropper: boolean; options: Array<{ id: string; label: string }> }>;
globalValueFields: Array<{ id: string; label: string; showEyeDropper: boolean; options: Array<{ id: string; label: string }> }>;
multipleSourceFields: Array<{ id: string; label: string; showEyeDropper: boolean; options: Array<{ id: string; label: string }> }>;
multipleValueFields: Array<{ id: string; label: string; showEyeDropper: boolean; options: Array<{ id: string; label: string }> }>;
selectDataMapping: "singleMachine" | "multipleMachine";
handleDataTypeSwitch: (newDataType: "singleMachine" | "multipleMachine") => void;
selectDataMapping: "singleMachine" | "multipleMachine" | "global";
handleDataTypeSwitch: (newDataType: "singleMachine" | "multipleMachine" | "global") => void;
updateDataSource: (blockId: string, elementId: string, dataSource: string | string[]) => void;
updateDataValue: (blockId: string, elementId: string, dataValue: string | string[]) => void;
updateCommonValue: (blockId: string, elementId: string, commonValue: string) => void;
@@ -38,6 +39,7 @@ const ElementData: React.FC<ElementDataProps> = ({
getLableValueDropdownItems,
singleSourceFields = [],
singleValueFields = [],
globalValueFields = [],
multipleSourceFields = [],
multipleValueFields = [],
selectDataMapping = "singleMachine",
@@ -180,6 +182,9 @@ const ElementData: React.FC<ElementDataProps> = ({
/>
<div className="type-switch">
<div className={`type ${selectDataMapping === "global" ? "active" : ""}`} onClick={() => handleDataTypeSwitch("global")}>
Global
</div>
<div className={`type ${selectDataMapping === "singleMachine" ? "active" : ""}`} onClick={() => handleDataTypeSwitch("singleMachine")}>
Single Machine
</div>
@@ -188,6 +193,54 @@ const ElementData: React.FC<ElementDataProps> = ({
</div>
</div>
{selectDataMapping === "global" && element.dataBinding?.dataType === "global" && (
<div className="fields-wrapper design-section">
{globalValueFields.map((field, index) => (
<DataSourceSelector
key={field.id}
label={field.label}
options={getFilteredOptions(field.options, element.dataBinding?.dataValue, index)}
selected={field.options.find((option) => option.id === element.dataBinding?.dataValue?.[index])?.label ?? ""}
onSelect={(value) => {
const currentDataValue = Array.isArray(element.dataBinding?.dataValue)
? element.dataBinding!.dataValue
: element.dataBinding?.dataValue
? [element.dataBinding.dataValue]
: [];
const newDataValue = [...currentDataValue];
newDataValue[index] = value.id;
updateDataValue(selectedBlock, selectedElement, newDataValue);
}}
showEyeDropper={field.showEyeDropper}
eyeDropperActive={isEyedropperActive("dataValue", index)}
onEyeDrop={() => handleEyeDrop("dataValue", index)}
showDeleteBtn={true}
onDelete={() => {
const current = Array.isArray(element.dataBinding?.dataValue)
? element.dataBinding!.dataValue
: element.dataBinding?.dataValue
? [element.dataBinding.dataValue]
: [];
const next = [...current];
next.splice(index, 1);
updateDataValue(selectedBlock, selectedElement, next);
}}
/>
))}
{globalValueFields.length < totalValueOptions && (
<div className="add-field" onClick={addField}>
<div className="add-icon">
<AddIcon />
</div>
<div className="label">Add Field</div>
</div>
)}
</div>
)}
{selectDataMapping === "singleMachine" && element.dataBinding?.dataType === "single-machine" && (
<div className="fields-wrapper design-section">
{singleSourceFields.map((field) => (

View File

@@ -22,7 +22,7 @@ interface ElementEditorProps {
updateGraphTitle: (blockId: string, elementId: string, title: string) => void;
updateGraphType: (blockId: string, elementId: string, type: GraphTypes) => void;
updateTextValue: (blockId: string, elementId: string, textValue: string) => void;
updateDataType: (blockId: string, elementId: string, dataType: "single-machine" | "multiple-machine") => void;
updateDataType: (blockId: string, elementId: string, dataType: "single-machine" | "multiple-machine" | "global") => void;
updateCommonValue: (blockId: string, elementId: string, commonValue: string) => void;
updateDataValue: (blockId: string, elementId: string, dataValue: string | string[]) => void;
updateDataSource: (blockId: string, elementId: string, dataSource: string | string[]) => void;
@@ -56,8 +56,12 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
const { getElementById } = simulationDashBoardStore();
const element = getElementById(selectedBlock, selectedElement);
const [selectType, setSelectType] = useState("design");
const [selectDataMapping, setSelectDataMapping] = useState<"singleMachine" | "multipleMachine">(
element?.type === "graph" && element.dataBinding?.dataType === "multiple-machine" ? "multipleMachine" : "singleMachine"
const [selectDataMapping, setSelectDataMapping] = useState<"singleMachine" | "multipleMachine" | "global">(
element?.type === "graph" && element.dataBinding?.dataType === "multiple-machine"
? "multipleMachine"
: element?.type === "graph" && element.dataBinding?.dataType === "global"
? "global"
: "singleMachine"
);
// Use shared position from VisualizationStore
@@ -241,7 +245,7 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
icon: <DeviceIcon />,
}));
return [{ id: "global", label: "Global System", icon: <DeviceIcon /> }, ...assetItems];
return [...assetItems];
}, [product?.eventDatas]);
const getLableValueDropdownItems = useCallback(
@@ -491,7 +495,7 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
return dataValues.map((value, index) => ({
id: `data-value-${index}`,
label: `Value ${index + 1}`,
label: `Data Value ${index + 1}`,
showEyeDropper: false,
options: valueOptions,
}));
@@ -500,7 +504,32 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
return [
{
id: "data-value",
label: "Value",
label: "Data Value",
showEyeDropper: false,
options: [],
},
];
}, [element, getLableValueDropdownItems]);
const globalValueFields = useMemo(() => {
if (element?.type === "graph" && element.dataBinding?.dataType === "global") {
const dataValues = Array.isArray(element.dataBinding.dataValue) ? element.dataBinding.dataValue : element.dataBinding.dataValue ? [element.dataBinding.dataValue] : [];
// Implicitly use 'global' as source
const valueOptions = getLableValueDropdownItems("global").flatMap((section) => section.items.map((item) => ({ id: item.id, label: item.label })));
return dataValues.map((value, index) => ({
id: `data-value-${index}`,
label: `Data Value ${index + 1}`,
showEyeDropper: false,
options: valueOptions,
}));
}
return [
{
id: "data-value",
label: "Data Value",
showEyeDropper: false,
options: [],
},
@@ -513,7 +542,7 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
return dataSources.map((_, index) => ({
id: `data-source-${index}`,
label: `Source ${index + 1}`,
label: `Data Source ${index + 1}`,
showEyeDropper: true,
options: getAssetDropdownItems().map((item) => ({
id: item.id,
@@ -526,19 +555,21 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
}, [element, getAssetDropdownItems]);
const multipleValueFields = useMemo(
() => [{ id: "data-value", label: "Value", showEyeDropper: false, options: getCommonValueDropdownItems().map((item) => ({ id: item.id, label: item.label })) }],
() => [{ id: "data-value", label: "Data Value", showEyeDropper: false, options: getCommonValueDropdownItems().map((item) => ({ id: item.id, label: item.label })) }],
[getCommonValueDropdownItems]
);
const handleDataTypeSwitch = (newDataType: "singleMachine" | "multipleMachine") => {
const handleDataTypeSwitch = (newDataType: "singleMachine" | "multipleMachine" | "global") => {
if (!element || element.type !== "graph") return;
setSelectDataMapping(newDataType);
if (newDataType === "singleMachine" && element.dataBinding?.dataType === "multiple-machine") {
if (newDataType === "singleMachine" && element.dataBinding?.dataType !== "single-machine") {
updateDataType(selectedBlock, selectedElement, "single-machine");
} else if (newDataType === "multipleMachine" && element.dataBinding?.dataType === "single-machine") {
} else if (newDataType === "multipleMachine" && element.dataBinding?.dataType !== "multiple-machine") {
updateDataType(selectedBlock, selectedElement, "multiple-machine");
} else if (newDataType === "global" && element.dataBinding?.dataType !== "global") {
updateDataType(selectedBlock, selectedElement, "global");
}
};
@@ -553,6 +584,11 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
const newDataSource = [...currentDataSource, ""];
updateDataSource(selectedBlock, selectedElement, newDataSource);
} else if (selectDataMapping === "global" && element?.type === "graph" && element.dataBinding?.dataType === "global") {
const currentDataValue = Array.isArray(element.dataBinding.dataValue) ? element.dataBinding.dataValue : element.dataBinding.dataValue ? [element.dataBinding.dataValue] : [];
const newDataValue = [...currentDataValue, ""];
updateDataValue(selectedBlock, selectedElement, newDataValue);
}
};
@@ -629,6 +665,7 @@ const ElementEditor: React.FC<ElementEditorProps> = ({
getLableValueDropdownItems={getLableValueDropdownItems}
singleSourceFields={singleSourceFields}
singleValueFields={singleValueFields}
globalValueFields={globalValueFields}
multipleSourceFields={multipleSourceFields}
multipleValueFields={multipleValueFields}
selectDataMapping={selectDataMapping}

View File

@@ -7,77 +7,6 @@ const TimeManager = () => {
const { speed } = useAnimationPlaySpeed();
const { simulationTimeRef } = useSceneContext();
const lastSpeedUpdateRef = useRef<number>(Date.now());
const prevSpeedRef = useRef<number>(speed);
// Reset accumulated time when simulation stops
useEffect(() => {
if (!isPlaying) {
simulationTimeRef.current = 0;
lastSpeedUpdateRef.current = Date.now();
} else {
lastSpeedUpdateRef.current = Date.now();
}
}, [isPlaying, simulationTimeRef]);
// Track accumulated simulation time
useEffect(() => {
if (!isPlaying) {
prevSpeedRef.current = speed;
return;
}
// Set up an interval to update time continuously
const intervalId = setInterval(() => {
const now = Date.now();
const deltaReal = now - lastSpeedUpdateRef.current;
// Add time segment using current speed
// We use interval instead of useFrame because TimeManager might run outside of Canvas or we want independent tick
// Actually, if we want frame-perfect sync, useFrame is better.
// But this component might not be inside Canvas in all setups (though we plan to put it there).
// Let's stick to useEffect loop for now to mirror the logic we had, or better yet, use requestAnimationFrame
simulationTimeRef.current += deltaReal * Math.max(1, speed);
lastSpeedUpdateRef.current = now;
}, 16); // ~60fps
return () => clearInterval(intervalId);
}, [speed, isPlaying, simulationTimeRef]);
// Handle speed changes accurately by updating before speed changes
// The interval handles the continuous update. This effect ensures we capture the exact moment speed changes if we want higher precision,
// but the interval driven approach with small steps is usually sufficient for "game time".
// Actually, "prevSpeedRef" logic was better for exact transitions.
// Let's refine the logic to match what we did in Analyzer:
useEffect(() => {
if (!isPlaying) {
prevSpeedRef.current = speed;
return;
}
const now = Date.now();
const deltaReal = now - lastSpeedUpdateRef.current;
// Add time segment using previous speed (before this update)
// Wait, if we use interval, we are continuously updating.
// If we mix interval and this effect, we double count.
// Let's ONLY use requestAnimationFrame (or a tight interval) to update time.
// And update refs.
}, [speed, isPlaying]); // This is tricky with intervals.
return null;
};
// Re-implementing correctly with requestAnimationFrame for smooth time
const TimeManagerRAF = () => {
const { isPlaying } = usePlayButtonStore();
const { speed } = useAnimationPlaySpeed();
const { simulationTimeRef } = useSceneContext();
const lastTimeRef = useRef<number>(Date.now());
useEffect(() => {
@@ -109,4 +38,4 @@ const TimeManagerRAF = () => {
return null;
};
export default TimeManagerRAF;
export default TimeManager;

View File

@@ -75,7 +75,7 @@ interface SimulationDashboardStore {
peekUpdateGraphTitle: (blockId: string, elementId: string, title: string) => Block[];
peekUpdateGraphType: (blockId: string, elementId: string, graphType: GraphTypes) => Block[];
peekUpdateTextValue: (blockId: string, elementId: string, textValue: string) => Block[];
peekUpdateDataType: (blockId: string, elementId: string, dataType: "single-machine" | "multiple-machine") => Block[];
peekUpdateDataType: (blockId: string, elementId: string, dataType: "global" | "single-machine" | "multiple-machine") => Block[];
peekUpdateCommonValue: (blockId: string, elementId: string, commonValue: string) => Block[];
peekUpdateDataValue: (blockId: string, elementId: string, dataValue: string | string[]) => Block[];
peekUpdateDataSource: (blockId: string, elementId: string, dataSource: string | string[]) => Block[];
@@ -892,11 +892,16 @@ export const createSimulationDashboardStore = () => {
element.dataBinding.dataSource = "";
element.dataBinding.dataValue = [];
delete element.dataBinding.commonValue;
} else {
} else if (dataType === "multiple-machine") {
element.dataBinding.dataType = "multiple-machine";
element.dataBinding.dataSource = [];
element.dataBinding.commonValue = "";
delete element.dataBinding.dataValue;
} else if (dataType === "global") {
element.dataBinding.dataType = "global";
element.dataBinding.dataSource = "global";
element.dataBinding.dataValue = [];
delete element.dataBinding.commonValue;
}
return blocks;

View File

@@ -4,7 +4,7 @@ type DataModel = Record<string, DataModelValue>;
type UIType = "label-value" | "text" | "graph" | "icon";
type DataType = "single-machine" | "multiple-machine";
type DataType = "global" | "single-machine" | "multiple-machine";
type ElementDataBinding = {
label?: string;
@@ -12,7 +12,7 @@ type ElementDataBinding = {
dataValue?: string | string[];
commonValue?: string;
dataType?: DataType;
}
};
type Position = {
x: number;