NeonClary Cursor commited on
Commit
ebbd1a5
·
1 Parent(s): 540f35d

Add settings menu for diagram layout and panel visibility

Browse files

Gear menu next to the theme toggle: choose random seed (default), fixed seed, or explicit node coordinates for the network diagram; toggle the four right-column panels on/off while preserving their order. Settings persist in localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/App.js CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useState, useEffect } from 'react';
2
  import Header from './components/Header';
3
  import DecidronSimulator from './components/decidron/DecidronSimulator';
 
4
  import './styles/variables.css';
5
  import './styles/layout.css';
6
  import './styles/components.css';
@@ -13,19 +14,29 @@ export default function App() {
13
  () => localStorage.getItem(THEME_KEY)
14
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
15
  );
 
16
 
17
  useEffect(() => {
18
  document.documentElement.setAttribute('data-theme', theme);
19
  localStorage.setItem(THEME_KEY, theme);
20
  }, [theme]);
21
 
 
 
 
 
22
  const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
23
 
24
  return (
25
  <div className="app">
26
- <Header theme={theme} onToggleTheme={toggleTheme} />
 
 
 
 
 
27
  <main className="app-main">
28
- <DecidronSimulator />
29
  </main>
30
  </div>
31
  );
 
1
  import React, { useState, useEffect } from 'react';
2
  import Header from './components/Header';
3
  import DecidronSimulator from './components/decidron/DecidronSimulator';
4
+ import { loadSettings, saveSettings } from './utils/settings';
5
  import './styles/variables.css';
6
  import './styles/layout.css';
7
  import './styles/components.css';
 
14
  () => localStorage.getItem(THEME_KEY)
15
  || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
16
  );
17
+ const [settings, setSettings] = useState(() => loadSettings());
18
 
19
  useEffect(() => {
20
  document.documentElement.setAttribute('data-theme', theme);
21
  localStorage.setItem(THEME_KEY, theme);
22
  }, [theme]);
23
 
24
+ useEffect(() => {
25
+ saveSettings(settings);
26
+ }, [settings]);
27
+
28
  const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
29
 
30
  return (
31
  <div className="app">
32
+ <Header
33
+ theme={theme}
34
+ onToggleTheme={toggleTheme}
35
+ settings={settings}
36
+ onSettingsChange={setSettings}
37
+ />
38
  <main className="app-main">
39
+ <DecidronSimulator settings={settings} />
40
  </main>
41
  </div>
42
  );
frontend/src/components/Header.js CHANGED
@@ -1,12 +1,11 @@
1
  import React from 'react';
2
  import { Sun, Moon } from 'lucide-react';
 
3
 
4
  /**
5
- * Slim branded header for the Decidron simulator. The CCAI chat header
6
- * (participant dropdown, downloads, settings) was removed when the repo
7
- * was trimmed to a lean scaffold.
8
  */
9
- export default function Header({ theme, onToggleTheme }) {
10
  return (
11
  <header className="app-header">
12
  <div className="header-left">
@@ -20,9 +19,10 @@ export default function Header({ theme, onToggleTheme }) {
20
  </h1>
21
  </div>
22
  <div className="header-right">
 
23
  <button
24
  type="button"
25
- className="btn-sm btn-outline"
26
  onClick={onToggleTheme}
27
  title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
28
  >
 
1
  import React from 'react';
2
  import { Sun, Moon } from 'lucide-react';
3
+ import SettingsMenu from './SettingsMenu';
4
 
5
  /**
6
+ * Slim branded header for the Decidron simulator.
 
 
7
  */
8
+ export default function Header({ theme, onToggleTheme, settings, onSettingsChange }) {
9
  return (
10
  <header className="app-header">
11
  <div className="header-left">
 
19
  </h1>
20
  </div>
21
  <div className="header-right">
22
+ <SettingsMenu settings={settings} onChange={onSettingsChange} />
23
  <button
24
  type="button"
25
+ className="btn-sm btn-outline icon-btn"
26
  onClick={onToggleTheme}
27
  title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
28
  >
frontend/src/components/SettingsMenu.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Settings } from 'lucide-react';
3
+ import { LAYOUT_MODES, PANEL_KEYS, PANEL_LABELS } from '../utils/settings';
4
+
5
+ const LAYOUT_OPTIONS = [
6
+ { id: LAYOUT_MODES.RANDOM, label: 'Random seed (default)', hint: 'Layout may shift on each run' },
7
+ { id: LAYOUT_MODES.FIXED_SEED, label: 'Fixed seed', hint: 'Same layout every run unless topology changes' },
8
+ { id: LAYOUT_MODES.EXPLICIT, label: 'Explicit coordinates', hint: 'Fixed positions; physics off' },
9
+ ];
10
+
11
+ export default function SettingsMenu({ settings, onChange }) {
12
+ const [open, setOpen] = useState(false);
13
+ const wrapRef = useRef(null);
14
+
15
+ useEffect(() => {
16
+ if (!open) return undefined;
17
+ const onDocClick = (e) => {
18
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
19
+ setOpen(false);
20
+ }
21
+ };
22
+ document.addEventListener('mousedown', onDocClick);
23
+ return () => document.removeEventListener('mousedown', onDocClick);
24
+ }, [open]);
25
+
26
+ const setLayoutMode = (layoutMode) => {
27
+ onChange({ ...settings, layoutMode });
28
+ };
29
+
30
+ const togglePanel = (key) => {
31
+ onChange({
32
+ ...settings,
33
+ panels: { ...settings.panels, [key]: !settings.panels[key] },
34
+ });
35
+ };
36
+
37
+ return (
38
+ <div className="ccai-dropdown-wrap decidron-settings-wrap" ref={wrapRef}>
39
+ <button
40
+ type="button"
41
+ className="btn-sm btn-outline icon-btn decidron-settings-trigger"
42
+ onClick={() => setOpen((v) => !v)}
43
+ title="Settings"
44
+ aria-expanded={open}
45
+ aria-haspopup="true"
46
+ >
47
+ <Settings size={16} />
48
+ </button>
49
+ {open && (
50
+ <div className="ccai-dropdown-panel decidron-settings-panel" role="menu">
51
+ <div className="ccai-dropdown-section">
52
+ <div className="ccai-dropdown-section-title">Diagram node placement</div>
53
+ {LAYOUT_OPTIONS.map((opt) => (
54
+ <label key={opt.id} className="decidron-settings-radio">
55
+ <input
56
+ type="radio"
57
+ name="layoutMode"
58
+ checked={settings.layoutMode === opt.id}
59
+ onChange={() => setLayoutMode(opt.id)}
60
+ />
61
+ <span>
62
+ <strong>{opt.label}</strong>
63
+ <span className="decidron-settings-hint">{opt.hint}</span>
64
+ </span>
65
+ </label>
66
+ ))}
67
+ </div>
68
+ <div className="ccai-dropdown-divider" />
69
+ <div className="ccai-dropdown-section">
70
+ <div className="ccai-dropdown-section-title">Right-side panels</div>
71
+ {PANEL_KEYS.map((key) => (
72
+ <label key={key} className="ccai-dropdown-item decidron-settings-check">
73
+ <input
74
+ type="checkbox"
75
+ checked={!!settings.panels[key]}
76
+ onChange={() => togglePanel(key)}
77
+ />
78
+ <span className="ccai-dropdown-item-text">
79
+ <span className="ccai-dropdown-item-name">{PANEL_LABELS[key]}</span>
80
+ </span>
81
+ </label>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ }
frontend/src/components/decidron/DecidronSimulator.js CHANGED
@@ -14,7 +14,7 @@ import '../../styles/decidron.css';
14
 
15
  const DEFAULT_VALUE = '97';
16
 
17
- export default function DecidronSimulator() {
18
  const [network, setNetwork] = useState(null);
19
  const [commands, setCommands] = useState([]);
20
  const [stats, setStats] = useState(null);
@@ -129,6 +129,7 @@ export default function DecidronSimulator() {
129
  const sensors = network.nodes.filter((n) => n.type === 'sensor');
130
  const shownDiagram = selected ? selected.diagram : network.diagram;
131
  const shownVersion = selected ? selected.version : network.version;
 
132
 
133
  return (
134
  <div className="decidron-app">
@@ -147,7 +148,7 @@ export default function DecidronSimulator() {
147
  Sensors (blue) feed coupled Decidrons (purple) that drive
148
  actuators (green). Commands can modify this topology.
149
  </p>
150
- <NetworkDiagram data={shownDiagram} />
151
  </div>
152
  <HistoryTimeline
153
  snapshots={history}
@@ -159,26 +160,30 @@ export default function DecidronSimulator() {
159
  </div>
160
 
161
  <div>
162
- <SensorInputForm
163
- sensors={sensors}
164
- channel={channel}
165
- value={value}
166
- onChannelChange={setChannel}
167
- onValueChange={setValue}
168
- pending={pending}
169
- onQueue={handleQueue}
170
- onRun={handleRun}
171
- running={running}
172
- />
173
- <CommandBuilder
174
- commands={commands}
175
- sensors={sensors}
176
- nodes={network.nodes}
177
- onCreate={handleCreate}
178
- onDelete={handleDelete}
179
- />
180
- <ResultsPanel results={results} />
181
- <StatsTable stats={stats} />
 
 
 
 
182
  </div>
183
  </div>
184
  </div>
 
14
 
15
  const DEFAULT_VALUE = '97';
16
 
17
+ export default function DecidronSimulator({ settings }) {
18
  const [network, setNetwork] = useState(null);
19
  const [commands, setCommands] = useState([]);
20
  const [stats, setStats] = useState(null);
 
129
  const sensors = network.nodes.filter((n) => n.type === 'sensor');
130
  const shownDiagram = selected ? selected.diagram : network.diagram;
131
  const shownVersion = selected ? selected.version : network.version;
132
+ const { panels, layoutMode } = settings;
133
 
134
  return (
135
  <div className="decidron-app">
 
148
  Sensors (blue) feed coupled Decidrons (purple) that drive
149
  actuators (green). Commands can modify this topology.
150
  </p>
151
+ <NetworkDiagram data={shownDiagram} layoutMode={layoutMode} />
152
  </div>
153
  <HistoryTimeline
154
  snapshots={history}
 
160
  </div>
161
 
162
  <div>
163
+ {panels.sensorInput && (
164
+ <SensorInputForm
165
+ sensors={sensors}
166
+ channel={channel}
167
+ value={value}
168
+ onChannelChange={setChannel}
169
+ onValueChange={setValue}
170
+ pending={pending}
171
+ onQueue={handleQueue}
172
+ onRun={handleRun}
173
+ running={running}
174
+ />
175
+ )}
176
+ {panels.commands && (
177
+ <CommandBuilder
178
+ commands={commands}
179
+ sensors={sensors}
180
+ nodes={network.nodes}
181
+ onCreate={handleCreate}
182
+ onDelete={handleDelete}
183
+ />
184
+ )}
185
+ {panels.results && <ResultsPanel results={results} />}
186
+ {panels.stats && <StatsTable stats={stats} />}
187
  </div>
188
  </div>
189
  </div>
frontend/src/components/decidron/NetworkDiagram.js CHANGED
@@ -1,40 +1,83 @@
1
- import React, { useEffect, useRef } from 'react';
2
  import { Network } from 'vis-network/standalone';
 
3
 
4
- const OPTIONS = {
5
- autoResize: true,
6
- nodes: {
7
- borderWidth: 2,
8
- font: { color: '#ffffff', size: 14 },
9
- },
10
- edges: {
11
- arrows: 'to',
12
- color: { color: '#9aa3b2', highlight: '#6366F1' },
13
- font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
14
- smooth: { type: 'cubicBezier', roundness: 0.4 },
15
- },
16
- groups: {
17
- sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
18
- decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
19
- actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
20
- },
21
- physics: {
22
- stabilization: { iterations: 150 },
23
- barnesHut: { springLength: 150, gravitationalConstant: -6000 },
24
- },
25
- interaction: { hover: true, tooltipDelay: 120 },
26
  };
27
 
28
- export default function NetworkDiagram({ data, height = 380 }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  const containerRef = useRef(null);
30
  const networkRef = useRef(null);
31
 
 
 
 
 
 
32
  useEffect(() => {
33
  if (!containerRef.current) return undefined;
34
  networkRef.current = new Network(
35
  containerRef.current,
36
- data || { nodes: [], edges: [] },
37
- OPTIONS,
38
  );
39
  return () => {
40
  if (networkRef.current) {
@@ -42,7 +85,7 @@ export default function NetworkDiagram({ data, height = 380 }) {
42
  networkRef.current = null;
43
  }
44
  };
45
- }, [data]);
46
 
47
  return (
48
  <div
 
1
+ import React, { useEffect, useRef, useMemo } from 'react';
2
  import { Network } from 'vis-network/standalone';
3
+ import { LAYOUT_MODES } from '../../utils/settings';
4
 
5
+ /** Known node positions for explicit-coordinate mode (vis-network canvas coords). */
6
+ const EXPLICIT_POSITIONS = {
7
+ temperature: { x: 0, y: -140 },
8
+ D1: { x: -120, y: 20 },
9
+ D2: { x: 120, y: 20 },
10
+ 'actuator-1': { x: 0, y: 140 },
11
+ D3: { x: 220, y: 20 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  };
13
 
14
+ function fallbackPosition(nodeId, index) {
15
+ const angle = (index * 2 * Math.PI) / 8;
16
+ return { x: Math.cos(angle) * 180, y: Math.sin(angle) * 120 };
17
+ }
18
+
19
+ function applyLayout(data, layoutMode) {
20
+ const base = data || { nodes: [], edges: [] };
21
+ const nodes = (base.nodes || []).map((node, index) => {
22
+ if (layoutMode !== LAYOUT_MODES.EXPLICIT) return { ...node };
23
+ const pos = EXPLICIT_POSITIONS[node.id] ?? fallbackPosition(node.id, index);
24
+ return { ...node, x: pos.x, y: pos.y, fixed: true };
25
+ });
26
+ return { nodes, edges: base.edges || [] };
27
+ }
28
+
29
+ function buildOptions(layoutMode) {
30
+ const options = {
31
+ autoResize: true,
32
+ nodes: {
33
+ borderWidth: 2,
34
+ font: { color: '#ffffff', size: 14 },
35
+ },
36
+ edges: {
37
+ arrows: 'to',
38
+ color: { color: '#9aa3b2', highlight: '#6366F1' },
39
+ font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
40
+ smooth: { type: 'cubicBezier', roundness: 0.4 },
41
+ },
42
+ groups: {
43
+ sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
44
+ decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
45
+ actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
46
+ },
47
+ interaction: { hover: true, tooltipDelay: 120 },
48
+ };
49
+
50
+ if (layoutMode === LAYOUT_MODES.EXPLICIT) {
51
+ options.physics = { enabled: false };
52
+ } else {
53
+ options.physics = {
54
+ stabilization: { iterations: 150 },
55
+ barnesHut: { springLength: 150, gravitationalConstant: -6000 },
56
+ };
57
+ if (layoutMode === LAYOUT_MODES.FIXED_SEED) {
58
+ options.layout = { randomSeed: 42 };
59
+ }
60
+ // RANDOM: no randomSeed — vis-network picks a new seed each build (default).
61
+ }
62
+
63
+ return options;
64
+ }
65
+
66
+ export default function NetworkDiagram({ data, layoutMode = LAYOUT_MODES.RANDOM, height = 380 }) {
67
  const containerRef = useRef(null);
68
  const networkRef = useRef(null);
69
 
70
+ const graphData = useMemo(
71
+ () => applyLayout(data, layoutMode),
72
+ [data, layoutMode],
73
+ );
74
+
75
  useEffect(() => {
76
  if (!containerRef.current) return undefined;
77
  networkRef.current = new Network(
78
  containerRef.current,
79
+ graphData,
80
+ buildOptions(layoutMode),
81
  );
82
  return () => {
83
  if (networkRef.current) {
 
85
  networkRef.current = null;
86
  }
87
  };
88
+ }, [graphData, layoutMode]);
89
 
90
  return (
91
  <div
frontend/src/styles/decidron.css CHANGED
@@ -182,3 +182,55 @@
182
  border-radius: 4px;
183
  font-size: 0.75rem;
184
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  border-radius: 4px;
183
  font-size: 0.75rem;
184
  }
185
+
186
+ /* Header settings menu */
187
+ .decidron-settings-wrap .decidron-settings-trigger {
188
+ width: 36px;
189
+ height: 36px;
190
+ padding: 0;
191
+ display: inline-flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ }
195
+
196
+ .decidron-settings-panel {
197
+ width: 300px;
198
+ }
199
+
200
+ .decidron-settings-radio {
201
+ display: flex;
202
+ align-items: flex-start;
203
+ gap: 0.5rem;
204
+ padding: 0.45rem 0.5rem;
205
+ border-radius: 8px;
206
+ cursor: pointer;
207
+ font-size: 0.82rem;
208
+ color: var(--text-secondary);
209
+ }
210
+
211
+ .decidron-settings-radio:hover {
212
+ background: var(--bg-tertiary);
213
+ }
214
+
215
+ .decidron-settings-radio input {
216
+ margin-top: 0.2rem;
217
+ flex-shrink: 0;
218
+ }
219
+
220
+ .decidron-settings-radio strong {
221
+ display: block;
222
+ color: var(--text-primary);
223
+ font-weight: 600;
224
+ font-size: 0.82rem;
225
+ }
226
+
227
+ .decidron-settings-hint {
228
+ display: block;
229
+ font-size: 0.72rem;
230
+ color: var(--text-tertiary);
231
+ margin-top: 0.1rem;
232
+ }
233
+
234
+ .decidron-settings-check {
235
+ cursor: pointer;
236
+ }
frontend/src/utils/settings.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const SETTINGS_KEY = 'decidron-simulator-settings';
2
+
3
+ export const LAYOUT_MODES = {
4
+ RANDOM: 'random',
5
+ FIXED_SEED: 'fixed-seed',
6
+ EXPLICIT: 'explicit',
7
+ };
8
+
9
+ /** Right-column panels in fixed top-to-bottom order. */
10
+ export const PANEL_KEYS = ['sensorInput', 'commands', 'results', 'stats'];
11
+
12
+ export const PANEL_LABELS = {
13
+ sensorInput: 'Sensor Input',
14
+ commands: 'Processing Commands',
15
+ results: 'Latest Run Results',
16
+ stats: 'Performance Statistics',
17
+ };
18
+
19
+ export const DEFAULT_SETTINGS = {
20
+ layoutMode: LAYOUT_MODES.RANDOM,
21
+ panels: {
22
+ sensorInput: true,
23
+ commands: true,
24
+ results: true,
25
+ stats: true,
26
+ },
27
+ };
28
+
29
+ export function loadSettings() {
30
+ try {
31
+ const raw = localStorage.getItem(SETTINGS_KEY);
32
+ if (!raw) return { ...DEFAULT_SETTINGS, panels: { ...DEFAULT_SETTINGS.panels } };
33
+ const parsed = JSON.parse(raw);
34
+ return {
35
+ layoutMode: Object.values(LAYOUT_MODES).includes(parsed.layoutMode)
36
+ ? parsed.layoutMode
37
+ : DEFAULT_SETTINGS.layoutMode,
38
+ panels: {
39
+ ...DEFAULT_SETTINGS.panels,
40
+ ...(parsed.panels || {}),
41
+ },
42
+ };
43
+ } catch {
44
+ return { ...DEFAULT_SETTINGS, panels: { ...DEFAULT_SETTINGS.panels } };
45
+ }
46
+ }
47
+
48
+ export function saveSettings(settings) {
49
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
50
+ }