@smartnet360/svelte-components 0.0.98 → 0.0.100

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.
@@ -65,12 +65,16 @@
65
65
  // Single root select mode - only one Level 0 node at a time (radio behavior)
66
66
  let singleRootSelect = $state(false);
67
67
 
68
+ // Single Level 1 select mode - only one Level 1 node per parent at a time (radio behavior)
69
+ let singleLevel1Select = $state(false);
70
+
68
71
  // Available field options for grouping levels
69
72
  const fieldOptions: { value: TreeGroupField; label: string }[] = [
70
73
  { value: 'site', label: 'Site' },
71
74
  { value: 'band', label: 'Band' },
72
75
  { value: 'azimuth', label: 'Azimuth' },
73
- { value: 'sector', label: 'Sector' }
76
+ { value: 'sector', label: 'Sector' },
77
+ { value: 'cellName', label: 'Cell Name' }
74
78
  ];
75
79
 
76
80
  // Handlers for level changes
@@ -96,10 +100,10 @@
96
100
  return fieldOptions.filter(opt => opt.value !== treeGrouping.level0);
97
101
  }); let treeStore = $state<ReturnType<typeof createTreeStore> | null>(null);
98
102
 
99
- // Rebuild tree whenever treeGrouping or singleRootSelect changes
103
+ // Rebuild tree whenever treeGrouping, singleRootSelect, or singleLevel1Select changes
100
104
  $effect(() => {
101
105
 
102
- log('🔄 Rebuilding tree with grouping', { treeGrouping, singleRootSelect });
106
+ log('🔄 Rebuilding tree with grouping', { treeGrouping, singleRootSelect, singleLevel1Select });
103
107
 
104
108
  // Clear any existing localStorage data to prevent stale state
105
109
  const storageKey = 'site-check:treeState';
@@ -124,12 +128,14 @@
124
128
  namespace: 'site-check',
125
129
  persistState: false, // Don't persist when grouping changes dynamically
126
130
  defaultExpandAll: false,
127
- singleRootSelect // Pass single root select mode
131
+ singleRootSelect, // Pass single root select mode
132
+ singleLevel1Select // Pass single Level 1 select mode
128
133
  });
129
134
  log('✅ Tree Store Created', {
130
135
  namespace: 'site-check',
131
136
  grouping: treeGrouping,
132
- singleRootSelect
137
+ singleRootSelect,
138
+ singleLevel1Select
133
139
  });
134
140
  });
135
141
 
@@ -361,6 +367,8 @@
361
367
  >
362
368
  <option value="band">Band</option>
363
369
  <option value="site">Site</option>
370
+ <option value="sector">Sector</option>
371
+ <option value="cellName">Cell Name</option>
364
372
  </select>
365
373
  </div>
366
374
  </div>
@@ -398,6 +406,23 @@
398
406
  Single selection on level 0
399
407
  </label>
400
408
  </div>
409
+
410
+ <!-- Single Level 1 Select Toggle -->
411
+ <div class="form-check mt-2">
412
+ <input
413
+ class="form-check-input"
414
+ type="checkbox"
415
+ id="singleLevel1SelectCheck"
416
+ checked={singleLevel1Select}
417
+ onchange={(e) => {
418
+ singleLevel1Select = e.currentTarget.checked;
419
+ log('🔘 Single Level 1 select mode:', singleLevel1Select);
420
+ }}
421
+ />
422
+ <label class="form-check-label small" for="singleLevel1SelectCheck">
423
+ Single selection on level 1
424
+ </label>
425
+ </div>
401
426
  </div>
402
427
  {/if}
403
428
  {/if} <!-- Tree View -->
@@ -409,7 +434,7 @@
409
434
  </div>
410
435
 
411
436
  <!-- Right: Charts -->
412
- <div class="col-lg-9 col-md-8 bg-light overflow-auto">
437
+ <div class="col-lg-9 col-md-8 bg-light d-flex flex-column" style="min-height: 0; height: 100%; overflow: hidden;">
413
438
  {#if chartData.length > 0}
414
439
  <ChartComponent
415
440
  layout={chartLayout}
@@ -14,11 +14,11 @@ export interface CellTrafficRecord {
14
14
  /**
15
15
  * Tree grouping field types
16
16
  */
17
- export type TreeGroupField = 'site' | 'azimuth' | 'band' | 'sector';
17
+ export type TreeGroupField = 'site' | 'azimuth' | 'band' | 'sector' | 'cellName';
18
18
  /**
19
19
  * Color dimension types - determines which field is used for chart coloring
20
20
  */
21
- export type ColorDimension = 'site' | 'band';
21
+ export type ColorDimension = 'site' | 'azimuth' | 'band' | 'sector' | 'cellName';
22
22
  /**
23
23
  * Configuration for tree hierarchy grouping
24
24
  * Defines which fields appear at each level of the tree
@@ -76,6 +76,8 @@ export interface TreeConfig<T = any> {
76
76
  showIndeterminate?: boolean;
77
77
  /** Single root selection mode - only one root node can be checked at a time (radio behavior) */
78
78
  singleRootSelect?: boolean;
79
+ /** Single Level 1 selection mode - only one Level 1 node per parent can be checked at a time (radio behavior) */
80
+ singleLevel1Select?: boolean;
79
81
  }
80
82
  /**
81
83
  * Store value exposed to consumers
@@ -102,6 +102,24 @@ export function createTreeStore(config) {
102
102
  }
103
103
  });
104
104
  }
105
+ // STEP 0.5: If singleLevel1Select mode and this is a Level 1 node being checked, uncheck sibling Level 1 nodes
106
+ if (config.singleLevel1Select && newChecked && nodeState.level === 1) {
107
+ log('🔘 Single Level 1 select mode: unchecking sibling Level 1 nodes', { path });
108
+ const parentPath = nodeState.parentPath;
109
+ // Find and uncheck all Level 1 siblings (same parent, same level, different path)
110
+ state.nodes.forEach((node, nodePath) => {
111
+ if (node.level === 1 &&
112
+ node.parentPath === parentPath &&
113
+ nodePath !== path) {
114
+ newCheckedPaths.delete(nodePath);
115
+ // Also uncheck all descendants of this sibling
116
+ const siblingDescendants = getDescendantPaths(nodePath, state.nodes, separator);
117
+ siblingDescendants.forEach(descendantPath => {
118
+ newCheckedPaths.delete(descendantPath);
119
+ });
120
+ }
121
+ });
122
+ }
105
123
  // STEP 1: Update this node
106
124
  if (newChecked) {
107
125
  newCheckedPaths.add(path);
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { Cell } from '../features/cells/types';
10
10
  /**
11
- * Generate demo cells: 100 sites × 3 sectors × 12 tech-bands = 3,600 cells
11
+ * Generate demo cells with varied density patterns in circular distribution
12
+ * Creates density zones radiating from center with random placement
12
13
  */
13
14
  export declare const demoCells: Cell[];
@@ -6,14 +6,68 @@
6
6
  * Each sector has 12 cells (all tech-band combinations)
7
7
  * Total: 100 sites × 3 sectors × 12 tech-bands = 3,600 cells
8
8
  */
9
- // Base location: San Francisco
10
- const BASE_LAT = 37.7749;
11
- const BASE_LNG = -122.4194;
12
- // Grid parameters for distributing sites
13
- const NUM_SITES = 1700;
14
- const GRID_SIZE = 10; // 10×10 grid
15
- const LAT_SPACING = 0.01; // ~1.1 km spacing
16
- const LNG_SPACING = 0.015; // ~1.1 km spacing (adjusted for longitude)
9
+ const BASE_LAT = 47.4979;
10
+ const BASE_LNG = 19.0402;
11
+ // Generate sites in a circular pattern with varying density
12
+ const NUM_SITES = 2000;
13
+ const RADIUS_KM = 15; // 15km radius circle
14
+ const RADIUS_DEGREES = RADIUS_KM / 111; // Approximate conversion
15
+ // Density zones (distance from center)
16
+ const DENSITY_ZONES = [
17
+ { maxRadius: 0.3, minSpacing: 0.0008, maxSpacing: 0.0015, name: 'Very Dense Core' }, // 0-3km: 80-150m spacing
18
+ { maxRadius: 0.5, minSpacing: 0.0015, maxSpacing: 0.003, name: 'Dense Inner' }, // 3-5km: 150-300m spacing
19
+ { maxRadius: 0.7, minSpacing: 0.003, maxSpacing: 0.006, name: 'Medium' }, // 5-7km: 300-600m spacing
20
+ { maxRadius: 0.85, minSpacing: 0.006, maxSpacing: 0.012, name: 'Sparse Suburban' }, // 7-12km: 600m-1.2km spacing
21
+ { maxRadius: 1.0, minSpacing: 0.012, maxSpacing: 0.025, name: 'Very Sparse Rural' } // 12-15km: 1.2-2.5km spacing
22
+ ];
23
+ /**
24
+ * Get density zone for a given normalized radius
25
+ */
26
+ function getDensityZone(normalizedRadius) {
27
+ for (const zone of DENSITY_ZONES) {
28
+ if (normalizedRadius <= zone.maxRadius) {
29
+ return zone;
30
+ }
31
+ }
32
+ return DENSITY_ZONES[DENSITY_ZONES.length - 1];
33
+ }
34
+ /**
35
+ * Generate random point within circle using polar coordinates
36
+ */
37
+ function generateRandomPointInCircle() {
38
+ // Use square root for uniform distribution in circle
39
+ const r = Math.sqrt(Math.random()) * RADIUS_DEGREES;
40
+ const theta = Math.random() * 2 * Math.PI;
41
+ const lat = BASE_LAT + r * Math.cos(theta);
42
+ const lng = BASE_LNG + r * Math.sin(theta);
43
+ const normalizedRadius = r / RADIUS_DEGREES;
44
+ return { lat, lng, normalizedRadius };
45
+ }
46
+ // Cluster configuration for varied density
47
+ // (kept for backward compatibility but not used with circular generation)
48
+ const CLUSTERS = [
49
+ // Dense urban cluster (top-left) - very tight spacing
50
+ { startRow: 0, endRow: 3, startCol: 0, endCol: 3, spacing: 0.3 },
51
+ // Medium density cluster (center) - normal spacing
52
+ { startRow: 3, endRow: 7, startCol: 3, endCol: 7, spacing: 1.0 },
53
+ // Sparse rural cluster (bottom-right) - wide spacing
54
+ { startRow: 7, endRow: 10, startCol: 7, endCol: 10, spacing: 2.5 },
55
+ // Random outliers scattered around
56
+ { startRow: 0, endRow: 10, startCol: 0, endCol: 10, spacing: 1.5 }
57
+ ];
58
+ /**
59
+ * Add random jitter to coordinates for natural variation
60
+ */
61
+ function addJitter(value, maxJitter) {
62
+ return value + (Math.random() - 0.5) * 2 * maxJitter;
63
+ }
64
+ /**
65
+ * Determine if site should be skipped (for creating gaps)
66
+ */
67
+ function shouldSkipSite(row, col) {
68
+ // Skip some sites randomly to create density variation (20% skip rate)
69
+ return Math.random() < 0.2;
70
+ }
17
71
  // Standard beamwidth for sectors
18
72
  const BEAMWIDTH = 65;
19
73
  // Cell tech-band definitions with proper fband format
@@ -51,20 +105,51 @@ const STATUSES = [
51
105
  'On_Air'
52
106
  ];
53
107
  /**
54
- * Generate demo cells: 100 sites × 3 sectors × 12 tech-bands = 3,600 cells
108
+ * Generate demo cells with varied density patterns in circular distribution
109
+ * Creates density zones radiating from center with random placement
55
110
  */
56
111
  export const demoCells = [];
57
112
  let cellCounter = 1;
58
- // Generate sites in a grid pattern
59
- for (let siteIndex = 0; siteIndex < NUM_SITES; siteIndex++) {
60
- const row = Math.floor(siteIndex / GRID_SIZE);
61
- const col = siteIndex % GRID_SIZE;
62
- // Calculate site position (centered grid around base location)
63
- const siteLat = BASE_LAT + (row - GRID_SIZE / 2) * LAT_SPACING;
64
- const siteLng = BASE_LNG + (col - GRID_SIZE / 2) * LNG_SPACING;
65
- const siteId = `DEMO-SITE-${String(siteIndex + 1).padStart(3, '0')}`;
66
- // Generate 3 sectors per site
67
- AZIMUTHS.forEach((azimuth, sectorIndex) => {
113
+ let actualSiteIndex = 0;
114
+ // Track used positions to maintain minimum spacing
115
+ const usedPositions = [];
116
+ /**
117
+ * Check if position is too close to existing sites
118
+ */
119
+ function isTooClose(lat, lng, minSpacing) {
120
+ for (const pos of usedPositions) {
121
+ const distance = Math.sqrt(Math.pow(lat - pos.lat, 2) + Math.pow(lng - pos.lng, 2));
122
+ const requiredSpacing = (minSpacing + pos.minSpacing) / 2;
123
+ if (distance < requiredSpacing) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+ // Generate sites in a circular pattern with density-based placement
130
+ for (let attempt = 0; attempt < NUM_SITES * 3 && actualSiteIndex < NUM_SITES; attempt++) {
131
+ // Generate random point in circle
132
+ const { lat, lng, normalizedRadius } = generateRandomPointInCircle();
133
+ // Get density zone for this radius
134
+ const zone = getDensityZone(normalizedRadius);
135
+ // Random spacing within zone range
136
+ const minSpacing = zone.minSpacing + Math.random() * (zone.maxSpacing - zone.minSpacing);
137
+ // Check if too close to existing sites
138
+ if (isTooClose(lat, lng, minSpacing)) {
139
+ continue; // Try another position
140
+ }
141
+ // Add random jitter for natural variation
142
+ const jitterAmount = minSpacing * 0.3; // 30% of spacing
143
+ const siteLat = addJitter(lat, jitterAmount);
144
+ const siteLng = addJitter(lng, jitterAmount);
145
+ // Record position
146
+ usedPositions.push({ lat: siteLat, lng: siteLng, minSpacing });
147
+ const siteId = `DEMO-SITE-${String(actualSiteIndex + 1).padStart(4, '0')}`;
148
+ actualSiteIndex++;
149
+ // Generate 3 sectors per site (with some random 1 or 2 sector sites)
150
+ const numSectors = Math.random() < 0.1 ? (Math.random() < 0.5 ? 1 : 2) : 3; // 10% chance of 1-2 sectors
151
+ const sectorsToGenerate = AZIMUTHS.slice(0, numSectors);
152
+ sectorsToGenerate.forEach((azimuth, sectorIndex) => {
68
153
  // Generate 12 tech-bands per sector
69
154
  TECH_BANDS.forEach((techBand, techIndex) => {
70
155
  const cellId = `CELL-${String(cellCounter).padStart(4, '0')}`;
@@ -118,10 +203,11 @@ for (let siteIndex = 0; siteIndex < NUM_SITES; siteIndex++) {
118
203
  // Other
119
204
  other: {
120
205
  demoCell: true,
121
- siteNumber: siteIndex + 1,
206
+ siteNumber: actualSiteIndex,
122
207
  sector: sectorIndex + 1,
123
208
  techBandKey: `${techBand.tech}_${techBand.band}`,
124
- gridPosition: { row, col }
209
+ radius: normalizedRadius,
210
+ densityZone: zone.name
125
211
  },
126
212
  customSubgroup: `Sector-${sectorIndex + 1}`
127
213
  });
@@ -48,8 +48,8 @@
48
48
  onAction,
49
49
  actionButtonLabel = 'Process Cluster',
50
50
  featureIcon = 'geo-alt-fill',
51
- idPropertyOptions = ['siteId','sectorId', 'cellName','id'],
52
- defaultIdProperty = 'siteId'
51
+ idPropertyOptions = ['none','siteId','sectorId', 'cellName','id'],
52
+ defaultIdProperty = 'none'
53
53
  }: Props = $props();
54
54
 
55
55
  // Get map from context
@@ -114,33 +114,73 @@
114
114
  const map = get(mapStore);
115
115
  if (!map) return;
116
116
 
117
- // Remove markers that are no longer in the selection
118
- const currentIds = new Set(features.map(f => f.id));
119
- for (const [id, marker] of markers.entries()) {
120
- if (!currentIds.has(id)) {
117
+ // Group features by coordinates
118
+ const featuresByLocation = new Map<string, SelectedFeature[]>();
119
+
120
+ for (const feature of features) {
121
+ const lat = feature.properties?.latitude || feature.properties?.lat;
122
+ const lon = feature.properties?.longitude || feature.properties?.lon || feature.properties?.lng;
123
+
124
+ if (lat && lon) {
125
+ const key = `${lon.toFixed(6)},${lat.toFixed(6)}`; // Round to avoid floating point issues
126
+ if (!featuresByLocation.has(key)) {
127
+ featuresByLocation.set(key, []);
128
+ }
129
+ featuresByLocation.get(key)!.push(feature);
130
+ } else {
131
+ console.warn('[FeatureSelectionControl] No coordinates found for feature', feature.id);
132
+ }
133
+ }
134
+
135
+ // Track which location keys are currently active
136
+ const activeLocationKeys = new Set(featuresByLocation.keys());
137
+
138
+ // Remove markers that are no longer needed
139
+ for (const [key, marker] of markers.entries()) {
140
+ if (!activeLocationKeys.has(key)) {
121
141
  marker.remove();
122
- markers.delete(id);
123
- console.log('[FeatureSelectionControl] Removed marker for', id);
142
+ markers.delete(key);
143
+ console.log('[FeatureSelectionControl] Removed marker at', key);
124
144
  }
125
145
  }
126
146
 
127
- // Add markers for new selections
128
- for (const feature of features) {
129
- if (!markers.has(feature.id)) {
130
- // Try to extract coordinates from properties
131
- const lat = feature.properties?.latitude || feature.properties?.lat;
132
- const lon = feature.properties?.longitude || feature.properties?.lon || feature.properties?.lng;
133
-
134
- if (lat && lon) {
135
- const marker = new mapboxgl.Marker({ color: '#FF6B35' })
136
- .setLngLat([lon, lat])
137
- .addTo(map);
138
-
139
- markers.set(feature.id, marker);
140
- console.log('[FeatureSelectionControl] Added marker for', feature.id, 'at', [lon, lat]);
141
- } else {
142
- console.warn('[FeatureSelectionControl] No coordinates found for feature', feature.id);
147
+ // Create or update markers for each unique location
148
+ for (const [locationKey, featuresAtLocation] of featuresByLocation) {
149
+ const [lon, lat] = locationKey.split(',').map(Number);
150
+
151
+ // Build multi-line label HTML
152
+ const labelHTML = featuresAtLocation
153
+ .map(f => `<div style="padding: 2px 0;">${f.id}</div>`)
154
+ .join('');
155
+
156
+ // Check if marker already exists at this location
157
+ if (markers.has(locationKey)) {
158
+ // Update existing marker's popup
159
+ const marker = markers.get(locationKey)!;
160
+ const popup = marker.getPopup();
161
+ if (popup) {
162
+ popup.setHTML(`<div class="marker-label" style="font-size: 12px; line-height: 1.4;">${labelHTML}</div>`);
143
163
  }
164
+ } else {
165
+ // Create new marker with popup
166
+ const popup = new mapboxgl.Popup({
167
+ closeButton: false,
168
+ closeOnClick: false,
169
+ offset: 45,
170
+ className: 'selection-marker-popup',
171
+ anchor: 'bottom'
172
+ }).setHTML(`<div class="marker-label" style="font-size: 12px; line-height: 1.4;">${labelHTML}</div>`);
173
+
174
+ const marker = new mapboxgl.Marker({ color: '#FF6B35' })
175
+ .setLngLat([lon, lat])
176
+ .setPopup(popup)
177
+ .addTo(map);
178
+
179
+ // Show popup immediately
180
+ marker.togglePopup();
181
+
182
+ markers.set(locationKey, marker);
183
+ console.log('[FeatureSelectionControl] Added marker at', locationKey, 'with', featuresAtLocation.length, 'items');
144
184
  }
145
185
  }
146
186
  }
@@ -416,4 +456,17 @@
416
456
  color: var(--bs-btn-disabled-color, var(--bs-btn-color, var(--bs-body-color)));
417
457
  opacity: var(--bs-btn-disabled-opacity, 0.65);
418
458
  }
459
+
460
+ /* Style for marker popup labels */
461
+ :global(.selection-marker-popup .mapboxgl-popup-content) {
462
+ padding: 8px 12px;
463
+ background: rgba(255, 255, 255, 0.95);
464
+ border-radius: 4px;
465
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
466
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
467
+ }
468
+
469
+ :global(.selection-marker-popup .mapboxgl-popup-tip) {
470
+ border-top-color: rgba(255, 255, 255, 0.95);
471
+ }
419
472
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartnet360/svelte-components",
3
- "version": "0.0.98",
3
+ "version": "0.0.100",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",