@principal-ai/file-city-react 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"ArchitectureMapHighlightLayers.d.ts","sourceRoot":"","sources":["../../src/components/ArchitectureMapHighlightLayers.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ArchitectureMapHighlightLayers.d.ts","sourceRoot":"","sources":["../../src/components/ArchitectureMapHighlightLayers.tsx"],"names":[],"mappings":"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"}
@@ -210,6 +210,9 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
210
210
  const isAnimating = targetZoom !== null;
211
211
  // Track the last zoomToPath to detect changes
212
212
  const lastZoomToPathRef = (0, react_1.useRef)(null);
213
+ // Throttle ref for hover updates (improves performance with large datasets)
214
+ const lastHoverUpdateRef = (0, react_1.useRef)(0);
215
+ const HOVER_THROTTLE_MS = 16; // ~60fps max for hover updates
213
216
  (0, react_1.useEffect)(() => {
214
217
  // Reset user interaction state when enableZoom is disabled
215
218
  if (!enableZoom) {
@@ -693,6 +696,66 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
693
696
  }
694
697
  return layers;
695
698
  }, [stableLayers, dynamicLayers, abstractionLayer]);
699
+ // Memoize abstracted paths lookup - only recalculates when abstraction layer changes
700
+ const { abstractedPathsSet, abstractedPathLookup } = (0, react_1.useMemo)(() => {
701
+ const pathsSet = new Set();
702
+ const abstractionLayerDef = allLayers.find(l => l.id === 'directory-abstraction');
703
+ if (abstractionLayerDef && abstractionLayerDef.enabled) {
704
+ abstractionLayerDef.items.forEach(item => {
705
+ if (item.type === 'directory') {
706
+ pathsSet.add(item.path);
707
+ }
708
+ });
709
+ }
710
+ return {
711
+ abstractedPathsSet: pathsSet,
712
+ abstractedPathLookup: new PathHierarchyLookup(pathsSet),
713
+ };
714
+ }, [allLayers]);
715
+ // Memoize visible districts - only recalculates when data or abstraction changes, NOT on hover
716
+ const visibleDistrictsMemo = (0, react_1.useMemo)(() => {
717
+ if (!filteredCityData)
718
+ return [];
719
+ let districts = abstractedPathLookup.size > 0
720
+ ? filteredCityData.districts.filter(district => {
721
+ // Check for root abstraction first
722
+ if (abstractedPathLookup.has('')) {
723
+ return !district.path || district.path === '';
724
+ }
725
+ if (!district.path)
726
+ return true;
727
+ if (abstractedPathLookup.has(district.path))
728
+ return true;
729
+ return !abstractedPathLookup.isChildOfAbstracted(district.path);
730
+ })
731
+ : filteredCityData.districts;
732
+ // If root is abstracted and there's no root district, create one for the cover
733
+ if (abstractedPathsSet.has('')) {
734
+ const hasRootDistrict = districts.some(d => !d.path || d.path === '');
735
+ if (!hasRootDistrict) {
736
+ districts = [
737
+ {
738
+ path: '',
739
+ worldBounds: filteredCityData.bounds,
740
+ fileCount: filteredCityData.buildings.length,
741
+ type: 'directory',
742
+ },
743
+ ...districts,
744
+ ];
745
+ }
746
+ }
747
+ return districts;
748
+ }, [filteredCityData, abstractedPathLookup, abstractedPathsSet]);
749
+ // Memoize visible buildings - only recalculates when data or abstraction changes, NOT on hover
750
+ const visibleBuildingsMemo = (0, react_1.useMemo)(() => {
751
+ if (!filteredCityData)
752
+ return [];
753
+ return abstractedPathLookup.size > 0
754
+ ? filteredCityData.buildings.filter(building => {
755
+ return !abstractedPathLookup.isPathAbstracted(building.path);
756
+ })
757
+ : filteredCityData.buildings;
758
+ }, [filteredCityData, abstractedPathLookup]);
696
759
  // Update hit test cache when geometry or abstraction changes
697
760
  // Note: We don't depend on zoomState here because:
698
761
  // 1. The spatial grid only depends on buildings/districts and abstraction
@@ -762,64 +825,12 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
762
825
  y: ((z - coordinateSystemData.bounds.minZ) * scale + offsetZ) * zoomState.scale +
763
826
  zoomState.offsetY,
764
827
  });
765
- // Get abstracted paths for filtering child districts
766
- const abstractedPathsForDistricts = new Set();
767
- const abstractionLayerForDistricts = allLayers.find(l => l.id === 'directory-abstraction');
768
- if (abstractionLayerForDistricts && abstractionLayerForDistricts.enabled) {
769
- abstractionLayerForDistricts.items.forEach(item => {
770
- if (item.type === 'directory') {
771
- abstractedPathsForDistricts.add(item.path);
772
- }
773
- });
774
- }
775
- // Create PathHierarchyLookup for O(depth) containment checks
776
- const pathLookup = new PathHierarchyLookup(abstractedPathsForDistricts);
777
- // Keep abstracted districts (for covers) but filter out their children
778
- let visibleDistricts = pathLookup.size > 0
779
- ? filteredCityData.districts.filter(district => {
780
- // Check for root abstraction first
781
- if (pathLookup.has('')) {
782
- // If root is abstracted, only show root district
783
- return !district.path || district.path === '';
784
- }
785
- if (!district.path)
786
- return true; // Keep root
787
- // Keep the abstracted district itself (we need it for the cover)
788
- if (pathLookup.has(district.path)) {
789
- return true;
790
- }
791
- // Filter out children of abstracted directories using O(depth) lookup
792
- return !pathLookup.isChildOfAbstracted(district.path);
793
- })
794
- : filteredCityData.districts;
795
- // If root is abstracted and there's no root district, create one for the cover
796
- if (abstractedPathsForDistricts.has('')) {
797
- const hasRootDistrict = visibleDistricts.some(d => !d.path || d.path === '');
798
- if (!hasRootDistrict) {
799
- visibleDistricts = [
800
- {
801
- path: '',
802
- worldBounds: filteredCityData.bounds,
803
- fileCount: filteredCityData.buildings.length, // Total file count
804
- type: 'directory',
805
- },
806
- ...visibleDistricts,
807
- ];
808
- }
809
- }
828
+ // Use memoized visible districts and buildings (pre-filtered, doesn't recalculate on hover)
810
829
  // Draw districts with layer support
811
- (0, drawLayeredBuildings_1.drawLayeredDistricts)(ctx, visibleDistricts, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredDistrict, fullSize, resolvedDefaultDirectoryColor, filteredCityData.metadata.layoutConfig, abstractedPathsForDistricts, // Pass abstracted paths to skip labels
830
+ (0, drawLayeredBuildings_1.drawLayeredDistricts)(ctx, visibleDistrictsMemo, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredDistrict, fullSize, resolvedDefaultDirectoryColor, filteredCityData.metadata.layoutConfig, abstractedPathsSet, // Pass abstracted paths to skip labels
812
831
  showDirectoryLabels, districtBorderRadius);
813
- // Filter out buildings that are in abstracted directories
814
- // Reuse the pathLookup created earlier for O(depth) containment checks
815
- const visibleBuildings = pathLookup.size > 0
816
- ? filteredCityData.buildings.filter(building => {
817
- // Use PathHierarchyLookup for efficient O(depth) check
818
- return !pathLookup.isPathAbstracted(building.path);
819
- })
820
- : filteredCityData.buildings;
821
832
  // Draw buildings with layer support
822
- (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildings, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
833
+ (0, drawLayeredBuildings_1.drawLayeredBuildings)(ctx, visibleBuildingsMemo, worldToCanvas, scale * zoomState.scale, allLayers, interactionState.hoveredBuilding, resolvedDefaultBuildingColor, showFileNames, resolvedHoverBorderColor, disableOpacityDimming, showFileTypeIcons, buildingBorderRadius);
823
834
  // Performance monitoring end available for debugging
824
835
  // Performance stats available but not logged to reduce console noise
825
836
  // Uncomment for debugging: render time, buildings/districts counts, layer counts
@@ -848,6 +859,10 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
848
859
  resolvedDefaultBuildingColor,
849
860
  districtBorderRadius,
850
861
  showFileTypeIcons,
862
+ // Memoized values for performance (don't recalculate on hover)
863
+ visibleDistrictsMemo,
864
+ visibleBuildingsMemo,
865
+ abstractedPathsSet,
851
866
  ]);
852
867
  // Optimized hit testing
853
868
  const performHitTest = (0, react_1.useCallback)((canvasX, canvasY) => {
@@ -974,6 +989,12 @@ onHover, buildingBorderRadius = 0, districtBorderRadius = 0, }) {
974
989
  const handleMouseMoveInternal = (0, react_1.useCallback)((e) => {
975
990
  if (!canvasRef.current || !containerRef.current || !filteredCityData || zoomState.isDragging)
976
991
  return;
992
+ // Throttle hover updates to improve performance with large datasets
993
+ const now = performance.now();
994
+ if (now - lastHoverUpdateRef.current < HOVER_THROTTLE_MS) {
995
+ return;
996
+ }
997
+ lastHoverUpdateRef.current = now;
977
998
  // Get the container rect for mouse position
978
999
  const containerRect = containerRef.current.getBoundingClientRect();
979
1000
  // Get mouse position relative to the container
@@ -0,0 +1,17 @@
1
+ import { CityData } from '@principal-ai/file-city-builder';
2
+ /**
3
+ * Generate a large number of file paths with realistic nested directory structures
4
+ */
5
+ export declare function generateLargeFilePaths(fileCount: number): string[];
6
+ /**
7
+ * Create CityData with a large number of files for stress testing subdirectory zoom
8
+ *
9
+ * @param fileCount - Number of files to generate (default: 8000)
10
+ * @param useCache - Whether to cache results (default: true)
11
+ */
12
+ export declare function createStressTestCityData(fileCount?: number, useCache?: boolean): CityData;
13
+ /**
14
+ * Clear the stress test cache (useful for testing memory)
15
+ */
16
+ export declare function clearStressTestCache(): void;
17
+ //# sourceMappingURL=stress-test-data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stress-test-data.d.ts","sourceRoot":"","sources":["../../src/stories/stress-test-data.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EAGT,MAAM,iCAAiC,CAAC;AA2CzC;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAoElE;AAoBD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,GAAE,MAAa,EAAE,QAAQ,GAAE,OAAc,GAAG,QAAQ,CAwBrG;AAED;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateLargeFilePaths = generateLargeFilePaths;
4
+ exports.createStressTestCityData = createStressTestCityData;
5
+ exports.clearStressTestCache = clearStressTestCache;
6
+ const file_city_builder_1 = require("@principal-ai/file-city-builder");
7
+ // Common file extensions for realistic distribution
8
+ const FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.md', '.test.ts', '.spec.tsx'];
9
+ // Top-level source directories
10
+ const TOP_LEVEL_DIRS = ['src', 'lib', 'packages', 'modules'];
11
+ // Second-level directories (domain areas)
12
+ const DOMAIN_DIRS = ['components', 'utils', 'services', 'hooks', 'types', 'helpers', 'core', 'api', 'features', 'pages'];
13
+ // Third-level directories (categories within domains)
14
+ const CATEGORY_DIRS = ['ui', 'forms', 'layout', 'navigation', 'data', 'auth', 'common', 'shared', 'internal', 'public'];
15
+ // Fourth-level directories (specific feature areas)
16
+ const FEATURE_DIRS = ['Button', 'Modal', 'Table', 'Card', 'Input', 'Select', 'Dialog', 'Menu', 'Tabs', 'List', 'Grid', 'Panel'];
17
+ // Fifth-level directories (variants/subfeatures)
18
+ const VARIANT_DIRS = ['variants', 'styles', 'hooks', 'utils', 'types', 'tests', '__tests__', 'stories', 'docs'];
19
+ // File name prefixes for realistic naming
20
+ const FILE_PREFIXES = ['index', 'main', 'handler', 'controller', 'service', 'helper', 'util', 'config', 'constants', 'types'];
21
+ /**
22
+ * Seeded random number generator for consistent results
23
+ */
24
+ function seededRandom(seed) {
25
+ return () => {
26
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
27
+ return seed / 0x7fffffff;
28
+ };
29
+ }
30
+ /**
31
+ * Generate a large number of file paths with realistic nested directory structures
32
+ */
33
+ function generateLargeFilePaths(fileCount) {
34
+ const files = [];
35
+ const random = seededRandom(42); // Consistent seed for reproducible results
36
+ for (let i = 0; i < fileCount; i++) {
37
+ const extension = FILE_EXTENSIONS[i % FILE_EXTENSIONS.length];
38
+ // Determine nesting depth (0-5 levels) with weighted distribution
39
+ // More files at medium depths (2-3), fewer at extremes
40
+ const depthRoll = random();
41
+ let depth;
42
+ if (depthRoll < 0.1)
43
+ depth = 1; // 10% at depth 1
44
+ else if (depthRoll < 0.25)
45
+ depth = 2; // 15% at depth 2
46
+ else if (depthRoll < 0.50)
47
+ depth = 3; // 25% at depth 3
48
+ else if (depthRoll < 0.75)
49
+ depth = 4; // 25% at depth 4
50
+ else if (depthRoll < 0.90)
51
+ depth = 5; // 15% at depth 5
52
+ else
53
+ depth = 6; // 10% at depth 6
54
+ const pathParts = [];
55
+ // Level 1: Top-level directory
56
+ // Weight 'src' heavily so it contains ~50% of files (for stress testing large subdirectory zoom)
57
+ const topLevelRoll = random();
58
+ if (topLevelRoll < 0.5) {
59
+ pathParts.push('src'); // 50% of files in src
60
+ }
61
+ else if (topLevelRoll < 0.7) {
62
+ pathParts.push('lib'); // 20% in lib
63
+ }
64
+ else if (topLevelRoll < 0.85) {
65
+ pathParts.push('packages'); // 15% in packages
66
+ }
67
+ else {
68
+ pathParts.push('modules'); // 15% in modules
69
+ }
70
+ // Level 2: Domain directory
71
+ if (depth >= 2) {
72
+ pathParts.push(DOMAIN_DIRS[Math.floor(random() * DOMAIN_DIRS.length)]);
73
+ }
74
+ // Level 3: Category directory
75
+ if (depth >= 3) {
76
+ pathParts.push(CATEGORY_DIRS[Math.floor(random() * CATEGORY_DIRS.length)]);
77
+ }
78
+ // Level 4: Feature directory
79
+ if (depth >= 4) {
80
+ pathParts.push(FEATURE_DIRS[Math.floor(random() * FEATURE_DIRS.length)]);
81
+ }
82
+ // Level 5: Variant directory
83
+ if (depth >= 5) {
84
+ pathParts.push(VARIANT_DIRS[Math.floor(random() * VARIANT_DIRS.length)]);
85
+ }
86
+ // Level 6: Additional nesting for very deep files
87
+ if (depth >= 6) {
88
+ pathParts.push(`nested${Math.floor(random() * 5)}`);
89
+ }
90
+ // Generate filename
91
+ const prefix = FILE_PREFIXES[Math.floor(random() * FILE_PREFIXES.length)];
92
+ const suffix = Math.floor(i / 10); // Group files by suffix number
93
+ const fileName = `${prefix}${suffix}${extension}`;
94
+ pathParts.push(fileName);
95
+ files.push(pathParts.join('/'));
96
+ }
97
+ return files;
98
+ }
99
+ /**
100
+ * Convert file paths to FileInfo objects
101
+ */
102
+ function createFileInfoList(paths) {
103
+ return paths.map(path => ({
104
+ name: path.split('/').pop() || path,
105
+ path: path,
106
+ relativePath: path,
107
+ size: 500 + Math.floor(Math.random() * 5000), // 500-5500 bytes
108
+ extension: path.includes('.') ? '.' + (path.split('.').pop() || '') : '',
109
+ lastModified: new Date(),
110
+ isDirectory: false,
111
+ }));
112
+ }
113
+ // Cache for stress test data to avoid regenerating
114
+ const stressTestCache = new Map();
115
+ /**
116
+ * Create CityData with a large number of files for stress testing subdirectory zoom
117
+ *
118
+ * @param fileCount - Number of files to generate (default: 8000)
119
+ * @param useCache - Whether to cache results (default: true)
120
+ */
121
+ function createStressTestCityData(fileCount = 8000, useCache = true) {
122
+ if (useCache && stressTestCache.has(fileCount)) {
123
+ return stressTestCache.get(fileCount);
124
+ }
125
+ const filePaths = generateLargeFilePaths(fileCount);
126
+ const fileInfos = createFileInfoList(filePaths);
127
+ const fileTree = (0, file_city_builder_1.buildFileSystemTreeFromFileInfoList)(fileInfos, `stress-test-${fileCount}`);
128
+ const builder = new file_city_builder_1.CodeCityBuilderWithGrid();
129
+ const cityData = builder.buildCityFromFileSystem(fileTree, '', {
130
+ paddingTop: 2,
131
+ paddingBottom: 2,
132
+ paddingLeft: 2,
133
+ paddingRight: 2,
134
+ paddingInner: 1,
135
+ paddingOuter: 3,
136
+ });
137
+ if (useCache) {
138
+ stressTestCache.set(fileCount, cityData);
139
+ }
140
+ return cityData;
141
+ }
142
+ /**
143
+ * Clear the stress test cache (useful for testing memory)
144
+ */
145
+ function clearStressTestCache() {
146
+ stressTestCache.clear();
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/file-city-react",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "React components for File City visualization",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -358,6 +358,10 @@ function ArchitectureMapHighlightLayersInner({
358
358
  // Track the last zoomToPath to detect changes
359
359
  const lastZoomToPathRef = useRef<string | null>(null);
360
360
 
361
+ // Throttle ref for hover updates (improves performance with large datasets)
362
+ const lastHoverUpdateRef = useRef<number>(0);
363
+ const HOVER_THROTTLE_MS = 16; // ~60fps max for hover updates
364
+
361
365
  useEffect(() => {
362
366
  // Reset user interaction state when enableZoom is disabled
363
367
  if (!enableZoom) {
@@ -1003,6 +1007,70 @@ function ArchitectureMapHighlightLayersInner({
1003
1007
  return layers;
1004
1008
  }, [stableLayers, dynamicLayers, abstractionLayer]);
1005
1009
 
1010
+ // Memoize abstracted paths lookup - only recalculates when abstraction layer changes
1011
+ const { abstractedPathsSet, abstractedPathLookup } = useMemo(() => {
1012
+ const pathsSet = new Set<string>();
1013
+ const abstractionLayerDef = allLayers.find(l => l.id === 'directory-abstraction');
1014
+ if (abstractionLayerDef && abstractionLayerDef.enabled) {
1015
+ abstractionLayerDef.items.forEach(item => {
1016
+ if (item.type === 'directory') {
1017
+ pathsSet.add(item.path);
1018
+ }
1019
+ });
1020
+ }
1021
+ return {
1022
+ abstractedPathsSet: pathsSet,
1023
+ abstractedPathLookup: new PathHierarchyLookup(pathsSet),
1024
+ };
1025
+ }, [allLayers]);
1026
+
1027
+ // Memoize visible districts - only recalculates when data or abstraction changes, NOT on hover
1028
+ const visibleDistrictsMemo = useMemo(() => {
1029
+ if (!filteredCityData) return [];
1030
+
1031
+ let districts =
1032
+ abstractedPathLookup.size > 0
1033
+ ? filteredCityData.districts.filter(district => {
1034
+ // Check for root abstraction first
1035
+ if (abstractedPathLookup.has('')) {
1036
+ return !district.path || district.path === '';
1037
+ }
1038
+ if (!district.path) return true;
1039
+ if (abstractedPathLookup.has(district.path)) return true;
1040
+ return !abstractedPathLookup.isChildOfAbstracted(district.path);
1041
+ })
1042
+ : filteredCityData.districts;
1043
+
1044
+ // If root is abstracted and there's no root district, create one for the cover
1045
+ if (abstractedPathsSet.has('')) {
1046
+ const hasRootDistrict = districts.some(d => !d.path || d.path === '');
1047
+ if (!hasRootDistrict) {
1048
+ districts = [
1049
+ {
1050
+ path: '',
1051
+ worldBounds: filteredCityData.bounds,
1052
+ fileCount: filteredCityData.buildings.length,
1053
+ type: 'directory' as const,
1054
+ },
1055
+ ...districts,
1056
+ ];
1057
+ }
1058
+ }
1059
+
1060
+ return districts;
1061
+ }, [filteredCityData, abstractedPathLookup, abstractedPathsSet]);
1062
+
1063
+ // Memoize visible buildings - only recalculates when data or abstraction changes, NOT on hover
1064
+ const visibleBuildingsMemo = useMemo(() => {
1065
+ if (!filteredCityData) return [];
1066
+
1067
+ return abstractedPathLookup.size > 0
1068
+ ? filteredCityData.buildings.filter(building => {
1069
+ return !abstractedPathLookup.isPathAbstracted(building.path);
1070
+ })
1071
+ : filteredCityData.buildings;
1072
+ }, [filteredCityData, abstractedPathLookup]);
1073
+
1006
1074
  // Update hit test cache when geometry or abstraction changes
1007
1075
  // Note: We don't depend on zoomState here because:
1008
1076
  // 1. The spatial grid only depends on buildings/districts and abstraction
@@ -1106,63 +1174,11 @@ function ArchitectureMapHighlightLayersInner({
1106
1174
  zoomState.offsetY,
1107
1175
  });
1108
1176
 
1109
- // Get abstracted paths for filtering child districts
1110
- const abstractedPathsForDistricts = new Set<string>();
1111
- const abstractionLayerForDistricts = allLayers.find(l => l.id === 'directory-abstraction');
1112
- if (abstractionLayerForDistricts && abstractionLayerForDistricts.enabled) {
1113
- abstractionLayerForDistricts.items.forEach(item => {
1114
- if (item.type === 'directory') {
1115
- abstractedPathsForDistricts.add(item.path);
1116
- }
1117
- });
1118
- }
1119
-
1120
- // Create PathHierarchyLookup for O(depth) containment checks
1121
- const pathLookup = new PathHierarchyLookup(abstractedPathsForDistricts);
1122
-
1123
- // Keep abstracted districts (for covers) but filter out their children
1124
- let visibleDistricts =
1125
- pathLookup.size > 0
1126
- ? filteredCityData.districts.filter(district => {
1127
- // Check for root abstraction first
1128
- if (pathLookup.has('')) {
1129
- // If root is abstracted, only show root district
1130
- return !district.path || district.path === '';
1131
- }
1132
-
1133
- if (!district.path) return true; // Keep root
1134
-
1135
- // Keep the abstracted district itself (we need it for the cover)
1136
- if (pathLookup.has(district.path)) {
1137
- return true;
1138
- }
1139
-
1140
- // Filter out children of abstracted directories using O(depth) lookup
1141
- return !pathLookup.isChildOfAbstracted(district.path);
1142
- })
1143
- : filteredCityData.districts;
1144
-
1145
- // If root is abstracted and there's no root district, create one for the cover
1146
- if (abstractedPathsForDistricts.has('')) {
1147
- const hasRootDistrict = visibleDistricts.some(d => !d.path || d.path === '');
1148
-
1149
- if (!hasRootDistrict) {
1150
- visibleDistricts = [
1151
- {
1152
- path: '',
1153
- worldBounds: filteredCityData.bounds,
1154
- fileCount: filteredCityData.buildings.length, // Total file count
1155
- type: 'directory',
1156
- },
1157
- ...visibleDistricts,
1158
- ];
1159
- }
1160
- }
1161
-
1177
+ // Use memoized visible districts and buildings (pre-filtered, doesn't recalculate on hover)
1162
1178
  // Draw districts with layer support
1163
1179
  drawLayeredDistricts(
1164
1180
  ctx,
1165
- visibleDistricts,
1181
+ visibleDistrictsMemo,
1166
1182
  worldToCanvas,
1167
1183
  scale * zoomState.scale,
1168
1184
  allLayers,
@@ -1170,25 +1186,15 @@ function ArchitectureMapHighlightLayersInner({
1170
1186
  fullSize,
1171
1187
  resolvedDefaultDirectoryColor,
1172
1188
  filteredCityData.metadata.layoutConfig,
1173
- abstractedPathsForDistricts, // Pass abstracted paths to skip labels
1189
+ abstractedPathsSet, // Pass abstracted paths to skip labels
1174
1190
  showDirectoryLabels,
1175
1191
  districtBorderRadius,
1176
1192
  );
1177
1193
 
1178
- // Filter out buildings that are in abstracted directories
1179
- // Reuse the pathLookup created earlier for O(depth) containment checks
1180
- const visibleBuildings =
1181
- pathLookup.size > 0
1182
- ? filteredCityData.buildings.filter(building => {
1183
- // Use PathHierarchyLookup for efficient O(depth) check
1184
- return !pathLookup.isPathAbstracted(building.path);
1185
- })
1186
- : filteredCityData.buildings;
1187
-
1188
1194
  // Draw buildings with layer support
1189
1195
  drawLayeredBuildings(
1190
1196
  ctx,
1191
- visibleBuildings,
1197
+ visibleBuildingsMemo,
1192
1198
  worldToCanvas,
1193
1199
  scale * zoomState.scale,
1194
1200
  allLayers,
@@ -1229,6 +1235,10 @@ function ArchitectureMapHighlightLayersInner({
1229
1235
  resolvedDefaultBuildingColor,
1230
1236
  districtBorderRadius,
1231
1237
  showFileTypeIcons,
1238
+ // Memoized values for performance (don't recalculate on hover)
1239
+ visibleDistrictsMemo,
1240
+ visibleBuildingsMemo,
1241
+ abstractedPathsSet,
1232
1242
  ]);
1233
1243
 
1234
1244
  // Optimized hit testing
@@ -1406,6 +1416,13 @@ function ArchitectureMapHighlightLayersInner({
1406
1416
  if (!canvasRef.current || !containerRef.current || !filteredCityData || zoomState.isDragging)
1407
1417
  return;
1408
1418
 
1419
+ // Throttle hover updates to improve performance with large datasets
1420
+ const now = performance.now();
1421
+ if (now - lastHoverUpdateRef.current < HOVER_THROTTLE_MS) {
1422
+ return;
1423
+ }
1424
+ lastHoverUpdateRef.current = now;
1425
+
1409
1426
  // Get the container rect for mouse position
1410
1427
  const containerRect = containerRef.current.getBoundingClientRect();
1411
1428
  // Get mouse position relative to the container
@@ -0,0 +1,443 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+
4
+ import { ArchitectureMapHighlightLayers } from '../components/ArchitectureMapHighlightLayers';
5
+ import { HighlightLayer } from '../render/client/drawLayeredBuildings';
6
+ import { createStressTestCityData } from './stress-test-data';
7
+
8
+ // Wrapper component that regenerates cityData when fileCount changes
9
+ // and includes the click-to-zoom panel
10
+ function StressTestWrapper({
11
+ fileCount,
12
+ ...props
13
+ }: {
14
+ fileCount: number;
15
+ } & Omit<React.ComponentProps<typeof ArchitectureMapHighlightLayers>, 'cityData'>) {
16
+ const [zoomToPath, setZoomToPath] = useState<string | null>(null);
17
+ const [isAnimating, setIsAnimating] = useState(false);
18
+
19
+ const cityData = useMemo(() => {
20
+ console.log(`Generating city data for ${fileCount} files...`);
21
+ const start = performance.now();
22
+ const data = createStressTestCityData(fileCount, true);
23
+ const elapsed = performance.now() - start;
24
+ console.log(`Generated in ${elapsed.toFixed(2)}ms`);
25
+ return data;
26
+ }, [fileCount]);
27
+
28
+ // Get unique top-level directories for navigation buttons
29
+ const topLevelDirs = useMemo(() => {
30
+ return Array.from(
31
+ new Set(
32
+ cityData.districts
33
+ .map(d => d.path.split('/')[0])
34
+ .filter(Boolean),
35
+ ),
36
+ ).sort();
37
+ }, [cityData]);
38
+
39
+ // Get nested directories at different levels for navigation
40
+ const nestedDirs = useMemo(() => {
41
+ const allPaths = cityData.districts.map(d => d.path);
42
+
43
+ // Get depth-2 directories (e.g., src/components)
44
+ const depth2 = Array.from(
45
+ new Set(allPaths.filter(p => p.split('/').length === 2)),
46
+ ).sort().slice(0, 6);
47
+
48
+ // Get depth-3 directories (e.g., src/components/ui)
49
+ const depth3 = Array.from(
50
+ new Set(allPaths.filter(p => p.split('/').length === 3)),
51
+ ).sort().slice(0, 6);
52
+
53
+ // Get depth-4 directories (e.g., src/components/ui/Button)
54
+ const depth4 = Array.from(
55
+ new Set(allPaths.filter(p => p.split('/').length === 4)),
56
+ ).sort().slice(0, 4);
57
+
58
+ return { depth2, depth3, depth4 };
59
+ }, [cityData]);
60
+
61
+ const handleZoomTo = (path: string | null) => {
62
+ setIsAnimating(true);
63
+ setZoomToPath(path);
64
+ };
65
+
66
+ const handleZoomComplete = () => {
67
+ setIsAnimating(false);
68
+ };
69
+
70
+ // Create highlight layer for the focused directory
71
+ const highlightLayers: HighlightLayer[] = zoomToPath
72
+ ? [
73
+ {
74
+ id: 'zoom-focus',
75
+ name: 'Zoom Focus',
76
+ enabled: true,
77
+ color: '#3b82f6',
78
+ priority: 1,
79
+ items: [{ path: zoomToPath, type: 'directory' }],
80
+ },
81
+ ]
82
+ : [];
83
+
84
+ return (
85
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
86
+ <ArchitectureMapHighlightLayers
87
+ cityData={cityData}
88
+ zoomToPath={zoomToPath}
89
+ onZoomComplete={handleZoomComplete}
90
+ zoomAnimationSpeed={0.1}
91
+ highlightLayers={highlightLayers}
92
+ onFileClick={(path, type) => {
93
+ if (type === 'directory') {
94
+ handleZoomTo(path);
95
+ }
96
+ }}
97
+ {...props}
98
+ />
99
+
100
+ {/* Navigation Controls */}
101
+ <div
102
+ style={{
103
+ position: 'absolute',
104
+ top: 20,
105
+ left: 20,
106
+ zIndex: 100,
107
+ display: 'flex',
108
+ flexDirection: 'column',
109
+ gap: '8px',
110
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
111
+ padding: '16px',
112
+ borderRadius: '8px',
113
+ maxWidth: '220px',
114
+ maxHeight: 'calc(100vh - 100px)',
115
+ overflowY: 'auto',
116
+ }}
117
+ >
118
+ <div
119
+ style={{
120
+ color: '#3b82f6',
121
+ fontFamily: 'monospace',
122
+ fontSize: '12px',
123
+ fontWeight: 'bold',
124
+ }}
125
+ >
126
+ Stress Test: {fileCount.toLocaleString()} files
127
+ </div>
128
+ <div
129
+ style={{
130
+ color: '#9ca3af',
131
+ fontFamily: 'monospace',
132
+ fontSize: '10px',
133
+ marginBottom: '8px',
134
+ }}
135
+ >
136
+ {isAnimating ? 'Animating...' : 'Click to zoom'}
137
+ </div>
138
+
139
+ {/* Reset button */}
140
+ <button
141
+ onClick={() => handleZoomTo(null)}
142
+ disabled={isAnimating}
143
+ style={{
144
+ padding: '8px 12px',
145
+ backgroundColor: zoomToPath === null ? '#3b82f6' : '#374151',
146
+ color: 'white',
147
+ border: 'none',
148
+ borderRadius: '4px',
149
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
150
+ fontFamily: 'monospace',
151
+ fontSize: '12px',
152
+ opacity: isAnimating ? 0.6 : 1,
153
+ }}
154
+ >
155
+ Reset View
156
+ </button>
157
+
158
+ {/* Top-level directory buttons */}
159
+ {topLevelDirs.map(dir => (
160
+ <button
161
+ key={dir}
162
+ onClick={() => handleZoomTo(dir)}
163
+ disabled={isAnimating}
164
+ style={{
165
+ padding: '8px 12px',
166
+ backgroundColor: zoomToPath === dir ? '#3b82f6' : '#374151',
167
+ color: 'white',
168
+ border: 'none',
169
+ borderRadius: '4px',
170
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
171
+ fontFamily: 'monospace',
172
+ fontSize: '12px',
173
+ textAlign: 'left',
174
+ opacity: isAnimating ? 0.6 : 1,
175
+ }}
176
+ >
177
+ {dir}
178
+ </button>
179
+ ))}
180
+
181
+ {/* Depth 2 directories (e.g., src/components) */}
182
+ {nestedDirs.depth2.length > 0 && (
183
+ <div
184
+ style={{
185
+ borderTop: '1px solid #4b5563',
186
+ paddingTop: '8px',
187
+ marginTop: '4px',
188
+ }}
189
+ >
190
+ <div
191
+ style={{
192
+ color: '#9ca3af',
193
+ fontFamily: 'monospace',
194
+ fontSize: '10px',
195
+ marginBottom: '4px',
196
+ }}
197
+ >
198
+ Depth 2:
199
+ </div>
200
+ {nestedDirs.depth2.map(dir => (
201
+ <button
202
+ key={dir}
203
+ onClick={() => handleZoomTo(dir)}
204
+ disabled={isAnimating}
205
+ style={{
206
+ padding: '6px 10px',
207
+ backgroundColor: zoomToPath === dir ? '#3b82f6' : '#1f2937',
208
+ color: 'white',
209
+ border: 'none',
210
+ borderRadius: '4px',
211
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
212
+ fontFamily: 'monospace',
213
+ fontSize: '10px',
214
+ textAlign: 'left',
215
+ marginBottom: '4px',
216
+ width: '100%',
217
+ opacity: isAnimating ? 0.6 : 1,
218
+ overflow: 'hidden',
219
+ textOverflow: 'ellipsis',
220
+ whiteSpace: 'nowrap',
221
+ }}
222
+ title={dir}
223
+ >
224
+ {dir}
225
+ </button>
226
+ ))}
227
+ </div>
228
+ )}
229
+
230
+ {/* Depth 3 directories (e.g., src/components/ui) */}
231
+ {nestedDirs.depth3.length > 0 && (
232
+ <div
233
+ style={{
234
+ borderTop: '1px solid #4b5563',
235
+ paddingTop: '8px',
236
+ marginTop: '4px',
237
+ }}
238
+ >
239
+ <div
240
+ style={{
241
+ color: '#9ca3af',
242
+ fontFamily: 'monospace',
243
+ fontSize: '10px',
244
+ marginBottom: '4px',
245
+ }}
246
+ >
247
+ Depth 3:
248
+ </div>
249
+ {nestedDirs.depth3.map(dir => (
250
+ <button
251
+ key={dir}
252
+ onClick={() => handleZoomTo(dir)}
253
+ disabled={isAnimating}
254
+ style={{
255
+ padding: '6px 10px',
256
+ backgroundColor: zoomToPath === dir ? '#10b981' : '#1f2937',
257
+ color: 'white',
258
+ border: 'none',
259
+ borderRadius: '4px',
260
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
261
+ fontFamily: 'monospace',
262
+ fontSize: '10px',
263
+ textAlign: 'left',
264
+ marginBottom: '4px',
265
+ width: '100%',
266
+ opacity: isAnimating ? 0.6 : 1,
267
+ overflow: 'hidden',
268
+ textOverflow: 'ellipsis',
269
+ whiteSpace: 'nowrap',
270
+ }}
271
+ title={dir}
272
+ >
273
+ {dir}
274
+ </button>
275
+ ))}
276
+ </div>
277
+ )}
278
+
279
+ {/* Depth 4 directories (e.g., src/components/ui/Button) */}
280
+ {nestedDirs.depth4.length > 0 && (
281
+ <div
282
+ style={{
283
+ borderTop: '1px solid #4b5563',
284
+ paddingTop: '8px',
285
+ marginTop: '4px',
286
+ }}
287
+ >
288
+ <div
289
+ style={{
290
+ color: '#9ca3af',
291
+ fontFamily: 'monospace',
292
+ fontSize: '10px',
293
+ marginBottom: '4px',
294
+ }}
295
+ >
296
+ Depth 4:
297
+ </div>
298
+ {nestedDirs.depth4.map(dir => (
299
+ <button
300
+ key={dir}
301
+ onClick={() => handleZoomTo(dir)}
302
+ disabled={isAnimating}
303
+ style={{
304
+ padding: '6px 10px',
305
+ backgroundColor: zoomToPath === dir ? '#f59e0b' : '#1f2937',
306
+ color: 'white',
307
+ border: 'none',
308
+ borderRadius: '4px',
309
+ cursor: isAnimating ? 'not-allowed' : 'pointer',
310
+ fontFamily: 'monospace',
311
+ fontSize: '10px',
312
+ textAlign: 'left',
313
+ marginBottom: '4px',
314
+ width: '100%',
315
+ opacity: isAnimating ? 0.6 : 1,
316
+ overflow: 'hidden',
317
+ textOverflow: 'ellipsis',
318
+ whiteSpace: 'nowrap',
319
+ }}
320
+ title={dir}
321
+ >
322
+ {dir}
323
+ </button>
324
+ ))}
325
+ </div>
326
+ )}
327
+ </div>
328
+
329
+ {/* Status info */}
330
+ <div
331
+ style={{
332
+ position: 'absolute',
333
+ bottom: 20,
334
+ left: 20,
335
+ zIndex: 100,
336
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
337
+ padding: '12px',
338
+ borderRadius: '8px',
339
+ color: 'white',
340
+ fontFamily: 'monospace',
341
+ fontSize: '11px',
342
+ }}
343
+ >
344
+ <div>Zoomed to: {zoomToPath || '(root)'}</div>
345
+ <div style={{ color: '#9ca3af', marginTop: '4px' }}>
346
+ Click directories in the map or use buttons above
347
+ </div>
348
+ </div>
349
+ </div>
350
+ );
351
+ }
352
+
353
+ const meta = {
354
+ title: 'Performance/Stress Test',
355
+ component: StressTestWrapper,
356
+ parameters: {
357
+ layout: 'fullscreen',
358
+ },
359
+ decorators: [
360
+ Story => (
361
+ <div style={{ width: '100vw', height: '100vh' }}>
362
+ <Story />
363
+ </div>
364
+ ),
365
+ ],
366
+ argTypes: {
367
+ fileCount: {
368
+ control: { type: 'number', min: 100, max: 20000, step: 100 },
369
+ description: 'Number of files to generate for stress testing',
370
+ },
371
+ },
372
+ } satisfies Meta<typeof StressTestWrapper>;
373
+
374
+ export default meta;
375
+ type Story = StoryObj<typeof meta>;
376
+
377
+ /**
378
+ * Test subdirectory zoom with a large number of files.
379
+ * Use the fileCount control to adjust the number of files.
380
+ *
381
+ * Try clicking on directories to zoom in and test performance.
382
+ */
383
+ export const SubdirectoryZoom: Story = {
384
+ args: {
385
+ fileCount: 8000,
386
+ enableZoom: true,
387
+ showGrid: true,
388
+ showFileNames: false,
389
+ fullSize: true,
390
+ },
391
+ };
392
+
393
+ /**
394
+ * Smaller stress test starting point (1000 files)
395
+ */
396
+ export const SmallStressTest: Story = {
397
+ args: {
398
+ fileCount: 1000,
399
+ enableZoom: true,
400
+ showGrid: true,
401
+ showFileNames: false,
402
+ fullSize: true,
403
+ },
404
+ };
405
+
406
+ /**
407
+ * Medium stress test (5000 files)
408
+ */
409
+ export const MediumStressTest: Story = {
410
+ args: {
411
+ fileCount: 5000,
412
+ enableZoom: true,
413
+ showGrid: true,
414
+ showFileNames: false,
415
+ fullSize: true,
416
+ },
417
+ };
418
+
419
+ /**
420
+ * Large stress test (10000 files)
421
+ */
422
+ export const LargeStressTest: Story = {
423
+ args: {
424
+ fileCount: 10000,
425
+ enableZoom: true,
426
+ showGrid: true,
427
+ showFileNames: false,
428
+ fullSize: true,
429
+ },
430
+ };
431
+
432
+ /**
433
+ * Extreme stress test (20000 files) - may be slow!
434
+ */
435
+ export const ExtremeStressTest: Story = {
436
+ args: {
437
+ fileCount: 20000,
438
+ enableZoom: true,
439
+ showGrid: true,
440
+ showFileNames: false,
441
+ fullSize: true,
442
+ },
443
+ };
@@ -0,0 +1,176 @@
1
+ import {
2
+ CityData,
3
+ CodeCityBuilderWithGrid,
4
+ buildFileSystemTreeFromFileInfoList,
5
+ } from '@principal-ai/file-city-builder';
6
+
7
+ interface FileInfo {
8
+ name: string;
9
+ path: string;
10
+ relativePath: string;
11
+ size: number;
12
+ extension: string;
13
+ lastModified: Date;
14
+ isDirectory: boolean;
15
+ }
16
+
17
+ // Common file extensions for realistic distribution
18
+ const FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.md', '.test.ts', '.spec.tsx'];
19
+
20
+ // Top-level source directories
21
+ const TOP_LEVEL_DIRS = ['src', 'lib', 'packages', 'modules'];
22
+
23
+ // Second-level directories (domain areas)
24
+ const DOMAIN_DIRS = ['components', 'utils', 'services', 'hooks', 'types', 'helpers', 'core', 'api', 'features', 'pages'];
25
+
26
+ // Third-level directories (categories within domains)
27
+ const CATEGORY_DIRS = ['ui', 'forms', 'layout', 'navigation', 'data', 'auth', 'common', 'shared', 'internal', 'public'];
28
+
29
+ // Fourth-level directories (specific feature areas)
30
+ const FEATURE_DIRS = ['Button', 'Modal', 'Table', 'Card', 'Input', 'Select', 'Dialog', 'Menu', 'Tabs', 'List', 'Grid', 'Panel'];
31
+
32
+ // Fifth-level directories (variants/subfeatures)
33
+ const VARIANT_DIRS = ['variants', 'styles', 'hooks', 'utils', 'types', 'tests', '__tests__', 'stories', 'docs'];
34
+
35
+ // File name prefixes for realistic naming
36
+ const FILE_PREFIXES = ['index', 'main', 'handler', 'controller', 'service', 'helper', 'util', 'config', 'constants', 'types'];
37
+
38
+ /**
39
+ * Seeded random number generator for consistent results
40
+ */
41
+ function seededRandom(seed: number): () => number {
42
+ return () => {
43
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
44
+ return seed / 0x7fffffff;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Generate a large number of file paths with realistic nested directory structures
50
+ */
51
+ export function generateLargeFilePaths(fileCount: number): string[] {
52
+ const files: string[] = [];
53
+ const random = seededRandom(42); // Consistent seed for reproducible results
54
+
55
+ for (let i = 0; i < fileCount; i++) {
56
+ const extension = FILE_EXTENSIONS[i % FILE_EXTENSIONS.length];
57
+
58
+ // Determine nesting depth (0-5 levels) with weighted distribution
59
+ // More files at medium depths (2-3), fewer at extremes
60
+ const depthRoll = random();
61
+ let depth: number;
62
+ if (depthRoll < 0.1) depth = 1; // 10% at depth 1
63
+ else if (depthRoll < 0.25) depth = 2; // 15% at depth 2
64
+ else if (depthRoll < 0.50) depth = 3; // 25% at depth 3
65
+ else if (depthRoll < 0.75) depth = 4; // 25% at depth 4
66
+ else if (depthRoll < 0.90) depth = 5; // 15% at depth 5
67
+ else depth = 6; // 10% at depth 6
68
+
69
+ const pathParts: string[] = [];
70
+
71
+ // Level 1: Top-level directory
72
+ // Weight 'src' heavily so it contains ~50% of files (for stress testing large subdirectory zoom)
73
+ const topLevelRoll = random();
74
+ if (topLevelRoll < 0.5) {
75
+ pathParts.push('src'); // 50% of files in src
76
+ } else if (topLevelRoll < 0.7) {
77
+ pathParts.push('lib'); // 20% in lib
78
+ } else if (topLevelRoll < 0.85) {
79
+ pathParts.push('packages'); // 15% in packages
80
+ } else {
81
+ pathParts.push('modules'); // 15% in modules
82
+ }
83
+
84
+ // Level 2: Domain directory
85
+ if (depth >= 2) {
86
+ pathParts.push(DOMAIN_DIRS[Math.floor(random() * DOMAIN_DIRS.length)]);
87
+ }
88
+
89
+ // Level 3: Category directory
90
+ if (depth >= 3) {
91
+ pathParts.push(CATEGORY_DIRS[Math.floor(random() * CATEGORY_DIRS.length)]);
92
+ }
93
+
94
+ // Level 4: Feature directory
95
+ if (depth >= 4) {
96
+ pathParts.push(FEATURE_DIRS[Math.floor(random() * FEATURE_DIRS.length)]);
97
+ }
98
+
99
+ // Level 5: Variant directory
100
+ if (depth >= 5) {
101
+ pathParts.push(VARIANT_DIRS[Math.floor(random() * VARIANT_DIRS.length)]);
102
+ }
103
+
104
+ // Level 6: Additional nesting for very deep files
105
+ if (depth >= 6) {
106
+ pathParts.push(`nested${Math.floor(random() * 5)}`);
107
+ }
108
+
109
+ // Generate filename
110
+ const prefix = FILE_PREFIXES[Math.floor(random() * FILE_PREFIXES.length)];
111
+ const suffix = Math.floor(i / 10); // Group files by suffix number
112
+ const fileName = `${prefix}${suffix}${extension}`;
113
+
114
+ pathParts.push(fileName);
115
+ files.push(pathParts.join('/'));
116
+ }
117
+
118
+ return files;
119
+ }
120
+
121
+ /**
122
+ * Convert file paths to FileInfo objects
123
+ */
124
+ function createFileInfoList(paths: string[]): FileInfo[] {
125
+ return paths.map(path => ({
126
+ name: path.split('/').pop() || path,
127
+ path: path,
128
+ relativePath: path,
129
+ size: 500 + Math.floor(Math.random() * 5000), // 500-5500 bytes
130
+ extension: path.includes('.') ? '.' + (path.split('.').pop() || '') : '',
131
+ lastModified: new Date(),
132
+ isDirectory: false,
133
+ }));
134
+ }
135
+
136
+ // Cache for stress test data to avoid regenerating
137
+ const stressTestCache = new Map<number, CityData>();
138
+
139
+ /**
140
+ * Create CityData with a large number of files for stress testing subdirectory zoom
141
+ *
142
+ * @param fileCount - Number of files to generate (default: 8000)
143
+ * @param useCache - Whether to cache results (default: true)
144
+ */
145
+ export function createStressTestCityData(fileCount: number = 8000, useCache: boolean = true): CityData {
146
+ if (useCache && stressTestCache.has(fileCount)) {
147
+ return stressTestCache.get(fileCount)!;
148
+ }
149
+
150
+ const filePaths = generateLargeFilePaths(fileCount);
151
+ const fileInfos = createFileInfoList(filePaths);
152
+ const fileTree = buildFileSystemTreeFromFileInfoList(fileInfos as any, `stress-test-${fileCount}`);
153
+
154
+ const builder = new CodeCityBuilderWithGrid();
155
+ const cityData = builder.buildCityFromFileSystem(fileTree, '', {
156
+ paddingTop: 2,
157
+ paddingBottom: 2,
158
+ paddingLeft: 2,
159
+ paddingRight: 2,
160
+ paddingInner: 1,
161
+ paddingOuter: 3,
162
+ });
163
+
164
+ if (useCache) {
165
+ stressTestCache.set(fileCount, cityData);
166
+ }
167
+
168
+ return cityData;
169
+ }
170
+
171
+ /**
172
+ * Clear the stress test cache (useful for testing memory)
173
+ */
174
+ export function clearStressTestCache(): void {
175
+ stressTestCache.clear();
176
+ }