@smartnet360/svelte-components 0.0.97 → 0.0.99

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.
@@ -17,18 +17,19 @@
17
17
  baseMetrics: string[];
18
18
  mode: Mode;
19
19
  markers?: ChartMarker[];
20
- cellStyling?: CellStylingConfig; // Optional cell styling config (defaults to defaultCellStyling)
21
- initialGrouping?: TreeGroupingConfig; // Optional initial tree grouping (defaults to Site → Azimuth → Cell)
22
- showGroupingSelector?: boolean; // Show/hide the grouping dropdown (default: true)
23
- onSearch?: (searchTerm: string) => void; // Optional: Search callback (if provided, shows search box)
24
- searchPlaceholder?: string; // Optional: Search box placeholder text (default: "Search...")
25
- plotlyLayout?: Record<string, any>; // Optional Plotly layout configuration
20
+ cellStyling?: CellStylingConfig; // Optional cell styling config (defaults to defaultCellStyling)
21
+ initialGrouping?: TreeGroupingConfig; // Optional initial tree grouping (defaults to Site → Azimuth → Cell)
22
+ showGroupingSelector?: boolean; // Show/hide the grouping dropdown (default: true)
23
+ useSectorLineStyles?: boolean; // Enable sector-based line style differentiation (default: false)
24
+ onSearch?: (searchTerm: string) => void; // Optional: Search callback (if provided, shows search box)
25
+ searchPlaceholder?: string; // Optional: Search box placeholder text (default: "Search...")
26
+ plotlyLayout?: Record<string, any>; // Optional Plotly layout configuration
26
27
  }
27
28
 
28
29
  let { rawData, multiCellLayout, singleLteLayout,
29
30
  singleNrLayout, baseMetrics, mode = "scrollspy", markers = [],
30
31
  cellStyling = defaultCellStyling, initialGrouping = defaultTreeGrouping,
31
- showGroupingSelector = true, onSearch, searchPlaceholder = "Search...", plotlyLayout }: Props = $props();
32
+ showGroupingSelector = true, useSectorLineStyles = false, onSearch, searchPlaceholder = "Search...", plotlyLayout }: Props = $props();
32
33
 
33
34
  // Search state
34
35
  let searchTerm = $state('');
@@ -61,12 +62,19 @@
61
62
  // Color dimension state (defaults to 'band' for semantic RF characteristics)
62
63
  let colorDimension = $state<ColorDimension>('band');
63
64
 
65
+ // Single root select mode - only one Level 0 node at a time (radio behavior)
66
+ let singleRootSelect = $state(false);
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
+
64
71
  // Available field options for grouping levels
65
72
  const fieldOptions: { value: TreeGroupField; label: string }[] = [
66
73
  { value: 'site', label: 'Site' },
67
74
  { value: 'band', label: 'Band' },
68
75
  { value: 'azimuth', label: 'Azimuth' },
69
- { value: 'sector', label: 'Sector' }
76
+ { value: 'sector', label: 'Sector' },
77
+ { value: 'cellName', label: 'Cell Name' }
70
78
  ];
71
79
 
72
80
  // Handlers for level changes
@@ -92,10 +100,10 @@
92
100
  return fieldOptions.filter(opt => opt.value !== treeGrouping.level0);
93
101
  }); let treeStore = $state<ReturnType<typeof createTreeStore> | null>(null);
94
102
 
95
- // Rebuild tree whenever treeGrouping changes
103
+ // Rebuild tree whenever treeGrouping, singleRootSelect, or singleLevel1Select changes
96
104
  $effect(() => {
97
105
 
98
- log(' Rebuilding tree with grouping', { treeGrouping });
106
+ log('🔄 Rebuilding tree with grouping', { treeGrouping, singleRootSelect, singleLevel1Select });
99
107
 
100
108
  // Clear any existing localStorage data to prevent stale state
101
109
  const storageKey = 'site-check:treeState';
@@ -119,11 +127,15 @@
119
127
  nodes: treeNodes,
120
128
  namespace: 'site-check',
121
129
  persistState: false, // Don't persist when grouping changes dynamically
122
- defaultExpandAll: false
130
+ defaultExpandAll: false,
131
+ singleRootSelect, // Pass single root select mode
132
+ singleLevel1Select // Pass single Level 1 select mode
123
133
  });
124
134
  log('✅ Tree Store Created', {
125
135
  namespace: 'site-check',
126
- grouping: treeGrouping
136
+ grouping: treeGrouping,
137
+ singleRootSelect,
138
+ singleLevel1Select
127
139
  });
128
140
  });
129
141
 
@@ -157,9 +169,9 @@
157
169
 
158
170
  // Expand layout based on selected cells and chosen base layout
159
171
  let chartLayout = $derived.by(() => {
160
- // Pass cellStyling, treeGrouping, and colorDimension - helper will decide per-section whether to use styling,
161
- // and generate appropriate labels based on grouping and colors based on colorDimension
162
- const expanded = expandLayoutForCells(selectedBaseLayout, filteredData, treeGrouping, colorDimension, cellStyling);
172
+ // Pass cellStyling, treeGrouping, colorDimension, and useSectorLineStyles - helper will decide per-section whether to use styling,
173
+ // and generate appropriate labels based on grouping, colors based on colorDimension, and line styles based on useSectorLineStyles
174
+ const expanded = expandLayoutForCells(selectedBaseLayout, filteredData, treeGrouping, colorDimension, useSectorLineStyles, cellStyling);
163
175
  log('📐 Chart Layout:', {
164
176
  layoutName: selectedBaseLayout.layoutName,
165
177
  layoutDefaultColors: selectedBaseLayout.useDefaultChartColors ?? false,
@@ -355,10 +367,62 @@
355
367
  >
356
368
  <option value="band">Band</option>
357
369
  <option value="site">Site</option>
370
+ <option value="sector">Sector</option>
371
+ <option value="cellName">Cell Name</option>
358
372
  </select>
359
373
  </div>
360
374
  </div>
361
375
 
376
+ <!-- Single Root Select Toggle -->
377
+ <div class="form-check mt-2">
378
+ <input
379
+ class="form-check-input"
380
+ type="checkbox"
381
+ id="singleRootSelectCheck"
382
+ checked={singleRootSelect}
383
+ onchange={(e) => {
384
+ singleRootSelect = e.currentTarget.checked;
385
+ log('🔘 Single root select mode:', singleRootSelect);
386
+
387
+ // When enabling single root mode, uncheck all roots except the first one
388
+ if (singleRootSelect && treeStore) {
389
+ const store = $treeStore;
390
+ if (store) {
391
+ const checkedRoots = store.state.rootPaths.filter(path =>
392
+ store.state.checkedPaths.has(path)
393
+ );
394
+ if (checkedRoots.length > 1) {
395
+ log('🔘 Multiple roots selected, keeping only first one:', checkedRoots[0]);
396
+ // Uncheck all except the first
397
+ for (let i = 1; i < checkedRoots.length; i++) {
398
+ store.toggle(checkedRoots[i]);
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }}
404
+ />
405
+ <label class="form-check-label small" for="singleRootSelectCheck">
406
+ Single selection on level 0
407
+ </label>
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>
362
426
  </div>
363
427
  {/if}
364
428
  {/if} <!-- Tree View -->
@@ -12,6 +12,7 @@ interface Props {
12
12
  cellStyling?: CellStylingConfig;
13
13
  initialGrouping?: TreeGroupingConfig;
14
14
  showGroupingSelector?: boolean;
15
+ useSectorLineStyles?: boolean;
15
16
  onSearch?: (searchTerm: string) => void;
16
17
  searchPlaceholder?: string;
17
18
  plotlyLayout?: Record<string, any>;
@@ -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
@@ -13,7 +13,7 @@ import { type StackGroupMode } from './transforms.js';
13
13
  * @param stackGroupMode - Optional stackgroup strategy for stacked charts (default: 'none' = single stack)
14
14
  * @returns Expanded layout with cell-specific KPIs
15
15
  */
16
- export declare function expandLayoutForCells(baseLayout: Layout, data: CellTrafficRecord[], grouping: TreeGroupingConfig, colorDimension: ColorDimension, stylingConfig?: CellStylingConfig, stackGroupMode?: StackGroupMode): Layout;
16
+ export declare function expandLayoutForCells(baseLayout: Layout, data: CellTrafficRecord[], grouping: TreeGroupingConfig, colorDimension: ColorDimension, useSectorLineStyles: boolean, stylingConfig?: CellStylingConfig, stackGroupMode?: StackGroupMode): Layout;
17
17
  /**
18
18
  * Extract base metric names from a layout configuration
19
19
  * Returns unique metric rawNames that need to be pivoted
@@ -11,7 +11,7 @@ import { createStyledKPI, sortCellsByBandFrequency, assignStackGroups } from './
11
11
  * @param stackGroupMode - Optional stackgroup strategy for stacked charts (default: 'none' = single stack)
12
12
  * @returns Expanded layout with cell-specific KPIs
13
13
  */
14
- export function expandLayoutForCells(baseLayout, data, grouping, colorDimension, stylingConfig, stackGroupMode = 'none') {
14
+ export function expandLayoutForCells(baseLayout, data, grouping, colorDimension, useSectorLineStyles, stylingConfig, stackGroupMode = 'none') {
15
15
  // Get unique cells and their metadata, sorted by band frequency
16
16
  const cellMap = new Map();
17
17
  data.forEach((record) => {
@@ -39,8 +39,8 @@ export function expandLayoutForCells(baseLayout, data, grouping, colorDimension,
39
39
  ...section,
40
40
  charts: section.charts.map((chart) => ({
41
41
  ...chart,
42
- yLeft: expandKPIs(chart.yLeft, cells, grouping, colorDimension, effectiveStyling, stackGroupMode),
43
- yRight: expandKPIs(chart.yRight, cells, grouping, colorDimension, effectiveStyling, stackGroupMode)
42
+ yLeft: expandKPIs(chart.yLeft, cells, grouping, colorDimension, useSectorLineStyles, effectiveStyling, stackGroupMode),
43
+ yRight: expandKPIs(chart.yRight, cells, grouping, colorDimension, useSectorLineStyles, effectiveStyling, stackGroupMode)
44
44
  }))
45
45
  };
46
46
  })
@@ -60,13 +60,13 @@ export function expandLayoutForCells(baseLayout, data, grouping, colorDimension,
60
60
  * @param stackGroupMode - Stackgroup strategy for this set of KPIs
61
61
  * @returns Expanded array of KPIs (styled or default, with stackgroups assigned)
62
62
  */
63
- function expandKPIs(baseKPIs, cells, grouping, colorDimension, stylingConfig, stackGroupMode = 'none') {
63
+ function expandKPIs(baseKPIs, cells, grouping, colorDimension, useSectorLineStyles, stylingConfig, stackGroupMode = 'none') {
64
64
  let expandedKPIs = [];
65
65
  baseKPIs.forEach((baseKPI) => {
66
66
  cells.forEach(([cellName, record]) => {
67
67
  if (stylingConfig) {
68
68
  // Apply custom styling (band colors, sector line styles)
69
- const styledKPI = createStyledKPI(baseKPI.rawName, record, baseKPI.unit, grouping, colorDimension, stylingConfig);
69
+ const styledKPI = createStyledKPI(baseKPI.rawName, record, baseKPI.unit, grouping, colorDimension, useSectorLineStyles, stylingConfig);
70
70
  expandedKPIs.push({
71
71
  ...styledKPI,
72
72
  stackGroup: undefined // Initialize for treeshake-safe property assignment
@@ -84,4 +84,4 @@ export declare function transformChartData(data: CellTrafficRecord[], baseMetric
84
84
  * @param stylingConfig - Optional styling configuration
85
85
  * @returns KPI with cell-specific styling applied
86
86
  */
87
- export declare function createStyledKPI(metricName: string, cellRecord: CellTrafficRecord, unit: string, grouping: TreeGroupingConfig, colorDimension: ColorDimension, stylingConfig?: CellStylingConfig): KPI;
87
+ export declare function createStyledKPI(metricName: string, cellRecord: CellTrafficRecord, unit: string, grouping: TreeGroupingConfig, colorDimension: ColorDimension, useSectorLineStyles: boolean, stylingConfig?: CellStylingConfig): KPI;
@@ -483,7 +483,7 @@ function generateSiteColor(siteName) {
483
483
  * @param stylingConfig - Optional styling configuration
484
484
  * @returns KPI with cell-specific styling applied
485
485
  */
486
- export function createStyledKPI(metricName, cellRecord, unit, grouping, colorDimension, stylingConfig) {
486
+ export function createStyledKPI(metricName, cellRecord, unit, grouping, colorDimension, useSectorLineStyles, stylingConfig) {
487
487
  const { band, sector, azimuth, cellName, siteName } = cellRecord;
488
488
  // Determine color based on colorDimension
489
489
  let color;
@@ -495,8 +495,8 @@ export function createStyledKPI(metricName, cellRecord, unit, grouping, colorDim
495
495
  // Generate consistent color for site
496
496
  color = generateSiteColor(siteName);
497
497
  }
498
- // Get line style from sector (if config provided)
499
- const lineStyle = stylingConfig?.sectorLineStyles?.[sector.toString()];
498
+ // Get line style from sector only if explicitly enabled
499
+ const lineStyle = useSectorLineStyles ? stylingConfig?.sectorLineStyles?.[sector.toString()] : undefined;
500
500
  // Generate label based on tree grouping configuration
501
501
  const displayName = generateAdaptiveLabel(cellRecord, grouping);
502
502
  // Build KPI with cell-specific styling
@@ -74,6 +74,10 @@ export interface TreeConfig<T = any> {
74
74
  persistState?: boolean;
75
75
  /** Show indeterminate checkbox states */
76
76
  showIndeterminate?: boolean;
77
+ /** Single root selection mode - only one root node can be checked at a time (radio behavior) */
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;
77
81
  }
78
82
  /**
79
83
  * Store value exposed to consumers
@@ -87,6 +87,39 @@ export function createTreeStore(config) {
87
87
  const newChecked = !state.checkedPaths.has(path);
88
88
  const newCheckedPaths = new Set(state.checkedPaths);
89
89
  log('📌 Toggle action', { path, newChecked });
90
+ // STEP 0: If singleRootSelect mode and this is a root node being checked, uncheck all other roots
91
+ if (config.singleRootSelect && newChecked && nodeState.level === 0) {
92
+ log('🔘 Single root select mode: unchecking other roots', { path });
93
+ // Uncheck all root nodes and their descendants
94
+ state.rootPaths.forEach(rootPath => {
95
+ if (rootPath !== path) {
96
+ newCheckedPaths.delete(rootPath);
97
+ // Also uncheck all descendants of this root
98
+ const rootDescendants = getDescendantPaths(rootPath, state.nodes, separator);
99
+ rootDescendants.forEach(descendantPath => {
100
+ newCheckedPaths.delete(descendantPath);
101
+ });
102
+ }
103
+ });
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
+ }
90
123
  // STEP 1: Update this node
91
124
  if (newChecked) {
92
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>
@@ -84,47 +84,63 @@
84
84
 
85
85
  <!-- Auto Size -->
86
86
  <div class="row align-items-center g-2 mb-3">
87
- <div class="col-4 text-secondary fw-semibold small text-uppercase">Auto Size</div>
88
- <div class="col-3"></div>
89
- <div class="col-5">
90
- <div class="form-check form-switch m-0 d-flex align-items-center justify-content-end">
91
- <input
92
- id="cell-autosize-toggle"
93
- type="checkbox"
94
- class="form-check-input"
95
- role="switch"
96
- bind:checked={displayStore.useAutoSize}
97
- />
87
+ <div class="col-4 text-secondary fw-semibold small text-uppercase">Density Caps</div>
88
+ <div class="col-8">
89
+ <div class="d-flex gap-3">
90
+ <div class="form-check form-switch m-0">
91
+ <input
92
+ id="cell-mincap-toggle"
93
+ type="checkbox"
94
+ class="form-check-input"
95
+ role="switch"
96
+ bind:checked={displayStore.useMinCap}
97
+ />
98
+ <label class="form-check-label small text-secondary" for="cell-mincap-toggle">
99
+ Min
100
+ </label>
101
+ </div>
102
+ <div class="form-check form-switch m-0">
103
+ <input
104
+ id="cell-maxcap-toggle"
105
+ type="checkbox"
106
+ class="form-check-input"
107
+ role="switch"
108
+ bind:checked={displayStore.useMaxCap}
109
+ />
110
+ <label class="form-check-label small text-secondary" for="cell-maxcap-toggle">
111
+ Max
112
+ </label>
113
+ </div>
98
114
  </div>
99
115
  </div>
100
116
  </div>
101
117
 
102
- {#if displayStore.useAutoSize}
103
- <!-- Auto Size Mode -->
118
+ {#if displayStore.useMinCap || displayStore.useMaxCap}
119
+ <!-- Cap Mode -->
104
120
  <div class="row align-items-center g-2 mb-3 ps-3">
105
- <div class="col-4 text-secondary small">Mode</div>
121
+ <div class="col-4 text-secondary small">Cap Mode</div>
106
122
  <div class="col-8">
107
123
  <select
108
124
  class="form-select form-select-sm"
109
125
  bind:value={displayStore.autoSizeMode}
110
126
  >
111
- <option value="logarithmic">Logarithmic (smooth)</option>
112
- <option value="percentage">Proportional (40%)</option>
113
- <option value="tiered">Tiered (4 levels)</option>
114
- <option value="hybrid">Hybrid (stepped proportional)</option>
127
+ <option value="logarithmic">Logarithmic</option>
128
+ <option value="percentage">Proportional</option>
129
+ <option value="tiered">Tiered</option>
130
+ <option value="hybrid">Hybrid</option>
115
131
  </select>
116
132
  </div>
117
133
  </div>
118
134
 
119
- <!-- Auto Size Base Multiplier -->
135
+ <!-- Cap Base -->
120
136
  <div class="row align-items-center g-2 mb-3 ps-3">
121
- <div class="col-4 text-secondary small">Base Size</div>
137
+ <div class="col-4 text-secondary small">Cap Base</div>
122
138
  <div class="col-3 text-end">
123
139
  <span class="badge bg-white text-muted border">{displayStore.autoSizeBase.toFixed(1)}x</span>
124
140
  </div>
125
141
  <div class="col-5">
126
142
  <input
127
- id="cell-autosize-base-slider"
143
+ id="cell-cap-base-slider"
128
144
  type="range"
129
145
  class="form-range w-100"
130
146
  min="0.5"
@@ -134,78 +150,6 @@
134
150
  />
135
151
  </div>
136
152
  </div>
137
- {:else}
138
- <!-- Density-Based Caps (Manual Mode Only) -->
139
- <div class="ps-3 border-start border-2 mb-3">
140
- <!-- Min Cap -->
141
- <div class="row align-items-center g-2 mb-2">
142
- <div class="col-7 text-secondary small">Min Size Cap (Density)</div>
143
- <div class="col-5">
144
- <div class="form-check form-switch m-0 d-flex align-items-center justify-content-end">
145
- <input
146
- id="cell-mincap-toggle"
147
- type="checkbox"
148
- class="form-check-input"
149
- role="switch"
150
- bind:checked={displayStore.useMinCap}
151
- />
152
- </div>
153
- </div>
154
- </div>
155
-
156
- <!-- Max Cap -->
157
- <div class="row align-items-center g-2 mb-2">
158
- <div class="col-7 text-secondary small">Max Size Cap (Density)</div>
159
- <div class="col-5">
160
- <div class="form-check form-switch m-0 d-flex align-items-center justify-content-end">
161
- <input
162
- id="cell-maxcap-toggle"
163
- type="checkbox"
164
- class="form-check-input"
165
- role="switch"
166
- bind:checked={displayStore.useMaxCap}
167
- />
168
- </div>
169
- </div>
170
- </div>
171
-
172
- {#if displayStore.useMinCap || displayStore.useMaxCap}
173
- <!-- Cap Mode (shares auto-size settings) -->
174
- <div class="row align-items-center g-2 mb-2 mt-2">
175
- <div class="col-4 text-secondary small">Cap Mode</div>
176
- <div class="col-8">
177
- <select
178
- class="form-select form-select-sm"
179
- bind:value={displayStore.autoSizeMode}
180
- >
181
- <option value="logarithmic">Logarithmic</option>
182
- <option value="percentage">Proportional</option>
183
- <option value="tiered">Tiered</option>
184
- <option value="hybrid">Hybrid</option>
185
- </select>
186
- </div>
187
- </div>
188
-
189
- <!-- Cap Base -->
190
- <div class="row align-items-center g-2 mb-2">
191
- <div class="col-4 text-secondary small">Cap Base</div>
192
- <div class="col-3 text-end">
193
- <span class="badge bg-white text-muted border">{displayStore.autoSizeBase.toFixed(1)}x</span>
194
- </div>
195
- <div class="col-5">
196
- <input
197
- id="cell-cap-base-slider"
198
- type="range"
199
- class="form-range w-100"
200
- min="0.5"
201
- max="3.0"
202
- step="0.1"
203
- bind:value={displayStore.autoSizeBase}
204
- />
205
- </div>
206
- </div>
207
- {/if}
208
- </div>
209
153
  {/if} <div class="border-top my-3"></div>
210
154
 
211
155
  <!-- Show Labels -->
@@ -100,14 +100,10 @@
100
100
  // Initial setup
101
101
  addLayers();
102
102
 
103
- // Events for updating - conditional based on auto-size
103
+ // Events for updating - always listen to zoom/move
104
104
  map.on('style.load', addLayers);
105
-
106
- // Only listen to zoom/move events if NOT using auto-size
107
- if (!displayStore.useAutoSize) {
108
- map.on('moveend', updateLayer);
109
- map.on('zoomend', updateLayer);
110
- }
105
+ map.on('moveend', updateLayer);
106
+ map.on('zoomend', updateLayer);
111
107
 
112
108
  // Cleanup
113
109
  return () => {
@@ -131,7 +127,6 @@
131
127
  const _l1 = displayStore.level1;
132
128
  const _l2 = displayStore.level2;
133
129
  const _layerGrouping = displayStore.layerGrouping;
134
- const _useAutoSize = displayStore.useAutoSize;
135
130
  const _autoSizeMode = displayStore.autoSizeMode;
136
131
  const _autoSizeBase = displayStore.autoSizeBase;
137
132
  const _useMinCap = displayStore.useMinCap;
@@ -151,101 +146,79 @@
151
146
  }
152
147
 
153
148
  function renderCells(map: mapboxgl.Map) {
149
+ const bounds = map.getBounds();
150
+ if (!bounds) return;
151
+
154
152
  const zoom = map.getZoom();
155
153
  const centerLat = map.getCenter().lat;
156
154
 
157
155
  console.log(`[CellsLayer] Rendering.. Zoom: ${zoom.toFixed(2)}, Cells: ${dataStore.filteredCells.length}`);
158
156
 
159
- // 1. Calculate base radius (only used in manual mode)
157
+ // Calculate base radius from pixel size
160
158
  const baseRadiusMeters = calculateRadiusInMeters(centerLat, zoom, displayStore.targetPixelSize);
161
-
162
- if (!displayStore.useAutoSize) {
163
- console.log(`[CellsLayer] Base radius: ${baseRadiusMeters.toFixed(2)}m for target ${displayStore.targetPixelSize}px`);
164
- }
159
+ console.log(`[CellsLayer] Base radius: ${baseRadiusMeters.toFixed(2)}m for target ${displayStore.targetPixelSize}px`);
165
160
 
166
- // 2. Group cells (Level 1=Tech, Level 2=Band for now hardcoded)
167
- // In real app, this comes from a store
161
+ // Group cells
168
162
  const groups = groupCells(dataStore.filteredCells, displayStore.level1, displayStore.level2);
169
163
  console.log(`[CellsLayer] Groups: ${groups.size}`);
170
164
 
171
165
  const features: GeoJSON.Feature[] = [];
172
166
  let groupIndex = 0;
173
167
 
174
- // 3. Iterate groups and generate features
168
+ // Iterate groups and generate features
175
169
  for (const [groupId, cells] of groups) {
176
- // Get style from registry
177
170
  const defaultColor = getColorForGroup(groupIndex++);
178
171
  const style = registry.getStyle(groupId, defaultColor);
179
172
 
180
173
  if (!style.visible) continue;
181
174
 
182
175
  for (const cell of cells) {
183
- // 4. BBox Filter - SKIP if auto-size is enabled
184
- if (!displayStore.useAutoSize) {
185
- const bounds = map.getBounds();
186
- if (!bounds || !bounds.contains([cell.longitude, cell.latitude])) {
187
- continue;
188
- }
176
+ // Viewport filter
177
+ if (!bounds.contains([cell.longitude, cell.latitude])) {
178
+ continue;
189
179
  }
190
180
 
191
- // 5. Z-Index Lookup
181
+ // Z-Index Lookup
192
182
  const zIndexKey = `${cell.tech}_${cell.frq}` as TechnologyBandKey;
193
183
  const zIndex = displayStore.currentZIndex[zIndexKey] ?? 10;
194
184
 
195
- // 6. Calculate radius with z-index scaling
185
+ // Calculate radius with z-index scaling
196
186
  const MAX_Z = 35;
197
- let radiusMeters: number;
187
+ const scaleFactor = 1 + Math.max(0, MAX_Z - zIndex) * 0.08;
188
+ let radiusMeters = baseRadiusMeters * scaleFactor;
198
189
 
199
- if (displayStore.useAutoSize) {
200
- // Auto-size mode: get target radius for this site
190
+ // Apply density-based caps if enabled
191
+ if (displayStore.useMinCap || displayStore.useMaxCap) {
201
192
  const siteDistance = dataStore.siteDistanceStore.getDistance(cell.siteId, 500);
202
193
  const autoRadius = calculateAutoRadius(siteDistance, displayStore.autoSizeMode);
203
-
204
- // Apply base size multiplier
205
194
  const baseAdjusted = autoRadius * displayStore.autoSizeBase;
195
+ const scaledAuto = baseAdjusted * scaleFactor;
206
196
 
207
- // Scale based on z-index for stacking visibility
208
- // Lower z-index (background) = larger, higher z-index (foreground) = smaller
209
- const scaleFactor = 1 + Math.max(0, MAX_Z - zIndex) * 0.08; // 8% per layer
210
- radiusMeters = baseAdjusted * scaleFactor;
211
- } else {
212
- // Manual mode: base from pixel size, then scale by z-index
213
- const scaleFactor = 1 + Math.max(0, MAX_Z - zIndex) * 0.08;
214
- radiusMeters = baseRadiusMeters * scaleFactor;
197
+ // Apply caps: min = 60% of auto-size, max = 140% of auto-size
198
+ const minCap = scaledAuto * 0.6;
199
+ const maxCap = scaledAuto * 1.4;
215
200
 
216
- // Apply density-based caps if enabled
217
- if (displayStore.useMinCap || displayStore.useMaxCap) {
218
- const siteDistance = dataStore.siteDistanceStore.getDistance(cell.siteId, 500);
219
- const autoRadius = calculateAutoRadius(siteDistance, displayStore.autoSizeMode);
220
- const baseAdjusted = autoRadius * displayStore.autoSizeBase;
221
- const scaledAuto = baseAdjusted * scaleFactor;
222
-
223
- // Apply caps: min = 60% of auto-size, max = 140% of auto-size
224
- const minCap = scaledAuto * 0.6;
225
- const maxCap = scaledAuto * 1.4;
226
-
227
- if (displayStore.useMinCap && radiusMeters < minCap) {
228
- radiusMeters = minCap;
229
- }
230
- if (displayStore.useMaxCap && radiusMeters > maxCap) {
231
- radiusMeters = maxCap;
232
- }
201
+ if (displayStore.useMinCap && radiusMeters < minCap) {
202
+ radiusMeters = minCap;
203
+ }
204
+ if (displayStore.useMaxCap && radiusMeters > maxCap) {
205
+ radiusMeters = maxCap;
233
206
  }
234
207
  }
235
208
 
236
- // 7. Apply beamwidth boost from displayStore preset
209
+ // Apply beamwidth boost
237
210
  const beamwidthBoost = displayStore.currentBeamwidthBoost[zIndexKey] || 0;
238
211
  const adjustedBeamwidth = cell.beamwidth + beamwidthBoost;
239
212
 
240
- // 8. Generate Arc
213
+ // Generate Arc
241
214
  const feature = generateCellArc(cell, radiusMeters, zIndex, style.color, adjustedBeamwidth);
242
215
  features.push(feature);
243
216
  }
244
217
  }
245
218
 
246
- console.log(`[CellsLayer] Generated ${features.length} features ${displayStore.useAutoSize ? '(all cells)' : 'in view'}`);
219
+ console.log(`[CellsLayer] Generated ${features.length} features in view`);
247
220
 
248
- // 8. Update Source
221
+ // Update Source
249
222
  const source = map.getSource(sourceId) as mapboxgl.GeoJSONSource;
250
223
  if (source) {
251
224
  source.setData({
@@ -7,7 +7,6 @@ export declare class CellDisplayStore {
7
7
  lineWidth: number;
8
8
  showLabels: boolean;
9
9
  layerGrouping: LayerGroupingPreset;
10
- useAutoSize: boolean;
11
10
  autoSizeMode: AutoSizeMode;
12
11
  autoSizeBase: number;
13
12
  useMinCap: boolean;
@@ -8,11 +8,10 @@ export class CellDisplayStore {
8
8
  lineWidth = $state(1);
9
9
  showLabels = $state(false);
10
10
  layerGrouping = $state('frequency');
11
- // Auto-size settings
12
- useAutoSize = $state(false);
11
+ // Auto-size settings (used by density caps)
13
12
  autoSizeMode = $state('logarithmic');
14
13
  autoSizeBase = $state(1.0);
15
- // Density-based caps (for manual mode)
14
+ // Density-based caps
16
15
  useMinCap = $state(false);
17
16
  useMaxCap = $state(false);
18
17
  // Grouping
@@ -42,7 +41,6 @@ export class CellDisplayStore {
42
41
  this.lineWidth = parsed.lineWidth ?? 1;
43
42
  this.showLabels = parsed.showLabels ?? false;
44
43
  this.layerGrouping = parsed.layerGrouping ?? 'frequency';
45
- this.useAutoSize = parsed.useAutoSize ?? false;
46
44
  this.autoSizeMode = parsed.autoSizeMode ?? 'logarithmic';
47
45
  this.autoSizeBase = parsed.autoSizeBase ?? 1.0;
48
46
  this.useMinCap = parsed.useMinCap ?? false;
@@ -69,7 +67,6 @@ export class CellDisplayStore {
69
67
  lineWidth: this.lineWidth,
70
68
  showLabels: this.showLabels,
71
69
  layerGrouping: this.layerGrouping,
72
- useAutoSize: this.useAutoSize,
73
70
  autoSizeMode: this.autoSizeMode,
74
71
  autoSizeBase: this.autoSizeBase,
75
72
  useMinCap: this.useMinCap,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartnet360/svelte-components",
3
- "version": "0.0.97",
3
+ "version": "0.0.99",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",