@smartnet360/svelte-components 0.0.143 → 0.0.145
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.
- package/dist/apps/antenna-tools/components/AntennaTools.svelte +7 -3
- package/dist/map-v3/features/custom/components/CustomCellSetManager.svelte +114 -4
- package/dist/map-v3/features/custom/layers/CustomCellsLayer.svelte +2 -8
- package/dist/map-v3/features/custom/logic/csv-parser.d.ts +25 -2
- package/dist/map-v3/features/custom/logic/csv-parser.js +70 -21
- package/dist/map-v3/features/custom/logic/tree-adapter.js +5 -3
- package/dist/map-v3/features/custom/stores/custom-cell-sets.svelte.d.ts +5 -1
- package/dist/map-v3/features/custom/stores/custom-cell-sets.svelte.js +6 -3
- package/dist/shared/csv-import/ColumnMapper.svelte +288 -0
- package/dist/shared/csv-import/ColumnMapper.svelte.d.ts +29 -0
- package/dist/shared/csv-import/column-detector.d.ts +58 -0
- package/dist/shared/csv-import/column-detector.js +228 -0
- package/dist/shared/csv-import/index.d.ts +10 -0
- package/dist/shared/csv-import/index.js +12 -0
- package/dist/shared/csv-import/types.d.ts +67 -0
- package/dist/shared/csv-import/types.js +70 -0
- package/package.json +1 -1
|
@@ -110,11 +110,14 @@
|
|
|
110
110
|
if (externalAntenna1) {
|
|
111
111
|
const found = findAntennaBySpec(antennas, externalAntenna1);
|
|
112
112
|
if (found) {
|
|
113
|
+
// Compute available tilts INLINE to avoid stale state issue
|
|
114
|
+
const tiltsForAntenna1 = getAvailableTiltsForAntenna(found);
|
|
115
|
+
|
|
113
116
|
// Use untrack to prevent infinite loop from state updates
|
|
114
117
|
untrack(() => {
|
|
115
118
|
selectedAntenna = found;
|
|
116
|
-
|
|
117
|
-
ant1ElectricalTilt = findTiltIndex(
|
|
119
|
+
availableElectricalTilts = tiltsForAntenna1;
|
|
120
|
+
ant1ElectricalTilt = findTiltIndex(tiltsForAntenna1, externalAntenna1.electricalTilt ?? 0);
|
|
118
121
|
ant1MechanicalTilt = externalAntenna1.mechanicalTilt ?? 0;
|
|
119
122
|
});
|
|
120
123
|
} else {
|
|
@@ -126,9 +129,10 @@
|
|
|
126
129
|
if (externalAntenna2) {
|
|
127
130
|
const found2 = findAntennaBySpec(antennas, externalAntenna2);
|
|
128
131
|
if (found2) {
|
|
132
|
+
const tiltsForAntenna2 = getAvailableTiltsForAntenna(found2);
|
|
129
133
|
untrack(() => {
|
|
130
134
|
selectedAntenna2 = found2;
|
|
131
|
-
ant2ElectricalTilt = findTiltIndex(
|
|
135
|
+
ant2ElectricalTilt = findTiltIndex(tiltsForAntenna2, externalAntenna2.electricalTilt ?? 0);
|
|
132
136
|
ant2MechanicalTilt = externalAntenna2.mechanicalTilt ?? 0;
|
|
133
137
|
if (viewMode === 'single') {
|
|
134
138
|
viewMode = 'compare';
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
import { buildCustomCellTree } from '../logic/tree-adapter';
|
|
18
18
|
import type { CustomSetsApiClient } from '../db/custom-sets-api';
|
|
19
19
|
import ServerSetBrowser from './ServerSetBrowser.svelte';
|
|
20
|
+
import { extractCsvHeaders } from '../logic/csv-parser';
|
|
21
|
+
import {
|
|
22
|
+
ColumnMapper,
|
|
23
|
+
autoDetectImport,
|
|
24
|
+
getFieldsForMode,
|
|
25
|
+
type ColumnMapping,
|
|
26
|
+
type ImportMode
|
|
27
|
+
} from '../../../../shared/csv-import';
|
|
20
28
|
|
|
21
29
|
interface Props {
|
|
22
30
|
/** The custom cell sets store */
|
|
@@ -57,6 +65,15 @@
|
|
|
57
65
|
let importError = $state('');
|
|
58
66
|
let fileInput: HTMLInputElement;
|
|
59
67
|
|
|
68
|
+
// Column mapping state
|
|
69
|
+
let showMappingModal = $state(false);
|
|
70
|
+
let pendingCsvContent = $state<string | null>(null);
|
|
71
|
+
let csvHeaders = $state<string[]>([]);
|
|
72
|
+
let columnMapping = $state<ColumnMapping>({});
|
|
73
|
+
let importMode = $state<ImportMode>('cell');
|
|
74
|
+
let includeAllExtras = $state(true);
|
|
75
|
+
let selectedExtras = $state<number[]>([]);
|
|
76
|
+
|
|
60
77
|
// Quick Add state
|
|
61
78
|
let showQuickAdd = $state(false);
|
|
62
79
|
let quickAddText = $state('');
|
|
@@ -154,8 +171,23 @@
|
|
|
154
171
|
reader.onload = (e) => {
|
|
155
172
|
try {
|
|
156
173
|
const content = e.target?.result as string;
|
|
157
|
-
|
|
158
|
-
|
|
174
|
+
// Extract headers and show mapping modal
|
|
175
|
+
const headers = extractCsvHeaders(content);
|
|
176
|
+
if (headers.length === 0) {
|
|
177
|
+
throw new Error('CSV file appears to be empty');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Store content for later parsing
|
|
181
|
+
pendingCsvContent = content;
|
|
182
|
+
csvHeaders = headers;
|
|
183
|
+
|
|
184
|
+
// Auto-detect import mode and column mapping
|
|
185
|
+
const detected = autoDetectImport(headers);
|
|
186
|
+
importMode = detected.mode;
|
|
187
|
+
columnMapping = detected.mapping;
|
|
188
|
+
|
|
189
|
+
// Show mapping modal
|
|
190
|
+
showMappingModal = true;
|
|
159
191
|
} catch (err) {
|
|
160
192
|
importError = err instanceof Error ? err.message : 'Failed to parse CSV';
|
|
161
193
|
importResult = null;
|
|
@@ -168,6 +200,34 @@
|
|
|
168
200
|
reader.readAsText(file);
|
|
169
201
|
input.value = '';
|
|
170
202
|
}
|
|
203
|
+
|
|
204
|
+
function confirmMapping() {
|
|
205
|
+
if (!pendingCsvContent) return;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
importResult = setsStore.importFromCsv(pendingCsvContent, importFileName, {
|
|
209
|
+
columnMapping,
|
|
210
|
+
includeAllExtras,
|
|
211
|
+
selectedExtraIndices: selectedExtras
|
|
212
|
+
});
|
|
213
|
+
showMappingModal = false;
|
|
214
|
+
showImportModal = true;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
importError = err instanceof Error ? err.message : 'Failed to parse CSV';
|
|
217
|
+
showMappingModal = false;
|
|
218
|
+
importResult = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function closeMappingModal() {
|
|
223
|
+
showMappingModal = false;
|
|
224
|
+
pendingCsvContent = null;
|
|
225
|
+
csvHeaders = [];
|
|
226
|
+
columnMapping = {};
|
|
227
|
+
importMode = 'cell';
|
|
228
|
+
includeAllExtras = true;
|
|
229
|
+
selectedExtras = [];
|
|
230
|
+
}
|
|
171
231
|
|
|
172
232
|
function confirmImport() {
|
|
173
233
|
if (!importResult) return;
|
|
@@ -390,8 +450,8 @@
|
|
|
390
450
|
<input
|
|
391
451
|
type="range"
|
|
392
452
|
class="form-range"
|
|
393
|
-
min="
|
|
394
|
-
max="
|
|
453
|
+
min="5"
|
|
454
|
+
max="100"
|
|
395
455
|
step="5"
|
|
396
456
|
value={globalSectorSize}
|
|
397
457
|
oninput={handleGlobalSectorSizeChange}
|
|
@@ -523,6 +583,56 @@
|
|
|
523
583
|
</div>
|
|
524
584
|
</MapControl>
|
|
525
585
|
|
|
586
|
+
<!-- Column Mapping Modal -->
|
|
587
|
+
{#if showMappingModal && csvHeaders.length > 0}
|
|
588
|
+
{@const fieldsConfig = getFieldsForMode(importMode)}
|
|
589
|
+
{@const isValid = fieldsConfig.required.every(f =>
|
|
590
|
+
columnMapping[f.type] !== null && columnMapping[f.type] !== undefined
|
|
591
|
+
)}
|
|
592
|
+
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
|
593
|
+
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
|
594
|
+
<div class="modal-content">
|
|
595
|
+
<div class="modal-header py-2">
|
|
596
|
+
<h6 class="modal-title">
|
|
597
|
+
<i class="bi bi-grid-3x3 me-2"></i>
|
|
598
|
+
Import: {importFileName}
|
|
599
|
+
</h6>
|
|
600
|
+
<button type="button" class="btn-close" aria-label="Close" onclick={closeMappingModal}></button>
|
|
601
|
+
</div>
|
|
602
|
+
<div class="modal-body py-2">
|
|
603
|
+
<ColumnMapper
|
|
604
|
+
headers={csvHeaders}
|
|
605
|
+
mode={importMode}
|
|
606
|
+
mapping={columnMapping}
|
|
607
|
+
{includeAllExtras}
|
|
608
|
+
{selectedExtras}
|
|
609
|
+
onmodechange={(m) => importMode = m}
|
|
610
|
+
onchange={(m) => columnMapping = m}
|
|
611
|
+
onextraschange={(all, selected) => {
|
|
612
|
+
includeAllExtras = all;
|
|
613
|
+
selectedExtras = selected;
|
|
614
|
+
}}
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
<div class="modal-footer py-2">
|
|
618
|
+
<button type="button" class="btn btn-secondary btn-sm" onclick={closeMappingModal}>
|
|
619
|
+
Cancel
|
|
620
|
+
</button>
|
|
621
|
+
<button
|
|
622
|
+
type="button"
|
|
623
|
+
class="btn btn-primary btn-sm"
|
|
624
|
+
onclick={confirmMapping}
|
|
625
|
+
disabled={!isValid}
|
|
626
|
+
>
|
|
627
|
+
<i class="bi bi-arrow-right me-1"></i>
|
|
628
|
+
Continue
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
{/if}
|
|
635
|
+
|
|
526
636
|
<!-- Import Result Modal -->
|
|
527
637
|
{#if showImportModal && importResult}
|
|
528
638
|
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
|
@@ -203,9 +203,7 @@
|
|
|
203
203
|
customGroup: item.customGroup,
|
|
204
204
|
sizeFactor: item.sizeFactor,
|
|
205
205
|
setName: set.name,
|
|
206
|
-
...
|
|
207
|
-
Object.entries(item.extraFields).map(([k, v]) => [`extra_${k}`, v])
|
|
208
|
-
)
|
|
206
|
+
popupInfo: { ...item.extraFields }
|
|
209
207
|
}
|
|
210
208
|
});
|
|
211
209
|
} else if (item.geometry === 'cell' && item.resolvedCell) {
|
|
@@ -226,11 +224,7 @@
|
|
|
226
224
|
feature.properties.customGroup = item.customGroup;
|
|
227
225
|
feature.properties.sizeFactor = item.sizeFactor;
|
|
228
226
|
feature.properties.setName = set.name;
|
|
229
|
-
|
|
230
|
-
// Add extra fields
|
|
231
|
-
for (const [key, value] of Object.entries(item.extraFields)) {
|
|
232
|
-
feature.properties[`extra_${key}`] = value;
|
|
233
|
-
}
|
|
227
|
+
feature.properties.popupInfo = { ...item.extraFields };
|
|
234
228
|
}
|
|
235
229
|
|
|
236
230
|
cellFeatures.push(feature);
|
|
@@ -9,21 +9,44 @@
|
|
|
9
9
|
* Required column: id (or cellName/txId for backwards compat)
|
|
10
10
|
* Optional columns: customGroup, sizeFactor, lat, lon, + any extras for tooltips
|
|
11
11
|
* Supports both comma (,) and semicolon (;) delimiters
|
|
12
|
+
*
|
|
13
|
+
* Supports user-defined column mapping via ColumnMapping from csv-import module.
|
|
12
14
|
*/
|
|
13
15
|
import type { CustomCellImportResult } from '../types';
|
|
14
16
|
import type { Cell } from '../../../../shared/demo';
|
|
17
|
+
import type { ColumnMapping } from '../../../../shared/csv-import';
|
|
15
18
|
/**
|
|
16
19
|
* Supported delimiters
|
|
17
20
|
*/
|
|
18
21
|
export type CsvDelimiter = ',' | ';' | 'auto';
|
|
22
|
+
/**
|
|
23
|
+
* Extract headers from CSV content
|
|
24
|
+
* @param csvContent Raw CSV content
|
|
25
|
+
* @param delimiter Delimiter to use: ',' (comma), ';' (semicolon), or 'auto' (detect)
|
|
26
|
+
* @returns Array of header strings
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractCsvHeaders(csvContent: string, delimiter?: CsvDelimiter): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Options for CSV parsing
|
|
31
|
+
*/
|
|
32
|
+
export interface CsvParseOptions {
|
|
33
|
+
/** Delimiter to use: ',' (comma), ';' (semicolon), or 'auto' (detect) */
|
|
34
|
+
delimiter?: CsvDelimiter;
|
|
35
|
+
/** User-defined column mapping (overrides auto-detection) */
|
|
36
|
+
columnMapping?: ColumnMapping;
|
|
37
|
+
/** Whether to include all extra columns (default: true) */
|
|
38
|
+
includeAllExtras?: boolean;
|
|
39
|
+
/** Specific extra column indices to include (when includeAllExtras is false) */
|
|
40
|
+
selectedExtraIndices?: number[];
|
|
41
|
+
}
|
|
19
42
|
/**
|
|
20
43
|
* Parse a CSV string into custom items (cells or points)
|
|
21
44
|
* @param csvContent Raw CSV content
|
|
22
45
|
* @param cellLookup Map of cellName -> Cell for resolving cell data
|
|
23
|
-
* @param
|
|
46
|
+
* @param options Parsing options
|
|
24
47
|
* @returns Import result with items, unmatched IDs, groups, and extra columns
|
|
25
48
|
*/
|
|
26
|
-
export declare function parseCustomCellsCsv(csvContent: string, cellLookup: Map<string, Cell>,
|
|
49
|
+
export declare function parseCustomCellsCsv(csvContent: string, cellLookup: Map<string, Cell>, options?: CsvParseOptions): CustomCellImportResult;
|
|
27
50
|
/**
|
|
28
51
|
* Build a cell lookup map from an array of cells
|
|
29
52
|
* Creates lookups for both cellName and txId for flexible matching
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Required column: id (or cellName/txId for backwards compat)
|
|
10
10
|
* Optional columns: customGroup, sizeFactor, lat, lon, + any extras for tooltips
|
|
11
11
|
* Supports both comma (,) and semicolon (;) delimiters
|
|
12
|
+
*
|
|
13
|
+
* Supports user-defined column mapping via ColumnMapping from csv-import module.
|
|
12
14
|
*/
|
|
13
15
|
/**
|
|
14
16
|
* Known/reserved column names
|
|
@@ -29,14 +31,29 @@ function detectDelimiter(headerLine) {
|
|
|
29
31
|
const semicolonCount = (headerLine.match(/;/g) || []).length;
|
|
30
32
|
return semicolonCount > commaCount ? ';' : ',';
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Extract headers from CSV content
|
|
36
|
+
* @param csvContent Raw CSV content
|
|
37
|
+
* @param delimiter Delimiter to use: ',' (comma), ';' (semicolon), or 'auto' (detect)
|
|
38
|
+
* @returns Array of header strings
|
|
39
|
+
*/
|
|
40
|
+
export function extractCsvHeaders(csvContent, delimiter = 'auto') {
|
|
41
|
+
const lines = csvContent.trim().split('\n');
|
|
42
|
+
if (lines.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
const headerLine = lines[0];
|
|
45
|
+
const actualDelimiter = delimiter === 'auto' ? detectDelimiter(headerLine) : delimiter;
|
|
46
|
+
return parseCSVLine(headerLine, actualDelimiter);
|
|
47
|
+
}
|
|
32
48
|
/**
|
|
33
49
|
* Parse a CSV string into custom items (cells or points)
|
|
34
50
|
* @param csvContent Raw CSV content
|
|
35
51
|
* @param cellLookup Map of cellName -> Cell for resolving cell data
|
|
36
|
-
* @param
|
|
52
|
+
* @param options Parsing options
|
|
37
53
|
* @returns Import result with items, unmatched IDs, groups, and extra columns
|
|
38
54
|
*/
|
|
39
|
-
export function parseCustomCellsCsv(csvContent, cellLookup,
|
|
55
|
+
export function parseCustomCellsCsv(csvContent, cellLookup, options = {}) {
|
|
56
|
+
const { delimiter = 'auto', columnMapping, includeAllExtras = true, selectedExtraIndices = [] } = options;
|
|
40
57
|
const lines = csvContent.trim().split('\n');
|
|
41
58
|
if (lines.length < 2) {
|
|
42
59
|
return {
|
|
@@ -55,34 +72,66 @@ export function parseCustomCellsCsv(csvContent, cellLookup, delimiter = 'auto')
|
|
|
55
72
|
// Parse header
|
|
56
73
|
const headers = parseCSVLine(headerLine, actualDelimiter);
|
|
57
74
|
const normalizedHeaders = headers.map(normalizeColumnName);
|
|
58
|
-
//
|
|
59
|
-
let idIndex
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
// Use column mapping if provided, otherwise auto-detect
|
|
76
|
+
let idIndex;
|
|
77
|
+
let groupIndex;
|
|
78
|
+
let sizeFactorIndex;
|
|
79
|
+
let latIndex;
|
|
80
|
+
let lonIndex;
|
|
81
|
+
if (columnMapping) {
|
|
82
|
+
// Use user-defined mapping
|
|
83
|
+
idIndex = columnMapping.id ?? -1;
|
|
84
|
+
groupIndex = columnMapping.group ?? -1;
|
|
85
|
+
sizeFactorIndex = columnMapping.sizeFactor ?? -1;
|
|
86
|
+
latIndex = columnMapping.lat ?? -1;
|
|
87
|
+
lonIndex = columnMapping.lon ?? -1;
|
|
62
88
|
}
|
|
63
|
-
|
|
64
|
-
|
|
89
|
+
else {
|
|
90
|
+
// Auto-detect: Find ID column - prefer 'id', then 'cellname', then 'txid'
|
|
91
|
+
idIndex = normalizedHeaders.findIndex(h => h === 'id');
|
|
92
|
+
if (idIndex === -1) {
|
|
93
|
+
idIndex = normalizedHeaders.findIndex(h => h === 'cellname');
|
|
94
|
+
}
|
|
95
|
+
if (idIndex === -1) {
|
|
96
|
+
idIndex = normalizedHeaders.findIndex(h => h === 'txid');
|
|
97
|
+
}
|
|
98
|
+
// Find optional columns
|
|
99
|
+
groupIndex = normalizedHeaders.findIndex(h => h === 'customgroup');
|
|
100
|
+
sizeFactorIndex = normalizedHeaders.findIndex(h => h === 'sizefactor');
|
|
101
|
+
// Find lat/lon columns for point geometry
|
|
102
|
+
latIndex = normalizedHeaders.findIndex(h => h === 'lat' || h === 'latitude');
|
|
103
|
+
lonIndex = normalizedHeaders.findIndex(h => h === 'lon' || h === 'lng' || h === 'longitude');
|
|
65
104
|
}
|
|
66
105
|
if (idIndex === -1) {
|
|
67
106
|
throw new Error('CSV must contain an "id", "cellName", or "txId" column');
|
|
68
107
|
}
|
|
69
|
-
// Find optional columns
|
|
70
|
-
const groupIndex = normalizedHeaders.findIndex(h => h === 'customgroup');
|
|
71
|
-
const sizeFactorIndex = normalizedHeaders.findIndex(h => h === 'sizefactor');
|
|
72
|
-
// Find lat/lon columns for point geometry
|
|
73
|
-
let latIndex = normalizedHeaders.findIndex(h => h === 'lat' || h === 'latitude');
|
|
74
|
-
let lonIndex = normalizedHeaders.findIndex(h => h === 'lon' || h === 'lng' || h === 'longitude');
|
|
75
108
|
const hasLatLon = latIndex !== -1 && lonIndex !== -1;
|
|
76
|
-
// Find extra columns
|
|
109
|
+
// Find extra columns based on user selection
|
|
77
110
|
const extraColumns = [];
|
|
78
111
|
const extraIndices = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
// Get indices that are already mapped to known fields
|
|
113
|
+
const mappedIndices = new Set([idIndex, groupIndex, sizeFactorIndex, latIndex, lonIndex].filter(i => i >= 0));
|
|
114
|
+
if (includeAllExtras) {
|
|
115
|
+
// Include all non-reserved, non-mapped columns
|
|
116
|
+
headers.forEach((header, idx) => {
|
|
117
|
+
if (mappedIndices.has(idx))
|
|
118
|
+
return;
|
|
119
|
+
const normalized = normalizeColumnName(header);
|
|
120
|
+
if (!RESERVED_COLUMNS.includes(normalized)) {
|
|
121
|
+
extraColumns.push(header.trim());
|
|
122
|
+
extraIndices.push(idx);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Include only specifically selected columns
|
|
128
|
+
for (const idx of selectedExtraIndices) {
|
|
129
|
+
if (idx >= 0 && idx < headers.length && !mappedIndices.has(idx)) {
|
|
130
|
+
extraColumns.push(headers[idx].trim());
|
|
131
|
+
extraIndices.push(idx);
|
|
132
|
+
}
|
|
84
133
|
}
|
|
85
|
-
}
|
|
134
|
+
}
|
|
86
135
|
// Parse data rows
|
|
87
136
|
const cells = [];
|
|
88
137
|
const unmatchedTxIds = [];
|
|
@@ -10,8 +10,9 @@ export function buildCustomCellTree(set) {
|
|
|
10
10
|
// Group cells by customGroup
|
|
11
11
|
const groupMap = new Map();
|
|
12
12
|
for (const cell of set.cells) {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Skip cells that failed to resolve, but always include points
|
|
14
|
+
if (!cell.resolvedCell && cell.geometry !== 'point')
|
|
15
|
+
continue;
|
|
15
16
|
if (!groupMap.has(cell.customGroup)) {
|
|
16
17
|
groupMap.set(cell.customGroup, []);
|
|
17
18
|
}
|
|
@@ -58,7 +59,8 @@ export function buildCustomCellTree(set) {
|
|
|
58
59
|
export function getGroupCounts(set) {
|
|
59
60
|
const counts = new Map();
|
|
60
61
|
for (const cell of set.cells) {
|
|
61
|
-
|
|
62
|
+
// Skip cells that failed to resolve, but always include points
|
|
63
|
+
if (!cell.resolvedCell && cell.geometry !== 'point')
|
|
62
64
|
continue;
|
|
63
65
|
const current = counts.get(cell.customGroup) || 0;
|
|
64
66
|
counts.set(cell.customGroup, current + 1);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { Cell } from '../../../../shared/demo';
|
|
8
8
|
import type { CellDataStore } from '../../cells/stores/cell.data.svelte';
|
|
9
9
|
import type { CustomCellSet, CustomCellImportResult } from '../types';
|
|
10
|
+
import { type CsvParseOptions } from '../logic/csv-parser';
|
|
10
11
|
/** Function that returns the current cells array */
|
|
11
12
|
type CellsGetter = () => Cell[];
|
|
12
13
|
/**
|
|
@@ -29,8 +30,11 @@ export declare class CustomCellSetsStore {
|
|
|
29
30
|
constructor(cells: Cell[] | CellsGetter, namespace?: string);
|
|
30
31
|
/**
|
|
31
32
|
* Import a CSV file and create a new custom cell set
|
|
33
|
+
* @param csvContent Raw CSV content
|
|
34
|
+
* @param fileName Name of the file being imported
|
|
35
|
+
* @param options Parsing options (column mapping, extra fields selection)
|
|
32
36
|
*/
|
|
33
|
-
importFromCsv(csvContent: string, fileName: string): CustomCellImportResult;
|
|
37
|
+
importFromCsv(csvContent: string, fileName: string, options?: CsvParseOptions): CustomCellImportResult;
|
|
34
38
|
/**
|
|
35
39
|
* Create a new set from import result
|
|
36
40
|
*/
|
|
@@ -67,12 +67,15 @@ export class CustomCellSetsStore {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Import a CSV file and create a new custom cell set
|
|
70
|
+
* @param csvContent Raw CSV content
|
|
71
|
+
* @param fileName Name of the file being imported
|
|
72
|
+
* @param options Parsing options (column mapping, extra fields selection)
|
|
70
73
|
*/
|
|
71
|
-
importFromCsv(csvContent, fileName) {
|
|
74
|
+
importFromCsv(csvContent, fileName, options) {
|
|
72
75
|
// Build lookup from all cells
|
|
73
76
|
const cellLookup = buildCellLookup(this.getCells());
|
|
74
|
-
// Parse CSV
|
|
75
|
-
const result = parseCustomCellsCsv(csvContent, cellLookup);
|
|
77
|
+
// Parse CSV with options
|
|
78
|
+
const result = parseCustomCellsCsv(csvContent, cellLookup, options || {});
|
|
76
79
|
return result;
|
|
77
80
|
}
|
|
78
81
|
/**
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Column Mapper Component
|
|
4
|
+
*
|
|
5
|
+
* Compact UI for mapping CSV columns to expected fields.
|
|
6
|
+
* Includes import mode selection (Cell/Point/Site) with auto-detection.
|
|
7
|
+
* Supports extra field selection for tooltips.
|
|
8
|
+
*/
|
|
9
|
+
import type { ColumnMapping, ImportFieldsConfig, ImportMode } from './types';
|
|
10
|
+
import { getFieldsForMode } from './types';
|
|
11
|
+
import { detectColumnMapping } from './column-detector';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** CSV column headers */
|
|
15
|
+
headers: string[];
|
|
16
|
+
/** Current import mode */
|
|
17
|
+
mode: ImportMode;
|
|
18
|
+
/** Current mapping (can be auto-detected or user-modified) */
|
|
19
|
+
mapping: ColumnMapping;
|
|
20
|
+
/** Whether to include all extra columns */
|
|
21
|
+
includeAllExtras?: boolean;
|
|
22
|
+
/** Selected extra column indices (when not including all) */
|
|
23
|
+
selectedExtras?: number[];
|
|
24
|
+
/** Called when mode changes */
|
|
25
|
+
onmodechange?: (mode: ImportMode) => void;
|
|
26
|
+
/** Called when mapping changes */
|
|
27
|
+
onchange?: (mapping: ColumnMapping) => void;
|
|
28
|
+
/** Called when extra fields settings change */
|
|
29
|
+
onextraschange?: (includeAll: boolean, selected: number[]) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
headers,
|
|
34
|
+
mode,
|
|
35
|
+
mapping,
|
|
36
|
+
includeAllExtras = true,
|
|
37
|
+
selectedExtras = [],
|
|
38
|
+
onmodechange,
|
|
39
|
+
onchange,
|
|
40
|
+
onextraschange
|
|
41
|
+
}: Props = $props();
|
|
42
|
+
|
|
43
|
+
// Get fields config based on current mode
|
|
44
|
+
let fieldsConfig = $derived(getFieldsForMode(mode));
|
|
45
|
+
|
|
46
|
+
// All fields to display
|
|
47
|
+
let allFields = $derived([...fieldsConfig.required, ...fieldsConfig.optional]);
|
|
48
|
+
|
|
49
|
+
// Get mapped column indices (to exclude from extra fields options)
|
|
50
|
+
let mappedIndices = $derived(new Set(
|
|
51
|
+
Object.values(mapping).filter((v): v is number => v !== null && v !== undefined)
|
|
52
|
+
));
|
|
53
|
+
|
|
54
|
+
// Available columns for extra fields (not already mapped)
|
|
55
|
+
let availableExtras = $derived(
|
|
56
|
+
headers
|
|
57
|
+
.map((header, idx) => ({ header, idx }))
|
|
58
|
+
.filter(({ idx }) => !mappedIndices.has(idx))
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Validation state
|
|
62
|
+
let missingRequired = $derived(
|
|
63
|
+
fieldsConfig.required.filter(f =>
|
|
64
|
+
mapping[f.type] === null || mapping[f.type] === undefined
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function handleModeChange(newMode: ImportMode) {
|
|
69
|
+
if (newMode === mode) return;
|
|
70
|
+
|
|
71
|
+
// Re-detect mapping for new mode
|
|
72
|
+
const newFieldsConfig = getFieldsForMode(newMode);
|
|
73
|
+
const newMapping = detectColumnMapping(headers, newFieldsConfig);
|
|
74
|
+
|
|
75
|
+
onmodechange?.(newMode);
|
|
76
|
+
onchange?.(newMapping);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleFieldChange(fieldType: string, event: Event) {
|
|
80
|
+
const select = event.target as HTMLSelectElement;
|
|
81
|
+
const value = select.value;
|
|
82
|
+
|
|
83
|
+
const newMapping = { ...mapping };
|
|
84
|
+
|
|
85
|
+
if (value === '') {
|
|
86
|
+
newMapping[fieldType] = null;
|
|
87
|
+
} else {
|
|
88
|
+
newMapping[fieldType] = parseInt(value, 10);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onchange?.(newMapping);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getFieldValue(fieldType: string): string {
|
|
95
|
+
const value = mapping[fieldType];
|
|
96
|
+
return value === null || value === undefined ? '' : String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getMappedHeader(fieldType: string): string | null {
|
|
100
|
+
const idx = mapping[fieldType];
|
|
101
|
+
return idx !== null && idx !== undefined ? headers[idx] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extra fields handlers
|
|
105
|
+
function handleIncludeAllChange(event: Event) {
|
|
106
|
+
const checked = (event.target as HTMLInputElement).checked;
|
|
107
|
+
onextraschange?.(checked, selectedExtras);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handleExtraFieldChange(slotIndex: number, event: Event) {
|
|
111
|
+
const select = event.target as HTMLSelectElement;
|
|
112
|
+
const value = select.value;
|
|
113
|
+
|
|
114
|
+
const newSelected = [...selectedExtras];
|
|
115
|
+
|
|
116
|
+
if (value === '') {
|
|
117
|
+
// Remove this slot
|
|
118
|
+
newSelected[slotIndex] = -1;
|
|
119
|
+
} else {
|
|
120
|
+
newSelected[slotIndex] = parseInt(value, 10);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Filter out -1 values and duplicates
|
|
124
|
+
const filtered = newSelected.filter((v, i) => v >= 0 && newSelected.indexOf(v) === i);
|
|
125
|
+
onextraschange?.(includeAllExtras, filtered);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getExtraSlotValue(slotIndex: number): string {
|
|
129
|
+
const value = selectedExtras[slotIndex];
|
|
130
|
+
return value === undefined || value < 0 ? '' : String(value);
|
|
131
|
+
}
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<div class="column-mapper">
|
|
135
|
+
<!-- Import Mode Selection -->
|
|
136
|
+
<div class="mode-selection mb-3 pb-2 border-bottom">
|
|
137
|
+
<div class="d-flex align-items-center gap-3">
|
|
138
|
+
<span class="small text-muted">Import as:</span>
|
|
139
|
+
<div class="btn-group btn-group-sm" role="group" aria-label="Import mode">
|
|
140
|
+
<input
|
|
141
|
+
type="radio"
|
|
142
|
+
class="btn-check"
|
|
143
|
+
name="importMode"
|
|
144
|
+
id="mode-cell"
|
|
145
|
+
checked={mode === 'cell'}
|
|
146
|
+
onchange={() => handleModeChange('cell')}
|
|
147
|
+
/>
|
|
148
|
+
<label class="btn btn-outline-secondary" for="mode-cell">
|
|
149
|
+
<i class="bi bi-broadcast me-1"></i>Cells
|
|
150
|
+
</label>
|
|
151
|
+
|
|
152
|
+
<input
|
|
153
|
+
type="radio"
|
|
154
|
+
class="btn-check"
|
|
155
|
+
name="importMode"
|
|
156
|
+
id="mode-point"
|
|
157
|
+
checked={mode === 'point'}
|
|
158
|
+
onchange={() => handleModeChange('point')}
|
|
159
|
+
/>
|
|
160
|
+
<label class="btn btn-outline-secondary" for="mode-point">
|
|
161
|
+
<i class="bi bi-geo-alt me-1"></i>Points
|
|
162
|
+
</label>
|
|
163
|
+
|
|
164
|
+
<input
|
|
165
|
+
type="radio"
|
|
166
|
+
class="btn-check"
|
|
167
|
+
name="importMode"
|
|
168
|
+
id="mode-site"
|
|
169
|
+
checked={mode === 'site'}
|
|
170
|
+
onchange={() => handleModeChange('site')}
|
|
171
|
+
/>
|
|
172
|
+
<label class="btn btn-outline-secondary" for="mode-site">
|
|
173
|
+
<i class="bi bi-pin-map me-1"></i>Sites
|
|
174
|
+
</label>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<!-- Column Mapping Grid -->
|
|
180
|
+
<div class="mapping-grid">
|
|
181
|
+
{#each allFields as field (field.type)}
|
|
182
|
+
{@const isRequired = field.required}
|
|
183
|
+
{@const isMapped = mapping[field.type] !== null && mapping[field.type] !== undefined}
|
|
184
|
+
{@const fieldId = `column-map-${field.type}`}
|
|
185
|
+
|
|
186
|
+
<div class="mapping-row d-flex align-items-center gap-2 mb-2">
|
|
187
|
+
<label
|
|
188
|
+
class="field-label mb-0"
|
|
189
|
+
for={fieldId}
|
|
190
|
+
title={field.description || field.label}
|
|
191
|
+
>
|
|
192
|
+
{field.label}{#if isRequired}<span class="text-danger">*</span>{/if}
|
|
193
|
+
</label>
|
|
194
|
+
<select
|
|
195
|
+
id={fieldId}
|
|
196
|
+
class="form-select form-select-sm flex-grow-1"
|
|
197
|
+
class:is-invalid={isRequired && !isMapped}
|
|
198
|
+
class:is-valid={isMapped}
|
|
199
|
+
value={getFieldValue(field.type)}
|
|
200
|
+
onchange={(e) => handleFieldChange(field.type, e)}
|
|
201
|
+
>
|
|
202
|
+
<option value="">{field.description || '-- Select --'}</option>
|
|
203
|
+
{#each headers as header, idx}
|
|
204
|
+
<option value={String(idx)}>{header}</option>
|
|
205
|
+
{/each}
|
|
206
|
+
</select>
|
|
207
|
+
</div>
|
|
208
|
+
{/each}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<!-- Extra Fields Section -->
|
|
212
|
+
{#if availableExtras.length > 0}
|
|
213
|
+
<div class="extra-fields mt-3 pt-2 border-top">
|
|
214
|
+
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
215
|
+
<span class="small fw-medium">Extra columns for popups</span>
|
|
216
|
+
<div class="form-check form-check-inline mb-0">
|
|
217
|
+
<input
|
|
218
|
+
class="form-check-input"
|
|
219
|
+
type="checkbox"
|
|
220
|
+
id="include-all-extras"
|
|
221
|
+
checked={includeAllExtras}
|
|
222
|
+
onchange={handleIncludeAllChange}
|
|
223
|
+
/>
|
|
224
|
+
<label class="form-check-label small" for="include-all-extras">
|
|
225
|
+
Include all ({availableExtras.length})
|
|
226
|
+
</label>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{#if !includeAllExtras}
|
|
231
|
+
<div class="extra-slots">
|
|
232
|
+
{#each [0, 1, 2] as slotIndex}
|
|
233
|
+
{@const slotId = `extra-slot-${slotIndex}`}
|
|
234
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
235
|
+
<select
|
|
236
|
+
id={slotId}
|
|
237
|
+
class="form-select form-select-sm"
|
|
238
|
+
value={getExtraSlotValue(slotIndex)}
|
|
239
|
+
onchange={(e) => handleExtraFieldChange(slotIndex, e)}
|
|
240
|
+
aria-label={`Extra field ${slotIndex + 1}`}
|
|
241
|
+
>
|
|
242
|
+
<option value="">-- Extra {slotIndex + 1} --</option>
|
|
243
|
+
{#each availableExtras as { header, idx }}
|
|
244
|
+
<option value={String(idx)}>{header}</option>
|
|
245
|
+
{/each}
|
|
246
|
+
</select>
|
|
247
|
+
</div>
|
|
248
|
+
{/each}
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
251
|
+
</div>
|
|
252
|
+
{/if}
|
|
253
|
+
|
|
254
|
+
<!-- Validation Warning -->
|
|
255
|
+
{#if missingRequired.length > 0}
|
|
256
|
+
<div class="alert alert-warning py-1 px-2 mt-2 mb-0 small">
|
|
257
|
+
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
258
|
+
Required: {missingRequired.map(f => f.label).join(', ')}
|
|
259
|
+
</div>
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<style>
|
|
264
|
+
.column-mapper {
|
|
265
|
+
font-size: 0.875rem;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.field-label {
|
|
269
|
+
min-width: 80px;
|
|
270
|
+
font-weight: 500;
|
|
271
|
+
font-size: 0.8125rem;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.form-select-sm {
|
|
275
|
+
font-size: 0.8125rem;
|
|
276
|
+
padding: 0.25rem 0.5rem;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.mapping-row .form-select.is-valid {
|
|
280
|
+
border-color: var(--bs-success);
|
|
281
|
+
background-image: none; /* Remove check icon */
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.btn-group-sm .btn {
|
|
285
|
+
font-size: 0.75rem;
|
|
286
|
+
padding: 0.25rem 0.5rem;
|
|
287
|
+
}
|
|
288
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column Mapper Component
|
|
3
|
+
*
|
|
4
|
+
* Compact UI for mapping CSV columns to expected fields.
|
|
5
|
+
* Includes import mode selection (Cell/Point/Site) with auto-detection.
|
|
6
|
+
* Supports extra field selection for tooltips.
|
|
7
|
+
*/
|
|
8
|
+
import type { ColumnMapping, ImportMode } from './types';
|
|
9
|
+
interface Props {
|
|
10
|
+
/** CSV column headers */
|
|
11
|
+
headers: string[];
|
|
12
|
+
/** Current import mode */
|
|
13
|
+
mode: ImportMode;
|
|
14
|
+
/** Current mapping (can be auto-detected or user-modified) */
|
|
15
|
+
mapping: ColumnMapping;
|
|
16
|
+
/** Whether to include all extra columns */
|
|
17
|
+
includeAllExtras?: boolean;
|
|
18
|
+
/** Selected extra column indices (when not including all) */
|
|
19
|
+
selectedExtras?: number[];
|
|
20
|
+
/** Called when mode changes */
|
|
21
|
+
onmodechange?: (mode: ImportMode) => void;
|
|
22
|
+
/** Called when mapping changes */
|
|
23
|
+
onchange?: (mapping: ColumnMapping) => void;
|
|
24
|
+
/** Called when extra fields settings change */
|
|
25
|
+
onextraschange?: (includeAll: boolean, selected: number[]) => void;
|
|
26
|
+
}
|
|
27
|
+
declare const ColumnMapper: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type ColumnMapper = ReturnType<typeof ColumnMapper>;
|
|
29
|
+
export default ColumnMapper;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CSV Import - Column Detector
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects column mappings based on header names.
|
|
5
|
+
* Uses expandable alias maps for flexible matching.
|
|
6
|
+
*/
|
|
7
|
+
import type { ColumnMapping, StandardFieldType, ImportFieldsConfig, ImportMode } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* Alias map for auto-detecting column types
|
|
10
|
+
* Add new aliases here to expand recognition
|
|
11
|
+
*/
|
|
12
|
+
export declare const COLUMN_ALIASES: Record<StandardFieldType, string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Find the best matching field type for a column header
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectFieldType(header: string): StandardFieldType | null;
|
|
17
|
+
/**
|
|
18
|
+
* Auto-detect column mapping from headers
|
|
19
|
+
* Returns suggested mapping based on alias matching
|
|
20
|
+
*/
|
|
21
|
+
export declare function detectColumnMapping(headers: string[], fieldsConfig: ImportFieldsConfig): ColumnMapping;
|
|
22
|
+
/**
|
|
23
|
+
* Validate that all required fields are mapped
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateMapping(mapping: ColumnMapping, fieldsConfig: ImportFieldsConfig): {
|
|
26
|
+
valid: boolean;
|
|
27
|
+
missing: string[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Detect the most likely import mode based on headers
|
|
31
|
+
* Returns 'point' if lat/lon found, 'site' if site-related, otherwise 'cell'
|
|
32
|
+
*/
|
|
33
|
+
export declare function detectImportMode(headers: string[]): ImportMode;
|
|
34
|
+
/**
|
|
35
|
+
* Auto-detect import mode and get initial mapping
|
|
36
|
+
*/
|
|
37
|
+
export declare function autoDetectImport(headers: string[]): {
|
|
38
|
+
mode: ImportMode;
|
|
39
|
+
mapping: ColumnMapping;
|
|
40
|
+
fieldsConfig: ImportFieldsConfig;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Detect delimiter from CSV content
|
|
44
|
+
*/
|
|
45
|
+
export declare function detectDelimiter(headerLine: string): ',' | ';';
|
|
46
|
+
/**
|
|
47
|
+
* Parse CSV header line and return headers array
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseHeaderLine(line: string, delimiter: ',' | ';'): string[];
|
|
50
|
+
/**
|
|
51
|
+
* Get CSV headers and suggested mapping
|
|
52
|
+
*/
|
|
53
|
+
export declare function analyzeCSVHeaders(csvContent: string, fieldsConfig: ImportFieldsConfig): {
|
|
54
|
+
headers: string[];
|
|
55
|
+
delimiter: ',' | ';';
|
|
56
|
+
suggestedMapping: ColumnMapping;
|
|
57
|
+
rowCount: number;
|
|
58
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CSV Import - Column Detector
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects column mappings based on header names.
|
|
5
|
+
* Uses expandable alias maps for flexible matching.
|
|
6
|
+
*/
|
|
7
|
+
import { getFieldsForMode } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* Alias map for auto-detecting column types
|
|
10
|
+
* Add new aliases here to expand recognition
|
|
11
|
+
*/
|
|
12
|
+
export const COLUMN_ALIASES = {
|
|
13
|
+
// Universal identifiers
|
|
14
|
+
id: [
|
|
15
|
+
'id', 'cellname', 'cell_name', 'txid', 'tx_id',
|
|
16
|
+
'site_id', 'siteid', 'site_code', 'sitecode',
|
|
17
|
+
'name', 'code', 'identifier', 'key', 'cell_id', 'cellid'
|
|
18
|
+
],
|
|
19
|
+
// Coordinates
|
|
20
|
+
lat: [
|
|
21
|
+
'lat', 'latitude', 'y', 'y_coord', 'ycoord',
|
|
22
|
+
'northing', 'north', 'lat_deg', 'latitude_deg'
|
|
23
|
+
],
|
|
24
|
+
lon: [
|
|
25
|
+
'lon', 'lng', 'long', 'longitude', 'x', 'x_coord', 'xcoord',
|
|
26
|
+
'easting', 'east', 'lon_deg', 'longitude_deg'
|
|
27
|
+
],
|
|
28
|
+
// Grouping
|
|
29
|
+
group: [
|
|
30
|
+
'customgroup', 'custom_group', 'group', 'category',
|
|
31
|
+
'type', 'region', 'area', 'zone', 'cluster', 'segment'
|
|
32
|
+
],
|
|
33
|
+
// Sizing
|
|
34
|
+
sizeFactor: [
|
|
35
|
+
'sizefactor', 'size_factor', 'size', 'scale', 'weight', 'factor'
|
|
36
|
+
],
|
|
37
|
+
// Cell-specific
|
|
38
|
+
tech: [
|
|
39
|
+
'tech', 'technology', 'rat', 'radio', 'system',
|
|
40
|
+
'network_type', 'networktype', 'access_technology'
|
|
41
|
+
],
|
|
42
|
+
band: [
|
|
43
|
+
'band', 'fband', 'frequency_band', 'frequencyband',
|
|
44
|
+
'freq', 'frequency', 'carrier', 'earfcn', 'arfcn'
|
|
45
|
+
],
|
|
46
|
+
azimuth: [
|
|
47
|
+
'azimuth', 'az', 'bearing', 'direction', 'heading',
|
|
48
|
+
'antenna_azimuth', 'ant_azimuth', 'ant_az'
|
|
49
|
+
],
|
|
50
|
+
tilt: [
|
|
51
|
+
'tilt', 'downtilt', 'etilt', 'mtilt', 'electrical_tilt',
|
|
52
|
+
'mechanical_tilt', 'antenna_tilt', 'ant_tilt'
|
|
53
|
+
],
|
|
54
|
+
siteId: [
|
|
55
|
+
'siteid', 'site_id', 'site', 'location_id', 'locationid',
|
|
56
|
+
'enodeb_id', 'enodebid', 'gnodeb_id', 'gnodebid', 'bts_id'
|
|
57
|
+
],
|
|
58
|
+
siteName: [
|
|
59
|
+
'sitename', 'site_name', 'location_name', 'locationname',
|
|
60
|
+
'site_label', 'sitelabel', 'station_name'
|
|
61
|
+
],
|
|
62
|
+
status: [
|
|
63
|
+
'status', 'state', 'cell_status', 'cellstatus',
|
|
64
|
+
'operational_status', 'op_status', 'active'
|
|
65
|
+
],
|
|
66
|
+
height: [
|
|
67
|
+
'height', 'antenna_height', 'ant_height', 'agl',
|
|
68
|
+
'elevation', 'tower_height', 'mast_height'
|
|
69
|
+
],
|
|
70
|
+
power: [
|
|
71
|
+
'power', 'tx_power', 'txpower', 'eirp',
|
|
72
|
+
'transmit_power', 'output_power', 'pwr'
|
|
73
|
+
]
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Normalize a column header for comparison
|
|
77
|
+
*/
|
|
78
|
+
function normalizeHeader(header) {
|
|
79
|
+
return header
|
|
80
|
+
.trim()
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[\s\-\.]/g, '_'); // Normalize separators to underscore
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find the best matching field type for a column header
|
|
86
|
+
*/
|
|
87
|
+
export function detectFieldType(header) {
|
|
88
|
+
const normalized = normalizeHeader(header);
|
|
89
|
+
for (const [fieldType, aliases] of Object.entries(COLUMN_ALIASES)) {
|
|
90
|
+
if (aliases.some(alias => normalized === alias || normalized.includes(alias))) {
|
|
91
|
+
return fieldType;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Auto-detect column mapping from headers
|
|
98
|
+
* Returns suggested mapping based on alias matching
|
|
99
|
+
*/
|
|
100
|
+
export function detectColumnMapping(headers, fieldsConfig) {
|
|
101
|
+
const mapping = {};
|
|
102
|
+
const usedIndices = new Set();
|
|
103
|
+
// Get all field types we're looking for
|
|
104
|
+
const allFields = [...fieldsConfig.required, ...fieldsConfig.optional];
|
|
105
|
+
// First pass: exact matches and strong matches
|
|
106
|
+
for (const field of allFields) {
|
|
107
|
+
const aliases = COLUMN_ALIASES[field.type] || [];
|
|
108
|
+
for (let i = 0; i < headers.length; i++) {
|
|
109
|
+
if (usedIndices.has(i))
|
|
110
|
+
continue;
|
|
111
|
+
const normalized = normalizeHeader(headers[i]);
|
|
112
|
+
// Check for exact match first
|
|
113
|
+
if (aliases.includes(normalized)) {
|
|
114
|
+
mapping[field.type] = i;
|
|
115
|
+
usedIndices.add(i);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Second pass: partial matches for unmapped fields
|
|
121
|
+
for (const field of allFields) {
|
|
122
|
+
if (mapping[field.type] !== undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const aliases = COLUMN_ALIASES[field.type] || [];
|
|
125
|
+
for (let i = 0; i < headers.length; i++) {
|
|
126
|
+
if (usedIndices.has(i))
|
|
127
|
+
continue;
|
|
128
|
+
const normalized = normalizeHeader(headers[i]);
|
|
129
|
+
// Check for partial match
|
|
130
|
+
if (aliases.some(alias => normalized.includes(alias) || alias.includes(normalized))) {
|
|
131
|
+
mapping[field.type] = i;
|
|
132
|
+
usedIndices.add(i);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Set null for any fields not found
|
|
138
|
+
for (const field of allFields) {
|
|
139
|
+
if (mapping[field.type] === undefined) {
|
|
140
|
+
mapping[field.type] = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return mapping;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Validate that all required fields are mapped
|
|
147
|
+
*/
|
|
148
|
+
export function validateMapping(mapping, fieldsConfig) {
|
|
149
|
+
const missing = [];
|
|
150
|
+
for (const field of fieldsConfig.required) {
|
|
151
|
+
if (mapping[field.type] === null || mapping[field.type] === undefined) {
|
|
152
|
+
missing.push(field.label);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
valid: missing.length === 0,
|
|
157
|
+
missing
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Detect the most likely import mode based on headers
|
|
162
|
+
* Returns 'point' if lat/lon found, 'site' if site-related, otherwise 'cell'
|
|
163
|
+
*/
|
|
164
|
+
export function detectImportMode(headers) {
|
|
165
|
+
const normalizedHeaders = headers.map(h => normalizeHeader(h));
|
|
166
|
+
// Check for lat/lon presence
|
|
167
|
+
const hasLat = normalizedHeaders.some(h => COLUMN_ALIASES.lat.some(alias => h === alias || h.includes(alias)));
|
|
168
|
+
const hasLon = normalizedHeaders.some(h => COLUMN_ALIASES.lon.some(alias => h === alias || h.includes(alias)));
|
|
169
|
+
const hasLatLon = hasLat && hasLon;
|
|
170
|
+
// Check for site-specific headers
|
|
171
|
+
const hasSiteId = normalizedHeaders.some(h => COLUMN_ALIASES.siteId.some(alias => h === alias || h.includes(alias)));
|
|
172
|
+
const hasSiteName = normalizedHeaders.some(h => COLUMN_ALIASES.siteName.some(alias => h === alias || h.includes(alias)));
|
|
173
|
+
// Check for cell-specific headers
|
|
174
|
+
const hasCellId = normalizedHeaders.some(h => ['cellname', 'cell_name', 'cellid', 'cell_id', 'txid', 'tx_id'].some(alias => h === alias || h.includes(alias)));
|
|
175
|
+
// Decision logic:
|
|
176
|
+
// 1. If has lat/lon AND site-specific headers → site mode
|
|
177
|
+
// 2. If has lat/lon AND no cell-specific headers → point mode
|
|
178
|
+
// 3. Otherwise → cell mode (ID resolves against existing cells)
|
|
179
|
+
if (hasLatLon && (hasSiteId || hasSiteName) && !hasCellId) {
|
|
180
|
+
return 'site';
|
|
181
|
+
}
|
|
182
|
+
if (hasLatLon && !hasCellId) {
|
|
183
|
+
return 'point';
|
|
184
|
+
}
|
|
185
|
+
return 'cell';
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Auto-detect import mode and get initial mapping
|
|
189
|
+
*/
|
|
190
|
+
export function autoDetectImport(headers) {
|
|
191
|
+
const mode = detectImportMode(headers);
|
|
192
|
+
const fieldsConfig = getFieldsForMode(mode);
|
|
193
|
+
const mapping = detectColumnMapping(headers, fieldsConfig);
|
|
194
|
+
return { mode, mapping, fieldsConfig };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Detect delimiter from CSV content
|
|
198
|
+
*/
|
|
199
|
+
export function detectDelimiter(headerLine) {
|
|
200
|
+
const commaCount = (headerLine.match(/,/g) || []).length;
|
|
201
|
+
const semicolonCount = (headerLine.match(/;/g) || []).length;
|
|
202
|
+
return semicolonCount > commaCount ? ';' : ',';
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Parse CSV header line and return headers array
|
|
206
|
+
*/
|
|
207
|
+
export function parseHeaderLine(line, delimiter) {
|
|
208
|
+
// Simple parse (doesn't handle quoted fields with delimiters inside)
|
|
209
|
+
return line.split(delimiter).map(h => h.trim().replace(/^["']|["']$/g, ''));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Get CSV headers and suggested mapping
|
|
213
|
+
*/
|
|
214
|
+
export function analyzeCSVHeaders(csvContent, fieldsConfig) {
|
|
215
|
+
const lines = csvContent.trim().split('\n');
|
|
216
|
+
if (lines.length === 0) {
|
|
217
|
+
return { headers: [], delimiter: ',', suggestedMapping: {}, rowCount: 0 };
|
|
218
|
+
}
|
|
219
|
+
const delimiter = detectDelimiter(lines[0]);
|
|
220
|
+
const headers = parseHeaderLine(lines[0], delimiter);
|
|
221
|
+
const suggestedMapping = detectColumnMapping(headers, fieldsConfig);
|
|
222
|
+
return {
|
|
223
|
+
headers,
|
|
224
|
+
delimiter,
|
|
225
|
+
suggestedMapping,
|
|
226
|
+
rowCount: lines.length - 1
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV Import Module
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for flexible CSV column mapping.
|
|
5
|
+
* Supports auto-detection of columns via aliases and manual mapping UI.
|
|
6
|
+
*/
|
|
7
|
+
export type { StandardFieldType, FieldDefinition, ColumnMapping, ImportFieldsConfig, ImportMode } from './types';
|
|
8
|
+
export { CUSTOM_POINTS_FIELDS, CELL_IMPORT_FIELDS, CELL_MODE_FIELDS, POINT_MODE_FIELDS, SITE_MODE_FIELDS, getFieldsForMode } from './types';
|
|
9
|
+
export { COLUMN_ALIASES, detectColumnMapping, detectImportMode, autoDetectImport, validateMapping, analyzeCSVHeaders } from './column-detector';
|
|
10
|
+
export { default as ColumnMapper } from './ColumnMapper.svelte';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV Import Module
|
|
3
|
+
*
|
|
4
|
+
* Shared utilities for flexible CSV column mapping.
|
|
5
|
+
* Supports auto-detection of columns via aliases and manual mapping UI.
|
|
6
|
+
*/
|
|
7
|
+
// Field configurations
|
|
8
|
+
export { CUSTOM_POINTS_FIELDS, CELL_IMPORT_FIELDS, CELL_MODE_FIELDS, POINT_MODE_FIELDS, SITE_MODE_FIELDS, getFieldsForMode } from './types';
|
|
9
|
+
// Column detection utilities
|
|
10
|
+
export { COLUMN_ALIASES, detectColumnMapping, detectImportMode, autoDetectImport, validateMapping, analyzeCSVHeaders } from './column-detector';
|
|
11
|
+
// UI Component
|
|
12
|
+
export { default as ColumnMapper } from './ColumnMapper.svelte';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CSV Import - Types
|
|
3
|
+
*
|
|
4
|
+
* Reusable types for CSV column mapping across different import features.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Import mode - determines which fields are shown and required
|
|
8
|
+
*/
|
|
9
|
+
export type ImportMode = 'cell' | 'point' | 'site';
|
|
10
|
+
/**
|
|
11
|
+
* Standard field types that can be mapped
|
|
12
|
+
*/
|
|
13
|
+
export type StandardFieldType = 'id' | 'lat' | 'lon' | 'group' | 'sizeFactor' | 'tech' | 'band' | 'azimuth' | 'tilt' | 'siteId' | 'siteName' | 'status' | 'height' | 'power';
|
|
14
|
+
/**
|
|
15
|
+
* Definition of a field for mapping
|
|
16
|
+
*/
|
|
17
|
+
export interface FieldDefinition {
|
|
18
|
+
/** Field type identifier */
|
|
19
|
+
type: StandardFieldType;
|
|
20
|
+
/** Display label for UI */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Whether this field is required */
|
|
23
|
+
required: boolean;
|
|
24
|
+
/** Description/help text */
|
|
25
|
+
description?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Mapping of field types to CSV column indices
|
|
29
|
+
*/
|
|
30
|
+
export interface ColumnMapping {
|
|
31
|
+
[fieldType: string]: number | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Result of parsing CSV headers
|
|
35
|
+
*/
|
|
36
|
+
export interface CsvHeadersResult {
|
|
37
|
+
/** All column headers from CSV */
|
|
38
|
+
headers: string[];
|
|
39
|
+
/** Detected delimiter */
|
|
40
|
+
delimiter: ',' | ';';
|
|
41
|
+
/** Auto-detected column mapping (best guesses) */
|
|
42
|
+
suggestedMapping: ColumnMapping;
|
|
43
|
+
/** Total row count (excluding header) */
|
|
44
|
+
rowCount: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Configuration for a specific import context
|
|
48
|
+
*/
|
|
49
|
+
export interface ImportFieldsConfig {
|
|
50
|
+
/** Fields that must be mapped */
|
|
51
|
+
required: FieldDefinition[];
|
|
52
|
+
/** Fields that can optionally be mapped */
|
|
53
|
+
optional: FieldDefinition[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Predefined field configurations for import modes
|
|
57
|
+
*/
|
|
58
|
+
/** Cell mode - ID resolves against existing cell data */
|
|
59
|
+
export declare const CELL_MODE_FIELDS: ImportFieldsConfig;
|
|
60
|
+
/** Point mode - creates markers at lat/lon coordinates */
|
|
61
|
+
export declare const POINT_MODE_FIELDS: ImportFieldsConfig;
|
|
62
|
+
/** Site mode - creates site markers at lat/lon */
|
|
63
|
+
export declare const SITE_MODE_FIELDS: ImportFieldsConfig;
|
|
64
|
+
/** Get field config for a given import mode */
|
|
65
|
+
export declare function getFieldsForMode(mode: ImportMode): ImportFieldsConfig;
|
|
66
|
+
export declare const CUSTOM_POINTS_FIELDS: ImportFieldsConfig;
|
|
67
|
+
export declare const CELL_IMPORT_FIELDS: ImportFieldsConfig;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CSV Import - Types
|
|
3
|
+
*
|
|
4
|
+
* Reusable types for CSV column mapping across different import features.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Predefined field configurations for import modes
|
|
8
|
+
*/
|
|
9
|
+
/** Cell mode - ID resolves against existing cell data */
|
|
10
|
+
export const CELL_MODE_FIELDS = {
|
|
11
|
+
required: [
|
|
12
|
+
{ type: 'id', label: 'Cell ID', required: true, description: 'e.g. cellName, txId' }
|
|
13
|
+
],
|
|
14
|
+
optional: [
|
|
15
|
+
{ type: 'group', label: 'Group', required: false, description: 'e.g. customGroup, category' },
|
|
16
|
+
{ type: 'sizeFactor', label: 'Size', required: false, description: 'e.g. sizeFactor, weight' }
|
|
17
|
+
]
|
|
18
|
+
};
|
|
19
|
+
/** Point mode - creates markers at lat/lon coordinates */
|
|
20
|
+
export const POINT_MODE_FIELDS = {
|
|
21
|
+
required: [
|
|
22
|
+
{ type: 'id', label: 'ID / Name', required: true, description: 'e.g. name, label, id' },
|
|
23
|
+
{ type: 'lat', label: 'Latitude', required: true, description: 'e.g. lat, latitude, y' },
|
|
24
|
+
{ type: 'lon', label: 'Longitude', required: true, description: 'e.g. lon, lng, longitude, x' }
|
|
25
|
+
],
|
|
26
|
+
optional: [
|
|
27
|
+
{ type: 'group', label: 'Group', required: false, description: 'e.g. category, type' },
|
|
28
|
+
{ type: 'sizeFactor', label: 'Size', required: false, description: 'e.g. size, weight' }
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
/** Site mode - creates site markers at lat/lon */
|
|
32
|
+
export const SITE_MODE_FIELDS = {
|
|
33
|
+
required: [
|
|
34
|
+
{ type: 'id', label: 'Site ID', required: true, description: 'e.g. siteId, site_id, enodeb_id' },
|
|
35
|
+
{ type: 'lat', label: 'Latitude', required: true, description: 'e.g. lat, latitude' },
|
|
36
|
+
{ type: 'lon', label: 'Longitude', required: true, description: 'e.g. lon, longitude' }
|
|
37
|
+
],
|
|
38
|
+
optional: [
|
|
39
|
+
{ type: 'siteName', label: 'Site Name', required: false, description: 'e.g. site_name, location' },
|
|
40
|
+
{ type: 'group', label: 'Group', required: false, description: 'e.g. region, area' }
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
/** Get field config for a given import mode */
|
|
44
|
+
export function getFieldsForMode(mode) {
|
|
45
|
+
switch (mode) {
|
|
46
|
+
case 'cell': return CELL_MODE_FIELDS;
|
|
47
|
+
case 'point': return POINT_MODE_FIELDS;
|
|
48
|
+
case 'site': return SITE_MODE_FIELDS;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Legacy exports for backward compatibility
|
|
52
|
+
export const CUSTOM_POINTS_FIELDS = CELL_MODE_FIELDS;
|
|
53
|
+
export const CELL_IMPORT_FIELDS = {
|
|
54
|
+
required: [
|
|
55
|
+
{ type: 'id', label: 'Cell ID', required: true, description: 'Unique cell identifier' },
|
|
56
|
+
{ type: 'lat', label: 'Latitude', required: true },
|
|
57
|
+
{ type: 'lon', label: 'Longitude', required: true },
|
|
58
|
+
{ type: 'azimuth', label: 'Azimuth', required: true, description: 'Antenna direction (0-360°)' }
|
|
59
|
+
],
|
|
60
|
+
optional: [
|
|
61
|
+
{ type: 'siteId', label: 'Site ID', required: false },
|
|
62
|
+
{ type: 'siteName', label: 'Site Name', required: false },
|
|
63
|
+
{ type: 'tech', label: 'Technology', required: false, description: 'e.g., LTE, NR, GSM' },
|
|
64
|
+
{ type: 'band', label: 'Band', required: false, description: 'Frequency band' },
|
|
65
|
+
{ type: 'tilt', label: 'Tilt', required: false },
|
|
66
|
+
{ type: 'height', label: 'Height', required: false },
|
|
67
|
+
{ type: 'power', label: 'Power', required: false },
|
|
68
|
+
{ type: 'status', label: 'Status', required: false }
|
|
69
|
+
]
|
|
70
|
+
};
|