@smartnet360/svelte-components 0.0.126 → 0.0.128
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/map-v3/demo/DemoMap.svelte +18 -0
- package/dist/map-v3/features/cells/custom/stores/custom-cell-sets.svelte.d.ts +16 -5
- package/dist/map-v3/features/cells/custom/stores/custom-cell-sets.svelte.js +26 -13
- package/dist/map-v3/features/sites/custom/components/CustomSiteFilterControl.svelte +203 -0
- package/dist/map-v3/features/sites/custom/components/CustomSiteFilterControl.svelte.d.ts +15 -0
- package/dist/map-v3/features/sites/custom/components/CustomSiteSetManager.svelte +261 -0
- package/dist/map-v3/features/sites/custom/components/CustomSiteSetManager.svelte.d.ts +10 -0
- package/dist/map-v3/features/sites/custom/index.d.ts +13 -0
- package/dist/map-v3/features/sites/custom/index.js +16 -0
- package/dist/map-v3/features/sites/custom/layers/CustomSitesLayer.svelte +201 -0
- package/dist/map-v3/features/sites/custom/layers/CustomSitesLayer.svelte.d.ts +8 -0
- package/dist/map-v3/features/sites/custom/logic/csv-parser.d.ts +12 -0
- package/dist/map-v3/features/sites/custom/logic/csv-parser.js +182 -0
- package/dist/map-v3/features/sites/custom/logic/tree-adapter.d.ts +16 -0
- package/dist/map-v3/features/sites/custom/logic/tree-adapter.js +59 -0
- package/dist/map-v3/features/sites/custom/stores/custom-site-sets.svelte.d.ts +78 -0
- package/dist/map-v3/features/sites/custom/stores/custom-site-sets.svelte.js +248 -0
- package/dist/map-v3/features/sites/custom/types.d.ts +74 -0
- package/dist/map-v3/features/sites/custom/types.js +8 -0
- package/dist/map-v3/index.d.ts +2 -0
- package/dist/map-v3/index.js +4 -0
- package/package.json +1 -1
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
CustomCellSetManager,
|
|
27
27
|
createCustomCellSetsStore
|
|
28
28
|
} from '../features/cells/custom';
|
|
29
|
+
// Custom Sites Feature
|
|
30
|
+
import {
|
|
31
|
+
CustomSitesLayer,
|
|
32
|
+
CustomSiteSetManager,
|
|
33
|
+
CustomSiteSetsStore
|
|
34
|
+
} from '../features/sites/custom';
|
|
29
35
|
import { demoCells } from './demo-cells';
|
|
30
36
|
import { demoRepeaters } from './demo-repeaters';
|
|
31
37
|
|
|
@@ -51,6 +57,9 @@
|
|
|
51
57
|
// Custom Cells Store
|
|
52
58
|
const customCellSets = createCustomCellSetsStore(cellData, 'demo-map');
|
|
53
59
|
|
|
60
|
+
// Custom Sites Store
|
|
61
|
+
const customSiteSets = new CustomSiteSetsStore();
|
|
62
|
+
|
|
54
63
|
onMount(() => {
|
|
55
64
|
// Load dummy data
|
|
56
65
|
// Need to cast or map if types slightly differ, but they should match
|
|
@@ -96,6 +105,12 @@
|
|
|
96
105
|
setsStore={customCellSets}
|
|
97
106
|
/>
|
|
98
107
|
|
|
108
|
+
<!-- Custom Sites Manager (includes filter controls for each set) -->
|
|
109
|
+
<CustomSiteSetManager
|
|
110
|
+
position="top-left"
|
|
111
|
+
setsStore={customSiteSets}
|
|
112
|
+
/>
|
|
113
|
+
|
|
99
114
|
<FeatureSettingsControl
|
|
100
115
|
position="top-right"
|
|
101
116
|
cellDisplayStore={cellDisplay}
|
|
@@ -117,6 +132,9 @@
|
|
|
117
132
|
<!-- Custom Cells Layer (renders on top of regular cells) -->
|
|
118
133
|
<CustomCellsLayer setsStore={customCellSets} />
|
|
119
134
|
|
|
135
|
+
<!-- Custom Sites Layer (renders custom point markers) -->
|
|
136
|
+
<CustomSitesLayer setsStore={customSiteSets} />
|
|
137
|
+
|
|
120
138
|
<FeatureSelectionControl
|
|
121
139
|
position="bottom-left"
|
|
122
140
|
cellDataStore={cellData}
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Custom Cell Sets Store
|
|
3
3
|
*
|
|
4
4
|
* Manages multiple custom cell sets, each loaded from a CSV file.
|
|
5
|
-
* Resolves cell data from
|
|
5
|
+
* Resolves cell data from a provided cell array.
|
|
6
6
|
*/
|
|
7
|
+
import type { Cell } from '../../types';
|
|
7
8
|
import type { CellDataStore } from '../../stores/cell.data.svelte';
|
|
8
9
|
import type { CustomCellSet, CustomCellImportResult } from '../types';
|
|
10
|
+
/** Function that returns the current cells array */
|
|
11
|
+
type CellsGetter = () => Cell[];
|
|
9
12
|
/**
|
|
10
13
|
* Store for managing custom cell sets
|
|
11
14
|
*/
|
|
@@ -14,11 +17,16 @@ export declare class CustomCellSetsStore {
|
|
|
14
17
|
sets: CustomCellSet[];
|
|
15
18
|
/** Version counter for reactivity */
|
|
16
19
|
version: number;
|
|
17
|
-
/**
|
|
18
|
-
private
|
|
20
|
+
/** Function to get current cells */
|
|
21
|
+
private getCells;
|
|
19
22
|
/** Storage key for persistence */
|
|
20
23
|
private storageKey;
|
|
21
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Create a new CustomCellSetsStore
|
|
26
|
+
* @param cells - Either a Cell array or a getter function that returns cells
|
|
27
|
+
* @param namespace - Storage namespace for persistence
|
|
28
|
+
*/
|
|
29
|
+
constructor(cells: Cell[] | CellsGetter, namespace?: string);
|
|
22
30
|
/**
|
|
23
31
|
* Import a CSV file and create a new custom cell set
|
|
24
32
|
*/
|
|
@@ -74,5 +82,8 @@ export declare class CustomCellSetsStore {
|
|
|
74
82
|
}
|
|
75
83
|
/**
|
|
76
84
|
* Factory function to create a custom cell sets store
|
|
85
|
+
* @param cells - Cell array, getter function, or CellDataStore
|
|
86
|
+
* @param namespace - Storage namespace for persistence
|
|
77
87
|
*/
|
|
78
|
-
export declare function createCustomCellSetsStore(
|
|
88
|
+
export declare function createCustomCellSetsStore(cells: Cell[] | CellsGetter | CellDataStore, namespace?: string): CustomCellSetsStore;
|
|
89
|
+
export {};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Custom Cell Sets Store
|
|
3
3
|
*
|
|
4
4
|
* Manages multiple custom cell sets, each loaded from a CSV file.
|
|
5
|
-
* Resolves cell data from
|
|
5
|
+
* Resolves cell data from a provided cell array.
|
|
6
6
|
*/
|
|
7
7
|
import { browser } from '$app/environment';
|
|
8
8
|
import { CUSTOM_CELL_PALETTE } from '../types';
|
|
@@ -21,12 +21,18 @@ export class CustomCellSetsStore {
|
|
|
21
21
|
sets = $state([]);
|
|
22
22
|
/** Version counter for reactivity */
|
|
23
23
|
version = $state(0);
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/** Function to get current cells */
|
|
25
|
+
getCells;
|
|
26
26
|
/** Storage key for persistence */
|
|
27
27
|
storageKey;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Create a new CustomCellSetsStore
|
|
30
|
+
* @param cells - Either a Cell array or a getter function that returns cells
|
|
31
|
+
* @param namespace - Storage namespace for persistence
|
|
32
|
+
*/
|
|
33
|
+
constructor(cells, namespace = 'default') {
|
|
34
|
+
// Normalize to a getter function
|
|
35
|
+
this.getCells = typeof cells === 'function' ? cells : () => cells;
|
|
30
36
|
this.storageKey = `${namespace}:custom-cell-sets`;
|
|
31
37
|
if (browser) {
|
|
32
38
|
this.load();
|
|
@@ -36,14 +42,15 @@ export class CustomCellSetsStore {
|
|
|
36
42
|
const _v = this.version;
|
|
37
43
|
this.save();
|
|
38
44
|
});
|
|
39
|
-
// Re-resolve cells when
|
|
45
|
+
// Re-resolve cells when cell data changes (only works if getter is reactive)
|
|
40
46
|
$effect(() => {
|
|
41
|
-
const
|
|
47
|
+
const currentCells = this.getCells();
|
|
48
|
+
const cellCount = currentCells.length;
|
|
42
49
|
if (cellCount > 0 && this.sets.length > 0) {
|
|
43
50
|
// Check if any cells need resolution
|
|
44
51
|
const needsResolution = this.sets.some(set => set.cells.some(c => !c.resolvedCell));
|
|
45
52
|
if (needsResolution) {
|
|
46
|
-
console.log('[CustomCellSetsStore] Re-resolving cells after
|
|
53
|
+
console.log('[CustomCellSetsStore] Re-resolving cells after data loaded');
|
|
47
54
|
this.refreshResolutions();
|
|
48
55
|
}
|
|
49
56
|
}
|
|
@@ -54,8 +61,8 @@ export class CustomCellSetsStore {
|
|
|
54
61
|
* Import a CSV file and create a new custom cell set
|
|
55
62
|
*/
|
|
56
63
|
importFromCsv(csvContent, fileName) {
|
|
57
|
-
// Build lookup from all cells
|
|
58
|
-
const cellLookup = buildCellLookup(this.
|
|
64
|
+
// Build lookup from all cells
|
|
65
|
+
const cellLookup = buildCellLookup(this.getCells());
|
|
59
66
|
// Parse CSV
|
|
60
67
|
const result = parseCustomCellsCsv(csvContent, cellLookup);
|
|
61
68
|
return result;
|
|
@@ -180,7 +187,7 @@ export class CustomCellSetsStore {
|
|
|
180
187
|
* Re-resolve cells after main cell data changes
|
|
181
188
|
*/
|
|
182
189
|
refreshResolutions() {
|
|
183
|
-
const cellLookup = buildCellLookup(this.
|
|
190
|
+
const cellLookup = buildCellLookup(this.getCells());
|
|
184
191
|
for (const set of this.sets) {
|
|
185
192
|
for (const cell of set.cells) {
|
|
186
193
|
cell.resolvedCell = cellLookup.get(cell.txId);
|
|
@@ -236,7 +243,13 @@ export class CustomCellSetsStore {
|
|
|
236
243
|
}
|
|
237
244
|
/**
|
|
238
245
|
* Factory function to create a custom cell sets store
|
|
246
|
+
* @param cells - Cell array, getter function, or CellDataStore
|
|
247
|
+
* @param namespace - Storage namespace for persistence
|
|
239
248
|
*/
|
|
240
|
-
export function createCustomCellSetsStore(
|
|
241
|
-
|
|
249
|
+
export function createCustomCellSetsStore(cells, namespace = 'default') {
|
|
250
|
+
// Handle CellDataStore by extracting a getter
|
|
251
|
+
if (cells && typeof cells === 'object' && 'rawCells' in cells) {
|
|
252
|
+
return new CustomCellSetsStore(() => cells.rawCells, namespace);
|
|
253
|
+
}
|
|
254
|
+
return new CustomCellSetsStore(cells, namespace);
|
|
242
255
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Custom Site Filter Control
|
|
4
|
+
*
|
|
5
|
+
* TreeView with color pickers for a single custom site set.
|
|
6
|
+
* Shows groups with their site counts and allows color customization.
|
|
7
|
+
*/
|
|
8
|
+
import { untrack, onDestroy } from 'svelte';
|
|
9
|
+
import { MapControl } from '../../../../shared';
|
|
10
|
+
import { createTreeStore, TreeView } from '../../../../../core/TreeView';
|
|
11
|
+
import type { CustomSiteSetsStore } from '../stores/custom-site-sets.svelte';
|
|
12
|
+
import type { CustomSiteSet } from '../types';
|
|
13
|
+
import { buildCustomSiteTree } from '../logic/tree-adapter';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** The custom site sets store */
|
|
17
|
+
setsStore: CustomSiteSetsStore;
|
|
18
|
+
/** The specific set to display */
|
|
19
|
+
set: CustomSiteSet;
|
|
20
|
+
/** Control position on map */
|
|
21
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
22
|
+
/** Callback when set is removed */
|
|
23
|
+
onremove?: (setId: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
setsStore,
|
|
28
|
+
set,
|
|
29
|
+
position = 'top-left',
|
|
30
|
+
onremove
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
onDestroy(() => {
|
|
34
|
+
console.log(`[CustomSiteFilterControl] onDestroy called for set: ${set.id}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Build tree from set
|
|
38
|
+
let treeStore = $derived.by(() => {
|
|
39
|
+
const _version = setsStore.version;
|
|
40
|
+
const _set = set;
|
|
41
|
+
|
|
42
|
+
return untrack(() => {
|
|
43
|
+
const nodes = buildCustomSiteTree(_set);
|
|
44
|
+
return createTreeStore({
|
|
45
|
+
nodes,
|
|
46
|
+
namespace: `custom-sites:${_set.id}`,
|
|
47
|
+
persistState: true,
|
|
48
|
+
defaultExpandAll: true
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Sync tree selection -> store visibility
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const val = treeStore;
|
|
56
|
+
let changes = 0;
|
|
57
|
+
|
|
58
|
+
val.state.nodes.forEach((nodeState) => {
|
|
59
|
+
// Skip root node
|
|
60
|
+
if (nodeState.node.id === `root-${set.id}`) return;
|
|
61
|
+
// Skip folder nodes
|
|
62
|
+
if (nodeState.node.children && nodeState.node.children.length > 0) return;
|
|
63
|
+
|
|
64
|
+
const groupId = nodeState.node.metadata?.groupId;
|
|
65
|
+
if (!groupId) return;
|
|
66
|
+
|
|
67
|
+
const isVisible = val.state.checkedPaths.has(nodeState.path);
|
|
68
|
+
const currentlyVisible = set.visibleGroups.has(groupId);
|
|
69
|
+
|
|
70
|
+
if (isVisible !== currentlyVisible) {
|
|
71
|
+
setsStore.toggleGroupVisibility(set.id, groupId);
|
|
72
|
+
changes++;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (changes > 0) {
|
|
77
|
+
console.log(`[CustomSiteFilterControl] Synced ${changes} visibility changes`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function handleColorChange(groupId: string, event: Event) {
|
|
82
|
+
const input = event.target as HTMLInputElement;
|
|
83
|
+
setsStore.setGroupColor(set.id, groupId, input.value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleRemove() {
|
|
87
|
+
if (onremove) {
|
|
88
|
+
onremove(set.id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function handleToggleVisibility() {
|
|
93
|
+
setsStore.toggleSetVisibility(set.id);
|
|
94
|
+
}
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<MapControl {position} title={set.name} icon="geo-alt" controlWidth="280px">
|
|
98
|
+
{#snippet actions()}
|
|
99
|
+
<button
|
|
100
|
+
class="btn btn-sm btn-outline-secondary border-0 p-1 px-2"
|
|
101
|
+
title={set.visible ? 'Hide Layer' : 'Show Layer'}
|
|
102
|
+
aria-label={set.visible ? 'Hide Layer' : 'Show Layer'}
|
|
103
|
+
onclick={handleToggleVisibility}
|
|
104
|
+
>
|
|
105
|
+
<i class="bi bi-eye{set.visible ? '-fill' : '-slash'}"></i>
|
|
106
|
+
</button>
|
|
107
|
+
<button
|
|
108
|
+
class="btn btn-sm btn-outline-danger border-0 p-1 px-2"
|
|
109
|
+
title="Remove Set"
|
|
110
|
+
aria-label="Remove Set"
|
|
111
|
+
onclick={handleRemove}
|
|
112
|
+
>
|
|
113
|
+
<i class="bi bi-trash"></i>
|
|
114
|
+
</button>
|
|
115
|
+
{/snippet}
|
|
116
|
+
|
|
117
|
+
<div class="custom-site-filter-control">
|
|
118
|
+
<!-- Set Info -->
|
|
119
|
+
<div class="set-info mb-2 px-1">
|
|
120
|
+
<small class="text-muted">
|
|
121
|
+
{set.sites.length} sites in {set.groups.length} groups
|
|
122
|
+
</small>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- Size Slider -->
|
|
126
|
+
<div class="size-control mb-2 px-1">
|
|
127
|
+
<label for="baseSize-{set.id}" class="form-label small mb-1">
|
|
128
|
+
Base Size: {set.baseSize}px
|
|
129
|
+
</label>
|
|
130
|
+
<input
|
|
131
|
+
type="range"
|
|
132
|
+
class="form-range"
|
|
133
|
+
id="baseSize-{set.id}"
|
|
134
|
+
min="2"
|
|
135
|
+
max="30"
|
|
136
|
+
step="1"
|
|
137
|
+
value={set.baseSize}
|
|
138
|
+
oninput={(e) => setsStore.setBaseSize(set.id, parseInt((e.target as HTMLInputElement).value))}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Opacity Slider -->
|
|
143
|
+
<div class="opacity-control mb-2 px-1">
|
|
144
|
+
<label for="opacity-{set.id}" class="form-label small mb-1">
|
|
145
|
+
Opacity: {Math.round(set.opacity * 100)}%
|
|
146
|
+
</label>
|
|
147
|
+
<input
|
|
148
|
+
type="range"
|
|
149
|
+
class="form-range"
|
|
150
|
+
id="opacity-{set.id}"
|
|
151
|
+
min="0.1"
|
|
152
|
+
max="1"
|
|
153
|
+
step="0.1"
|
|
154
|
+
value={set.opacity}
|
|
155
|
+
oninput={(e) => setsStore.setOpacity(set.id, parseFloat((e.target as HTMLInputElement).value))}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<!-- Group Color Pickers -->
|
|
160
|
+
<div class="group-colors mb-2">
|
|
161
|
+
<div class="small text-muted mb-1 px-1">Group Colors:</div>
|
|
162
|
+
{#each set.groups as group}
|
|
163
|
+
<div class="d-flex align-items-center gap-2 px-1 py-1">
|
|
164
|
+
<input
|
|
165
|
+
type="color"
|
|
166
|
+
class="form-control form-control-color"
|
|
167
|
+
value={set.groupColors.get(group) || '#666666'}
|
|
168
|
+
onchange={(e) => handleColorChange(group, e)}
|
|
169
|
+
title="Color for {group}"
|
|
170
|
+
style="width: 28px; height: 28px; padding: 2px;"
|
|
171
|
+
/>
|
|
172
|
+
<span class="small flex-grow-1 text-truncate">{group}</span>
|
|
173
|
+
<span class="badge bg-secondary">
|
|
174
|
+
{set.sites.filter(s => s.customGroup === group).length}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
{/each}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- TreeView -->
|
|
181
|
+
<div class="tree-container">
|
|
182
|
+
<TreeView store={treeStore} />
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</MapControl>
|
|
186
|
+
|
|
187
|
+
<style>
|
|
188
|
+
.custom-site-filter-control {
|
|
189
|
+
max-height: 400px;
|
|
190
|
+
overflow-y: auto;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.tree-container {
|
|
194
|
+
max-height: 200px;
|
|
195
|
+
overflow-y: auto;
|
|
196
|
+
border-top: 1px solid var(--bs-border-color);
|
|
197
|
+
padding-top: 0.5rem;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.form-control-color {
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CustomSiteSetsStore } from '../stores/custom-site-sets.svelte';
|
|
2
|
+
import type { CustomSiteSet } from '../types';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** The custom site sets store */
|
|
5
|
+
setsStore: CustomSiteSetsStore;
|
|
6
|
+
/** The specific set to display */
|
|
7
|
+
set: CustomSiteSet;
|
|
8
|
+
/** Control position on map */
|
|
9
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
10
|
+
/** Callback when set is removed */
|
|
11
|
+
onremove?: (setId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
declare const CustomSiteFilterControl: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type CustomSiteFilterControl = ReturnType<typeof CustomSiteFilterControl>;
|
|
15
|
+
export default CustomSiteFilterControl;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Custom Site Set Manager
|
|
4
|
+
*
|
|
5
|
+
* Main control panel for managing custom site sets.
|
|
6
|
+
* Provides CSV upload, set listing, and integrates filter controls.
|
|
7
|
+
*/
|
|
8
|
+
import { MapControl } from '../../../../shared';
|
|
9
|
+
import { CustomSiteSetsStore } from '../stores/custom-site-sets.svelte';
|
|
10
|
+
import CustomSiteFilterControl from './CustomSiteFilterControl.svelte';
|
|
11
|
+
import type { CustomSiteImportResult } from '../types';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Control position on map */
|
|
15
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
16
|
+
/** Optional external store (creates internal one if not provided) */
|
|
17
|
+
setsStore?: CustomSiteSetsStore;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
position = 'top-left',
|
|
22
|
+
setsStore: externalStore
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
// Use external store or create internal one
|
|
26
|
+
const setsStore = externalStore ?? new CustomSiteSetsStore();
|
|
27
|
+
|
|
28
|
+
// Reactive array for iteration
|
|
29
|
+
let setsArray = $derived(setsStore.sets);
|
|
30
|
+
|
|
31
|
+
// Modal state
|
|
32
|
+
let showImportModal = $state(false);
|
|
33
|
+
let importResult = $state<(CustomSiteImportResult & { setId?: string }) | null>(null);
|
|
34
|
+
let selectedFile = $state<File | null>(null);
|
|
35
|
+
let isImporting = $state(false);
|
|
36
|
+
|
|
37
|
+
function handleFileSelect(event: Event) {
|
|
38
|
+
const input = event.target as HTMLInputElement;
|
|
39
|
+
selectedFile = input.files?.[0] ?? null;
|
|
40
|
+
importResult = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handleImport() {
|
|
44
|
+
if (!selectedFile) return;
|
|
45
|
+
|
|
46
|
+
isImporting = true;
|
|
47
|
+
importResult = null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const content = await selectedFile.text();
|
|
51
|
+
importResult = setsStore.importFromCsv(content, selectedFile.name);
|
|
52
|
+
|
|
53
|
+
if (importResult.setId) {
|
|
54
|
+
// Success - close modal after a short delay to show results
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
showImportModal = false;
|
|
57
|
+
selectedFile = null;
|
|
58
|
+
importResult = null;
|
|
59
|
+
}, 1500);
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
importResult = {
|
|
63
|
+
sites: [],
|
|
64
|
+
groups: [],
|
|
65
|
+
invalidRows: 0,
|
|
66
|
+
errors: [`Failed to read file: ${e}`]
|
|
67
|
+
};
|
|
68
|
+
} finally {
|
|
69
|
+
isImporting = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleCloseModal() {
|
|
74
|
+
showImportModal = false;
|
|
75
|
+
selectedFile = null;
|
|
76
|
+
importResult = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleRemoveSet(setId: string) {
|
|
80
|
+
setsStore.removeSet(setId);
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<MapControl {position} title="Custom Sites" icon="geo-alt-fill" controlWidth="300px">
|
|
85
|
+
{#snippet actions()}
|
|
86
|
+
<button
|
|
87
|
+
class="btn btn-sm btn-outline-primary border-0 p-1 px-2"
|
|
88
|
+
title="Import CSV"
|
|
89
|
+
aria-label="Import CSV"
|
|
90
|
+
onclick={() => showImportModal = true}
|
|
91
|
+
>
|
|
92
|
+
<i class="bi bi-upload"></i>
|
|
93
|
+
</button>
|
|
94
|
+
{/snippet}
|
|
95
|
+
|
|
96
|
+
<div class="custom-site-manager">
|
|
97
|
+
{#if setsArray.length === 0}
|
|
98
|
+
<div class="text-center text-muted py-3">
|
|
99
|
+
<i class="bi bi-geo-alt fs-3 d-block mb-2"></i>
|
|
100
|
+
<p class="small mb-2">No custom sites loaded</p>
|
|
101
|
+
<button
|
|
102
|
+
class="btn btn-sm btn-outline-primary"
|
|
103
|
+
onclick={() => showImportModal = true}
|
|
104
|
+
>
|
|
105
|
+
<i class="bi bi-upload me-1"></i>
|
|
106
|
+
Import CSV
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
{:else}
|
|
110
|
+
<ul class="list-group list-group-flush">
|
|
111
|
+
{#each setsArray as set (set.id)}
|
|
112
|
+
<li class="list-group-item d-flex justify-content-between align-items-center px-2 py-2">
|
|
113
|
+
<div class="d-flex align-items-center gap-2 flex-grow-1 min-w-0">
|
|
114
|
+
<button
|
|
115
|
+
class="btn btn-sm p-0 border-0"
|
|
116
|
+
title={set.visible ? 'Hide' : 'Show'}
|
|
117
|
+
aria-label={set.visible ? 'Hide' : 'Show'}
|
|
118
|
+
onclick={() => setsStore.toggleSetVisibility(set.id)}
|
|
119
|
+
>
|
|
120
|
+
<i class="bi bi-eye{set.visible ? '-fill text-primary' : '-slash text-muted'}"></i>
|
|
121
|
+
</button>
|
|
122
|
+
<span class="text-truncate small" title={set.name}>
|
|
123
|
+
{set.name}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="d-flex align-items-center gap-1">
|
|
127
|
+
<span class="badge bg-secondary small">
|
|
128
|
+
{set.sites.length}
|
|
129
|
+
</span>
|
|
130
|
+
<button
|
|
131
|
+
class="btn btn-sm btn-outline-danger border-0 p-1"
|
|
132
|
+
title="Remove"
|
|
133
|
+
aria-label="Remove"
|
|
134
|
+
onclick={() => handleRemoveSet(set.id)}
|
|
135
|
+
>
|
|
136
|
+
<i class="bi bi-x"></i>
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</li>
|
|
140
|
+
{/each}
|
|
141
|
+
</ul>
|
|
142
|
+
{/if}
|
|
143
|
+
|
|
144
|
+
<!-- CSV Format Help -->
|
|
145
|
+
<div class="format-help mt-2 px-2">
|
|
146
|
+
<details class="small">
|
|
147
|
+
<summary class="text-muted">CSV Format</summary>
|
|
148
|
+
<div class="mt-1 text-muted" style="font-size: 0.75rem;">
|
|
149
|
+
<strong>Required:</strong> id, lat, lon<br>
|
|
150
|
+
<strong>Optional:</strong> customGroup, sizeFactor<br>
|
|
151
|
+
<em>Extra columns available in tooltips</em>
|
|
152
|
+
</div>
|
|
153
|
+
</details>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</MapControl>
|
|
157
|
+
|
|
158
|
+
<!-- Import Modal -->
|
|
159
|
+
{#if showImportModal}
|
|
160
|
+
<div class="modal show d-block" tabindex="-1" role="dialog">
|
|
161
|
+
<div class="modal-dialog modal-dialog-centered">
|
|
162
|
+
<div class="modal-content">
|
|
163
|
+
<div class="modal-header">
|
|
164
|
+
<h5 class="modal-title">
|
|
165
|
+
<i class="bi bi-upload me-2"></i>
|
|
166
|
+
Import Custom Sites
|
|
167
|
+
</h5>
|
|
168
|
+
<button type="button" class="btn-close" aria-label="Close" onclick={handleCloseModal}></button>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="modal-body">
|
|
171
|
+
<!-- File Input -->
|
|
172
|
+
<div class="mb-3">
|
|
173
|
+
<label for="csvFile" class="form-label">Select CSV File</label>
|
|
174
|
+
<input
|
|
175
|
+
type="file"
|
|
176
|
+
class="form-control"
|
|
177
|
+
id="csvFile"
|
|
178
|
+
accept=".csv"
|
|
179
|
+
onchange={handleFileSelect}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<!-- Format Info -->
|
|
184
|
+
<div class="alert alert-info small py-2">
|
|
185
|
+
<strong>Required columns:</strong> id, lat, lon<br>
|
|
186
|
+
<strong>Optional:</strong> customGroup (or subgroup), sizeFactor
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<!-- Import Result -->
|
|
190
|
+
{#if importResult}
|
|
191
|
+
{#if importResult.setId}
|
|
192
|
+
<div class="alert alert-success py-2">
|
|
193
|
+
<i class="bi bi-check-circle me-1"></i>
|
|
194
|
+
Imported {importResult.sites.length} sites in {importResult.groups.length} groups
|
|
195
|
+
</div>
|
|
196
|
+
{:else if importResult.errors.length > 0}
|
|
197
|
+
<div class="alert alert-danger py-2">
|
|
198
|
+
<strong>Import failed:</strong>
|
|
199
|
+
<ul class="mb-0 ps-3 mt-1">
|
|
200
|
+
{#each importResult.errors.slice(0, 5) as error}
|
|
201
|
+
<li class="small">{error}</li>
|
|
202
|
+
{/each}
|
|
203
|
+
</ul>
|
|
204
|
+
</div>
|
|
205
|
+
{/if}
|
|
206
|
+
{/if}
|
|
207
|
+
</div>
|
|
208
|
+
<div class="modal-footer">
|
|
209
|
+
<button type="button" class="btn btn-secondary" onclick={handleCloseModal}>
|
|
210
|
+
Cancel
|
|
211
|
+
</button>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class="btn btn-primary"
|
|
215
|
+
disabled={!selectedFile || isImporting}
|
|
216
|
+
onclick={handleImport}
|
|
217
|
+
>
|
|
218
|
+
{#if isImporting}
|
|
219
|
+
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
220
|
+
Importing...
|
|
221
|
+
{:else}
|
|
222
|
+
<i class="bi bi-upload me-1"></i>
|
|
223
|
+
Import
|
|
224
|
+
{/if}
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="modal-backdrop show"></div>
|
|
231
|
+
{/if}
|
|
232
|
+
|
|
233
|
+
<style>
|
|
234
|
+
.custom-site-manager {
|
|
235
|
+
font-size: 0.875rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.list-group-item {
|
|
239
|
+
background: transparent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.modal {
|
|
243
|
+
z-index: 2000;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.modal-backdrop {
|
|
247
|
+
z-index: 1999;
|
|
248
|
+
}
|
|
249
|
+
</style>
|
|
250
|
+
|
|
251
|
+
<!-- Filter Controls for each set (rendered outside the manager control) -->
|
|
252
|
+
{#each setsArray as set (set.id)}
|
|
253
|
+
{#key set.id}
|
|
254
|
+
<CustomSiteFilterControl
|
|
255
|
+
{position}
|
|
256
|
+
{setsStore}
|
|
257
|
+
{set}
|
|
258
|
+
onremove={(id) => setsStore.removeSet(id)}
|
|
259
|
+
/>
|
|
260
|
+
{/key}
|
|
261
|
+
{/each}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CustomSiteSetsStore } from '../stores/custom-site-sets.svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Control position on map */
|
|
4
|
+
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
5
|
+
/** Optional external store (creates internal one if not provided) */
|
|
6
|
+
setsStore?: CustomSiteSetsStore;
|
|
7
|
+
}
|
|
8
|
+
declare const CustomSiteSetManager: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type CustomSiteSetManager = ReturnType<typeof CustomSiteSetManager>;
|
|
10
|
+
export default CustomSiteSetManager;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Sites Feature
|
|
3
|
+
*
|
|
4
|
+
* Allows users to upload CSV files with custom site locations
|
|
5
|
+
* and display them as styled circle markers on the map.
|
|
6
|
+
*/
|
|
7
|
+
export type { CustomSite, CustomSiteSet, CustomSiteImportResult, CustomSiteSetSerialized } from './types';
|
|
8
|
+
export { CustomSiteSetsStore } from './stores/custom-site-sets.svelte';
|
|
9
|
+
export { parseCustomSitesCsv } from './logic/csv-parser';
|
|
10
|
+
export { buildCustomSiteTree, getGroupCounts } from './logic/tree-adapter';
|
|
11
|
+
export { default as CustomSiteSetManager } from './components/CustomSiteSetManager.svelte';
|
|
12
|
+
export { default as CustomSiteFilterControl } from './components/CustomSiteFilterControl.svelte';
|
|
13
|
+
export { default as CustomSitesLayer } from './layers/CustomSitesLayer.svelte';
|