Skip to content

Commit f38e67e

Browse files
committed
Add preset petclinic landscapes
1 parent 9a968d9 commit f38e67e

8 files changed

Lines changed: 3279 additions & 6 deletions

File tree

public/resources/preset-landscapes/distributed-petclinic.json

Lines changed: 1895 additions & 0 deletions
Large diffs are not rendered by default.

public/resources/preset-landscapes/petclinic-demo.json

Lines changed: 1141 additions & 0 deletions
Large diffs are not rendered by default.

src/backend/controllers/landscape.controller.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { Request, Response } from 'express';
2+
import { readdir, readFile } from 'fs/promises';
3+
import path from 'path';
4+
import { fileURLToPath } from 'url';
25
import { LandscapeService } from '../services/landscape.service';
36

7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
410
export class LandscapeController {
511
private landscapeService: LandscapeService;
612

@@ -48,4 +54,131 @@ export class LandscapeController {
4854
res.status(400).json({ error: error.message || 'Failed to update landscape' });
4955
}
5056
};
57+
58+
/**
59+
* GET /api/landscape/presets - List available preset landscapes
60+
*/
61+
listPresets = async (req: Request, res: Response): Promise<void> => {
62+
try {
63+
// Try multiple possible paths for preset landscapes
64+
const possiblePaths = [
65+
// Development: from source directory
66+
path.join(process.cwd(), 'public/resources/preset-landscapes'),
67+
// Development: relative to compiled controller
68+
path.join(__dirname, '../../public/resources/preset-landscapes'),
69+
// Production: from dist
70+
path.join(__dirname, '../../dist/public/resources/preset-landscapes'),
71+
// Production: from process.cwd
72+
path.join(process.cwd(), 'dist/public/resources/preset-landscapes'),
73+
];
74+
75+
let presetDir: string | null = null;
76+
for (const possiblePath of possiblePaths) {
77+
try {
78+
await readdir(possiblePath);
79+
presetDir = possiblePath;
80+
break;
81+
} catch {
82+
// Continue to next path
83+
}
84+
}
85+
86+
if (!presetDir) {
87+
// No preset directory found, return empty array
88+
res.status(200).json([]);
89+
return;
90+
}
91+
92+
const files = await readdir(presetDir);
93+
const presetFiles = files
94+
.filter((file) => file.endsWith('.json'))
95+
.map((file) => ({
96+
name: file.replace('.json', ''),
97+
filename: file,
98+
}));
99+
100+
res.status(200).json(presetFiles);
101+
} catch (error: any) {
102+
res.status(500).json({ error: error.message || 'Failed to list preset landscapes' });
103+
}
104+
};
105+
106+
/**
107+
* GET /api/landscape/presets/:name - Load a specific preset landscape
108+
*/
109+
loadPreset = async (req: Request, res: Response): Promise<void> => {
110+
try {
111+
const presetName = req.params.name;
112+
if (!presetName || !presetName.match(/^[a-zA-Z0-9_-]+$/)) {
113+
res.status(400).json({ error: 'Invalid preset name' });
114+
return;
115+
}
116+
117+
// Try multiple possible paths for preset landscapes
118+
const possiblePaths = [
119+
// Development: from source directory
120+
path.join(process.cwd(), 'public/resources/preset-landscapes', `${presetName}.json`),
121+
// Development: relative to compiled controller
122+
path.join(__dirname, '../../public/resources/preset-landscapes', `${presetName}.json`),
123+
// Production: from dist
124+
path.join(__dirname, '../../dist/public/resources/preset-landscapes', `${presetName}.json`),
125+
// Production: from process.cwd
126+
path.join(process.cwd(), 'dist/public/resources/preset-landscapes', `${presetName}.json`),
127+
];
128+
129+
let presetFile: string | null = null;
130+
for (const possiblePath of possiblePaths) {
131+
try {
132+
await readFile(possiblePath);
133+
presetFile = possiblePath;
134+
break;
135+
} catch {
136+
// Continue to next path
137+
}
138+
}
139+
140+
if (!presetFile) {
141+
res.status(404).json({ error: `Preset landscape "${presetName}" not found` });
142+
return;
143+
}
144+
145+
const fileContent = await readFile(presetFile, 'utf-8');
146+
const landscapeData = JSON.parse(fileContent);
147+
148+
if (!Array.isArray(landscapeData)) {
149+
res.status(400).json({ error: 'Invalid landscape file: must be an array' });
150+
return;
151+
}
152+
153+
// Validate the data structure first by attempting to serialize it
154+
// This ensures the preset file itself doesn't have circular references
155+
try {
156+
JSON.stringify(landscapeData);
157+
} catch {
158+
res.status(400).json({ error: 'Preset landscape file contains circular references' });
159+
return;
160+
}
161+
162+
// Deep clone the landscape data to avoid mutating the original
163+
// This is necessary because updateLandscape will reconstruct parent references
164+
// which modifies objects in place, creating circular references
165+
const clonedLandscapeData = JSON.parse(JSON.stringify(landscapeData));
166+
167+
// Update the landscape store with the cloned preset (this reconstructs parent references internally)
168+
// The cloned data will be modified, but the original landscapeData remains clean
169+
this.landscapeService.updateLandscape(clonedLandscapeData);
170+
171+
// Return the original landscape data (already in CleanedLandscape format)
172+
// This avoids any circular reference issues from the reconstruction process
173+
res.status(200).json(landscapeData);
174+
} catch (error: any) {
175+
if (error.code === 'ENOENT') {
176+
res.status(404).json({ error: `Preset landscape "${req.params.name}" not found` });
177+
} else if (error instanceof SyntaxError) {
178+
res.status(400).json({ error: 'Invalid JSON in preset landscape file' });
179+
} else {
180+
res.status(500).json({ error: error.message || 'Failed to load preset landscape' });
181+
}
182+
}
183+
};
51184
}

src/backend/routes/landscape.routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ const landscapeController = new LandscapeController(landscapeService);
1010
router.get('/', landscapeController.getLandscape);
1111
router.post('/', landscapeController.generateLandscape);
1212
router.put('/', landscapeController.updateLandscape);
13+
router.get('/presets', landscapeController.listPresets);
14+
router.get('/presets/:name', landscapeController.loadPreset);
1315

1416
export { router as landscapeRoutes };

src/frontend/api/client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ export class ApiClient {
9494
throw new Error(error.error || 'Failed to generate trace');
9595
}
9696
}
97+
98+
/**
99+
* List available preset landscapes
100+
*/
101+
async listPresetLandscapes(): Promise<Array<{ name: string; filename: string }>> {
102+
const response = await fetch(`${API_BASE_URL}/landscape/presets`);
103+
if (!response.ok) {
104+
throw new Error(`Failed to list preset landscapes: ${response.statusText}`);
105+
}
106+
return response.json();
107+
}
108+
109+
/**
110+
* Load a preset landscape
111+
*/
112+
async loadPresetLandscape(presetName: string): Promise<CleanedLandscape[]> {
113+
const response = await fetch(`${API_BASE_URL}/landscape/presets/${presetName}`);
114+
if (!response.ok) {
115+
const error = await response.json();
116+
throw new Error(error.error || `Failed to load preset landscape: ${response.statusText}`);
117+
}
118+
return response.json();
119+
}
97120
}
98121

99122
export const apiClient = new ApiClient();

src/frontend/components/LandscapeEditor.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CleanedClass, CleanedLandscape, CleanedPackage } from '../../backend/shared/types';
2-
import React, { useRef, useState } from 'react';
2+
import React, { useEffect, useRef, useState } from 'react';
3+
import { apiClient } from '../api/client';
34
import { AppNode } from './landscape-editor/AppNode';
45
import { LandscapeToolbar } from './landscape-editor/LandscapeToolbar';
56
import { LandscapeEditorHandlers, NodeId } from './landscape-editor/types';
@@ -15,6 +16,41 @@ export function LandscapeEditor({ landscape, onLandscapeUpdated, onError }: Land
1516
const [expandedNodes, setExpandedNodes] = useState<Set<NodeId>>(new Set());
1617
const [localLandscape, setLocalLandscape] = useState<CleanedLandscape[]>(landscape);
1718
const isInternalUpdateRef = useRef(false);
19+
const [availablePresets, setAvailablePresets] = useState<Array<{ name: string; filename: string }>>([]);
20+
const [isLoadingPresets, setIsLoadingPresets] = useState(false);
21+
22+
useEffect(() => {
23+
loadPresets();
24+
}, []);
25+
26+
const loadPresets = async () => {
27+
try {
28+
setIsLoadingPresets(true);
29+
const presets = await apiClient.listPresetLandscapes();
30+
setAvailablePresets(presets);
31+
if (presets.length === 0) {
32+
console.log('No preset landscapes found');
33+
} else {
34+
console.log(`Loaded ${presets.length} preset landscape(s):`, presets.map((p) => p.name).join(', '));
35+
}
36+
} catch (err: any) {
37+
console.error('Failed to load preset landscapes:', err.message);
38+
setAvailablePresets([]);
39+
} finally {
40+
setIsLoadingPresets(false);
41+
}
42+
};
43+
44+
const loadPreset = async (presetName: string) => {
45+
try {
46+
const presetLandscape = await apiClient.loadPresetLandscape(presetName);
47+
setLocalLandscape(presetLandscape);
48+
onLandscapeUpdated(presetLandscape);
49+
setExpandedNodes(new Set());
50+
} catch (err: any) {
51+
onError(err.message || 'Failed to load preset landscape');
52+
}
53+
};
1854

1955
React.useEffect(() => {
2056
// Only update local landscape if the change came from outside (prop change)
@@ -922,6 +958,9 @@ export function LandscapeEditor({ landscape, onLandscapeUpdated, onError }: Land
922958
onCollapseAll={collapseAll}
923959
onSaveLandscape={saveLandscape}
924960
onLoadLandscape={loadLandscape}
961+
onLoadPreset={loadPreset}
962+
availablePresets={availablePresets}
963+
isLoadingPresets={isLoadingPresets}
925964
/>
926965
<div className="material-card p-4 max-h-[600px] overflow-y-auto font-mono text-sm bg-light">
927966
{localLandscape.length === 0 ? (

src/frontend/components/landscape-editor/LandscapeToolbar.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChevronsDown, ChevronsUp, FileUp, Plus, Save } from 'lucide-react';
1+
import { ChevronsDown, ChevronsUp, FileUp, Plus, Save, BookOpen } from 'lucide-react';
22
import { LandscapeToolbarProps } from './types';
33

44
export function LandscapeToolbar({
@@ -7,7 +7,19 @@ export function LandscapeToolbar({
77
onCollapseAll,
88
onSaveLandscape,
99
onLoadLandscape,
10+
onLoadPreset,
11+
availablePresets = [],
12+
isLoadingPresets = false,
1013
}: LandscapeToolbarProps) {
14+
const handlePresetChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
15+
const presetName = event.target.value;
16+
if (presetName && onLoadPreset) {
17+
onLoadPreset(presetName);
18+
// Reset select to default
19+
event.target.value = '';
20+
}
21+
};
22+
1123
return (
1224
<div className="flex flex-wrap gap-3 mb-6">
1325
<button type="button" onClick={onAddApp} className="material-button-secondary px-4 py-2 flex items-center gap-2">
@@ -30,10 +42,31 @@ export function LandscapeToolbar({
3042
<ChevronsUp className="w-4 h-4" />
3143
Collapse All
3244
</button>
33-
<button type="button" onClick={onSaveLandscape} className="material-button px-4 py-2 flex items-center gap-2">
34-
<Save className="w-4 h-4" />
35-
Save Landscape
36-
</button>
45+
{onLoadPreset && (
46+
<div className="relative inline-block">
47+
<select
48+
onChange={handlePresetChange}
49+
disabled={isLoadingPresets || availablePresets.length === 0}
50+
className="material-button-secondary px-4 py-2 pr-10 appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed bg-transparent"
51+
defaultValue=""
52+
aria-label="Load preset landscape"
53+
>
54+
<option value="" disabled>
55+
{isLoadingPresets
56+
? 'Loading presets...'
57+
: availablePresets.length === 0
58+
? 'No presets available'
59+
: 'Load Preset'}
60+
</option>
61+
{availablePresets.map((preset) => (
62+
<option key={preset.name} value={preset.name}>
63+
{preset.name}
64+
</option>
65+
))}
66+
</select>
67+
<BookOpen className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
68+
</div>
69+
)}
3770
<label className="material-button-secondary px-4 py-2 flex items-center gap-2 cursor-pointer">
3871
<FileUp className="w-4 h-4" />
3972
Load Landscape
@@ -45,6 +78,10 @@ export function LandscapeToolbar({
4578
aria-label="Load landscape from file"
4679
/>
4780
</label>
81+
<button type="button" onClick={onSaveLandscape} className="material-button px-4 py-2 flex items-center gap-2">
82+
<Save className="w-4 h-4" />
83+
Save Landscape
84+
</button>
4885
</div>
4986
);
5087
}

src/frontend/components/landscape-editor/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,7 @@ export interface LandscapeToolbarProps {
7171
onCollapseAll: () => void;
7272
onSaveLandscape: () => void;
7373
onLoadLandscape: (event: React.ChangeEvent<HTMLInputElement>) => void;
74+
onLoadPreset?: (presetName: string) => void;
75+
availablePresets?: Array<{ name: string; filename: string }>;
76+
isLoadingPresets?: boolean;
7477
}

0 commit comments

Comments
 (0)